import { html, unsafeCSS, nothing, type PropertyValues, } from 'lit'; import { html as staticHtml, unsafeStatic } from 'lit/static-html.js'; import { PieElement } from '@justeattakeaway/pie-webc-core/src/internals/PieElement'; import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { consume } from '@lit/context'; import 'element-internals-polyfill'; import { validPropertyValues, FormControlMixin, DelegatesFocusMixin, AssociatedLabelMixin, wrapNativeEvent, safeCustomElement, ariaContext, type PIEInputElement, type ContextualAria, } from '@justeattakeaway/pie-webc-core'; import '@justeattakeaway/pie-icons-webc/dist/IconCheck.js'; import styles from './switch.scss?inline'; import { type SwitchProps, labelPlacements, defaultProps } from './defs'; // Valid values available to consumers export * from './defs'; const componentSelector = 'pie-switch'; /** * @tagname pie-switch * @event {CustomEvent} change - when the switch checked state is changed. */ @safeCustomElement('pie-switch') export class PieSwitch extends AssociatedLabelMixin(FormControlMixin(DelegatesFocusMixin(PieElement))) implements SwitchProps, PIEInputElement { @property({ type: String }) public label: SwitchProps['label']; @property({ type: String }) @validPropertyValues(componentSelector, labelPlacements, defaultProps.labelPlacement) public labelPlacement = defaultProps.labelPlacement; @property({ type: Object }) public aria: SwitchProps['aria']; @property({ type: Boolean, reflect: true }) public checked = defaultProps.checked; @property({ type: Boolean, reflect: true }) public defaultChecked = defaultProps.defaultChecked; @property({ type: Boolean, reflect: true }) public required = defaultProps.required; @property({ type: String }) public value = defaultProps.value; @property({ type: String, reflect: true }) public name: SwitchProps['name']; @property({ type: Boolean, reflect: true }) public disabled = defaultProps.disabled; // Optional ARIA supplied by an ancestor (for example a `pie-list-item`), folded into the // input's ARIA below as a fallback behind the switch's own label. Undefined when standalone. @consume({ context: ariaContext, subscribe: true }) @state() private _contextAria?: ContextualAria; @query('input[type="checkbox"]') private input!: HTMLInputElement; @query('.c-switch') private switchBody!: HTMLElement; @query('label, input[type="checkbox"]') public focusTarget!: HTMLElement; private _abortController!: AbortController; @state() private _isAnimationAllowed = false; protected firstUpdated (changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); const { signal } = this._abortController; this.handleFormAssociation(); // This ensures that invalid events triggered by checkValidity() are propagated to the custom element // for consumers to listen to: https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity this.input.addEventListener('invalid', (event) => { this.dispatchEvent(new Event('invalid', event)); }, { signal }); } connectedCallback (): void { super.connectedCallback(); this._abortController = new AbortController(); const { signal } = this._abortController; this.addEventListener('click', (event: Event) => { const [source] = event.composedPath(); if (this.disabled || source === this.input) { return; } // Only programmatically click the input if the explicit target // of the click was the host element itself (e.g., via an external label). // This ignores clicks bubbling up from the internal shadow DOM and prevents loops. // Also forward clicks from the visual switch body when no internal label exists. const isInsideSwitchBody = !this.label && event.composedPath().includes(this.switchBody); if (source === this || isInsideSwitchBody) { this.input.click(); } }, { signal }); } disconnectedCallback () : void { super.disconnectedCallback(); this._abortController?.abort(); } protected updated (): void { this.handleFormAssociation(); } static styles = unsafeCSS(styles); /** * Ensures that the form value and validation state are in sync with the component. */ private handleFormAssociation () : void { const isFormAssociated = !!this._internals.form && !!this.name && !!this.value; if (isFormAssociated) { if (this.disabled) { this._internals.setFormValue(null); this._internals.setValidity({}); } else if (this.checked) { this._internals.setFormValue(this.value); this._internals.setValidity({}); } else { this._internals.setFormValue(null); this._internals.setValidity(this.validity, this.validationMessage, this.input); } } } /** * The handleChange function updates the checkbox state and emits an event for consumers. * @param {Event} event - This should be the change event that was listened for on an input element with `type="checkbox"`. */ private handleChange (event: Event) { const { checked } = event?.currentTarget as HTMLInputElement; this.checked = checked; const changedEvent = wrapNativeEvent(event); if (!this._isAnimationAllowed) { this._isAnimationAllowed = true; } this.dispatchEvent(changedEvent); this.handleFormAssociation(); } /** * Returns a boolean value which indicates validity of the value of the component. If the value is invalid, this method also fires the invalid event on the component. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity * @returns boolean */ public checkValidity (): boolean { return this.input.checkValidity(); } /** * If the value is invalid, this method also fires the invalid event on the element, and (if the event isn't canceled) reports the problem to the user. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity * @returns boolean */ public reportValidity (): boolean { return this.input.reportValidity(); } /** * Allows a consumer to set a custom validation message on the switch. An empty string counts as valid. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setCustomValidity */ public setCustomValidity (message: string): void { this.input.setCustomValidity(message); this._internals.setValidity(this.validity, this.validationMessage, this.input); } /** * Called when the containing form is reset. * Resets checked state back to defaultChecked and emits a change event when needed. */ public formResetCallback () : void { if (this.checked === this.defaultChecked) { return; } this.checked = this.defaultChecked; const changeEvent = new Event('change', { bubbles: true, composed: true }); this.dispatchEvent(changeEvent); this.handleFormAssociation(); } /** * (Read-only) returns a ValidityState with the validity states that this element is in. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validity */ public get validity (): ValidityState { return this.input.validity; } /** * (Read-only) Returns a string representing a localized message that describes the validation constraints that the control does not satisfy (if any). * This string is empty if the component is valid. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validationMessage */ public get validationMessage (): string { return this.input.validationMessage; } private renderAriaDescription () { if (!this.aria?.describedBy) { return nothing; } // we apply aria-hidden to the element containing the description because it prevents some screen readers such as Apple VoiceOver from announcing the description once // on the input and again separately. The description is still announced once, when the input is focused/selected. return html` `; } private renderSwitchLabel () { const { label, labelPlacement } = this; if (!label) { return nothing; } // Using aria-hidden here to prevent the label from potentially being narrated twice by screen readers such as Apple VoiceOver. // Instead, we apply the label as an aria-label attribute on the input (if no aria.label prop is provided). return html` `; } render () { const { label, labelPlacement, aria, checked, disabled, required, _isAnimationAllowed, associatedLabelText, } = this; const ariaLabel = aria?.label || label || this._contextAria?.label || associatedLabelText; const classes = { 'c-switch-wrapper': true, 'c-switch-wrapper--allow-animation': _isAnimationAllowed, [`c-switch-wrapper--label-${labelPlacement}`]: true, }; const tag = unsafeStatic(label ? 'label' : 'div'); return staticHtml` <${tag} class="${classMap(classes)}" ?disabled=${disabled}>
${checked ? html`` : nothing}
${this.renderSwitchLabel()} ${this.renderAriaDescription()} `; } } declare global { interface HTMLElementTagNameMap { [componentSelector]: PieSwitch; } }