import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query, state } from 'lit/decorators.js'; import { HasSlotController } from '../internal/slot'; import { html } from 'lit'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import { styles } from './nile-checkbox-group.css'; import { Nile_Events } from '../internal/enum'; import type { CSSResultGroup, PropertyValues } from 'lit'; import type NileCheckbox from '../nile-checkbox/nile-checkbox'; /** * @summary Checkbox groups manage multiple [checkboxes](/components/checkbox) so they function as a multi-select form control. * Supports two association modes: slotted children or property-based remote association via `for`/`group`. * * @slot - The default slot where `` elements are placed (slotted mode). * @slot label - The checkbox group's label. Required for proper accessibility. Alternatively, use the `label` attribute. * * @event change - Emitted when the checkbox group's selected values change. * @event input - Emitted when the checkbox group receives user input. * * @csspart form-control - The fieldset wrapper. * @csspart form-control-label - The group label. * @csspart form-control-input - The wrapper around the checkbox options. * @csspart options-base - The container for the checkbox options. */ @customElement('nile-checkbox-group') export class NileCheckboxGroup extends NileElement { static styles: CSSResultGroup = styles; private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private boundDocumentListener: ((event: Event) => void) | null = null; @query('slot:not([name])') defaultSlot: HTMLSlotElement; @state() defaultValue: string[] = []; /** The checkbox group's label. Required for proper accessibility. */ @property() label = ''; /** The name of the checkbox group, submitted as a name/value pair with form data. */ @property() name = ''; /** The current selected values, as an array of checkbox value strings. */ @property({ type: Array }) value: string[] = []; /** * Group name for property-based association. When set, the group discovers * checkboxes anywhere in the DOM that have a matching `group` attribute * instead of relying on slotted children. */ @property({ reflect: true, attribute: true}) for = ''; /** * Associates the group with a `
` element by its `id`. The form must * be in the same document or shadow root. */ @property({ reflect: true }) form = ''; /** At least one checkbox must be checked before the form can submit. */ @property({ type: Boolean, reflect: true }) required = false; /** Layout direction of the checkboxes. */ @property({ reflect: true }) orientation: 'vertical' | 'horizontal' = 'vertical'; /** Display checkboxes in an inline (horizontal) layout. Alias for orientation="horizontal". */ @property({ type: Boolean, reflect: true }) labelInline = false; /** Disables all associated checkboxes. */ @property({ type: Boolean, reflect: true }) disabled = false; @property({ attribute: true, reflect: true }) helpText = ''; @property({ attribute: true, reflect: true }) errorMessage = ''; /** Maximum number of checkboxes that can be checked. Unchecked boxes are disabled when the limit is reached. */ @property({ type: Number, reflect: true }) max: number | undefined; /** Minimum selections required for validation. */ @property({ type: Number, reflect: true }) min: number | undefined; /** Shows a visible border around the fieldset wrapper. */ @property({ type: Boolean, reflect: true }) bordered = false; /** Legend text displayed at the top of the fieldset when bordered is enabled. */ @property({ type: String, reflect: true }) legend = ''; private get isHorizontal(): boolean { return this.orientation === 'horizontal' || this.labelInline; } connectedCallback() { super.connectedCallback(); this.defaultValue = [...this.value]; this.setupDocumentListener(); this.emit(Nile_Events.NILE_INIT); } disconnectedCallback() { super.disconnectedCallback(); this.teardownDocumentListener(); this.emit(Nile_Events.NILE_DESTROY); } private setupDocumentListener() { if (this.for) { this.boundDocumentListener = (event: Event) => { const target = event.target as NileCheckbox; if ( target.tagName?.toLowerCase() === 'nile-checkbox' && target.group === this.for ) { this.handleCheckboxChange(target); } }; document.addEventListener(Nile_Events.NILE_CHANGE, this.boundDocumentListener); } } private teardownDocumentListener() { if (this.boundDocumentListener) { document.removeEventListener(Nile_Events.NILE_CHANGE, this.boundDocumentListener); this.boundDocumentListener = null; } } @watch('for') handleForChange() { this.teardownDocumentListener(); this.setupDocumentListener(); this.syncCheckboxStates(); this.enforceMaxDisabled(); } private getAllCheckboxes(): NileCheckbox[] { if (this.for) { const root = this.getRootNode() as Document | ShadowRoot; return [...root.querySelectorAll(`nile-checkbox[group="${this.for}"]`)]; } return [...this.querySelectorAll('nile-checkbox')]; } private handleCheckboxClick(event: MouseEvent) { const target = (event.target as HTMLElement).closest('nile-checkbox'); if (!target) return; this.handleCheckboxChange(target); } private handleChildInput(event: Event) { event.stopPropagation(); } private handleCheckboxChange(target: NileCheckbox) { if (target.disabled || this.disabled) return; const checkboxes = this.getAllCheckboxes(); const checkedCount = checkboxes.filter(cb => cb.checked).length; if (this.max !== undefined && checkedCount > this.max) { target.checked = false; return; } const newValue = checkboxes .filter(cb => cb.checked) .map(cb => String(cb.value)); const oldValue = this.value; this.value = newValue; this.enforceMaxDisabled(); if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { this.emit(Nile_Events.NILE_CHANGE, { value: this.value }); } } private enforceMaxDisabled() { if (this.max === undefined) return; const checkboxes = this.getAllCheckboxes(); const checkedCount = checkboxes.filter(cb => cb.checked).length; const atLimit = checkedCount >= this.max; checkboxes.forEach(cb => { if (!cb.checked && !this.disabled) { cb.disabled = atLimit; } }); } protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('max')) { this.enforceMaxDisabled(); } } @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { if (this.disabled) { this.getAllCheckboxes().forEach(cb => { cb.disabled = true; }); } else { this.getAllCheckboxes().forEach(cb => { cb.disabled = cb.hasAttribute('disabled'); }); this.enforceMaxDisabled(); } } private handleInitialDisabledState() { if (this.disabled) { this.getAllCheckboxes().forEach(cb => { cb.disabled = true; }); } else { this.getAllCheckboxes().forEach(cb => { cb.disabled = cb.hasAttribute('disabled') ? true : false; }); } } private handleLabelClick() { if (this.disabled) return; const checkboxes = this.getAllCheckboxes(); const first = checkboxes[0]; if (first) { first.focus(); } } private handleSlotChange() { const checkboxes = this.getAllCheckboxes(); checkboxes.forEach(cb => { cb.checked = this.value.includes(String(cb.value)); }); this.handleInitialDisabledState(); this.enforceMaxDisabled(); } private syncCheckboxStates() { const checkboxes = this.getAllCheckboxes(); checkboxes.forEach(cb => { cb.checked = this.value.includes(String(cb.value)); }); } @watch('value') handleValueChange() { if (this.hasUpdated) { if (this.max !== undefined && this.value.length > this.max) { this.value = this.value.slice(0, this.max); } this.syncCheckboxStates(); this.enforceMaxDisabled(); } } render() { const hasLabelSlot = this.hasSlotController.test('label'); const hasLabelSuffixSlot = this.hasSlotController.test('label-suffix'); const hasLabel = this.label ? true : !!hasLabelSlot; const hasHelpText = this.helpText ? true : false; const hasErrorMessage = this.errorMessage ? true : false; const defaultSlot = html` `; return html`
${this.legend ? html`${this.legend}` : ``} ${hasLabelSuffixSlot ? html` ` : ``}
${defaultSlot}
${hasHelpText ? html` ${this.helpText} ` : ``} ${hasErrorMessage ? html` ${this.errorMessage} ` : ``}
`; } } export default NileCheckboxGroup; declare global { interface HTMLElementTagNameMap { 'nile-checkbox-group': NileCheckboxGroup; } }