/** * AgnosticUI v2 SelectionButtonGroup - Core Implementation * * A container component that manages single (radio) or multiple (checkbox) selection * using button-styled UI. Provides keyboard navigation and accessibility features. * * @element ag-selection-button-group * @slot - Default slot for ag-selection-button elements * @csspart fieldset - The fieldset element * @csspart legend - The legend element * @csspart content - The content wrapper containing buttons */ import { LitElement, html, css, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { FaceMixin, type ValidationMessages } from '../../../shared/face-mixin'; import type { AgSelectionButton, SelectionButtonTheme, SelectionButtonSize, SelectionButtonShape } from '../../SelectionButton/core/_SelectionButton.js'; export type SelectionButtonType = 'radio' | 'checkbox'; export type SelectionButtonGroupTheme = SelectionButtonTheme; export type SelectionButtonGroupSize = SelectionButtonSize; export type SelectionButtonGroupShape = SelectionButtonShape; export interface SelectionButtonChangeEventDetail { /** The value of the button that triggered the change */ value: string; /** Whether the button is now selected */ checked: boolean; /** All currently selected values */ selectedValues: string[]; } export type SelectionButtonChangeEvent = CustomEvent; export interface SelectionButtonGroupProps { /** Selection mode: 'radio' (single) or 'checkbox' (multiple) */ type: SelectionButtonType; /** Name attribute for the input elements */ name: string; /** Legend text for the fieldset (accessibility) */ legend?: string; /** Visually hide the legend while keeping it accessible */ legendHidden?: boolean; /** Theme variant for buttons */ theme?: SelectionButtonGroupTheme; /** Size variant for buttons */ size?: SelectionButtonGroupSize; /** Shape variant for buttons */ shape?: SelectionButtonGroupShape; /** Controlled value for radio mode */ value?: string; /** Controlled values for checkbox mode */ values?: string[]; /** Disable all buttons in the group */ disabled?: boolean; /** Require at least one selection before the form can be submitted */ required?: boolean; validationMessages?: ValidationMessages; /** Callback for selection changes */ onSelectionChange?: (event: SelectionButtonChangeEvent) => void; } export class AgSelectionButtonGroup extends FaceMixin(LitElement) implements SelectionButtonGroupProps { static override styles = css` :host { display: block; } .selection-button-group { border: 0; padding: 0; margin: 0; min-inline-size: auto; } .selection-button-group__legend { font-weight: 600; font-size: var(--ag-font-size-base); color: var(--ag-text-primary); padding: 0; margin-block-end: var(--ag-space-4); } .selection-button-group__legend--hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } .selection-button-group__content { display: flex; flex-wrap: wrap; gap: var(--ag-selection-button-group-gap, var(--ag-space-2)); } `; @property({ type: String, reflect: true }) declare type: SelectionButtonType; @property({ type: String }) declare legend: string; @property({ type: Boolean, attribute: 'legend-hidden' }) declare legendHidden: boolean; @property({ type: String, reflect: true }) declare theme: SelectionButtonGroupTheme; @property({ type: String, reflect: true }) declare size: SelectionButtonGroupSize; @property({ type: String, reflect: true }) declare shape: SelectionButtonGroupShape; @property({ type: String }) declare value: string; @property({ type: Array }) declare values: string[]; @property({ type: Boolean, reflect: true }) declare disabled: boolean; @property({ type: Boolean, reflect: true }) declare required: boolean; @property({ attribute: false }) declare validationMessages: ValidationMessages | undefined; @property({ attribute: false }) declare onSelectionChange: ((event: SelectionButtonChangeEvent) => void) | undefined; // Internal state for uncontrolled mode @state() declare _internalSelectedValues: string[]; constructor() { super(); this.type = 'radio'; this.legend = ''; this.legendHidden = false; this.theme = ''; this.size = 'md'; this.shape = ''; this.value = ''; this.values = []; this.disabled = false; this.required = false; this._internalSelectedValues = []; this.validationMessages = undefined; } // Get current selected values (controlled or uncontrolled) private _getSelectedValues(): string[] { if (this.type === 'radio') { // For radio: use controlled value if set, otherwise internal state if (this.value) { return [this.value]; } return this._internalSelectedValues; } // For checkbox: use controlled values if set (non-empty), otherwise internal state if (this.values && this.values.length > 0) { return this.values; } return this._internalSelectedValues; } // ─── FACE ───────────────────────────────────────────────────────────────── /** * Sync the form value to ElementInternals. * Radio: submits the selected value as a string, or null if nothing selected. * Checkbox: submits all selected values via FormData overload. */ private _syncFormValue(): void { const selected = this._getSelectedValues(); if (this.type === 'radio') { this._internals.setFormValue(selected.length > 0 ? selected[0] : null); } else { if (selected.length === 0) { this._internals.setFormValue(null); } else { const formData = new FormData(); selected.forEach(val => formData.append(this.name, val)); this._internals.setFormValue(formData); } } } private _syncValidity(): void { const selected = this._getSelectedValues(); if (this.required && selected.length === 0) { this._internals.setValidity( { valueMissing: true }, this.validationMessages?.valueMissing ?? 'Please select an option.' ); } else { this._internals.setValidity({}); } } /** * FACE lifecycle: called when the parent form is reset. * Clears all selections and re-syncs child buttons. */ override formResetCallback(): void { this._internalSelectedValues = []; this._internals.setFormValue(null); this._syncValidity(); this._syncChildButtons(); this._syncStates(); } /** * FACE lifecycle: called on session restore or browser autofill. * Restores selected value(s) from the previously saved form state. * Radio mode: state is a single string. Checkbox mode: state is FormData. */ override formStateRestoreCallback( state: File | string | FormData | null, _mode: 'restore' | 'autocomplete' ): void { if (state === null) { this._internalSelectedValues = []; } else if (state instanceof FormData) { this._internalSelectedValues = Array.from(state.getAll(this.name)) as string[]; } else if (typeof state === 'string') { this._internalSelectedValues = [state]; } this._syncFormValue(); this._syncValidity(); this._syncChildButtons(); 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) — group is disabled * :state(required) — group 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); } // ─── End FACE ───────────────────────────────────────────────────────────── override connectedCallback() { super.connectedCallback(); this.addEventListener('selection-button-change', this._handleButtonChange as EventListener); this.addEventListener('keydown', this._handleKeyDown); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('selection-button-change', this._handleButtonChange as EventListener); this.removeEventListener('keydown', this._handleKeyDown); } override updated(changedProperties: Map) { super.updated(changedProperties); // Sync props to child buttons if ( changedProperties.has('type') || changedProperties.has('name') || changedProperties.has('theme') || changedProperties.has('size') || changedProperties.has('shape') || changedProperties.has('disabled') || changedProperties.has('value') || changedProperties.has('values') || changedProperties.has('_internalSelectedValues') ) { this._syncChildButtons(); } // FACE: sync for programmatic value/values changes if ( changedProperties.has('value') || changedProperties.has('values') || changedProperties.has('_internalSelectedValues') ) { this._syncFormValue(); this._syncValidity(); } else if (changedProperties.has('required')) { this._syncValidity(); } if ( changedProperties.has('disabled') || changedProperties.has('required') || changedProperties.has('_internalSelectedValues') ) { this._syncStates(); } } override firstUpdated() { this._syncChildButtons(); this._syncFormValue(); this._syncValidity(); this._syncStates(); } private _getButtons(): AgSelectionButton[] { const slot = this.shadowRoot?.querySelector('slot'); if (!slot) return []; return slot .assignedElements({ flatten: true }) .filter((el): el is AgSelectionButton => el.tagName.toLowerCase() === 'ag-selection-button'); } private _syncChildButtons() { const buttons = this._getButtons(); const selectedValues = this._getSelectedValues(); buttons.forEach((button) => { const newChecked = selectedValues.includes(button.value); button._type = this.type; button._name = this.name; button._theme = this.theme; button._size = this.size; button._shape = this.shape; button.checked = newChecked; if (this.disabled) { button.disabled = true; } }); } private _handleButtonChange = (e: CustomEvent<{ value: string; checked: boolean }>) => { e.stopPropagation(); const { value, checked } = e.detail; let newSelectedValues: string[]; if (this.type === 'radio') { // Radio: only one selected newSelectedValues = checked ? [value] : []; } else { // Checkbox: toggle in list - use current selected values const current = [...this._getSelectedValues()]; if (checked) { if (!current.includes(value)) { current.push(value); } } else { const index = current.indexOf(value); if (index > -1) { current.splice(index, 1); } } newSelectedValues = current; } // Update internal state (for uncontrolled mode) this._internalSelectedValues = newSelectedValues; // FACE: sync form value and validity on user interaction this._syncFormValue(); this._syncValidity(); // Dispatch event const changeEvent = new CustomEvent('selection-change', { detail: { value, checked, selectedValues: newSelectedValues, }, bubbles: true, composed: true, }); this.dispatchEvent(changeEvent); if (this.onSelectionChange) { this.onSelectionChange(changeEvent); } }; private _handleKeyDown = (e: KeyboardEvent) => { const buttons = this._getButtons().filter((button) => !button.disabled); if (buttons.length === 0) return; // Find currently focused button (check shadowRoot for focus) const focusedButton = buttons.find(button => { try { return button.shadowRoot?.activeElement || button === document.activeElement; } catch { return button === document.activeElement; } }); const currentIndex = focusedButton ? buttons.indexOf(focusedButton) : -1; let nextIndex: number | null = null; switch (e.key) { case 'ArrowDown': case 'ArrowRight': e.preventDefault(); nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % buttons.length; break; case 'ArrowUp': case 'ArrowLeft': e.preventDefault(); nextIndex = currentIndex === -1 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length; break; case 'Home': e.preventDefault(); nextIndex = 0; break; case 'End': e.preventDefault(); nextIndex = buttons.length - 1; break; } if (nextIndex !== null) { const nextButton = buttons[nextIndex]; nextButton.focus(); // For radio groups, arrow keys also select if (this.type === 'radio') { this._handleButtonChange(new CustomEvent('selection-button-change', { detail: { value: nextButton.value, checked: true }, })); } } }; private _handleSlotChange() { this._syncChildButtons(); } override render() { const legendClasses = [ 'selection-button-group__legend', this.legendHidden ? 'selection-button-group__legend--hidden' : '', ].filter(Boolean).join(' '); return html`
${this.legend ? html`${this.legend}` : nothing}
`; } }