/** * Copyright Aquera Inc 2026 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html } from 'lit'; import { customElement, property, query, queryAll, state, } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { defaultValue } from '../internal/default-value'; import { FormControlController, validValidityState } from '../internal/form'; import { HasSlotController } from '../internal/slot'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import { KeyCode, Nile_Events } from '../internal/enum'; import type { CSSResultGroup } from 'lit'; import type { NileFormControl } from '../internal/nile-element'; import { styles } from './nile-otp-input.css'; import { OtpInputMode, OtpInputType, OtpEnterKeyHint, OtpAutoComplete, OtpCellPattern, } from './nile-otp-input.enum'; import '../nile-form-help-text'; import '../nile-form-error-message'; /** * @summary OTP input renders a segmented set of cells but behaves like a single logical form control. * @tag nile-otp-input * * @slot label - The input label. Alternatively, use the `label` attribute. * @slot help-text - Helpful guidance text. Alternatively, use the `help-text` attribute. * * @event nile-input - Emitted whenever the OTP value changes from user input. * @event nile-change - Emitted whenever the OTP value changes from user input. * @event nile-complete - Emitted when all OTP cells are filled. * @event nile-focus - Emitted when focus enters the component. * @event nile-blur - Emitted when focus leaves the component. * @event nile-paste - Emitted when OTP text is pasted. * @event nile-invalid - Emitted when the control is invalid. * * @csspart form-control - Wrapper for label, input, and help/error content. * @csspart form-control-label - Label wrapper. * @csspart form-control-input - Input wrapper. * @csspart form-control-help-text - Help text wrapper. * @csspart form-control-error-message - Error message wrapper. * @csspart base - OTP cell container. * @csspart cell - Individual OTP cell. * @csspart separator - Separator element between cell groups. */ @customElement('nile-otp-input') export class NileOtpInput extends NileElement implements NileFormControl { static styles: CSSResultGroup = styles; private readonly formControlController: FormControlController = new FormControlController(this, { assumeInteractionOn: [Nile_Events.NILE_BLUR, Nile_Events.NILE_INPUT], }); private readonly hasSlotController = new HasSlotController( this, 'help-text', 'label' ); private customValidationMessage = ''; private wasComplete = false; @query('.otp__value-input') valueInput: HTMLInputElement; @queryAll('.otp__cell') cellInputs: NodeListOf; @state() private hasFocus = false; @state() private activeIndex = -1; @state() private cells: string[] = this.createCells(''); /** The name of the input, submitted as a name/value pair with form data. */ @property({ reflect: true, type: String, attribute: true }) name = ''; /** The current value of the OTP control. */ @property({ reflect: true, type: String, attribute: true }) value = ''; /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue = ''; /** Number of OTP cells. Values below 4 are clamped to 4. */ @property({ type: Number, reflect: true, attribute: true }) length = 6; /** Restricts input to numeric digits when true. Overridden by `alphanumeric`. */ @property({ type: Boolean, reflect: true, attribute: true }) numericOnly = true; /** Allows both letters and digits. When present, overrides `numeric-only`. */ @property({ type: Boolean, reflect: true, attribute: true }) alphanumeric = false; /** The input's label. */ @property({ reflect: true, attribute: true, type: String }) label = ''; @property({ attribute: true, reflect: true, type: String }) helpText = ''; @property({ attribute: true, reflect: true, type: String }) errorMessage = ''; /** Placeholder shown inside each OTP cell. */ @property({ reflect: true, attribute: true, type: String }) placeholder = ''; /** Optional separator text rendered between configured OTP groups (for example "-"). */ @property({ reflect: true, type: String }) separator = ''; /** Renders a separator after each N cells when `separator` is set. */ @property({ type: Number, attribute: true, reflect: true }) separatorEvery = 0; /** Comma-separated zero-based cell indexes after which separators are rendered. */ @property({ attribute: 'separator-positions', type: String, reflect: true }) separatorPositions = ''; /** Masks filled cells with dots, showing each character briefly while typing. */ @property({ type: Boolean, reflect: true }) masked = false; /** Sets the input to a warning state, changing its visual appearance. */ @property({ type: Boolean, attribute: true, reflect: true }) warning = false; /** Sets the input to an error state, changing its visual appearance. */ @property({ type: Boolean, attribute: true, reflect: true }) error = false; /** Sets the input to a success state, changing its visual appearance. */ @property({ type: Boolean, attribute: true, reflect: true }) success = false; /** Disables the control. */ @property({ type: Boolean, reflect: true, attribute: true }) disabled = false; /** Makes the control readonly. */ @property({ type: Boolean, attribute: true, reflect: true }) readonly = false; /** * By default, form controls are associated with the nearest containing `
` element. This attribute allows you * to place the form control outside of a form and associate it with the form that has this `id`. */ @property({ reflect: true, attribute: true, type: String }) form = ''; /** Makes this field required. */ @property({ type: Boolean, reflect: true, attribute: true }) required = false; /** Optional regex pattern for full OTP validation. */ @property({ reflect: true, attribute: true, type: String }) pattern: string; /** Indicates that the input should receive focus on page load. */ @property({ type: Boolean, reflect: true, attribute: true }) autofocus = false; /** Controls keyboard type shown on supporting virtual keyboards. */ @property({ reflect: true, attribute: true, type: String }) inputmode: | 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; /** The autocomplete mode used on the first OTP cell. */ @property({ reflect: true, type: String }) autocomplete: string = OtpAutoComplete.ONE_TIME_CODE; connectedCallback() { super.connectedCallback(); this.emit(Nile_Events.NILE_INIT); } disconnectedCallback() { super.disconnectedCallback(); this.emit(Nile_Events.NILE_DESTROY); } firstUpdated() { const normalized = this.normalizeValue(this.value); if (normalized !== this.value) { this.value = normalized; return; } this.syncCellsFromValue(normalized); this.wasComplete = this.isComplete(normalized); this.valueInput.setCustomValidity(this.customValidationMessage); this.formControlController.updateValidity(); if (this.autofocus) { this.focus(); } } /** Gets the validity state object. */ get validity() { return this.valueInput?.validity ?? validValidityState; } /** Gets the validation message. */ get validationMessage() { return this.valueInput?.validationMessage ?? ''; } /** Returns true when all OTP cells have values. */ get complete() { return this.isComplete(this.value); } private getNormalizedLength() { const parsed = Number.isFinite(this.length) ? Math.trunc(this.length) : 6; return Math.max(4, parsed); } private isNumericMode() { return this.numericOnly && !this.alphanumeric; } private getResolvedInputMode() { return this.inputmode ?? (this.isNumericMode() ? OtpInputMode.NUMERIC : OtpInputMode.TEXT); } private getValidationPattern() { if (this.pattern) { return this.pattern; } const normalizedLength = this.getNormalizedLength(); return this.isNumericMode() ? `[0-9]{${normalizedLength}}` : `[A-Za-z0-9]{${normalizedLength}}`; } private isAllowedCharacter(char: string) { return this.isNumericMode() ? /^[0-9]$/.test(char) : /^[A-Za-z0-9]$/.test(char); } private toOtpCharacters(value: string) { return Array.from(value ?? '').filter(char => this.isAllowedCharacter(char) ); } private normalizeValue(value: string) { return this.toOtpCharacters(value ?? '') .slice(0, this.getNormalizedLength()) .join(''); } private createCells(value: string) { const normalizedLength = this.getNormalizedLength(); const normalizedChars = this.toOtpCharacters(value).slice( 0, normalizedLength ); return Array.from( { length: normalizedLength }, (_, index) => normalizedChars[index] ?? '' ); } private syncCellsFromValue(value: string) { this.cells = this.createCells(value); } private isComplete(value: string) { return value.length === this.getNormalizedLength(); } private getFirstEmptyIndex() { const index = this.cells.findIndex(char => char === ''); return index === -1 ? this.getNormalizedLength() - 1 : index; } private getSeparatorIndices() { const maxIndex = this.getNormalizedLength() - 2; const indices = new Set(); if (Number.isInteger(this.separatorEvery) && this.separatorEvery > 0) { for ( let index = this.separatorEvery - 1; index <= maxIndex; index += this.separatorEvery ) { indices.add(index); } } if (this.separatorPositions.trim().length > 0) { this.separatorPositions .split(',') .map(value => Number.parseInt(value.trim(), 10)) .filter( index => Number.isInteger(index) && index >= 0 && index <= maxIndex ) .forEach(index => indices.add(index)); } return indices; } private getCellPlaceholder(index: number) { if (!this.placeholder || this.cells[index]) { return undefined; } if (this.activeIndex === -1) { return index === this.getFirstEmptyIndex() ? this.placeholder : undefined; } return index === this.activeIndex ? this.placeholder : undefined; } private focusCell(index: number, options?: FocusOptions) { const input = this.cellInputs?.[index]; if (input) { input.focus(options); input.select(); } } private updateCell(index: number, value: string) { const nextCells = [...this.cells]; nextCells[index] = value; this.cells = nextCells; } private fillFromIndex(startIndex: number, chars: string[]) { const nextCells = [...this.cells]; let cursor = startIndex; for (const char of chars) { if (cursor >= nextCells.length) { break; } nextCells[cursor] = char; cursor += 1; } this.cells = nextCells; return cursor; } private commitUserValueUpdate() { const previousValue = this.value; const nextValue = this.cells.join(''); const isNowComplete = this.isComplete(nextValue); this.value = nextValue; this.valueInput.value = nextValue; this.valueInput.setCustomValidity(this.customValidationMessage); this.formControlController.updateValidity(); this.emit(Nile_Events.NILE_INPUT, { value: nextValue, complete: isNowComplete }); if (previousValue !== nextValue) { this.emit(Nile_Events.NILE_CHANGE, { value: nextValue, complete: isNowComplete }); } if (isNowComplete && !this.wasComplete) { this.emit(Nile_Events.NILE_COMPLETE, { value: nextValue }); } this.wasComplete = isNowComplete; } private handleInvalid(event: Event) { this.formControlController.setValidity(false); this.formControlController.emitInvalidEvent(event); } private handleCellFocus(event: Event) { const target = event.target as HTMLInputElement; const index = Number(target.dataset.index ?? -1); const firstEmpty = this.getFirstEmptyIndex(); if (index > firstEmpty) { this.focusCell(firstEmpty); return; } if (index < firstEmpty && !this.cells[index]) { this.focusCell(firstEmpty); return; } this.activeIndex = index; target.select(); if (!this.hasFocus) { this.hasFocus = true; this.emit(Nile_Events.NILE_FOCUS, { value: this.value }); } } private handleCellBlur() { queueMicrotask(() => { const active = this.shadowRoot?.activeElement; const isInsideOtp = active instanceof HTMLInputElement && active.classList.contains('otp__cell'); if (!isInsideOtp && this.hasFocus) { this.hasFocus = false; this.activeIndex = -1; this.emit(Nile_Events.NILE_BLUR, { value: this.value }); } }); } private handleCellInput(event: Event) { const target = event.target as HTMLInputElement; const index = Number(target.dataset.index ?? 0); if (this.disabled || this.readonly) { target.value = this.cells[index] ?? ''; return; } const chars = this.toOtpCharacters(target.value); if (chars.length === 0) { this.updateCell(index, ''); this.commitUserValueUpdate(); return; } if (chars.length === 1) { this.updateCell(index, chars[0]); this.commitUserValueUpdate(); const nextEmpty = this.getFirstEmptyIndex(); this.focusCell(nextEmpty); return; } const nextCursor = this.fillFromIndex(index, chars); this.commitUserValueUpdate(); const nextEmpty = this.getFirstEmptyIndex(); this.focusCell(nextEmpty); } private handleCellPaste(event: ClipboardEvent) { if (this.disabled || this.readonly) { return; } const pasted = event.clipboardData?.getData('text') ?? ''; const chars = this.toOtpCharacters(pasted); if (!chars.length) { return; } event.preventDefault(); this.fillFromIndex(0, chars); this.commitUserValueUpdate(); const nextEmpty = this.getFirstEmptyIndex(); this.focusCell(nextEmpty); this.emit(Nile_Events.NILE_PASTE, { value: this.value }); } private handleCellKeyDown(event: KeyboardEvent) { const hasModifier = event.metaKey || event.ctrlKey || event.altKey; const target = event.target as HTMLInputElement; const index = Number(target.dataset.index ?? 0); if (event.key === KeyCode.ENTER && !hasModifier && !event.shiftKey) { setTimeout(() => { if (!event.defaultPrevented && !event.isComposing) { this.formControlController.submit(); } }); return; } if (this.disabled || this.readonly) { return; } const isHandledKey = event.key === KeyCode.BACKSPACE || event.key === KeyCode.DELETE || event.key === KeyCode.ARROW_LEFT || event.key === KeyCode.ARROW_RIGHT || event.key === KeyCode.HOME || event.key === KeyCode.END || event.key === KeyCode.SPACE || (event.key.length === 1 && !hasModifier); if (!isHandledKey) { return; } event.preventDefault(); if (event.key === KeyCode.BACKSPACE) { if (this.cells[index]) { this.updateCell(index, ''); this.commitUserValueUpdate(); if (index > 0) { this.focusCell(index - 1); } return; } if (index > 0) { this.updateCell(index - 1, ''); this.commitUserValueUpdate(); this.focusCell(index - 1); } return; } if (event.key === KeyCode.DELETE) { if (this.cells[index]) { this.updateCell(index, ''); this.commitUserValueUpdate(); } return; } if (event.key === KeyCode.ARROW_LEFT) { if (index > 0) { this.focusCell(index - 1); } return; } if (event.key === KeyCode.ARROW_RIGHT) { const firstEmpty = this.getFirstEmptyIndex(); if (index < firstEmpty) { this.focusCell(index + 1); } return; } if (event.key === KeyCode.HOME) { this.focusCell(0); return; } if (event.key === KeyCode.END) { this.focusCell(this.getFirstEmptyIndex()); return; } if (event.key === KeyCode.SPACE) { return; } if (event.key.length === 1 && !hasModifier && this.isAllowedCharacter(event.key)) { this.updateCell(index, event.key); this.commitUserValueUpdate(); const nextEmpty = this.getFirstEmptyIndex(); this.focusCell(nextEmpty); } } @watch('length', { waitUntilFirstUpdate: true }) handleLengthChange() { const normalizedLength = this.getNormalizedLength(); if (this.length !== normalizedLength) { this.length = normalizedLength; return; } const normalizedValue = this.normalizeValue(this.value); this.syncCellsFromValue(normalizedValue); if (normalizedValue !== this.value) { this.value = normalizedValue; return; } this.wasComplete = this.isComplete(normalizedValue); this.valueInput.value = normalizedValue; this.valueInput.setCustomValidity(this.customValidationMessage); this.formControlController.updateValidity(); } @watch('value', { waitUntilFirstUpdate: true }) handleValueChange() { const normalizedValue = this.normalizeValue(this.value); if (normalizedValue !== this.value) { this.value = normalizedValue; return; } this.syncCellsFromValue(normalizedValue); this.wasComplete = this.isComplete(normalizedValue); this.valueInput.value = normalizedValue; this.valueInput.setCustomValidity(this.customValidationMessage); this.formControlController.updateValidity(); } @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { if (this.disabled) { this.hasFocus = false; this.activeIndex = -1; this.formControlController.setValidity(true); } else { this.formControlController.updateValidity(); } } @watch('numericOnly', { waitUntilFirstUpdate: true }) handleNumericOnlyChange() { const normalizedValue = this.normalizeValue(this.value); if (normalizedValue !== this.value) { this.value = normalizedValue; return; } this.syncCellsFromValue(normalizedValue); this.wasComplete = this.isComplete(normalizedValue); this.valueInput.value = normalizedValue; this.valueInput.setCustomValidity(this.customValidationMessage); this.formControlController.updateValidity(); } @watch('pattern', { waitUntilFirstUpdate: true }) handlePatternChange() { this.valueInput.setCustomValidity(this.customValidationMessage); this.formControlController.updateValidity(); } /** Checks validity without showing browser UI. */ checkValidity() { return this.valueInput.checkValidity(); } /** Returns associated form if one exists. */ getForm(): HTMLFormElement | null { return this.formControlController.getForm(); } /** Checks validity and shows browser UI when invalid. */ reportValidity() { return this.valueInput.reportValidity(); } /** Sets a custom validation message. Pass empty string to restore validity. */ setCustomValidity(message: string) { this.customValidationMessage = message; if (this.valueInput) { this.valueInput.setCustomValidity(message); } this.formControlController.updateValidity(); } /** Focuses the first empty cell, or the last one when complete. */ focus(options?: FocusOptions) { this.focusCell(this.getFirstEmptyIndex(), options); } /** Removes focus from whichever OTP cell is currently focused. */ blur() { const active = this.shadowRoot?.activeElement; if (active instanceof HTMLElement) { active.blur(); } } /** Clears all OTP cells. */ clear() { if (this.disabled || this.readonly) { return; } this.cells = Array.from({ length: this.getNormalizedLength() }, () => ''); this.commitUserValueUpdate(); this.focusCell(0); } render() { const normalizedLength = this.getNormalizedLength(); const separatorIndices = this.getSeparatorIndices(); const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); const hasLabel = Boolean(this.label || hasLabelSlot); const hasHelpText = Boolean(this.helpText || hasHelpTextSlot); const hasErrorMessage = Boolean(this.errorMessage); const describedBy = [ hasHelpText ? 'help-text' : '', hasErrorMessage ? 'error-message' : '', ] .filter(Boolean) .join(' '); return html`
${Array.from({ length: normalizedLength }, (_, index) => { const value = this.cells[index] ?? ''; return html` ${this.separator && separatorIndices.has(index) ? html` ` : ''} `; })} this.focus()} @invalid=${this.handleInvalid} />
${hasHelpText ? html`
${this.helpText}
` : ``} ${hasErrorMessage ? html`
${this.errorMessage}
` : ``}
`; } } export default NileOtpInput; declare global { interface HTMLElementTagNameMap { 'nile-otp-input': NileOtpInput; } }