Compare commits

...

10 Commits

Author SHA1 Message Date
konnorrogers
2dcba3723d Fix styling 2024-10-23 12:25:54 -04:00
konnorrogers
45f0c4c149 prettier 2024-10-21 16:53:53 -04:00
konnorrogers
7c3a3a3092 prettier 2024-10-21 16:49:07 -04:00
konnorrogers
16c0b60663 prettier 2024-10-21 16:40:47 -04:00
konnorrogers
cd2f170a2e working on form validations 2024-10-21 16:34:19 -04:00
konnorrogers
1cfac1158a prettier 2024-10-18 12:59:01 -04:00
konnorrogers
904271cf52 fixing validation errors 2024-10-18 12:58:45 -04:00
konnorrogers
2781a8d753 working on validations 2024-10-18 12:10:31 -04:00
konnorrogers
5db7144f6f working on validations 2024-10-17 17:25:05 -04:00
konnorrogers
793dfad82e prettier 2024-10-15 17:58:47 -04:00
20 changed files with 464 additions and 126 deletions

View File

@@ -4,6 +4,8 @@ description: TODO
layout: page
---
## Styling Validations
Adding the `wa-valid` or `wa-invalid` class to a form control will change its appearance. This is useful for applying validation styles to server-rendered form controls.
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
@@ -47,4 +49,129 @@ Adding the `wa-valid` or `wa-invalid` class to a form control will change its ap
</wa-radio-group><br>
<wa-button variant="brand">Submit Form</wa-button>
</div>
</div>
</div>
<br><br>
## Understanding Form Validations with Web Awesome
There are 2 types of errors attached to every instance of Web Awesome form control.
There are `server-error`s which will not affect a form's validation and will still allow a user to submit the form. Server errors are always present until cleared, either manually by doing `element.serverError = null` or by attempting to submit a form, regardless of the if the form passes / fails client validation. If you are using `requestSubmit()` to submit forms, its recommended you clear the serverErrors yourself.
- Forcing "user-invalid" and showing the client error
```js
el.hasInteracted = true // This makes the element `[data-wa-user-invalid]` or `:state(user-invalid)` in supported browsers.
el.showClientError = true // This makes the client error "visible"
```
- Escape hatch using attributes? `<wa-input use-native-validation>` (Not implemented)
- How to clear server errors prior to `requestSubmit`
```js
form.elements.forEach((el) => el.serverError = null)
form.requestSubmit(submitter)
```
The other type of error is `client-error`. Client errors *DO* affect form validation, and if a client error is present on a form control, it will prevent the form from submitting and fail validation. Client errors can be cleared either by doing `setCustomValidity("")` or by doing `el.customError = null`. Client errors will not prevent form submissions on both `disabled` or `readonly` elements.
- Styling the error validation: `wa-input::part(form-control-error-message) {}`
## Not implemented (yet) features
- Moving the validation around: `<wa-input error-placement="top | bottom">` (Not implemented)
- Slotting in error messages? `waInput.[server|client]Error = true | <wa-input [server|client]-error="">` and then show the "slotted" error?
(Not Implemented)
```html
<wa-input>
<div slot="server-error"></div>
<div slot="client-error"></div>
</wa-input>
```
### Mixed native and web awesome controls.
Web Awesome form controls will work seamlessly with native form controls. Do note, the first element that has a validation error will be the element that gets "focused". Here's an example below:
```html {.example}
Validating form with both native controls and web awesome form controls.
<br>
<form id="client-error-form">
<label>
Account #
<br>
<input name="account-number" required>
</label>
<br>
<wa-input name="name" client-error="im a client error" label="Name" help-text="I am help text" required></wa-input>
<br>
<input required>
<br><br>
<wa-button type="submit">Submit</wa-button>
<wa-button id="clear-client-error" type="button" appearance="outline">Clear custom client error</wa-button>
</form>
<script type="module">
document.querySelector("#clear-client-error").addEventListener("click", () => {
const form = document.querySelector("#client-error-form");
[...form.elements].forEach((el) => el.clientError = null)
})
</script>
```
And here's another example with the native control first showing its "popup" form validation thats native to the platform.
```html {.example}
Validate form with native controls.
<form>
<br>
<label>
Account #
<br>
<input name="account-number" required>
</label>
<br>
<wa-input name="email" server-error="I'm a server error" label="Email" required></wa-input>
<br>
<wa-input name="name" label="Name" help-text="I am help text" required></wa-input>
<br><br>
<wa-button type="submit">Submit</wa-button>
</form>
```
### With `novalidate` on your form
When `novalidate` is added to your form, validation errors will not prevent the form from submitting. However, their validations will
still be run in the background and attached the `:state(user-invalid)` to the form control, but it will not show the error message for the form control.
```html {.example}
<style>
input:user-invalid { background: red; }
</style>
With `novalidate` on the form:
<form id="novalidate" novalidate>
<br>
<wa-input name="email" server-error="I'm a server error" label="Email" required></wa-input>
<br>
<wa-input novalidate name="name" client-error="im a client error" label="Name" help-text="I am help text" required></wa-input>
<br>
<label>
Account #
<br>
<input name="account-number" required>
</label>
<br><br>
<wa-button type="submit">Submit</wa-button>
</form>
<script>
// This is just here so you don't navigate.
novalidate.addEventListener("submit", (e) => e.preventDefault())
</script>
```

View File

@@ -176,4 +176,7 @@ These attributes map to the browser's built-in pseudo classes for validation: [`
:::info
In the future, data attribute selectors will be replaced with custom states such as `:state(valid)` and `:state(invalid)`. Web Awesome is using data attributes as a workaround until browsers fully support [custom states](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states).
:::
:::
For more on form validations, check out [/docs/form-validations](/docs/form-validations)

View File

@@ -127,12 +127,6 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
@property() download?: string;
/**
* The "form owner" to associate the button with. If omitted, the closest containing form will be used instead. The
* value of this attribute must be an id of a form in the same document or shadow root as the button.
*/
@property({ reflect: true }) form: string | null = null;
/** Used to override the form owner's `action` attribute. */
@property({ attribute: 'formaction' }) formAction: string;

View File

@@ -201,7 +201,7 @@ describe('<wa-checkbox>', () => {
expect(checkbox.checkValidity()).to.be.false;
expect(checkbox.hasAttribute('data-wa-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-valid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(checkbox);

View File

@@ -116,13 +116,6 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
/** The default value of the form control. Primarily used for resetting the form control. */
@property({ type: Boolean, reflect: true, attribute: 'checked' }) defaultChecked = this.hasAttribute('checked');
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = null;
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;

View File

@@ -233,13 +233,6 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
*/
@property() swatches: string | string[] = '';
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = null;
/** Makes the color picker a required field. */
@property({ type: Boolean, reflect: true }) required = false;

View File

@@ -12,6 +12,18 @@ export default css`
display: block;
}
:host(:is([server-error], [data-wa-user-invalid])) {
--background-color: var(--wa-color-danger-fill-quiet);
--border-color: var(--wa-color-danger-border-loud);
}
.form-control__error-message {
display: flex;
align-items: center;
gap: var(--wa-space-2xs);
color: var(--wa-color-danger-on-normal);
}
:host([filled]) {
--background-color: var(--wa-color-neutral-fill-quiet);
--border-color: var(--background-color);

View File

@@ -1,8 +1,8 @@
// eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
import { aTimeout, expect, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement, isSafari } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { isSafari } from '../../internal/test.js';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import { sendKeys } from '@web/test-runner-commands'; // must come from the same module
import { serialize } from '../../../dist-cdn/webawesome.js';
@@ -549,6 +549,109 @@ describe('<wa-input>', () => {
expect(el.checkValidity()).to.be.false;
expect(el.validity.tooLong).to.be.true;
});
describe('Validation tests', () => {
it('Should show a validation error and focus the element on error', async () => {
const form = await fixture(html`
<form>
<wa-input required></wa-input>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const input = form.querySelector('wa-input')!;
const submitButton = form.querySelector("[type='submit']")!;
const errorElement = input.shadowRoot!.querySelector("[part~='form-control-error-message']")!;
await aTimeout(1);
expect(errorElement.checkVisibility()).to.be.false;
await clickOnElement(submitButton);
await aTimeout(1);
expect(errorElement.checkVisibility()).to.be.true;
expect(document.activeElement).to.equal(input);
});
it('Should show a validation error and focus the element on error', async () => {
const form = await fixture(html`
<form>
<wa-input required></wa-input>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const input = form.querySelector('wa-input')!;
const submitButton = form.querySelector("[type='submit']")!;
const errorElement = input.shadowRoot!.querySelector("[part~='form-control-error-message']")!;
await aTimeout(1);
expect(errorElement.checkVisibility()).to.be.false;
await clickOnElement(submitButton);
await aTimeout(1);
expect(errorElement.checkVisibility()).to.be.true;
expect(document.activeElement).to.equal(input);
});
it('Should work with native elements', async () => {
const form = await fixture(html`
<form>
<input required></wa-input>
<wa-input required></wa-input>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const waInput = form.querySelector('wa-input')!;
const nativeInput = form.querySelector('input')!;
const submitButton = form.querySelector("[type='submit']")!;
const errorElement = waInput.shadowRoot!.querySelector("[part~='form-control-error-message']")!;
await aTimeout(1);
expect(errorElement.checkVisibility()).to.be.false;
await clickOnElement(submitButton);
await aTimeout(1);
expect(errorElement.checkVisibility()).to.be.true;
// Should focus the native input on form error
expect(document.activeElement).to.equal(nativeInput);
await sendKeys({ type: 'Hello World' });
await clickOnElement(submitButton);
await aTimeout(1);
// Should focus the waInput now that native input is valid.
expect(document.activeElement).to.equal(waInput);
});
it('Should show all validation errors on attempted submit', async () => {
const form = await fixture(html`
<form>
<wa-input required></wa-input>
<wa-input required></wa-input>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const inputs = [...form.querySelectorAll('wa-input')];
const submitButton = form.querySelector("[type='submit']")!;
const errorElements = inputs.map(
input => input.shadowRoot!.querySelector("[part~='form-control-error-message']")!
);
await aTimeout(1);
errorElements.forEach(errorElement => {
expect(errorElement.checkVisibility()).to.be.false;
});
await clickOnElement(submitButton);
await aTimeout(1);
errorElements.forEach(errorElement => {
expect(errorElement.checkVisibility()).to.be.true;
});
expect(document.activeElement).to.equal(inputs[0]);
});
});
});
}
});

View File

@@ -17,7 +17,7 @@ import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-eleme
import componentStyles from '../../styles/component.styles.js';
import formControlStyles from '../../styles/form-control.styles.js';
import styles from './input.styles.js';
import type { CSSResultGroup } from 'lit';
import type { CSSResultGroup, PropertyValues } from 'lit';
import type WaButton from '../button/button.js';
/**
@@ -153,13 +153,6 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = null;
/** Makes the input a required field. */
@property({ type: Boolean, reflect: true }) required = false;
@@ -234,6 +227,22 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
this.dispatchEvent(new WaBlurEvent());
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
// This is a really ridiculous hack in Safari + VoiceOver to get around dynamic describedby labels.
// https://a11ysupport.io/tests/tech__aria__aria-describedby#assertion-aria-aria-describedby_attribute-convey_description_change-html-input(type-text)_element-vo_macos-safari
if (changedProperties.has('serverError') || changedProperties.has('clientError')) {
const errorMessageContainer = this.shadowRoot?.querySelector<HTMLElement>('#error-message');
if (errorMessageContainer) {
errorMessageContainer.style.display = 'none';
setTimeout(() => {
errorMessageContainer.style.display = '';
});
}
}
super.willUpdate(changedProperties);
}
private handleChange() {
this.value = this.input.value;
this.dispatchEvent(new WaChangeEvent());
@@ -276,7 +285,7 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
const form = this.getForm();
const form = this.form;
if (!form) {
return;
@@ -403,6 +412,15 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
(isServer || this.hasUpdated) &&
hasClearIcon &&
(typeof this.value === 'number' || (this.value && this.value.length > 0));
const hasValidationError = Boolean(
this.serverError || ((this.clientError || this.validationMessage) && this.willValidate && this.showClientError)
);
const validationMessage =
this.serverError ||
this.clientError ||
(this.hasUpdated && this.validationMessage ? this.validationMessage : '') ||
'';
return html`
<div
@@ -425,6 +443,19 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
<slot name="label">${this.label}</slot>
</label>
<div
id="error-message"
aria-live="assertive"
part="form-control-error-message"
class="form-control__error-message"
?hidden=${!hasValidationError}
>
<slot name="error-prefix"><wa-icon name="circle-exclamation"></wa-icon></slot>
<span part="form-control-error-text"
><slot name="error-text">${hasValidationError ? validationMessage : ''}</slot></span
>
</div>
<div part="form-control-input" class="form-control-input">
<div
part="base"
@@ -475,12 +506,17 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
pattern=${ifDefined(this.pattern)}
enterkeyhint=${ifDefined(this.enterkeyhint)}
inputmode=${ifDefined(this.inputmode)}
aria-describedby="help-text"
${
'' /** Eventually error-text should move to aria-errormessage once screen reader support is better. https://a11ysupport.io/tech/aria/aria-errormessage_attribute */
}
aria-describedby="help-text error-message"
aria-errormessage="error-message"
@change=${this.handleChange}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
aria-invalid=${ifDefined(hasValidationError ? 'true' : null)}
/>
${isClearIconVisible

View File

@@ -79,11 +79,6 @@ export default class WaRadioButton extends WebAwesomeFormAssociatedElement {
/** Draws a pill-style radio button with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
/**
* The string pointing to a form's id.
*/
@property({ reflect: true }) form: string | null = null;
/**
* Used for SSR. if true, will show slotted prefix on initial render.
*/

View File

@@ -20,16 +20,4 @@ export default css`
content: var(--wa-form-control-required-content);
margin-inline-start: var(--wa-form-control-required-content-offset);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`;

View File

@@ -47,11 +47,6 @@ export default class WaRadio extends WebAwesomeFormAssociatedElement {
@state() checked = false;
@state() protected hasFocus = false;
/**
* The string pointing to a form's id.
*/
@property({ reflect: true }) form: string | null = null;
/** The radio's value. When selected, the radio group will receive this value. */
@property({ reflect: true }) value: string;

View File

@@ -123,13 +123,6 @@ export default class WaRange extends WebAwesomeFormAssociatedElement {
*/
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form: null | string = null;
/**
* Used for SSR to render slotted labels. If true, will render slotted label content on first paint.
*/

View File

@@ -225,13 +225,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
*/
@property({ attribute: 'with-help-text', type: Boolean }) withHelpText = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = null;
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;

View File

@@ -105,13 +105,6 @@ export default class WaSwitch extends WebAwesomeFormAssociatedElement {
/** The default value of the form control. Primarily used for resetting the form control. */
@property({ type: Boolean, attribute: 'checked', reflect: true }) defaultChecked = this.hasAttribute('checked');
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = null;
/** Makes the switch a required field. */
@property({ type: Boolean, reflect: true }) required = false;

View File

@@ -115,13 +115,6 @@ export default class WaTextarea extends WebAwesomeFormAssociatedElement {
/** Makes the textarea readonly. */
@property({ type: Boolean, reflect: true }) readonly = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = null;
/** Makes the textarea a required field. */
@property({ type: Boolean, reflect: true }) required = false;

View File

@@ -178,20 +178,20 @@ function runAllValidityTests(
expect(control.getForm()).to.equal(form);
});
it('Should be invalid if a `customError` property is passed.', async () => {
it('Should be invalid if a `clientError` property is passed.', async () => {
const control = await createControl();
// expect(control.validity.valid).to.equal(true)
control.customError = 'MyError';
control.clientError = 'MyError';
await control.updateComplete;
expect(control.validity.valid).to.equal(false);
expect(control.hasAttribute('data-wa-invalid')).to.equal(true);
expect(control.validationMessage).to.equal('MyError');
});
it('Should be invalid if a `customError` attribute is passed.', async () => {
it('Should be invalid if a `clientError` attribute is passed.', async () => {
const control = await createControl();
// expect(control.validity.valid).to.equal(true)
control.setAttribute('custom-error', 'MyError');
control.setAttribute('client-error', 'MyError');
await control.updateComplete;
expect(control.hasAttribute('data-wa-invalid')).to.equal(true);
expect(control.validationMessage).to.equal('MyError');
@@ -218,25 +218,25 @@ function runAllValidityTests(
expect(control.hasAttribute('data-wa-disabled')).to.equal(false);
});
// it("This is the one edge case with ':disabled'. If you disable a fieldset, and then disable the element directly, it will not reflect the disabled attribute.", async () => {
// const control = await createControl();
// const fieldset = await fixture<HTMLFieldSetElement>(html`<fieldset></fieldset>`)
// expect(control.disabled).to.equal(false)
// fieldset.append(control)
// fieldset.disabled = true
// await control.updateComplete
// expect(control.disabled).to.equal(true)
// expect(control.hasAttribute("disabled")).to.equal(false)
// expect(control.matches(":disabled")).to.equal(true)
it.skip("This is the one edge case with ':disabled'. If you disable a fieldset, and then disable the element directly, it will not reflect the disabled attribute.", async () => {
const control = await createControl();
const fieldset = await fixture<HTMLFieldSetElement>(html`<fieldset></fieldset>`);
expect(control.disabled).to.equal(false);
fieldset.append(control);
fieldset.disabled = true;
await control.updateComplete;
expect(control.disabled).to.equal(false);
// expect(control.hasAttribute("disabled")).to.equal(false)
expect(control.matches(':disabled')).to.equal(true);
// control.disabled = true // This wont set the `disabled` attribute.
// fieldset.disabled = false
// await control.updateComplete
// expect(control.disabled).to.equal(true)
// expect(control.hasAttribute("disabled")).to.equal(true)
// expect(control.matches(":disabled")).to.equal(true)
// })
control.disabled = true; // This wont set the `disabled` attribute.
await control.updateComplete;
fieldset.disabled = false;
await control.updateComplete;
expect(control.disabled).to.equal(true);
// expect(control.hasAttribute("disabled")).to.equal(true)
expect(control.matches(':disabled')).to.equal(true);
});
it('Should reflect the disabled attribute if its attribute is directly added', async () => {
const control = await createControl();

View File

@@ -14,8 +14,8 @@ export const CustomErrorValidator = (): Validator => {
invalidKeys: []
};
if (element.customError) {
validity.message = element.customError;
if (element.clientError) {
validity.message = element.clientError;
validity.isValid = false;
validity.invalidKeys = ['customError'];
}

View File

@@ -94,7 +94,9 @@ export interface WebAwesomeFormControl extends WebAwesomeElement {
checked?: boolean;
defaultSelected?: boolean;
selected?: boolean;
form?: string | null;
get form(): HTMLFormElement | null;
set form(formId: string | null);
value?: unknown;
@@ -121,14 +123,88 @@ export interface WebAwesomeFormControl extends WebAwesomeElement {
hasInteracted: boolean;
valueHasChanged?: boolean;
/** Convenience API for `setCustomValidity()` */
customError: null | string;
/** Convenience API for `setCustomValidity()`. This will prevent form submissions. */
clientError: null | string;
/**
* Takes precedence over `clientError`. Will get cleared when you attempt to submit a form.
*/
serverError: null | string;
}
/**
* Single event, hoisted out of the component for deduping on root nodes.
*/
function handleSubmitAttempt(e: Event) {
// This may need to change if we ever get the ability to use custom element buttons as submitters.
// "[type='submit'], button:not([type='button'], [type='reset'])"
const submitter = (e.target as Element).closest<HTMLButtonElement>("button:not([type='button'], [type='reset'])");
const form = submitter?.form;
if (!form) {
return;
}
if (form.elements?.length <= 0) {
return;
}
const willValidate = !(submitter.formNoValidate || form.noValidate);
for (const element of submitter.form.elements) {
if ('showClientError' in element) {
element.showClientError = willValidate;
}
if ('hasInteracted' in element) {
element.hasInteracted = true;
}
if ('serverError' in element) {
element.serverError = null;
}
}
if (!willValidate) {
return;
}
e.preventDefault();
const rootNode = submitter.getRootNode();
rootNode.addEventListener('invalid', handleInvalidSubmit, { capture: true });
setTimeout(() => {
rootNode.removeEventListener('invalid', handleInvalidSubmit, { capture: true });
}, 10);
form.requestSubmit(submitter);
}
function handleInvalidSubmit(e: Event) {
const elements = (e.target as unknown as { form: HTMLFormElement | null })?.form?.elements;
if (!elements?.length) {
return;
}
const firstInvalidElement = Array.from(elements).find(el => {
return 'validity' in el && !(el.validity as ValidityState).valid;
}) as HTMLElement;
// We assume having the "clientError" property is one of our elements, or something implementing a similar interface.
if ('clientError' in firstInvalidElement && e.target === firstInvalidElement) {
e.preventDefault();
firstInvalidElement.focus();
return;
}
if (e.target !== firstInvalidElement) {
e.preventDefault();
}
}
// setFormValue omitted so that we can use `setValue`
export class WebAwesomeFormAssociatedElement
extends WebAwesomeElement
implements Omit<ElementInternals, 'form' | 'setFormValue'>, WebAwesomeFormControl
implements Omit<ElementInternals, 'setFormValue'>, WebAwesomeFormControl
{
static formAssociated = true;
@@ -179,9 +255,11 @@ export class WebAwesomeFormAssociatedElement
// Should these be private?
@property({ state: true, attribute: false }) valueHasChanged: boolean = false;
@property({ state: true, attribute: false }) hasInteracted: boolean = false;
@property({ state: true, attribute: false }) showClientError: boolean = false;
// This works around a limitation in Safari. It is a hacky way for us to preserve custom errors generated by the user.
@property({ attribute: 'custom-error', reflect: true }) customError: string | null = null;
@property({ attribute: 'client-error', reflect: true }) clientError: string | null = null;
@property({ attribute: 'server-error', reflect: true }) serverError: string | null = null;
private emittedEvents: string[] = [];
@@ -206,12 +284,19 @@ export class WebAwesomeFormAssociatedElement
super.connectedCallback();
this.updateValidity();
this.getRootNode().addEventListener('click', handleSubmitAttempt, { capture: true });
// Lazily evaluate after the constructor to allow people to override the `assumeInteractionOn`
this.assumeInteractionOn.forEach(event => {
this.addEventListener(event, this.handleInteraction);
});
}
disconnectedCallback() {
this.getRootNode().removeEventListener('click', handleSubmitAttempt, { capture: true });
super.disconnectedCallback();
}
firstUpdated(...args: Parameters<LitElement['firstUpdated']>) {
super.firstUpdated(...args);
this.updateValidity();
@@ -220,18 +305,16 @@ export class WebAwesomeFormAssociatedElement
emitInvalid = (e: Event) => {
if (e.target !== this) return;
// An "invalid" event counts as interacted, this is usually triggered by a button "submitting"
this.hasInteracted = true;
this.dispatchEvent(new WaInvalidEvent());
};
protected willUpdate(changedProperties: Parameters<LitElement['willUpdate']>[0]) {
if (!isServer && changedProperties.has('customError')) {
// We use null because it we really don't want it to show up in the attributes because `custom-error` does reflect
if (!this.customError) {
this.customError = null;
if (!isServer && changedProperties.has('clientError')) {
// We use null because it we really don't want it to show up in the attributes because `` does reflect
if (!this.clientError) {
this.clientError = null;
}
this.setCustomValidity(this.customError || '');
this.setCustomValidity(this.clientError || '');
}
if (changedProperties.has('value') || changedProperties.has('disabled')) {
@@ -280,10 +363,32 @@ export class WebAwesomeFormAssociatedElement
return this.internals.labels;
}
/**
* @deprecated
* `getForm()` has moved to `form` getter to align with the platform.
*/
getForm() {
return this.internals.form;
}
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
get form(): ElementInternals['form'] {
return this.internals.form;
}
@property({ noAccessor: true })
set form(val: string) {
if (val) {
this.setAttribute('form', val);
} else {
this.removeAttribute('form');
}
}
@property({ attribute: false, state: true, type: Object })
get validity() {
return this.internals.validity;
@@ -298,15 +403,21 @@ export class WebAwesomeFormAssociatedElement
return this.internals.validationMessage;
}
/**
* Re-runs validity checks and returns a boolean if the element passes validity. Do note, this *will* fire the "invalid" event.
*/
checkValidity() {
this.updateValidity();
return this.internals.checkValidity();
}
// reportValidity() *does not* trigger :user-invalid on native elements, so we don't either. This will also fire the "invalid" event.
/**
* Use this to trigger native constraint validation with a popup
*/
reportValidity() {
this.updateValidity();
// This seems reasonable. `reportValidity()` is kind of like "we expect you to have interacted"
this.hasInteracted = true;
// this.showClientError = true
return this.internals.reportValidity();
}
@@ -351,13 +462,13 @@ export class WebAwesomeFormAssociatedElement
*/
setCustomValidity(message: string) {
if (!message) {
// We use null because it we really don't want it to show up in the attributes because `custom-error` does reflect
this.customError = null;
// We use null because it we really don't want it to show up in the attributes because `` does reflect
this.clientError = null;
this.setValidity({});
return;
}
this.customError = message;
this.clientError = message;
this.setValidity({ customError: true }, message, this.validationTarget);
}
@@ -365,6 +476,7 @@ export class WebAwesomeFormAssociatedElement
this.resetValidity();
this.hasInteracted = false;
this.valueHasChanged = false;
this.showClientError = false;
this.emittedEvents = [];
this.updateValidity();
}
@@ -372,6 +484,9 @@ export class WebAwesomeFormAssociatedElement
formDisabledCallback(isDisabled: boolean) {
this.disabled = isDisabled;
if (isDisabled) {
this.showClientError = false;
}
this.updateValidity();
}
@@ -434,7 +549,7 @@ export class WebAwesomeFormAssociatedElement
const flags: Partial<ValidityKey> = {
// Don't trust custom errors from the Browser. Safari breaks the spec.
customError: Boolean(this.customError)
customError: Boolean(this.clientError)
};
const formControl = this.validationTarget || this.input || undefined;

View File

@@ -14,4 +14,16 @@ export default css`
[hidden] {
display: none !important;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`;