Files
webawesome/src/internal/webawesome-element.ts
Konnor Rogers c8e534de0b Add aria-invalid to <wa-select> due to issue with <input readonly> (#149)
* add aria-invalid to readonly input

* add comments about select.ts

* prettier

* prettier
2024-06-26 16:29:31 -04:00

464 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CustomErrorValidator } from './validators/custom-error-validator.js';
import { LitElement, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { WaInvalidEvent } from '../events/invalid.js';
export default class WebAwesomeElement extends LitElement {
// Make localization attributes reactive
@property() dir: string;
@property() lang: string;
}
export interface Validator<T extends WebAwesomeFormAssociatedElement = WebAwesomeFormAssociatedElement> {
observedAttributes?: string[];
checkValidity: (element: T) => {
message: string;
isValid: boolean;
invalidKeys: Exclude<keyof ValidityState, 'valid'>[];
};
message?: string | ((element: T) => string);
}
export interface WebAwesomeFormControl extends WebAwesomeElement {
// Form attributes
name: null | string;
value: unknown;
disabled?: boolean;
defaultValue?: unknown;
defaultChecked?: boolean;
checked?: boolean;
defaultSelected?: boolean;
selected?: boolean;
form?: string | null;
// 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;
// Form properties
hasInteracted: boolean;
valueHasChanged?: boolean;
/** Convenience API for `setCustomValidity()` */
customError: null | string;
}
// setFormValue omitted so that we can use `setValue`
export class WebAwesomeFormAssociatedElement
extends WebAwesomeElement
implements Omit<ElementInternals, 'form' | 'setFormValue'>, WebAwesomeFormControl
{
static formAssociated = true;
/**
* Validators are static because they have `observedAttributes`, essentially attributes to "watch"
* for changes. Whenever these attributes change, we want to be notified and update the validator.
*/
static get validators(): Validator[] {
return [CustomErrorValidator()];
}
// Append all Validator "observedAttributes" into the "observedAttributes" so they can run.
static get observedAttributes() {
const parentAttrs = new Set(super.observedAttributes || []);
for (const validator of this.validators) {
if (!validator.observedAttributes) {
continue;
}
for (const attr of validator.observedAttributes) {
parentAttrs.add(attr);
}
}
return [...parentAttrs];
}
// Form attributes
// These should properly just use `@property` accessors.
name: null | string = null;
value: unknown = null;
defaultValue: unknown = null;
disabled: boolean = false;
required: boolean = false;
// Form validation methods
internals: ElementInternals;
assumeInteractionOn: string[] = ['wa-input'];
// Additional
input?: (HTMLElement & { value: unknown }) | HTMLInputElement | HTMLTextAreaElement;
validators: Validator[] = [];
// Should these be private?
@property({ state: true, attribute: false }) valueHasChanged: boolean = false;
@property({ state: true, attribute: false }) hasInteracted: 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;
private emittedEvents: string[] = [];
constructor() {
super();
try {
this.internals = this.attachInternals();
} catch (_e) {
/* Need to tell people if they need a polyfill. */
/* eslint-disable-next-line */
console.error('Element internals are not supported in your browser. Consider using a polyfill');
}
// eslint-disable-next-line
this.addEventListener('invalid', this.emitInvalid);
}
connectedCallback() {
super.connectedCallback();
this.updateValidity();
// Lazily evaluate after the constructor to allow people to override the `assumeInteractionOn`
this.assumeInteractionOn.forEach(event => {
this.addEventListener(event, this.handleInteraction);
});
}
firstUpdated(...args: Parameters<LitElement['firstUpdated']>) {
super.firstUpdated(...args);
this.updateValidity();
}
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: PropertyValues<this>) {
if (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;
}
this.setCustomValidity(this.customError || '');
}
if (changedProperties.has('defaultValue')) {
if (!this.hasInteracted) {
this.value = this.defaultValue;
}
}
if (changedProperties.has('value') || changedProperties.has('disabled')) {
if (this.hasInteracted && this.value !== this.defaultValue) {
this.valueHasChanged = true;
}
if (this.input) {
this.input.value = this.value;
}
const value = this.value;
// Accounts for the snowflake case on `<wa-select>`
if (Array.isArray(value)) {
if (this.name) {
const formData = new FormData();
for (const val of value) {
formData.append(this.name, val as string);
}
this.setValue(formData, formData);
}
} else {
this.setValue(value as FormData | string | File | null, value as FormData | string | File | null);
}
}
if (changedProperties.has('disabled')) {
this.toggleCustomState('disabled', this.disabled);
if (this.hasAttribute('disabled') || !this.matches(':disabled')) {
this.toggleAttribute('disabled', this.disabled);
}
}
this.updateValidity();
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;
}
getForm() {
return this.internals.form;
}
@property({ attribute: false, state: true, type: Object })
get validity() {
return this.internals.validity;
}
// Not sure if this supports `novalidate`. Will need to test.
get willValidate() {
return this.internals.willValidate;
}
get validationMessage() {
return this.internals.validationMessage;
}
checkValidity() {
this.updateValidity();
return this.internals.checkValidity();
}
reportValidity() {
this.updateValidity();
// This seems reasonable. `reportValidity()` is kind of like "we expect you to have interacted"
this.hasInteracted = true;
return this.internals.reportValidity();
}
/**
* Override this to change where constraint validation popups are anchored.
*/
get validationTarget(): undefined | HTMLElement {
return (this.input || undefined) as undefined | HTMLElement;
}
setValidity(...args: Parameters<typeof this.internals.setValidity>) {
const flags = args[0];
const message = args[1];
let anchor = args[2];
if (!anchor) {
anchor = this.validationTarget;
}
this.internals.setValidity(flags, message, anchor || undefined);
this.requestUpdate('validity');
this.setCustomStates();
}
setCustomStates() {
const required = Boolean(this.required);
const isValid = this.internals.validity.valid;
const hasInteracted = this.hasInteracted;
this.toggleCustomState('required', required);
this.toggleCustomState('optional', !required);
this.toggleCustomState('invalid', !isValid);
this.toggleCustomState('valid', isValid);
this.toggleCustomState('user-invalid', !isValid && hasInteracted);
this.toggleCustomState('user-valid', isValid && hasInteracted);
}
/**
* Do not use this when creating a "Validator". This is intended for end users of components.
* We track manually defined custom errors so we don't clear them on accident in our validators.
*
*/
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;
this.setValidity({});
return;
}
this.customError = message;
this.setValidity({ customError: true }, message, this.validationTarget);
}
formResetCallback() {
this.resetValidity();
this.hasInteracted = false;
this.valueHasChanged = false;
this.emittedEvents = [];
this.updateValidity();
}
formDisabledCallback(isDisabled: boolean) {
this.disabled = isDisabled;
this.updateValidity();
}
/**
* Called when the browser is trying to restore elements state to state in which case reason is "restore", or when
* the browser is trying to fulfill autofill on behalf of user in which case reason is "autocomplete". In the case of
* "restore", state is a string, File, or FormData object previously set as the second argument to setFormValue.
*/
formStateRestoreCallback(state: string | File | FormData | null, reason: 'autocomplete' | 'restore') {
this.value = state;
if (reason === 'restore') {
this.resetValidity();
}
this.updateValidity();
}
setValue(...args: Parameters<typeof this.internals.setFormValue>) {
const [value, state] = args;
this.internals.setFormValue(value, state);
}
get allValidators() {
const staticValidators = (this.constructor as typeof WebAwesomeFormAssociatedElement).validators || [];
const validators = this.validators || [];
return [...staticValidators, ...validators];
}
/**
* Reset validity is a way of removing manual custom errors and native validation.
*/
resetValidity() {
this.setCustomValidity('');
this.setValidity({});
}
updateValidity() {
if (
this.disabled ||
this.hasAttribute('disabled') ||
!this.willValidate //
) {
this.resetValidity();
return;
}
const validators = this.allValidators;
if (!validators?.length) {
// If there's no validators, we do nothing. We also don't want to mess with custom errors, so we just stop here.
return;
}
type ValidityKey = { -readonly [P in keyof ValidityState]: ValidityState[P] };
const flags: Partial<ValidityKey> = {
// Don't trust custom errors from the Browser. Safari breaks the spec.
customError: Boolean(this.customError)
};
const formControl = this.validationTarget || this.input || undefined;
let finalMessage = '';
for (const validator of validators) {
const { isValid, message, invalidKeys } = validator.checkValidity(this);
if (isValid) {
continue;
}
if (!finalMessage) {
finalMessage = message;
}
if (invalidKeys?.length >= 0) {
(invalidKeys as (keyof ValidityState)[]).forEach(str => (flags[str] = true));
}
}
// This is a workaround for preserving custom errors
if (!finalMessage) {
finalMessage = this.validationMessage;
}
this.setValidity(flags, finalMessage, formControl);
}
// Custom states
addCustomState(state: string) {
try {
// @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 {
this.setAttribute(`data-wa-${state}`, '');
}
}
deleteCustomState(state: string) {
try {
// @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 {
this.removeAttribute(`data-wa-${state}`);
}
}
toggleCustomState(state: string, force: boolean) {
if (force) {
this.addCustomState(state);
return;
}
if (!force) {
this.deleteCustomState(state);
return;
}
this.toggleCustomState(state, !this.hasCustomState(state));
}
hasCustomState(state: string) {
let bool = false;
try {
// @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 {
if (!bool) {
bool = this.hasAttribute(`data-wa-${state}`);
}
}
return bool;
}
}