Separate WebAwesomeFormAssociatedElement (and friends) into a separate file

This commit is contained in:
Lea Verou
2025-01-07 12:31:15 -05:00
parent 5e3fed605e
commit 44dbdd14cc
18 changed files with 402 additions and 399 deletions

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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

View File

@@ -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

View File

@@ -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. */

View File

@@ -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 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') {
// @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);
}
}

View 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 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') {
// @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);
}
}