import { LitElement, html, css, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { formControlStyles } from '../../../shared/form-control-styles'; import { createFormControlIds, buildAriaDescribedBy, } from '../../../shared/form-control-utils'; import { FaceMixin, syncInnerInputValidity } from '../../../shared/face-mixin'; export type CheckboxSize = 'small' | 'medium' | 'large'; export type CheckboxTheme = 'default' | 'primary' | 'success' | 'monochrome'; // Event types export interface CheckboxChangeEventDetail { checked: boolean; value: string; name: string; indeterminate: boolean; } export type CheckboxChangeEvent = CustomEvent; /** * @csspart ag-checkbox-wrapper - The outer wrapper label element * @csspart ag-checkbox-input - The native checkbox input element * @csspart ag-checkbox-indicator - The custom visual checkbox indicator (box with checkmark) * @csspart ag-checkbox-label - The label text span */ export interface CheckboxProps { name?: string; value?: string; checked?: boolean; indeterminate?: boolean; disabled?: boolean; size?: CheckboxSize; theme?: CheckboxTheme; labelText?: string; labelPosition?: 'end' | 'start'; // Validation & hints required?: boolean; invalid?: boolean; errorMessage?: string; helpText?: string; // Event callbacks onClick?: (event: MouseEvent) => void; onChange?: (event: CheckboxChangeEvent) => void; } export class AgCheckbox extends FaceMixin(LitElement) implements CheckboxProps { static override styles = [ formControlStyles, css` :host { display: inline-block; } .checkbox-wrapper { display: inline-flex; align-items: center; cursor: pointer; user-select: none; gap: 0; } :host([disabled]) .checkbox-wrapper { cursor: not-allowed; opacity: 0.6; } .checkbox-wrapper--label-start { flex-direction: row-reverse; } .checkbox-input { position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0; padding: 0; clip: rect(0, 0, 0, 0); overflow: hidden; } .checkbox-label { position: relative; display: inline-flex; align-items: center; flex-shrink: 0; } /* The box - drawn with ::before */ .checkbox-label::before { content: ''; display: inline-block; flex-shrink: 0; margin-inline-end: var(--ag-space-3); border-radius: var(--ag-radius-sm); transition: all var(--ag-motion-fast) ease-in-out; } /* The checkmark - drawn with ::after */ .checkbox-label::after { content: ''; position: absolute; display: block; opacity: 0; transform: rotate(40deg) scale(0); transform-origin: center center; transition: all var(--ag-motion-fast) ease-in-out; } /* Size variants */ .checkbox-label--small::before { width: 14px; height: 14px; } .checkbox-label--small::after { inset-inline-start: 4px; top: 0px; width: 4px; height: 8px; border-inline-end: var(--ag-border-width-2) solid var(--ag-white); border-bottom: var(--ag-border-width-2) solid var(--ag-white); } .checkbox-label--medium::before { width: 16px; height: 16px; } .checkbox-label--medium::after { inset-inline-start: 5px; top: 1px; width: 4px; height: 9px; border-inline-end: var(--ag-border-width-2) solid var(--ag-white); border-bottom: var(--ag-border-width-2) solid var(--ag-white); } .checkbox-label--large::before { width: 18px; height: 18px; } .checkbox-label--large::after { inset-inline-start: 6px; top: 1px; width: 5px; height: 10px; border-inline-end: var(--ag-border-width-2) solid var(--ag-white); border-bottom: var(--ag-border-width-2) solid var(--ag-white); } /* Indeterminate state - horizontal line */ .checkbox-input:indeterminate + .checkbox-label::after { transform: rotate(0deg) scale(1) translate(-6px); opacity: 1; border-inline-end: none; border-bottom: var(--ag-border-width-2) solid var(--ag-white); width: 8px; height: 0; inset-inline-start: 50%; top: 50%; margin-inline-start: -4px; margin-block-start: -1px; } /* Checked state */ .checkbox-input:checked + .checkbox-label::after { opacity: 1; transform: rotate(40deg) scale(1) translate(2px); } /* Default theme - alias to primary */ .checkbox-label--default::before { border: var(--ag-border-width-2) solid var(--ag-primary-border); background: var(--ag-white); } .checkbox-input:checked + .checkbox-label--default::before, .checkbox-input:indeterminate + .checkbox-label--default::before { background: var(--ag-primary); border-color: var(--ag-primary); } .checkbox-input:focus + .checkbox-label--default::before { box-shadow: 0 0 0 var(--ag-focus-width) rgba(var(--ag-focus), 0.5); outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5); outline-offset: var(--ag-focus-offset); } /* Primary theme */ .checkbox-label--primary::before { border: var(--ag-border-width-2) solid var(--ag-primary-border); background: var(--ag-white); } .checkbox-input:checked + .checkbox-label--primary::before, .checkbox-input:indeterminate + .checkbox-label--primary::before { background: var(--ag-primary); border-color: var(--ag-primary); } .checkbox-input:focus + .checkbox-label--primary::before { box-shadow: 0 0 0 var(--ag-focus-width) rgba(var(--ag-focus), 0.5); outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5); outline-offset: var(--ag-focus-offset); } /* Success theme - green */ .checkbox-label--success::before { border: var(--ag-border-width-2) solid var(--ag-border); background: var(--ag-white); } .checkbox-input:checked + .checkbox-label--success::before, .checkbox-input:indeterminate + .checkbox-label--success::before { background: var(--ag-success); border-color: var(--ag-success); } .checkbox-input:focus + .checkbox-label--success::before { box-shadow: 0 0 0 var(--ag-focus-width) rgba(var(--ag-focus), 0.5); outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5); outline-offset: var(--ag-focus-offset); } /* Monochrome theme */ .checkbox-label--monochrome::before { border: var(--ag-border-width-2) solid var(--ag-black); background: var(--ag-white); } .checkbox-input:checked + .checkbox-label--monochrome::before, .checkbox-input:indeterminate + .checkbox-label--monochrome::before { background: var(--ag-black); border-color: var(--ag-black); } .checkbox-input:focus + .checkbox-label--monochrome::before { box-shadow: 0 0 0 var(--ag-focus-width) rgba(var(--ag-focus), 0.5); outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5); outline-offset: var(--ag-focus-offset); } .checkbox-label-copy { display: inline-block; } .checkbox-label-copy--small { font-size: var(--ag-font-size-sm); } .checkbox-label-copy--medium { font-size: var(--ag-font-size-sm); } .checkbox-label-copy--large { font-size: var(--ag-font-size-base); } /* Respect reduced motion preferences */ @media (prefers-reduced-motion: reduce) { .checkbox-label, .checkbox-label::before, .checkbox-label::after { transition: none; } } /* High contrast mode support */ @media (prefers-contrast: high) { .checkbox-label::before { outline: var(--ag-border-width-1) solid; } } `, ]; @property({ type: String }) declare value: string; @property({ type: Boolean, reflect: true }) declare checked: boolean; @property({ type: Boolean, reflect: true }) declare indeterminate: boolean; @property({ type: Boolean, reflect: true }) declare disabled: boolean; @property({ type: String }) declare size: CheckboxSize; @property({ type: String }) declare theme: CheckboxTheme; @property({ type: String }) declare labelText: string; @property({ type: String }) declare labelPosition: 'end' | 'start'; // Validation & hints @property({ type: Boolean, reflect: true }) declare required: boolean; @property({ type: Boolean, reflect: true }) declare invalid: boolean; @property({ type: String, attribute: 'error-message' }) declare errorMessage: string; @property({ type: String, attribute: 'help-text' }) declare helpText: string; @property({ attribute: false }) declare onClick?: (event: MouseEvent) => void; @property({ attribute: false }) declare onChange?: (event: CheckboxChangeEvent) => void; // Stable IDs for form control elements (created once) private _ids = createFormControlIds('ag-checkbox'); private inputRef?: HTMLInputElement; constructor() { super(); this.value = ''; this.checked = false; this.indeterminate = false; this.disabled = false; this.size = 'medium'; this.theme = 'primary'; this.labelText = ''; this.labelPosition = 'end'; this.required = false; this.invalid = false; this.errorMessage = ''; this.helpText = ''; } /** * Expose the internal focusable element for FormControl ARIA wiring */ get controlElement(): HTMLElement | null { return this.inputRef || null; } // ─── FACE ───────────────────────────────────────────────────────────────── /** * FACE lifecycle: called when the parent form is reset. * Restores checked and indeterminate to their default states. */ override formResetCallback(): void { this.checked = false; this.indeterminate = false; this._internals.setFormValue(null); this._internals.setValidity({}); this._syncStates(); } /** * FACE lifecycle: called on session restore or browser autofill. * Restores checked state from the previously saved form value. * A non-null state means the checkbox was checked; null means unchecked. */ override formStateRestoreCallback( state: File | string | FormData | null, _mode: 'restore' | 'autocomplete' ): void { this.checked = state !== null; this.indeterminate = false; this._internals.setFormValue(this.checked ? (this.value || 'on') : null); this._syncValidity(); this._syncStates(); } /** * Sync validity to ElementInternals by delegating to the inner * . Required validation is handled natively * by the inner input; we just mirror its state. */ private _syncValidity(): void { syncInnerInputValidity(this._internals, this.inputRef); } /** * 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(checked) — checkbox is checked * :state(indeterminate) — checkbox is in indeterminate state * :state(disabled) — checkbox is disabled * :state(required) — checkbox is required * :state(invalid) — FACE constraint validation is failing */ private _syncStates(): void { this._setState('checked', this.checked); this._setState('indeterminate', this.indeterminate); this._setState('disabled', this.disabled); this._setState('required', this.required); this._setState('invalid', !this._internals.validity.valid); } // ─── End FACE ───────────────────────────────────────────────────────────── override updated(changedProperties: Map) { super.updated(changedProperties); // Sync indeterminate state to native input if (changedProperties.has('indeterminate') && this.inputRef) { this.inputRef.indeterminate = this.indeterminate; } // FACE: sync form value and validity for programmatic changes to checked/indeterminate if (changedProperties.has('checked') || changedProperties.has('indeterminate')) { this._internals.setFormValue(this.checked ? (this.value || 'on') : null); this._syncValidity(); this._syncStates(); } } private handleClick(e: MouseEvent) { // Invoke native click callback if provided if (this.onClick) { this.onClick(e); } } private handleChange(e: Event) { if (this.disabled) { e.preventDefault(); return; } const input = e.target as HTMLInputElement; this.checked = input.checked; // When user clicks, clear indeterminate state if (this.indeterminate) { this.indeterminate = false; } // FACE: sync form value and validity on user interaction this._internals.setFormValue(this.checked ? (this.value || 'on') : null); this._syncValidity(); this._syncStates(); // Dispatch custom change event with dual-dispatch pattern const changeEvent = new CustomEvent('change', { detail: { checked: this.checked, value: this.value, name: this.name, indeterminate: this.indeterminate, }, bubbles: true, composed: true, }); // Dual-dispatch: DOM event first this.dispatchEvent(changeEvent); // Then invoke callback if provided if (this.onChange) { this.onChange(changeEvent); } } /** * Render helper text */ private _renderHelper() { if (!this.helpText) return nothing; return html`
${this.helpText}
`; } /** * Render error text */ private _renderError() { if (!this.invalid || !this.errorMessage) return nothing; return html`
${this.errorMessage}
`; } /** * Build ARIA describedby attribute */ private _getAriaDescribedBy(): string | undefined { return buildAriaDescribedBy({ helperId: this._ids.helperId, errorId: this._ids.errorId, hasHelper: !!this.helpText, hasError: this.invalid && !!this.errorMessage, }); } override render() { const wrapperClasses = ` checkbox-wrapper ${this.labelPosition === 'start' ? 'checkbox-wrapper--label-start' : ''} `; const labelClasses = ` checkbox-label checkbox-label--${this.size} checkbox-label--${this.theme} `; const labelCopyClasses = ` checkbox-label-copy checkbox-label-copy--${this.size} `; // Build aria-describedby const describedBy = this._getAriaDescribedBy(); // Main structure wraps everything return html`
${this._renderHelper()} ${this._renderError()}
`; } override firstUpdated() { this.inputRef = this.shadowRoot?.querySelector( '.checkbox-input' ) as HTMLInputElement; if (this.inputRef && this.indeterminate) { this.inputRef.indeterminate = this.indeterminate; } // FACE: set initial form value and sync validity after first render this._internals.setFormValue(this.checked ? (this.value || 'on') : null); this._syncValidity(); this._syncStates(); } }