import { html, unsafeCSS, nothing } from 'lit'; import { PieElement } from '@justeattakeaway/pie-webc-core/src/internals/PieElement'; import { classMap } from 'lit/directives/class-map.js'; import { property, query, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { consume } from '@lit/context'; import { wrapNativeEvent, FormControlMixin, DelegatesFocusMixin, validPropertyValues, safeCustomElement, ariaContext, type ContextualAria, } from '@justeattakeaway/pie-webc-core'; import '@justeattakeaway/pie-assistive-text'; import styles from './checkbox.scss?inline'; import { type CheckboxProps, defaultProps, statusTypes, labelPositions, labelFits, } from './defs'; // Valid values available to consumers export * from './defs'; const componentSelector = 'pie-checkbox'; const assistiveTextId = 'assistive-text'; /** * @tagname pie-checkbox * @slot - Default slot * @event {CustomEvent} change - when checked state is changed. */ @safeCustomElement('pie-checkbox') export class PieCheckbox extends DelegatesFocusMixin(FormControlMixin(PieElement)) implements CheckboxProps { @state() private _disabledByParent = false; @state() private _visuallyHiddenError = false; @state() private _isAnimationAllowed = false; // Optional ARIA supplied by an ancestor (for example a `pie-list-item`), applied to the // internal input (the element carrying the checkbox semantics). Undefined when standalone, so // it has no effect there. @consume({ context: ariaContext, subscribe: true }) @state() private _contextAria?: ContextualAria; @property({ type: String }) public value = defaultProps.value; @property({ type: String, reflect: true }) public name: CheckboxProps['name']; @property({ type: Boolean, reflect: true }) public checked = defaultProps.checked; @property({ type: Boolean, reflect: true }) public defaultChecked = defaultProps.defaultChecked; @property({ type: Boolean, reflect: true }) public disabled = defaultProps.disabled; @property({ type: Boolean, reflect: true }) public required = defaultProps.required; @property({ type: Boolean, reflect: true }) public indeterminate = defaultProps.indeterminate; @query('input[type="checkbox"]') private _checkbox!: HTMLInputElement; @property({ type: String }) public assistiveText: CheckboxProps['assistiveText']; @property({ type: String }) @validPropertyValues(componentSelector, statusTypes, defaultProps.status) public status = defaultProps.status; @property({ type: String, reflect: true }) @validPropertyValues(componentSelector, labelPositions, defaultProps.labelPosition) public labelPosition = defaultProps.labelPosition; @property({ type: String, reflect: true }) @validPropertyValues(componentSelector, labelFits, defaultProps.labelFit) public labelFit = defaultProps.labelFit; private _abortController!: AbortController; connectedCallback () : void { super.connectedCallback(); this._abortController = new AbortController(); const { signal } = this._abortController; this.addEventListener('pie-checkbox-group-disabled', (e: CustomEventInit) => { this._disabledByParent = e.detail.disabled; }, { signal }); this.addEventListener('pie-checkbox-group-error', (e: CustomEventInit) => { this._visuallyHiddenError = e.detail.error; }, { signal }); // Allows a click dispatched on the host (for example forwarded from a `pie-list-item` // row) to toggle the checkbox. this.addEventListener('click', this._handleClick, { signal }); } /** * Forwards a click dispatched directly on the host (for example from a `pie-list-item` row) * to the internal input. Clicks on the internal label already toggle the input natively via * its `for` attribute, so only host-level clicks are forwarded, to avoid a double toggle. * @param {MouseEvent} event */ private _handleClick (event: MouseEvent): void { if (event.composedPath()[0] !== this) return; if (this.disabled || this._disabledByParent) return; this._checkbox.click(); } disconnectedCallback () : void { super.disconnectedCallback(); this._abortController.abort(); } /** * (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._checkbox as HTMLInputElement).validity; } /** * Ensures that the form value is in sync with the component. */ private _handleFormAssociation () : void { const isFormAssociated = !!this.form && !!this.name; if (isFormAssociated) { this._internals.setFormValue(this.checked ? this.value : null); } } /** * Called after the disabled state of the element changes, * either because the disabled attribute of this element was added or removed; * or because the disabled state changed on a
that's an ancestor of this element. * @param disabled - The latest disabled state of the input. */ public formDisabledCallback (disabled: boolean): void { this.disabled = disabled; } protected updated (): void { this._handleFormAssociation(); this._applyContextAria(); } /** * When an ancestor provides ARIA (for example a `pie-list-item`), applies the fields this * control cares about to the internal input (the element that carries the checkbox semantics). * Does nothing when used standalone, where the name comes from the default slot / label. */ private _applyContextAria (): void { if (!this._checkbox) return; const aria = this._contextAria; if (aria?.label) { this._checkbox.setAttribute('aria-label', aria.label); } else { this._checkbox.removeAttribute('aria-label'); } if (aria?.description) { this._checkbox.setAttribute('aria-description', aria.description); } else { this._checkbox.removeAttribute('aria-description'); } } /** * Captures the native change event and wraps it in a custom event. * @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; if (!this._isAnimationAllowed) { this._isAnimationAllowed = true; } // This is because some events set `composed` to `false`. // Reference: https://javascript.info/shadow-dom-events#event-composed const customChangeEvent = wrapNativeEvent(event); this.dispatchEvent(customChangeEvent); this._handleFormAssociation(); } /** * Called when the form that contains this component is reset. * If the current checked state is different to the default checked state, * the checked state is reset to the default checked state and a `change` event is emitted. */ 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(); } render () { const { checked, value, name, disabled, _disabledByParent, _visuallyHiddenError, _isAnimationAllowed, required, indeterminate, assistiveText, status, labelPosition, labelFit, } = this; const componentDisabled = disabled || _disabledByParent; const checkboxClasses = { 'c-checkbox': true, [`c-checkbox--status-${status}`]: !componentDisabled, 'is-disabled': componentDisabled, 'is-checked': checked, 'is-indeterminate': indeterminate && !checked, 'c-checkbox--leading': labelPosition === 'leading', 'c-checkbox--fill': labelFit === 'fill', // Inside a `pie-list-item` the whole row is the hit target and tints on hover/active. // A transparent tick lets that row tint show through the (unchecked) box, matching // how the radio behaves. Only applies in a list item, so standalone checkboxes are // unaffected. 'c-checkbox--in-interactive-container': Boolean(this._contextAria), }; const labelClasses = { 'c-checkbox-tick': true, [`c-checkbox-tick--status-${status}`]: !componentDisabled, 'is-disabled': componentDisabled, 'is-checked': checked, 'is-indeterminate': indeterminate && !checked, 'is-animated': _isAnimationAllowed, }; return html`
${assistiveText ? html` ` : nothing}
`; } // Renders a `CSSResult` generated from SCSS by Vite static styles = unsafeCSS(styles); } declare global { interface HTMLElementTagNameMap { [componentSelector]: PieCheckbox; } }