/** * AgnosticUI v2 SelectionCardGroup - Core Implementation * * A container component that manages single (radio) or multiple (checkbox) selection * using card-based UI. Provides keyboard navigation and accessibility features. * * @element ag-selection-card-group * @slot - Default slot for ag-selection-card elements * @csspart fieldset - The fieldset element * @csspart legend - The legend element * @csspart content - The content wrapper containing cards */ import { LitElement, html, css, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import type { AgSelectionCard } from '../../SelectionCard/core/_SelectionCard.js'; import { FaceMixin, type ValidationMessages } from '../../../shared/face-mixin'; export type SelectionType = 'radio' | 'checkbox'; export type SelectionCardGroupTheme = 'success' | 'info' | 'error' | 'warning' | 'monochrome' | ''; export interface SelectionChangeEventDetail { /** The value of the card that triggered the change */ value: string; /** Whether the card is now selected */ checked: boolean; /** All currently selected values */ selectedValues: string[]; } export type SelectionChangeEvent = CustomEvent; export interface SelectionCardGroupProps { /** Selection mode: 'radio' (single) or 'checkbox' (multiple) */ type: SelectionType; /** 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 cards */ theme?: SelectionCardGroupTheme; /** Controlled value for radio mode */ value?: string; /** Controlled values for checkbox mode */ values?: string[]; /** Disable all cards 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: SelectionChangeEvent) => void; } export class AgSelectionCardGroup extends FaceMixin(LitElement) implements SelectionCardGroupProps { static override styles = css` :host { display: block; } .selection-card-group { border: 0; padding: 0; margin: 0; min-inline-size: auto; } .selection-card-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-card-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-card-group__content { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--ag-selection-card-group-gap, var(--ag-space-4)); } `; @property({ type: String, reflect: true }) declare type: SelectionType; @property({ type: String }) declare legend: string; @property({ type: Boolean, attribute: 'legend-hidden' }) declare legendHidden: boolean; @property({ type: String, reflect: true }) declare theme: SelectionCardGroupTheme; @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: SelectionChangeEvent) => void) | undefined; // Internal state for uncontrolled mode @state() declare _internalSelectedValues: string[]; constructor() { super(); this.type = 'radio'; this.legend = ''; this.legendHidden = false; this.theme = ''; 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 ───────────────────────────────────────────────────────────────── 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({}); } } override formResetCallback(): void { this._internalSelectedValues = []; this._internals.setFormValue(null); this._syncValidity(); this._syncChildCards(); 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._syncChildCards(); 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-card-change', this._handleCardChange as EventListener); this.addEventListener('keydown', this._handleKeyDown); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('selection-card-change', this._handleCardChange as EventListener); this.removeEventListener('keydown', this._handleKeyDown); } override updated(changedProperties: Map) { super.updated(changedProperties); // Sync props to child cards if ( changedProperties.has('type') || changedProperties.has('name') || changedProperties.has('theme') || changedProperties.has('disabled') || changedProperties.has('value') || changedProperties.has('values') || changedProperties.has('_internalSelectedValues') ) { this._syncChildCards(); } // 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._syncChildCards(); this._syncFormValue(); this._syncValidity(); this._syncStates(); } private _getCards(): AgSelectionCard[] { const slot = this.shadowRoot?.querySelector('slot'); if (!slot) return []; return slot .assignedElements({ flatten: true }) .filter((el): el is AgSelectionCard => el.tagName.toLowerCase() === 'ag-selection-card'); } private _syncChildCards() { const cards = this._getCards(); const selectedValues = this._getSelectedValues(); cards.forEach((card) => { card._type = this.type; card._name = this.name; card._theme = this.theme; card.checked = selectedValues.includes(card.value); if (this.disabled) { card.disabled = true; } }); } private _handleCardChange = (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 cards = this._getCards().filter((card) => !card.disabled); if (cards.length === 0) return; // Find currently focused card (check shadowRoot for focus) const focusedCard = cards.find(card => { try { return card.shadowRoot?.activeElement || card === document.activeElement; } catch { return card === document.activeElement; } }); const currentIndex = focusedCard ? cards.indexOf(focusedCard) : -1; let nextIndex: number | null = null; switch (e.key) { case 'ArrowDown': case 'ArrowRight': e.preventDefault(); nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cards.length; break; case 'ArrowUp': case 'ArrowLeft': e.preventDefault(); nextIndex = currentIndex === -1 ? cards.length - 1 : (currentIndex - 1 + cards.length) % cards.length; break; case 'Home': e.preventDefault(); nextIndex = 0; break; case 'End': e.preventDefault(); nextIndex = cards.length - 1; break; } if (nextIndex !== null) { const nextCard = cards[nextIndex]; nextCard.focus(); // For radio groups, arrow keys also select if (this.type === 'radio') { this._handleCardChange(new CustomEvent('selection-card-change', { detail: { value: nextCard.value, checked: true }, })); } } }; private _handleSlotChange() { this._syncChildCards(); } override render() { const legendClasses = [ 'selection-card-group__legend', this.legendHidden ? 'selection-card-group__legend--hidden' : '', ].filter(Boolean).join(' '); return html`
${this.legend ? html`${this.legend}` : nothing}
`; } }