more work on form association

This commit is contained in:
konnorrogers
2024-03-27 14:03:49 -04:00
parent 6b3d2fe052
commit 23dbadab91
6 changed files with 242 additions and 120 deletions

View File

@@ -5,7 +5,39 @@ layout: ../../../layouts/ComponentLayout.astro
---
```html:preview
<wa-textarea></wa-textarea>
<form id="form-textarea">
<wa-textarea required></wa-textarea>
<button type="reset">Reset</button>
<button>Submit</submit>
</form>
<script>
document.querySelector("#form-textarea wa-textarea").addEventListener("invalid", () => {
console.log("invalid")
})
document.querySelector("#form-textarea wa-textarea").addEventListener("wa-invalid", () => {
console.log("wa-invalid")
})
const control = document.querySelector("wa-textarea")
setTimeout(async () => {
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = [];
control.addEventListener('wa-invalid', e => emittedEvents.push(e));
control.reportValidity();
await new Promise((resolve) => setTimeout(resolve, 1))
console.log(emittedEvents)
}, 1000)
</script>
```
```jsx:react
@@ -156,4 +188,4 @@ Textareas will automatically resize to expand to fit their content when `resize`
import WaTextarea from '@shoelace-style/shoelace/dist/react/textarea';
const App = () => <WaTextarea resize="auto" />;
```
```

View File

@@ -1,18 +1,17 @@
import { classMap } from 'lit/directives/class-map.js';
import { defaultValue } from '../../internal/default-value.js';
import { FormControlController } from '../../internal/form.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
import { property, query, state } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import { WebAwesomeFormAssociated } from '../../internal/webawesome-element.js';
import componentStyles from '../../styles/component.styles.js';
import formControlStyles from '../../styles/form-control.styles.js';
import styles from './textarea.styles.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import type { CSSResultGroup } from 'lit';
import type { WebAwesomeFormControl } from '../../internal/webawesome-element.js';
/**
* @summary Textareas collect data from the user and allow multiple lines of text.
@@ -43,16 +42,19 @@ import type { WebAwesomeFormControl } from '../../internal/webawesome-element.js
* @cssproperty --border-width - The width of the textarea's borders.
* @cssproperty --box-shadow - The shadow effects around the edges of the textarea.
*/
export default class WaTextarea extends WebAwesomeElement implements WebAwesomeFormControl {
export default class WaTextarea extends WebAwesomeFormAssociated {
static formAssociated = true;
static styles: CSSResultGroup = [componentStyles, formControlStyles, styles];
static get validators() {
return [MirrorValidator];
}
private readonly formControlController = new FormControlController(this, {
assumeInteractionOn: ['wa-blur', 'wa-input']
});
assumeInteractionOn = ['wa-blur', 'wa-input'];
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private resizeObserver: ResizeObserver;
@query('.textarea__control') input: HTMLTextAreaElement;
@query('.textarea__control') formControl: HTMLTextAreaElement;
@state() private hasFocus = false;
@property() title = ''; // make reactive to pass through
@@ -95,7 +97,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
* 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 = '';
@property({ reflect: true }) form = null;
/** Makes the textarea a required field. */
@property({ type: Boolean, reflect: true }) required = false;
@@ -144,16 +146,6 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight());
@@ -165,7 +157,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
}
firstUpdated() {
this.formControlController.updateValidity();
this.checkValidity();
}
disconnectedCallback() {
@@ -176,27 +168,26 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
private handleBlur() {
this.hasFocus = false;
this.emit('wa-blur');
this.checkValidity();
}
private handleChange() {
this.value = this.input.value;
this.setTextareaHeight();
this.emit('wa-change');
this.checkValidity();
}
private handleFocus() {
this.hasFocus = true;
this.emit('wa-focus');
this.checkValidity();
}
private handleInput() {
this.value = this.input.value;
this.emit('wa-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
this.checkValidity();
}
private setTextareaHeight() {
@@ -208,12 +199,6 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
}
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid
this.formControlController.setValidity(this.disabled);
}
@watch('rows', { waitUntilFirstUpdate: true })
handleRowsChange() {
this.setTextareaHeight();
@@ -222,7 +207,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
@watch('value', { waitUntilFirstUpdate: true })
async handleValueChange() {
await this.updateComplete;
this.formControlController.updateValidity();
this.checkValidity();
this.setTextareaHeight();
}
@@ -234,6 +219,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
/** Removes focus from the textarea. */
blur() {
this.input.blur();
// this.checkValidity();
}
/** Selects all the text in the textarea. */
@@ -282,27 +268,6 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
}
}
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
/** Gets the associated form, if one exists. */
getForm(): HTMLFormElement | null {
return this.formControlController.getForm();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
}
/** Sets a custom validation message. Pass an empty string to restore validity. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.formControlController.updateValidity();
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
@@ -371,7 +336,6 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
></textarea>

View File

@@ -205,7 +205,7 @@ describe('<wa-textarea>', () => {
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const textarea = await fixture<HTMLFormElement>(html` <wa-textarea></wa-textarea> `);
const textarea = await fixture<HTMLFormElement>(html`<wa-textarea></wa-textarea>`);
textarea.setCustomValidity('Invalid selection');
await textarea.updateComplete;

View File

@@ -1,4 +1,4 @@
import { expect, fixture } from '@open-wc/testing';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import type { WebAwesomeFormControl } from '../webawesome-element.js';
type CreateControlFn = () => Promise<WebAwesomeFormControl>;
@@ -274,7 +274,11 @@ function runSpecialTests_standard(createControl: CreateControlFn) {
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'wa-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(1);
control.reportValidity();
// 2 is the expected amount. Calling `reportValidity()` will focus the textarea causing a second invalid event to fire.
expect(emittedEvents.length).to.equal(2);
});
}

View File

@@ -0,0 +1,43 @@
import type { Validator } from '../webawesome-element.js';
type ValidatorElement = HTMLElement & {
value: string | null | File | FormData;
formControl?: HTMLElement & ElementInternals;
};
/**
* This validator is for if you have an exact copy of your element in the shadow DOM. Rather than needing
* custom translations and error messages, you can simply rely on the element "formControl" in your shadow dom.
*/
export const MirrorValidator: Validator<ValidatorElement> = {
checkValidity(element) {
const formControl = element.formControl;
const validity: ReturnType<Validator<ValidatorElement>['checkValidity']> = {
message: '',
isValid: true,
invalidKeys: []
};
if (!formControl) return validity;
const isValid = formControl.checkValidity();
if (isValid) return validity;
validity.isValid = false;
validity.message = formControl.validationMessage;
for (const key in formControl.validity) {
if (key === 'valid') {
continue;
}
const checkedKey = key as Exclude<keyof ValidityState, 'valid'>;
if (formControl.validity[checkedKey]) {
validity.invalidKeys.push(checkedKey);
}
}
return validity;
}
};

View File

@@ -1,4 +1,4 @@
import { LitElement, type PropertyValueMap } from 'lit';
import { LitElement, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
// Match event type name strings that are registered on GlobalEventHandlersEventMap...
@@ -140,12 +140,12 @@ export default class WebAwesomeElement extends LitElement {
}
}
interface Validator<
export interface Validator<
T extends HTMLElement & { value: string | null | File | FormData } = HTMLElement & {
value: string | null | File | FormData;
}
> {
observedAttributes: string[];
observedAttributes?: string[];
checkValidity: (element: T) => {
message: string;
isValid: boolean;
@@ -153,8 +153,40 @@ interface Validator<
};
}
export interface WebAwesomeFormControl extends WebAwesomeElement {
// Form attributes
name: string;
value: unknown;
disabled?: boolean;
defaultValue?: unknown;
defaultChecked?: boolean;
form?: string;
// Constraint validation attributes
pattern?: string;
min?: number | string | Date;
max?: number | string | Date;
step?: number | 'any';
required?: boolean;
minlength?: number;
maxlength?: number;
// Form validation properties
readonly validity: ValidityState;
readonly validationMessage: string;
// Form validation methods
checkValidity: () => boolean;
getForm: () => HTMLFormElement | null;
reportValidity: () => boolean;
setCustomValidity: (message: string) => void;
}
// setFormValue omitted so that we can use `setValue`
export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<ElementInternals, 'setFormValue'> {
export class WebAwesomeFormAssociated
extends WebAwesomeElement
implements Omit<ElementInternals, 'form' | 'setFormValue'>, WebAwesomeFormControl
{
static formAssociated = true;
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
@@ -188,24 +220,17 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
// Form attributes
// These should properly just use `@property` accessors.
name: string;
value: string | FormData | null | File;
disabled?: boolean;
defaultValue: string | FormData | null | File;
defaultChecked?: boolean;
// Constraint validation attributes
pattern?: string;
min?: number | string | Date;
max?: number | string | Date;
step?: number | 'any';
required?: boolean;
minlength?: number;
maxlength?: number;
name: string = '';
value: string | FormData | null | File = null;
defaultValue: string | FormData | null | File = null;
disabled: boolean = false;
required: boolean = false;
// Form validation methods
internals: ElementInternals;
assumeInteractionOn: string[] = ['wa-input'];
// Additional
formControl?: HTMLElement & { value: string | FormData | null | File };
@@ -215,6 +240,8 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
valueHasChanged: boolean = false;
hasInteracted: boolean = false;
private emittedEvents: string[] = [];
constructor() {
super();
@@ -223,13 +250,62 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
} catch (_e) {
console.error('Element internals are not supported in your browser. Consider using a polyfill');
}
const ctor = this.constructor as typeof LitElement;
if (ctor.properties?.disabled?.reflect === true) {
console.warn(`The following element has their "disabled" property set to reflect.`);
console.warn(this);
console.warn('For further reading: https://github.com/whatwg/html/issues/8365');
}
this.addEventListener('invalid', this.emitInvalid);
}
connectedCallback() {
super.connectedCallback();
// Lazily evaluate after the constructor.
this.assumeInteractionOn.forEach(event => {
this.addEventListener(event, this.handleInteraction);
});
}
emitInvalid = (e: Event) => {
if (e.target !== this) return;
this.emit('wa-invalid');
};
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
changedProperties.has('formControl') ||
changedProperties.has('defaultValue') ||
changedProperties.has('value')
) {
this.setValue(this.value, this.value);
}
super.willUpdate(changedProperties);
}
private handleInteraction = (event: Event) => {
const emittedEvents = this.emittedEvents;
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.assumeInteractionOn?.length) {
this.hasInteracted = true;
}
};
get labels() {
return this.internals.labels;
}
get form() {
getForm() {
return this.internals.form;
}
@@ -264,13 +340,26 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
}
setValidity(...args: Parameters<typeof this.internals.setValidity>) {
let [flags, message, anchor] = args;
const flags = args[0];
const message = args[1]
let anchor = args[2]
if (!anchor) {
anchor = this.validationTarget;
}
this.internals.setValidity(flags, message, anchor);
this.internals.setValidity(flags, message, anchor || undefined);
const required = Boolean(this.required);
const isValid = this.internals.validity.valid;
const hasInteracted = this.hasInteracted;
this.toggleAttribute('data-required', required);
this.toggleAttribute('data-optional', !required);
this.toggleAttribute('data-invalid', !isValid);
this.toggleAttribute('data-valid', isValid);
this.toggleAttribute('data-user-invalid', !isValid && hasInteracted);
this.toggleAttribute('data-user-valid', isValid && hasInteracted);
}
setCustomValidity(message: string) {
@@ -278,7 +367,8 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
this.setValidity({});
return;
}
this.setValidity({ customError: true }, message);
this.setValidity({ customError: true }, message, this.validationTarget);
}
formResetCallback() {
@@ -290,6 +380,7 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
this.value = this.defaultValue;
this.hasInteracted = false;
this.valueHasChanged = false;
this.emittedEvents = [];
this.runValidators();
this.setValue(this.defaultValue, this.defaultValue);
}
@@ -323,40 +414,40 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
}
get allValidators() {
const staticValidators = (this.constructor as typeof WebAwesomeFormControl).validators || [];
const staticValidators = (this.constructor as typeof WebAwesomeFormAssociated).validators || [];
const validators = this.validators || [];
return [...staticValidators, ...validators];
}
runValidators() {
const element = this;
if (element.disabled || element.getAttribute('disabled')) {
element.setValidity({});
// We don't run validators on disabled elements to be inline with native HTMLElements.
if (this.disabled || this.getAttribute('disabled')) {
this.setValidity({});
// We don't run validators on disabled thiss to be inline with native HTMLElements.
// https://codepen.io/paramagicdev/pen/PoLogeL
return;
}
const validators =
/** @type {{allValidators?: Array<import("../types.js").Validator>}} */ /** @type {unknown} */ element.allValidators;
/** @type {{allValidators?: Array<import("../types.js").Validator>}} */ /** @type {unknown} */ this.allValidators;
if (!validators) {
element.setValidity({});
this.setValidity({});
return;
}
const flags = {
customError: element.validity.customError
type ValidityKey = { -readonly [P in keyof ValidityState]: ValidityState[P] }
const flags: Partial<ValidityKey> = {
customError: this.validity.customError
};
const formControl = element.formControl || undefined;
const formControl = this.validationTarget || this.formControl || undefined;
let finalMessage = '';
for (const validator of validators) {
const { isValid, message, invalidKeys } = validator.checkValidity(element);
const { isValid, message, invalidKeys } = validator.checkValidity(this);
if (isValid) {
continue;
@@ -367,23 +458,23 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
}
if (invalidKeys?.length >= 0) {
// @ts-expect-error
invalidKeys.forEach(str => (flags[str] = true));
(invalidKeys as (keyof ValidityState)[]).forEach(str => (flags[str] = true));
}
}
// This is a workaround for preserving custom errors
if (!finalMessage) {
finalMessage = element.validationMessage;
finalMessage = this.validationMessage;
}
element.setValidity(flags, finalMessage, formControl);
this.setValidity(flags, finalMessage, formControl);
}
// Custom states
addCustomState(state: string) {
try {
// @ts-expect-error
this.internals.states.add(state);
// @ts-expect-error CustomStateSet doesn't exist in TS yet.
(this.internals.states as Set<string>).add(state);
} catch (_) {
// Without this, test suite errors.
} finally {
@@ -393,8 +484,8 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
deleteCustomState(state: string) {
try {
// @ts-expect-error
this.internals.states.delete(state);
// @ts-expect-error CustomStateSet doesn't exist in TS yet.
(this.internals.states as Set<string>).delete(state);
} catch (_) {
// Without this, test suite errors.
} finally {
@@ -417,31 +508,19 @@ export class WebAwesomeFormControl extends WebAwesomeElement implements Omit<Ele
}
hasCustomState(state: string) {
let bool = false
try {
// @ts-expect-error
return this.internals.states.has(state);
// @ts-expect-error CustomStateSet doesn't exist in TS yet.
bool = (this.internals.states as Set<string>).has(state);
} catch (_) {
// Without this, test suite errors.
} finally {
return this.hasAttribute(`data-${state}`);
}
}
protected willUpdate(changedProperties: PropertyValueMap<typeof this>): void {
// if (changedProperties.has("formControl")) {
// this.formControl?.addEventListener("focusout", this.handleInteraction)
// this.formControl?.addEventListener("blur", this.handleInteraction)
// this.formControl?.addEventListener("click", this.handleInteraction)
// }
if (
changedProperties.has('formControl') ||
changedProperties.has('defaultValue') ||
changedProperties.has('value')
) {
this.setValue(this.value, this.value);
if (!bool) {
bool = this.hasAttribute(`data-${state}`);
}
}
super.willUpdate(changedProperties);
return bool
}
}