mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
fix all tests
This commit is contained in:
@@ -5,17 +5,7 @@ layout: component.njk
|
||||
---
|
||||
|
||||
```html {.example}
|
||||
<wa-input id="disabled-input"></wa-input>
|
||||
|
||||
<style>
|
||||
wa-input:disabled::part(input) {
|
||||
background-color: blue;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module">
|
||||
document.querySelector("#disabled-input").disabled = true
|
||||
</script>
|
||||
<form><wa-input></wa-input></form>
|
||||
```
|
||||
|
||||
{% raw %}
|
||||
|
||||
@@ -16,6 +16,7 @@ New versions of Web Awesome are released as-needed and generally occur when a cr
|
||||
- Checkboxes will no longer have a `checked` attribute set when their `checked` property is changed. IE: `el.checked = true`. Instead, use the `:state(:checked)` and for unsupported browsers, use `[data-checked]`
|
||||
- `data-optional`, `data-required`, `data-invalid`, `data-valid`, `data-user-invalid`, and `data-user-valid` have all been renamed to have a `data-wa-*` prefix. Like so: `data-wa-valid`, `data-wa-invalid`, to avoid any conflicts with user provided attributes.
|
||||
- `<wa-checkbox>` and `<wa-switch>` now use `:state(checked)` and `[data-wa-checked]` for CSS styling their "checked" state. The "checked" attribute now maps to `defaultChecked` just like native HTML checkboxes.
|
||||
- `getFormControls()` has been removed. We use Form Associated Custom Elements now and can reliably grab Web Awesome Elements via `formElement.elements`.
|
||||
|
||||
|
||||
- Added `setKitCode()` and `getKitCode()` functions as well as support for setting kit codes declaratively with `data-webawesome-kit`
|
||||
|
||||
@@ -411,6 +411,9 @@ Form controls should support submission and validation through the following con
|
||||
- All form controls must have an `invalid` property that reflects their validity
|
||||
- All form controls should mirror their native validation attributes such as `required`, `pattern`, `minlength`, `maxlength`, etc. when possible and use the `MirrorValidator`.
|
||||
- All form controls must be tested to work with the standard `<form>` element
|
||||
- Form controls that **DO NOT** have an editable value such as a button only need `@property({ reflect: true }) value`
|
||||
- Form controls that **DO** have an editable value such as an input or textarea should have: `@property({ attribute: false }) value` and `@property({ attribute: "value", reflect: true }) defaultValue`. We do this to align with how native form controls work.
|
||||
- Form controls which have an editable property such as `checked` or `selected` should also have a `defaultSelected` and `defaultChecked` property respectively for use when the form is "reset".
|
||||
|
||||
### System Icons
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
|
||||
import { drag } from '../../internal/drag.js';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { RequiredValidator } from '../../internal/validators/required-validator.js';
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('<wa-icon-button>', () => {
|
||||
it('default properties', async () => {
|
||||
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
|
||||
|
||||
expect(el.name).to.be.undefined;
|
||||
expect(el.name).to.be.null;
|
||||
expect(el.library).to.be.undefined;
|
||||
expect(el.src).to.be.undefined;
|
||||
expect(el.href).to.be.undefined;
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('<wa-input>', async () => {
|
||||
|
||||
expect(el.type).to.equal('text');
|
||||
expect(el.size).to.equal('medium');
|
||||
expect(el.name).to.equal('');
|
||||
expect(el.name).to.equal(null);
|
||||
expect(el.value).to.equal('');
|
||||
expect(el.defaultValue).to.equal('');
|
||||
expect(el.title).to.equal('');
|
||||
|
||||
@@ -290,7 +290,10 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
|
||||
|
||||
const button = [...form.elements].find((el: HTMLButtonElement) => el.type === "submit" && !el.disabled) as undefined | HTMLButtonElement | WaButton
|
||||
|
||||
if (!button) return
|
||||
if (!button) {
|
||||
form.requestSubmit(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (button.tagName.toLowerCase() === "button") {
|
||||
form.requestSubmit(button)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LitElement } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import componentStyles from '../../styles/component.styles.js';
|
||||
@@ -135,7 +134,7 @@ export default class WaRadioButton extends WebAwesomeFormAssociatedElement {
|
||||
})}
|
||||
aria-disabled=${this.disabled}
|
||||
type="button"
|
||||
.value=${ifDefined(this.value)}
|
||||
value=${ifDefined(this.value)}
|
||||
tabindex="${this.checked ? '0' : '-1'}"
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
|
||||
@@ -183,26 +183,6 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
|
||||
}
|
||||
}
|
||||
|
||||
private syncRadios() {
|
||||
if (customElements.get('wa-radio') && customElements.get('wa-radio-button')) {
|
||||
this.syncRadioElements();
|
||||
return;
|
||||
}
|
||||
|
||||
if (customElements.get('wa-radio')) {
|
||||
this.syncRadioElements();
|
||||
} else {
|
||||
customElements.whenDefined('wa-radio').then(() => this.syncRadios());
|
||||
}
|
||||
|
||||
if (customElements.get('wa-radio-button')) {
|
||||
this.syncRadioElements();
|
||||
} else {
|
||||
// Rerun this handler when <wa-radio> or <wa-radio-button> is registered
|
||||
customElements.whenDefined('wa-radio-button').then(() => this.syncRadios());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We use the first available radio as the validationTarget similar to native HTML that shows the validation popup on
|
||||
* the first radio element.
|
||||
@@ -218,7 +198,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
|
||||
|
||||
@watch('size', { waitUntilFirstUpdate: true })
|
||||
handleSizeChange() {
|
||||
this.syncRadios();
|
||||
this.syncRadioElements();
|
||||
}
|
||||
|
||||
formResetCallback(...args: Parameters<WebAwesomeFormAssociatedElement['formResetCallback']>) {
|
||||
@@ -291,7 +271,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
const defaultSlot = html` <slot @slotchange=${this.syncRadios}></slot> `;
|
||||
const defaultSlot = html` <slot @slotchange=${this.syncRadioElements}></slot> `;
|
||||
|
||||
return html`
|
||||
<fieldset
|
||||
|
||||
@@ -13,7 +13,7 @@ export default css`
|
||||
--checked-icon-scale: 0.4;
|
||||
--toggle-size: calc(1em * var(--wa-font-line-height-compact) - 0.125rem);
|
||||
|
||||
display: block;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:host(:focus-visible) {
|
||||
|
||||
@@ -64,6 +64,7 @@ export default class WaRadio extends WebAwesomeFormAssociatedElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener("click", this.handleClick)
|
||||
this.addEventListener('blur', this.handleBlur);
|
||||
this.addEventListener('focus', this.handleFocus);
|
||||
}
|
||||
@@ -107,6 +108,12 @@ export default class WaRadio extends WebAwesomeFormAssociatedElement {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
private handleClick = () => {
|
||||
if (!this.disabled) {
|
||||
this.checked = true;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<span
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('<wa-switch>', async () => {
|
||||
it('default properties', async () => {
|
||||
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
|
||||
|
||||
expect(el.name).to.equal('');
|
||||
expect(el.name).to.equal(null);
|
||||
expect(el.value).to.be.null;
|
||||
expect(el.title).to.equal('');
|
||||
expect(el.disabled).to.be.false;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('<wa-textarea>', async () => {
|
||||
const el = await fixture<WaTextarea>(html` <wa-textarea></wa-textarea> `);
|
||||
|
||||
expect(el.size).to.equal('medium');
|
||||
expect(el.name).to.equal('');
|
||||
expect(el.name).to.equal(null);
|
||||
expect(el.value).to.equal('');
|
||||
expect(el.defaultValue).to.equal('');
|
||||
expect(el.title).to.equal('');
|
||||
|
||||
@@ -61,7 +61,10 @@ export default class WaTextarea extends WebAwesomeFormAssociatedElement {
|
||||
@property({ reflect: true }) name: string | null = null;
|
||||
|
||||
/** The current value of the textarea, submitted as a name/value pair with form data. */
|
||||
@property({ attribute: false }) value = '';
|
||||
@property({ attribute: false }) value: null | string = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@property({ reflect: true, attribute: 'value' }) defaultValue: null | string = '';
|
||||
|
||||
/** The textarea's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
@@ -141,9 +144,6 @@ export default class WaTextarea extends WebAwesomeFormAssociatedElement {
|
||||
*/
|
||||
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@property({ reflect: true, attribute: 'value' }) defaultValue: string = '';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -215,7 +215,6 @@ export default class WaTextarea extends WebAwesomeFormAssociatedElement {
|
||||
/** Removes focus from the textarea. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
// this.checkValidity();
|
||||
}
|
||||
|
||||
/** Selects all the text in the textarea. */
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type { WebAwesomeFormControl } from './webawesome-element.js';
|
||||
import type WaButton from '../components/button/button.js';
|
||||
|
||||
//
|
||||
// We store a WeakMap of forms + controls so we can keep references to all Web Awesome controls within a given form. As
|
||||
// elements connect and disconnect to/from the DOM, their containing form is used as the key and the form control is
|
||||
// added and removed from the form's set, respectively.
|
||||
//
|
||||
export const formCollections: WeakMap<HTMLFormElement, Set<WebAwesomeFormControl>> = new WeakMap();
|
||||
|
||||
//
|
||||
// We store a WeakMap of reportValidity() overloads so we can override it when form controls connect to the DOM and
|
||||
// restore the original behavior when they disconnect.
|
||||
//
|
||||
const reportValidityOverloads: WeakMap<HTMLFormElement, () => boolean> = new WeakMap();
|
||||
const checkValidityOverloads: WeakMap<HTMLFormElement, () => boolean> = new WeakMap();
|
||||
|
||||
//
|
||||
// We store a Set of controls that users have interacted with. This allows us to determine the interaction state
|
||||
// without littering the DOM with additional data attributes.
|
||||
//
|
||||
const userInteractedControls: WeakSet<WebAwesomeFormControl> = new WeakSet();
|
||||
|
||||
//
|
||||
// We store a WeakMap of interactions for each form control so we can track when all conditions are met for validation.
|
||||
//
|
||||
const interactions = new WeakMap<WebAwesomeFormControl, string[]>();
|
||||
|
||||
export interface FormControlControllerOptions {
|
||||
/** A function that returns the form containing the form control. */
|
||||
form: (input: WebAwesomeFormControl) => HTMLFormElement | null;
|
||||
/** A function that returns the form control's name, which will be submitted with the form data. */
|
||||
name: (input: WebAwesomeFormControl) => string;
|
||||
/** A function that returns the form control's current value. */
|
||||
value: (input: WebAwesomeFormControl) => unknown | unknown[];
|
||||
/** A function that returns the form control's default value. */
|
||||
defaultValue: (input: WebAwesomeFormControl) => unknown | unknown[];
|
||||
/** A function that returns the form control's current disabled state. If disabled, the value won't be submitted. */
|
||||
disabled: (input: WebAwesomeFormControl) => boolean;
|
||||
/**
|
||||
* A function that maps to the form control's reportValidity() function. When the control is invalid, this will
|
||||
* prevent submission and trigger the browser's constraint violation warning.
|
||||
*/
|
||||
reportValidity: (input: WebAwesomeFormControl) => boolean;
|
||||
|
||||
/**
|
||||
* A function that maps to the form control's `checkValidity()` function. When the control is invalid, this will return false.
|
||||
* this is helpful is you want to check validation without triggering the native browser constraint violation warning.
|
||||
*/
|
||||
checkValidity: (input: WebAwesomeFormControl) => boolean;
|
||||
/** A function that sets the form control's value */
|
||||
setValue: (input: WebAwesomeFormControl, value: unknown) => void;
|
||||
/**
|
||||
* An array of event names to listen to. When all events in the list are emitted, the control will receive validity
|
||||
* states such as user-valid and user-invalid.user interacted validity states. */
|
||||
assumeInteractionOn: string[];
|
||||
}
|
||||
|
||||
/** A reactive controller to allow form controls to participate in form submission, validation, etc. */
|
||||
export class FormControlController implements ReactiveController {
|
||||
host: WebAwesomeFormControl & ReactiveControllerHost;
|
||||
form?: HTMLFormElement | null;
|
||||
options: FormControlControllerOptions;
|
||||
|
||||
constructor(host: ReactiveControllerHost & WebAwesomeFormControl, options?: Partial<FormControlControllerOptions>) {
|
||||
(this.host = host).addController(this);
|
||||
this.options = {
|
||||
form: input => {
|
||||
// If there's a form attribute, use it to find the target form by id
|
||||
// Controls may not always reflect the 'form' property. For example, `<sl-button>` doesn't reflect.
|
||||
const formId = input.form;
|
||||
|
||||
if (formId) {
|
||||
const root = input.getRootNode() as Document | ShadowRoot | HTMLElement;
|
||||
const form = root.querySelector(`#${formId}`);
|
||||
|
||||
if (form) {
|
||||
return form as HTMLFormElement;
|
||||
}
|
||||
}
|
||||
|
||||
return input.closest('form');
|
||||
},
|
||||
name: input => input.name || '',
|
||||
value: input => input.value,
|
||||
defaultValue: input => input.defaultValue,
|
||||
disabled: input => input.disabled ?? false,
|
||||
reportValidity: input => (typeof input.reportValidity === 'function' ? input.reportValidity() : true),
|
||||
checkValidity: input => (typeof input.checkValidity === 'function' ? input.checkValidity() : true),
|
||||
setValue: (input, value: string) => (input.value = value),
|
||||
assumeInteractionOn: ['wa-input'],
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
const form = this.options.form(this.host);
|
||||
|
||||
if (form) {
|
||||
this.attachForm(form);
|
||||
}
|
||||
|
||||
// Listen for interactions
|
||||
interactions.set(this.host, []);
|
||||
this.options.assumeInteractionOn.forEach(event => {
|
||||
this.host.addEventListener(event, this.handleInteraction);
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.detachForm();
|
||||
|
||||
// Clean up interactions
|
||||
interactions.delete(this.host);
|
||||
this.options.assumeInteractionOn.forEach(event => {
|
||||
this.host.removeEventListener(event, this.handleInteraction);
|
||||
});
|
||||
}
|
||||
|
||||
hostUpdated() {
|
||||
const form = this.options.form(this.host);
|
||||
|
||||
// Detach if the form no longer exists
|
||||
if (!form) {
|
||||
this.detachForm();
|
||||
}
|
||||
|
||||
// If the form has changed, reattach it
|
||||
if (form && this.form !== form) {
|
||||
this.detachForm();
|
||||
this.attachForm(form);
|
||||
}
|
||||
|
||||
if (this.host.hasUpdated) {
|
||||
this.setValidity(this.host.validity.valid);
|
||||
}
|
||||
}
|
||||
|
||||
private attachForm(form?: HTMLFormElement) {
|
||||
if (form) {
|
||||
this.form = form;
|
||||
|
||||
// Add this element to the form's collection
|
||||
if (formCollections.has(this.form)) {
|
||||
formCollections.get(this.form)!.add(this.host);
|
||||
} else {
|
||||
formCollections.set(this.form, new Set<WebAwesomeFormControl>([this.host]));
|
||||
}
|
||||
|
||||
this.form.addEventListener('formdata', this.handleFormData);
|
||||
this.form.addEventListener('submit', this.handleFormSubmit);
|
||||
this.form.addEventListener('reset', this.handleFormReset);
|
||||
|
||||
// Overload the form's reportValidity() method so it looks at Web Awesome form controls
|
||||
if (!reportValidityOverloads.has(this.form)) {
|
||||
reportValidityOverloads.set(this.form, this.form.reportValidity);
|
||||
this.form.reportValidity = () => this.reportFormValidity();
|
||||
}
|
||||
|
||||
// Overload the form's checkValidity() method so it looks at Web Awesome form controls
|
||||
if (!checkValidityOverloads.has(this.form)) {
|
||||
checkValidityOverloads.set(this.form, this.form.checkValidity);
|
||||
this.form.checkValidity = () => this.checkFormValidity();
|
||||
}
|
||||
} else {
|
||||
this.form = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private detachForm() {
|
||||
if (!this.form) return;
|
||||
|
||||
const formCollection = formCollections.get(this.form);
|
||||
|
||||
if (!formCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove this host from the form's collection
|
||||
formCollection.delete(this.host);
|
||||
|
||||
// Check to make sure there's no other form controls in the collection. If we do this
|
||||
// without checking if any other controls are still in the collection, then we will wipe out the
|
||||
// validity checks for all other elements.
|
||||
// see: https://github.com/shoelace-style/shoelace/issues/1703
|
||||
if (formCollection.size <= 0) {
|
||||
this.form.removeEventListener('formdata', this.handleFormData);
|
||||
this.form.removeEventListener('submit', this.handleFormSubmit);
|
||||
this.form.removeEventListener('reset', this.handleFormReset);
|
||||
|
||||
// Remove the overload and restore the original method
|
||||
if (reportValidityOverloads.has(this.form)) {
|
||||
this.form.reportValidity = reportValidityOverloads.get(this.form)!;
|
||||
reportValidityOverloads.delete(this.form);
|
||||
}
|
||||
|
||||
if (checkValidityOverloads.has(this.form)) {
|
||||
this.form.checkValidity = checkValidityOverloads.get(this.form)!;
|
||||
checkValidityOverloads.delete(this.form);
|
||||
}
|
||||
|
||||
// So it looks weird here to not always set the form to undefined. But I _think_ if we unattach this.form here,
|
||||
// we end up in this fun spot where future validity checks don't have a reference to the form validity handler.
|
||||
// First form element in sets the validity handler. So we can't clean up `this.form` until there are no other form elements in the form.
|
||||
this.form = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private handleFormData = (event: FormDataEvent) => {
|
||||
const disabled = this.options.disabled(this.host);
|
||||
const name = this.options.name(this.host);
|
||||
const value = this.options.value(this.host);
|
||||
|
||||
// For buttons, we only submit the value if they were the submitter. This is currently done in doAction() by
|
||||
// injecting the name/value on a temporary button, so we can just skip them here.
|
||||
const isButton = this.host.tagName.toLowerCase() === 'wa-button';
|
||||
|
||||
if (
|
||||
this.host.isConnected &&
|
||||
!disabled &&
|
||||
!isButton &&
|
||||
typeof name === 'string' &&
|
||||
name.length > 0 &&
|
||||
typeof value !== 'undefined'
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
(value as unknown[]).forEach(val => {
|
||||
event.formData.append(name, (val as string | number | boolean).toString());
|
||||
});
|
||||
} else {
|
||||
event.formData.append(name, (value as string | number | boolean).toString());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handleFormSubmit = (event: Event) => {
|
||||
const disabled = this.options.disabled(this.host);
|
||||
const reportValidity = this.options.reportValidity;
|
||||
|
||||
// Update the interacted state for all controls when the form is submitted
|
||||
if (this.form && !this.form.noValidate) {
|
||||
formCollections.get(this.form)?.forEach(control => {
|
||||
this.setUserInteracted(control, true);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.form && !this.form.noValidate && !disabled && !reportValidity(this.host)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
private handleFormReset = () => {
|
||||
this.options.setValue(this.host, this.options.defaultValue(this.host));
|
||||
this.setUserInteracted(this.host, false);
|
||||
interactions.set(this.host, []);
|
||||
};
|
||||
|
||||
private handleInteraction = (event: Event) => {
|
||||
const emittedEvents = interactions.get(this.host)!;
|
||||
|
||||
if (!emittedEvents.includes(event.type)) {
|
||||
emittedEvents.push(event.type);
|
||||
}
|
||||
|
||||
// Mark it as user-interacted as soon as all associated events have been emitted
|
||||
if (emittedEvents.length === this.options.assumeInteractionOn.length) {
|
||||
this.setUserInteracted(this.host, true);
|
||||
}
|
||||
};
|
||||
|
||||
private checkFormValidity = () => {
|
||||
//
|
||||
// This is very similar to the `reportFormValidity` function, but it does not trigger native constraint validation
|
||||
// Allow the user to simply check if the form is valid and handling validity in their own way.
|
||||
//
|
||||
// We preserve the original method in a WeakMap, but we don't call it from the overload because that would trigger
|
||||
// validations in an unexpected order. When the element disconnects, we revert to the original behavior. This won't
|
||||
// be necessary once we can use ElementInternals.
|
||||
//
|
||||
// Note that we're also honoring the form's novalidate attribute.
|
||||
//
|
||||
if (this.form && !this.form.noValidate) {
|
||||
// This seems sloppy, but checking all elements will cover native inputs, Web Awesome inputs, and other custom
|
||||
// elements that support the constraint validation API.
|
||||
const elements = this.form.querySelectorAll<HTMLInputElement>('*');
|
||||
|
||||
for (const element of elements) {
|
||||
if (typeof element.checkValidity === 'function') {
|
||||
if (!element.checkValidity()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private reportFormValidity = () => {
|
||||
//
|
||||
// Web Awesome form controls work hard to act like regular form controls. They support the Constraint Validation API
|
||||
// and its associated methods such as setCustomValidity() and reportValidity(). However, the HTMLFormElement also
|
||||
// has a reportValidity() method that will trigger validation on all child controls. Since we're not yet using
|
||||
// ElementInternals, we need to overload this method so it looks for any element with the reportValidity() method.
|
||||
//
|
||||
// We preserve the original method in a WeakMap, but we don't call it from the overload because that would trigger
|
||||
// validations in an unexpected order. When the element disconnects, we revert to the original behavior. This won't
|
||||
// be necessary once we can use ElementInternals.
|
||||
//
|
||||
// Note that we're also honoring the form's novalidate attribute.
|
||||
//
|
||||
if (this.form && !this.form.noValidate) {
|
||||
// This seems sloppy, but checking all elements will cover native inputs, Web Awesome inputs, and other custom
|
||||
// elements that support the constraint validation API.
|
||||
const elements = this.form.querySelectorAll<HTMLInputElement>('*');
|
||||
|
||||
for (const element of elements) {
|
||||
if (typeof element.reportValidity === 'function') {
|
||||
if (!element.reportValidity()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private setUserInteracted(el: WebAwesomeFormControl, hasInteracted: boolean) {
|
||||
if (hasInteracted) {
|
||||
userInteractedControls.add(el);
|
||||
} else {
|
||||
userInteractedControls.delete(el);
|
||||
}
|
||||
|
||||
el.requestUpdate();
|
||||
}
|
||||
|
||||
private doAction(type: 'submit' | 'reset', submitter?: HTMLInputElement | WaButton) {
|
||||
if (this.form) {
|
||||
const button = document.createElement('button');
|
||||
button.type = type;
|
||||
button.style.position = 'absolute';
|
||||
button.style.width = '0';
|
||||
button.style.height = '0';
|
||||
button.style.clipPath = 'inset(50%)';
|
||||
button.style.overflow = 'hidden';
|
||||
button.style.whiteSpace = 'nowrap';
|
||||
|
||||
// Pass name, value, and form attributes through to the temporary button
|
||||
if (submitter) {
|
||||
button.name = submitter.name;
|
||||
button.value = submitter.value;
|
||||
|
||||
['formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget'].forEach(attr => {
|
||||
if (submitter.hasAttribute(attr)) {
|
||||
button.setAttribute(attr, submitter.getAttribute(attr)!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.form.append(button);
|
||||
button.click();
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the associated `<form>` element, if one exists. */
|
||||
getForm() {
|
||||
return this.form ?? null;
|
||||
}
|
||||
|
||||
/** Resets the form, restoring all the control to their default value */
|
||||
reset(submitter?: HTMLInputElement | WaButton) {
|
||||
this.doAction('reset', submitter);
|
||||
}
|
||||
|
||||
/** Submits the form, triggering validation and form data injection. */
|
||||
submit(submitter?: HTMLInputElement | WaButton) {
|
||||
// Calling form.submit() bypasses the submit event and constraint validation. To prevent this, we can inject a
|
||||
// native submit button into the form, "click" it, then remove it to simulate a standard form submission.
|
||||
this.doAction('submit', submitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously sets the form control's validity. Call this when you know the future validity but need to update
|
||||
* the host element immediately, i.e. before Lit updates the component in the next update.
|
||||
*/
|
||||
setValidity(isValid: boolean) {
|
||||
const host = this.host;
|
||||
const hasInteracted = Boolean(userInteractedControls.has(host));
|
||||
const required = Boolean(host.required);
|
||||
|
||||
//
|
||||
// We're mapping the following "states" to data attributes. In the future, we can use ElementInternals.states to
|
||||
// create a similar mapping, but instead of [data-invalid] it will look like :--invalid.
|
||||
//
|
||||
// See this RFC for more details: https://github.com/shoelace-style/shoelace/issues/1011
|
||||
//
|
||||
host.toggleAttribute('data-required', required);
|
||||
host.toggleAttribute('data-optional', !required);
|
||||
host.toggleAttribute('data-invalid', !isValid);
|
||||
host.toggleAttribute('data-valid', isValid);
|
||||
host.toggleAttribute('data-user-invalid', !isValid && hasInteracted);
|
||||
host.toggleAttribute('data-user-valid', isValid && hasInteracted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the form control's validity based on the current value of `host.validity.valid`. Call this when anything
|
||||
* that affects constraint validation changes so the component receives the correct validity states.
|
||||
*/
|
||||
updateValidity() {
|
||||
const host = this.host;
|
||||
this.setValidity(host.validity.valid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a non-bubbling, cancelable custom event of type `wa-invalid`.
|
||||
* If the `wa-invalid` event will be cancelled then the original `invalid`
|
||||
* event (which may have been passed as argument) will also be cancelled.
|
||||
* If no original `invalid` event has been passed then the `wa-invalid`
|
||||
* event will be cancelled before being dispatched.
|
||||
*/
|
||||
emitInvalidEvent(originalInvalidEvent?: Event) {
|
||||
const slInvalidEvent = new CustomEvent<Record<PropertyKey, never>>('wa-invalid', {
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
cancelable: true,
|
||||
detail: {}
|
||||
});
|
||||
|
||||
if (!originalInvalidEvent) {
|
||||
slInvalidEvent.preventDefault();
|
||||
}
|
||||
|
||||
if (!this.host.dispatchEvent(slInvalidEvent)) {
|
||||
originalInvalidEvent?.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Predefined common validity states.
|
||||
* All of them are read-only.
|
||||
*/
|
||||
|
||||
// A validity state object that represents `valid`
|
||||
export const validValidityState: ValidityState = Object.freeze({
|
||||
badInput: false,
|
||||
customError: false,
|
||||
patternMismatch: false,
|
||||
rangeOverflow: false,
|
||||
rangeUnderflow: false,
|
||||
stepMismatch: false,
|
||||
tooLong: false,
|
||||
tooShort: false,
|
||||
typeMismatch: false,
|
||||
valid: true,
|
||||
valueMissing: false
|
||||
});
|
||||
|
||||
// A validity state object that represents `value missing`
|
||||
export const valueMissingValidityState: ValidityState = Object.freeze({
|
||||
...validValidityState,
|
||||
valid: false,
|
||||
valueMissing: true
|
||||
});
|
||||
|
||||
// A validity state object that represents a custom error
|
||||
export const customErrorValidityState: ValidityState = Object.freeze({
|
||||
...validValidityState,
|
||||
valid: false,
|
||||
customError: true
|
||||
});
|
||||
@@ -115,7 +115,7 @@ export interface WebAwesomeFormControl extends WebAwesomeElement {
|
||||
checked?: boolean;
|
||||
defaultSelected?: boolean;
|
||||
selected?: boolean;
|
||||
form?: string;
|
||||
form?: string | null;
|
||||
|
||||
// Constraint validation attributes
|
||||
pattern?: string;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { formCollections } from '../internal/form.js';
|
||||
|
||||
/**
|
||||
* Serializes a form and returns a plain object. If a form control with the same name appears more than once, the
|
||||
* property will be converted to an array.
|
||||
@@ -23,23 +21,3 @@ export function serialize(form: HTMLFormElement) {
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all form controls that are associated with the specified form. Includes both native and Web Awesome form
|
||||
* controls. Use this function in lieu of the `HTMLFormElement.elements` property, which doesn't recognize Web Awesome
|
||||
* form controls.
|
||||
*/
|
||||
export function getFormControls(form: HTMLFormElement) {
|
||||
const rootNode = form.getRootNode() as Document | ShadowRoot;
|
||||
const allNodes = [...rootNode.querySelectorAll('*')];
|
||||
const formControls = [...form.elements];
|
||||
const collection = formCollections.get(form);
|
||||
const waFormControls = collection ? Array.from(collection) : [];
|
||||
|
||||
// To return form controls in the right order, we sort by DOM index
|
||||
return [...formControls, ...waFormControls].sort((a: Element, b: Element) => {
|
||||
if (allNodes.indexOf(a) < allNodes.indexOf(b)) return -1;
|
||||
if (allNodes.indexOf(a) > allNodes.indexOf(b)) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user