/** * AgnosticUI Input Component * * A flexible input component supporting various types, sizes, and styles. * Handles labels, helper text, and error messages using shared form control utilities. */ import { LitElement, html, css, nothing } from 'lit'; import { property, state, query } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { hasSlotContent } from '../../../utils/slot'; import { formControlStyles } from '../../../shared/form-control-styles'; import { createFormControlIds, buildAriaDescribedBy, isHorizontalLabel, type LabelPosition, } from '../../../shared/form-control-utils'; import { FaceMixin, syncInnerInputValidity } from '../../../shared/face-mixin'; export type InputType = | 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url' | 'date' | 'datetime-local' | 'month' | 'time' | 'week' | 'textarea'; export type InputSize = 'small' | 'default' | 'large'; /** * Input component properties for type safety */ export interface InputProps { label?: string; labelHidden?: boolean; labelPosition?: LabelPosition; noLabel?: boolean; ariaLabel?: string; name?: string; type?: InputType; value?: string; placeholder?: string; rows?: number; cols?: number; size?: InputSize; capsule?: boolean; rounded?: boolean; underlined?: boolean; underlinedWithBackground?: boolean; inline?: boolean; min?: string; max?: string; required?: boolean; disabled?: boolean; readonly?: boolean; invalid?: boolean; errorMessage?: string; helpText?: string; onClick?: (event: MouseEvent) => void; onInput?: (event: InputEvent) => void; onChange?: (event: Event) => void; onFocus?: (event: FocusEvent) => void; onBlur?: (event: FocusEvent) => void; } export class AgInput extends FaceMixin(LitElement) implements InputProps { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true, }; static styles = [ formControlStyles, css` :host { display: block; } :host([inline]) { display: inline-block; } /* Wrapper with size classes */ .ag-input { display: block; } .ag-input--small { /* Size-specific wrapper styling if needed */ } .ag-input--large { /* Size-specific wrapper styling if needed */ } .ag-input--rounded { /* Rounded variant wrapper styling if needed */ } .ag-input--underlined { /* Underlined variant wrapper styling if needed */ } .ag-input--underlined-with-background { /* Underlined with background wrapper styling if needed */ } /* Input & Textarea Base Styles */ .ag-input__input, .ag-input__textarea { box-sizing: border-box; width: 100%; padding: var(--ag-space-2) var(--ag-space-3); font-size: var(--ag-font-size-sm); line-height: var(--ag-line-height-base); color: var(--ag-text-primary); background-color: var(--ag-background-primary); border: 1px solid var(--ag-border-subtle); border-radius: 0; transition: all var(--ag-motion-medium); } .ag-input__input::placeholder, .ag-input__textarea::placeholder { font-size: var(--ag-font-size-sm); color: var(--ag-text-muted); opacity: 1; } .ag-input__input:focus-visible, .ag-input__textarea:focus-visible { outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5); outline-offset: var(--ag-focus-offset); border-color: rgba(var(--ag-focus), 0.6); } .ag-input__textarea { resize: vertical; font-family: inherit; /* Ensure textarea uses same font as input */ } /* Sizes */ :host([size="small"]) .ag-input__input, :host([size="small"]) .ag-input__textarea { padding: var(--ag-space-1) var(--ag-space-2); font-size: var(--ag-font-size-sm); } :host([size="large"]) .ag-input__input, :host([size="large"]) .ag-input__textarea { padding: var(--ag-space-3) var(--ag-space-4); font-size: var(--ag-font-size-lg); } /* Variants */ :host([capsule]) .ag-input__input, :host([capsule]) .ag-input__textarea { border-radius: var(--ag-radius-full); } :host([rounded]) .ag-input__input, :host([rounded]) .ag-input__textarea { border-radius: var(--ag-radius-md); } :host([underlined]) .ag-input__input, :host([underlined]) .ag-input__textarea { border-radius: 0; border-width: 0 0 1px 0; } :host([underlined-with-background]) .ag-input__input, :host([underlined-with-background]) .ag-input__textarea { border-radius: 0; border-width: 0 0 1px 0; background-color: var(--ag-background-secondary); } /* States */ :host([disabled]) .ag-input__input, :host([disabled]) .ag-input__textarea { background-color: var(--ag-background-disabled); color: var(--ag-text-muted); cursor: not-allowed; } :host([invalid]) .ag-input__input, :host([invalid]) .ag-input__textarea { border-color: var(--ag-error-text); } :host([invalid]) .ag-input__input:focus-visible, :host([invalid]) .ag-input__textarea:focus-visible { border-color: rgba(var(--ag-danger-rgb), 0.6); outline-color: rgba(var(--ag-danger-rgb), 0.5); } /* Addons */ .ag-input__field { display: flex; align-items: stretch; } .ag-input__addon { display: flex; align-items: center; justify-content: center; padding: var(--ag-space-2) var(--ag-space-3); font-size: var(--ag-font-size-base); line-height: var(--ag-line-height-base); color: var(--ag-text-primary); border: 1px solid var(--ag-border-subtle); } /* Ensure nested content (icons, svgs) is also centered */ .ag-input__addon ::slotted(*) { display: flex; align-items: center; justify-content: center; } .ag-input__addon--left { border-inline-end: 0; border-radius: var(--ag-radius-md) 0 0 var(--ag-radius-md); } .ag-input__addon--right { border-inline-start: 0; border-radius: 0 var(--ag-radius-md) var(--ag-radius-md) 0; } /* When left addon is present */ .ag-input__field:has(.ag-input__addon--left) .ag-input__input, .ag-input__field:has(.ag-input__addon--left) .ag-input__textarea { border-start-start-radius: 0; border-end-start-radius: 0; } /* When right addon is present */ .ag-input__field:has(.ag-input__addon--right) .ag-input__input, .ag-input__field:has(.ag-input__addon--right) .ag-input__textarea { border-start-end-radius: 0; border-end-end-radius: 0; } .ag-input__field .ag-input__input, .ag-input__field .ag-input__textarea { flex: 1; } /* Capsule variant with addons */ :host([capsule]) .ag-input__addon--left { border-radius: var(--ag-radius-full) 0 0 var(--ag-radius-full); padding: var(--ag-space-2) var(--ag-space-4); } :host([capsule]) .ag-input__addon--right { border-radius: 0 var(--ag-radius-full) var(--ag-radius-full) 0; padding: var(--ag-space-2) var(--ag-space-4); } /* Remove border on INPUT where it meets the addon for capsule */ :host([capsule]) .ag-input__field:has(.ag-input__addon--left) .ag-input__input, :host([capsule]) .ag-input__field:has(.ag-input__addon--left) .ag-input__textarea { border-inline-start: 0; } :host([capsule]) .ag-input__field:has(.ag-input__addon--right) .ag-input__input, :host([capsule]) .ag-input__field:has(.ag-input__addon--right) .ag-input__textarea { border-inline-end: 0; } /* Underlined variant with addons */ :host([underlined]) .ag-input__addon, :host([underlined-with-background]) .ag-input__addon { border-top: 0; border-radius: 0; background: transparent; } :host([underlined]) .ag-input__addon--left, :host([underlined-with-background]) .ag-input__addon--left { border-inline-start: 0; border-inline-end: 0; } :host([underlined]) .ag-input__addon--right, :host([underlined-with-background]) .ag-input__addon--right { border-inline-end: 0; border-inline-start: 0; } /* Remove border on INPUT where it meets the addon for underlined */ :host([underlined]) .ag-input__field:has(.ag-input__addon--left) .ag-input__input, :host([underlined]) .ag-input__field:has(.ag-input__addon--left) .ag-input__textarea, :host([underlined-with-background]) .ag-input__field:has(.ag-input__addon--left) .ag-input__input, :host([underlined-with-background]) .ag-input__field:has(.ag-input__addon--left) .ag-input__textarea { border-inline-start: 0; } :host([underlined]) .ag-input__field:has(.ag-input__addon--right) .ag-input__input, :host([underlined]) .ag-input__field:has(.ag-input__addon--right) .ag-input__textarea, :host([underlined-with-background]) .ag-input__field:has(.ag-input__addon--right) .ag-input__input, :host([underlined-with-background]) .ag-input__field:has(.ag-input__addon--right) .ag-input__textarea { border-inline-end: 0; } /* Underlined with background variant - add background to addon */ :host([underlined-with-background]) .ag-input__addon { background-color: var(--ag-background-secondary); } `, ]; // Stable IDs for form control elements (created once) private _ids = createFormControlIds('ag-input'); // Reference to the actual input/textarea element @query('input, textarea') private _inputElement?: HTMLInputElement | HTMLTextAreaElement; // Label properties @property({ type: String }) declare label: string; @property({ type: Boolean, attribute: 'label-hidden' }) declare labelHidden: boolean; @property({ type: String, attribute: 'label-position' }) declare labelPosition: LabelPosition; @property({ type: Boolean, attribute: 'no-label' }) declare noLabel: boolean; @property({ type: String, attribute: 'aria-label' }) declare ariaLabel: string; // Input properties @property({ type: String }) declare type: InputType; @property({ type: String, reflect: true }) declare value: string; @property({ type: String }) declare placeholder: string; // Textarea-specific properties @property({ type: Number }) declare rows: number; @property({ type: Number }) declare cols: number; // Size and style variants @property({ type: String, reflect: true }) declare size: InputSize; @property({ type: Boolean, reflect: true }) declare capsule: boolean; @property({ type: Boolean, reflect: true }) declare rounded: boolean; @property({ type: Boolean, reflect: true }) declare underlined: boolean; @property({ type: Boolean, reflect: true, attribute: 'underlined-with-background' }) declare underlinedWithBackground: boolean; @property({ type: Boolean, reflect: true }) declare inline: boolean; // Range constraints (for date, number, time inputs) @property({ type: String }) declare min: string; @property({ type: String }) declare max: string; // Validation and state @property({ type: Boolean, reflect: true }) declare required: boolean; @property({ type: Boolean, reflect: true }) declare disabled: boolean; @property({ type: Boolean, reflect: true }) declare readonly: boolean; @property({ type: Boolean, reflect: true }) declare invalid: boolean; // Helper and error text @property({ type: String, attribute: 'error-message' }) declare errorMessage: string; @property({ type: String, attribute: 'help-text' }) declare helpText: string; // Event handlers @property({ attribute: false }) declare onClick?: (event: MouseEvent) => void; @property({ attribute: false }) declare onInput?: (event: InputEvent) => void; @property({ attribute: false }) declare onChange?: (event: Event) => void; @property({ attribute: false }) declare onFocus?: (event: FocusEvent) => void; @property({ attribute: false }) declare onBlur?: (event: FocusEvent) => void; // Addon slot tracking @state() private _hasLeftAddon = false; @state() private _hasRightAddon = false; constructor() { super(); this.label = ''; this.labelHidden = false; this.labelPosition = 'top'; this.noLabel = false; this.ariaLabel = ''; this.type = 'text'; this.value = ''; this.placeholder = ''; this.rows = 4; this.cols = 50; this.size = 'default'; this.capsule = false; this.rounded = false; this.underlined = false; this.underlinedWithBackground = false; this.inline = false; this.min = ''; this.max = ''; this.required = false; this.disabled = false; this.readonly = false; this.invalid = false; this.errorMessage = ''; this.helpText = ''; } /** * Expose the internal input element for external access */ get controlElement(): HTMLInputElement | HTMLTextAreaElement | undefined { return this._inputElement; } /** * Get the current value of the input */ getValue(): string { return this._inputElement?.value ?? ''; } /** * Set the value of the input */ setValue(value: string): void { if (this._inputElement) { this._inputElement.value = value; this.value = value; } } /** * Select the text in the input */ select(): void { this._inputElement?.select(); } // ─── FACE: AgInput-specific overrides ──────────────────────────────────── // Common boilerplate (formAssociated, _internals, name, form/validity // getters, checkValidity, reportValidity, formDisabledCallback) lives in // FaceMixin (shared/face-mixin.ts). /** * FACE lifecycle: called when the parent form is reset. * Restores value to empty and clears validity state. */ override formResetCallback(): void { this.value = ''; this._internals.setFormValue(''); this._internals.setValidity({}); this._syncStates(); } /** * FACE lifecycle: called on session restore or browser autofill. * Restores the input value from the previously saved form state. */ override formStateRestoreCallback( state: File | string | FormData | null, _mode: 'restore' | 'autocomplete' ): void { this.value = typeof state === 'string' ? state : ''; this._internals.setFormValue(this.value); 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) — input is disabled * :state(readonly) — input is read-only * :state(required) — input is required * :state(invalid) — FACE constraint validation is failing */ private _syncStates(): void { this._setState('disabled', this.disabled); this._setState('readonly', this.readonly); this._setState('required', this.required); this._setState('invalid', !this._internals.validity.valid); } /** * Sync the inner input's native validity state to ElementInternals. * Delegates constraint validation (required, minlength, type=email, etc.) * to the inner rather than reimplementing it from scratch. * Uses the shared syncInnerInputValidity() helper from face-mixin.ts. */ private _syncValidity(): void { syncInnerInputValidity(this._internals, this._inputElement); } // ─── End FACE ───────────────────────────────────────────────────────────── /** * Handle slot changes to detect addons */ private _handleSlotChange(e: Event) { const slot = e.target as HTMLSlotElement; const slotName = slot.name; if (slotName === 'addon-left') { this._hasLeftAddon = hasSlotContent(slot); } else if (slotName === 'addon-right') { this._hasRightAddon = hasSlotContent(slot); } this.requestUpdate(); } /** * Handle input events */ private _handleInput(e: Event) { const target = e.target as HTMLInputElement | HTMLTextAreaElement; this.value = target.value; // FACE: keep form submission value in sync this._internals.setFormValue(this.value); // FACE: mirror native constraint validity (required, minlength, type, etc.) this._syncValidity(); if (this.onInput) { this.onInput(e as InputEvent); } } /** * Handle change events */ private _handleChange(e: Event) { const target = e.target as HTMLInputElement | HTMLTextAreaElement; this.value = target.value; // FACE: keep form submission value in sync this._internals.setFormValue(this.value); // FACE: mirror native constraint validity (required, minlength, type, etc.) this._syncValidity(); if (this.onChange) { this.onChange(e); } // Native `change` is composed:false so it stops at the shadow root. // Re-dispatch a composed change from the host so Vue @change listeners // and other external DOM listeners receive it. this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); } /** * Handle focus events */ private _handleFocus(e: FocusEvent) { // Focus doesn't bubble - re-dispatch from host this.dispatchEvent( new FocusEvent('focus', { bubbles: true, composed: true, relatedTarget: e.relatedTarget, }) ); if (this.onFocus) { this.onFocus(e); } } /** * Handle blur events */ private _handleBlur(e: FocusEvent) { // Blur doesn't bubble - re-dispatch from host this.dispatchEvent( new FocusEvent('blur', { bubbles: true, composed: true, relatedTarget: e.relatedTarget, }) ); if (this.onBlur) { this.onBlur(e); } } /** * Handle click events */ private _handleClick(e: MouseEvent) { if (this.onClick) { this.onClick(e); } } /** * 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, }); } /** * Render the input or textarea element */ private _supportsMinMax(): boolean { return ['date', 'datetime-local', 'month', 'time', 'week', 'number', 'range'].includes(this.type); } private _renderInputElement() { const isTextarea = this.type === 'textarea'; // Build aria-describedby const describedBy = this._getAriaDescribedBy(); const describedByIds: string[] = []; if (describedBy) { describedByIds.push(describedBy); } if (isTextarea) { return html` `; } return html` `; } override updated(changedProperties: Map) { super.updated(changedProperties); if ( changedProperties.has('disabled') || changedProperties.has('readonly') || changedProperties.has('required') || changedProperties.has('invalid') ) { this._syncStates(); } } override firstUpdated() { // FACE: set initial form value and sync validity after first render this._internals.setFormValue(this.value ?? ''); this._syncValidity(); this._syncStates(); // Initial check for slot content setTimeout(() => { const leftAddonSlot = this.shadowRoot?.querySelector('slot[name="addon-left"]') as HTMLSlotElement; const rightAddonSlot = this.shadowRoot?.querySelector('slot[name="addon-right"]') as HTMLSlotElement; const hadLeftAddon = this._hasLeftAddon; const hadRightAddon = this._hasRightAddon; this._hasLeftAddon = hasSlotContent(leftAddonSlot); this._hasRightAddon = hasSlotContent(rightAddonSlot); // Only request update if something changed if (hadLeftAddon !== this._hasLeftAddon || hadRightAddon !== this._hasRightAddon) { this.requestUpdate(); } }, 0); } /** * Render custom label for Input (using shared utility but customized for Input) */ private _renderLabel() { if (!this.label || this.noLabel) return nothing; // Build position classes based on directional value const positionClasses: string[] = []; if (isHorizontalLabel(this.labelPosition)) { positionClasses.push('ag-form-control__label--horizontal'); positionClasses.push(`ag-form-control__label--${this.labelPosition}`); positionClasses.push('ag-input__label--horizontal'); positionClasses.push(`ag-input__label--${this.labelPosition}`); } else if (this.labelPosition === 'bottom') { positionClasses.push(`ag-form-control__label--${this.labelPosition}`); positionClasses.push(`ag-input__label--${this.labelPosition}`); } return html` `; } /** * Render custom helper text for Input */ private _renderHelper() { if (!this.helpText) return nothing; return html`
${this.helpText}
`; } /** * Render custom error message for Input. * role="alert" + aria-atomic="true" ensures screen readers announce the * message immediately when it becomes visible (e.g. after blur or submit). * The live region is always in the DOM so ATs register it on page load; * content is populated only when invalid so announcements fire on change. */ private _renderError() { return html` `; } render() { const hasAddons = this._hasLeftAddon || this._hasRightAddon; const isHorizontal = isHorizontalLabel(this.labelPosition); // Build wrapper class list const wrapperClasses = ['ag-input']; if (this.size === 'small') wrapperClasses.push('ag-input--small'); if (this.size === 'large') wrapperClasses.push('ag-input--large'); if (this.rounded) wrapperClasses.push('ag-input--rounded'); if (this.underlined) wrapperClasses.push('ag-input--underlined'); if (this.underlinedWithBackground) wrapperClasses.push('ag-input--underlined-with-background'); // Render input field (with or without addons) const inputField = hasAddons ? html`
${this._hasLeftAddon ? html`
` : html` `} ${this._renderInputElement()} ${this._hasRightAddon ? html`
` : html` `}
` : html` ${this._renderInputElement()} `; // For horizontal layout: [Label] [Input] structure with helper/error below if (isHorizontal) { return html`
${this._renderLabel()} ${inputField}
${this.helpText ? this._renderHelper() : nothing} ${this._renderError()}
`; } // For vertical layout: Different order for top vs bottom labels if (this.labelPosition === 'bottom') { // Bottom label: Input first, then helper/error, then label return html`
${inputField} ${this.helpText ? this._renderHelper() : nothing} ${this._renderError()} ${this._renderLabel()}
`; } // Top label (default): Label first, then helper/error, then input return html`
${this._renderLabel()} ${this.helpText ? this._renderHelper() : nothing} ${this._renderError()} ${inputField}
`; } }