mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-15 21:49:15 +00:00
Compare commits
10 Commits
native-cod
...
konnorroge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcba3723d | ||
|
|
45f0c4c149 | ||
|
|
7c3a3a3092 | ||
|
|
16c0b60663 | ||
|
|
cd2f170a2e | ||
|
|
1cfac1158a | ||
|
|
904271cf52 | ||
|
|
2781a8d753 | ||
|
|
5db7144f6f | ||
|
|
793dfad82e |
@@ -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>
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user