mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 12:09:26 +00:00
Separate WebAwesomeFormAssociatedElement (and friends) into a separate file
This commit is contained in:
@@ -7,7 +7,7 @@ import { WaFocusEvent } from '../../events/focus.js';
|
||||
import { WaInvalidEvent } from '../../events/invalid.js';
|
||||
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import nativeStyles from '../../styles/native/button.css';
|
||||
import appearanceStyles from '../../styles/utilities/appearance.css';
|
||||
import sizeStyles from '../../styles/utilities/size.css';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WaInputEvent } from '../../events/input.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { RequiredValidator } from '../../internal/validators/required-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import nativeStyles from '../../styles/native/checkbox.css';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import sizeStyles from '../../styles/utilities/size.css';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { clamp } from '../../internal/math.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { RequiredValidator } from '../../internal/validators/required-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import sizeStyles from '../../styles/utilities/size.css';
|
||||
import visuallyHidden from '../../styles/utilities/visually-hidden.css';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { WaBlurEvent } from '../../events/blur.js';
|
||||
import { WaFocusEvent } from '../../events/focus.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import '../icon/icon.js';
|
||||
import styles from './icon-button.css';
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WaInputEvent } from '../../events/input.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import nativeStyles from '../../styles/native/input.css';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import appearanceStyles from '../../styles/utilities/appearance.css';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { WaBlurEvent } from '../../events/blur.js';
|
||||
import { WaFocusEvent } from '../../events/focus.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import nativeStyles from '../../styles/native/button.css';
|
||||
import sizeStyles from '../../styles/utilities/size.css';
|
||||
import variantStyles from '../../styles/utilities/variants.css';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { uniqueId } from '../../internal/math.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { RequiredValidator } from '../../internal/validators/required-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import buttonGroupStyles from '../../styles/utilities/button-group.css';
|
||||
import sizeStyles from '../../styles/utilities/size.css';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { WaBlurEvent } from '../../events/blur.js';
|
||||
import { WaFocusEvent } from '../../events/focus.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import nativeStyles from '../../styles/native/radio.css';
|
||||
import sizeStyles from '../../styles/utilities/size.css';
|
||||
import '../icon/icon.js';
|
||||
|
||||
@@ -19,7 +19,7 @@ import { scrollIntoView } from '../../internal/scroll.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { RequiredValidator } from '../../internal/validators/required-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import nativeStyles from '../../styles/native/select.css';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import appearanceStyles from '../../styles/utilities/appearance.css';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { WaInputEvent } from '../../events/input.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import sliderStyles from '../../styles/native/slider.css';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WaInputEvent } from '../../events/input.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import sizeStyles from '../../styles/utilities/size.css';
|
||||
import styles from './switch.css';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { WaInputEvent } from '../../events/input.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js';
|
||||
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js';
|
||||
import nativeStyles from '../../styles/native/input.css';
|
||||
import formControlStyles from '../../styles/shadow/form-control.css';
|
||||
import appearanceStyles from '../../styles/utilities/appearance.css';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { aTimeout, expect } from '@open-wc/testing';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
import { html as staticHTML, unsafeStatic } from 'lit/static-html.js';
|
||||
import { clickOnElement } from '../test.js';
|
||||
import type { WebAwesomeFormControl } from '../webawesome-element.js';
|
||||
import type { WebAwesomeFormControl } from '../webawesome-formassociated-element.js';
|
||||
import type { clientFixture, hydratedFixture } from './fixture.js';
|
||||
import { fixtures } from './fixture.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Validator } from '../webawesome-element.js';
|
||||
import type { Validator } from '../webawesome-formassociated-element.js';
|
||||
|
||||
/**
|
||||
* This validator is for if you have an exact copy of your element in the shadow DOM. Rather than needing
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Validator } from '../webawesome-element.js';
|
||||
import type { Validator } from '../webawesome-formassociated-element.js';
|
||||
|
||||
/**
|
||||
* This validator is for if you have an exact copy of your element in the shadow DOM. Rather than needing
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Validator } from '../webawesome-element.js';
|
||||
import type { Validator } from '../webawesome-formassociated-element.js';
|
||||
|
||||
export interface RequiredValidatorOptions {
|
||||
/** This is a cheap way for us to get translation strings for the user without having proper translations. */
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { CSSResult, CSSResultGroup, PropertyValues } from 'lit';
|
||||
import { LitElement, isServer, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { WaInvalidEvent } from '../events/invalid.js';
|
||||
import componentStyles from '../styles/shadow/component.css';
|
||||
import { CustomErrorValidator } from './validators/custom-error-validator.js';
|
||||
|
||||
export default class WebAwesomeElement extends LitElement {
|
||||
constructor() {
|
||||
@@ -151,384 +149,3 @@ export default class WebAwesomeElement extends LitElement {
|
||||
return this.hasStatesSupport() ? this.internals.states.has(state) : false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
disabled?: boolean;
|
||||
defaultValue?: unknown;
|
||||
defaultChecked?: boolean;
|
||||
checked?: boolean;
|
||||
defaultSelected?: boolean;
|
||||
selected?: boolean;
|
||||
form?: string | null;
|
||||
|
||||
value?: unknown;
|
||||
|
||||
// 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
|
||||
/** The name of the input, submitted as a name/value pair with form data. */
|
||||
@property({ reflect: true }) name: string | null = null;
|
||||
|
||||
/** Disables the form control. */
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
|
||||
required: boolean = false;
|
||||
|
||||
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();
|
||||
|
||||
if (!isServer) {
|
||||
// eslint-disable-next-line
|
||||
this.addEventListener('invalid', this.emitInvalid);
|
||||
}
|
||||
}
|
||||
states: CustomStateSet;
|
||||
|
||||
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: 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;
|
||||
}
|
||||
this.setCustomValidity(this.customError || '');
|
||||
}
|
||||
|
||||
if (changedProperties.has('value') || changedProperties.has('disabled')) {
|
||||
// @ts-expect-error Some components will use an accessors, other use a property, so we dont want to limit them.
|
||||
const value = this.value as unknown;
|
||||
|
||||
// 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') || (!isServer && !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 element’s 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') {
|
||||
// @ts-expect-error We purposely do not have a value property. It makes things hard to extend.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
386
src/internal/webawesome-formassociated-element.ts
Normal file
386
src/internal/webawesome-formassociated-element.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { LitElement, isServer } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { WaInvalidEvent } from '../events/invalid.js';
|
||||
import { CustomErrorValidator } from './validators/custom-error-validator.js';
|
||||
import WebAwesomeElement from './webawesome-element.js';
|
||||
|
||||
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;
|
||||
disabled?: boolean;
|
||||
defaultValue?: unknown;
|
||||
defaultChecked?: boolean;
|
||||
checked?: boolean;
|
||||
defaultSelected?: boolean;
|
||||
selected?: boolean;
|
||||
form?: string | null;
|
||||
|
||||
value?: unknown;
|
||||
|
||||
// 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
|
||||
/** The name of the input, submitted as a name/value pair with form data. */
|
||||
@property({ reflect: true }) name: string | null = null;
|
||||
|
||||
/** Disables the form control. */
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
|
||||
required: boolean = false;
|
||||
|
||||
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();
|
||||
|
||||
if (!isServer) {
|
||||
// eslint-disable-next-line
|
||||
this.addEventListener('invalid', this.emitInvalid);
|
||||
}
|
||||
}
|
||||
states: CustomStateSet;
|
||||
|
||||
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: 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;
|
||||
}
|
||||
this.setCustomValidity(this.customError || '');
|
||||
}
|
||||
|
||||
if (changedProperties.has('value') || changedProperties.has('disabled')) {
|
||||
// @ts-expect-error Some components will use an accessors, other use a property, so we dont want to limit them.
|
||||
const value = this.value as unknown;
|
||||
|
||||
// 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') || (!isServer && !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 element’s 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') {
|
||||
// @ts-expect-error We purposely do not have a value property. It makes things hard to extend.
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user