import { LitElement, html, css } from 'lit'; import { property, query } from 'lit/decorators.js'; import { createFormControlIds, buildAriaDescribedBy, isHorizontalLabel, type LabelPosition, } from '../../../shared/form-control-utils'; import { formControlStyles } from '../../../shared/form-control-styles'; import { FaceMixin, syncInnerInputValidity } from '../../../shared/face-mixin'; export type SelectSize = 'small' | 'large' | ''; // Event types export interface SelectChangeEventDetail { value: string | string[]; } export type SelectChangeEvent = CustomEvent; export interface SelectProps { size?: SelectSize; multiple?: boolean; disabled?: boolean; name?: string; multipleSize?: number; // External label support label?: string; labelPosition?: LabelPosition; labelHidden?: boolean; noLabel?: boolean; required?: boolean; invalid?: boolean; errorMessage?: string; helpText?: string; // Event callbacks onClick?: (event: MouseEvent) => void; onFocus?: (event: FocusEvent) => void; onBlur?: (event: FocusEvent) => void; onChange?: (event: SelectChangeEvent) => void; } /** * Select component - A lightly styled native select element * * @slot - Option elements * * @csspart ag-select - The select element * * @fires change - Emitted when selection changes */ export class Select extends FaceMixin(LitElement) implements SelectProps { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true, }; @property({ type: String, reflect: true }) public size: SelectSize = ''; @property({ type: Boolean, reflect: true }) public multiple = false; @property({ type: Boolean, reflect: true }) public disabled = false; @property({ type: Number, attribute: 'multiple-size' }) public multipleSize?: number; // External label properties @property({ type: String }) public label = ''; @property({ type: String, attribute: 'label-position' }) public labelPosition: LabelPosition = 'top'; @property({ type: Boolean, attribute: 'label-hidden' }) public labelHidden = false; @property({ type: Boolean, attribute: 'no-label' }) public noLabel = false; @property({ type: Boolean }) public required = false; @property({ type: Boolean }) public invalid = false; @property({ type: String, attribute: 'error-message' }) public errorMessage = ''; @property({ type: String, attribute: 'help-text' }) public helpText = ''; @property({ attribute: false }) declare onClick?: (event: MouseEvent) => void; @property({ attribute: false }) declare onFocus?: (event: FocusEvent) => void; @property({ attribute: false }) declare onBlur?: (event: FocusEvent) => void; @property({ attribute: false }) declare onChange?: (event: SelectChangeEvent) => void; @query('select') private selectElement!: HTMLSelectElement; // ─── FACE ───────────────────────────────────────────────────────────────── /** * FACE lifecycle: called when the parent form is reset. * Restores each option to its defaultSelected state (the `selected` * attribute from the original HTML), then re-syncs the form value. */ override formResetCallback(): void { if (this.selectElement) { Array.from(this.selectElement.options).forEach(opt => (opt.selected = opt.defaultSelected)); } this._syncFormValue(); this._internals.setValidity({}); this._syncStates(); } /** * FACE lifecycle: called on session restore or browser autofill. * Restores the selected option(s) from the previously saved form state. * Uses updateComplete to ensure options are in the DOM before restoring. */ override formStateRestoreCallback( state: File | string | FormData | null, _mode: 'restore' | 'autocomplete' ): void { this.updateComplete.then(() => { if (!this.selectElement) return; if (this.multiple && state instanceof FormData) { const restored = new Set(Array.from(state.values()) as string[]); Array.from(this.selectElement.options).forEach(opt => { opt.selected = restored.has(opt.value); }); } else if (typeof state === 'string') { Array.from(this.selectElement.options).forEach(opt => { opt.selected = opt.value === state; }); } this._syncFormValue(); this._syncValidity(); this._syncStates(); }); } /** * Sync CustomStateSet states so :state() pseudo-classes work from external CSS. * * Must be called AFTER _syncValidity() so that :state(invalid) reads the * freshly-updated _internals.validity.valid value. * * Exposed states: * :state(disabled) — select is disabled * :state(required) — select is required * :state(invalid) — FACE constraint validation is failing */ private _syncStates(): void { this._setState('disabled', this.disabled); this._setState('required', this.required); this._setState('invalid', !this._internals.validity.valid); } /** * Sync the form value to ElementInternals. * Single select: submits the selected value as a string. * Multi-select: uses the FormData overload to submit all selected values * under the same key (matching native . */ private _syncValidity(): void { syncInnerInputValidity(this._internals, this.selectElement); } // ─── End FACE ───────────────────────────────────────────────────────────── override updated(changedProperties: Map) { super.updated(changedProperties); if ( changedProperties.has('disabled') || changedProperties.has('required') || changedProperties.has('invalid') ) { this._syncStates(); } } protected firstUpdated() { // Ensure options are moved after first render this.handleSlotChange(); // Listen for dynamic changes to slotted options const slotElement = this.shadowRoot?.querySelector('slot'); if (slotElement) { slotElement.addEventListener('slotchange', () => this.handleSlotChange()); } // FACE: set initial form value and sync validity after options are in place this._syncFormValue(); this._syncValidity(); this._syncStates(); } private handleSlotChange() { // Move option/optgroup elements from light DOM into the shadow DOM select // This is required because