import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, omit } from "solid-js"; import { createChangeEventDetails, createGenericEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { useCompositeListItem } from "../../internals/composite/composite-list.tsx"; import { fieldStateAttrs } from "../../internals/field-state-attrs"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { ariaInvalid as seedAriaInvalid, ariaLabelledBy as seedAriaLabelledBy, } from "../../internals/seed-aria-attr"; import type { BaseUIComponentProps } from "../../internals/types"; import { getOTPFieldInputState, useOTPFieldRootContext, } from "../root/otp-field-root-context.ts"; import type { OTPFieldInputState, OTPFieldRootChangeEventReason, } from "../root/otp-field-root-types.ts"; import { normalizeOTPValue, removeOTPCharacter, replaceOTPValue, stripOTPWhitespace, } from "../utils/otp.ts"; export type { OTPFieldInputState }; export interface OTPFieldInputProps extends Omit, "value"> { /** * Tag/component to render. Defaults to `"input"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; } /** * A single OTP slot. Renders an `` (or `as` override). * * §1 wave 2: replaces `renderElement` with `dynamic(() => props.as ?? "input")`. * Field-validity attrs flow through `fieldStateAttrs(inputState)`; OTP-specific * presence attrs (`data-complete`, `data-disabled`, `data-readonly`, * `data-required`) are emitted as inline reactive getters on the internal slot * bag. */ export function OTPFieldInput(props: OTPFieldInputProps): JSX.Element { const root = useOTPFieldRootContext(); const { ref: listItemRef, index } = useCompositeListItem(); const slotValue = createMemo((): string => root.value()[index()] ?? ""); const inputState = createMemo( (): OTPFieldInputState => getOTPFieldInputState(root.state(), slotValue(), index()), ); const externalAriaLabel = (): string | undefined => typeof props["aria-label"] === "string" ? props["aria-label"] : undefined; const externalAriaLabelledBy = (): string | undefined => typeof props["aria-labelledby"] === "string" ? props["aria-labelledby"] : undefined; const inheritedLabel = (): string | undefined => externalAriaLabelledBy() ?? root.inputAriaLabelledBy(); const ariaLabel = (): string | undefined => index() === 0 ? undefined : externalAriaLabel(); const userStyling = resolveClassStyle(props, () => inputState()); const userProps = omit( props, "class", "style", "attrs", "as", "aria-label", "aria-labelledby", ); const internalSlot = { get id(): string | undefined { return root.getInputId(index()); }, get value(): string { return slotValue(); }, get type(): "password" | "text" { return root.mask() ? "password" : "text"; }, get inputmode(): JSX.InputHTMLAttributes["inputmode"] { return root.inputMode(); }, get autocomplete(): string | undefined { return index() === 0 ? root.autoComplete() : "off"; }, autocorrect: "off" as const, spellcheck: false, get enterkeyhint(): "done" | "next" { return index() === root.length() - 1 ? "done" : "next"; }, get maxlength(): number { return index() === 0 ? root.length() : 1; }, get tabindex(): 0 | -1 { return root.activeIndex() === index() ? 0 : -1; }, get disabled(): true | undefined { return root.disabled() || undefined; }, get form(): string | undefined { return root.form(); }, get pattern(): string | undefined { return root.pattern(); }, get readonly(): true | undefined { return root.readOnly() || undefined; }, get required(): true | undefined { return root.required() || undefined; }, get "aria-label"(): string | undefined { return ariaLabel(); }, get "data-complete"(): "" | undefined { return root.state().complete ? "" : undefined; }, get "data-disabled"(): "" | undefined { return root.disabled() ? "" : undefined; }, get "data-readonly"(): "" | undefined { return root.readOnly() ? "" : undefined; }, get "data-required"(): "" | undefined { return root.required() ? "" : undefined; }, // `data-active="true"` on the focused slot at the cursor position (and // `"false"` otherwise), mirroring the `input-otp` library's per-slot // active marker. Consumers key the active-cell ring/border off // `data-[active=true]:…`; the field-wide `data-focused` covers every // slot, so it can't drive a single-cell highlight. Gated on `focused()` // so no cell lights up while the field is blurred. get "data-active"(): "true" | "false" { return root.state().focused && root.activeIndex() === index() ? "true" : "false"; }, onMouseDown(event: MouseEvent): void { if (event.defaultPrevented || root.disabled()) return; event.preventDefault(); root.focusInput(index()); }, onFocus(event: FocusEvent & { currentTarget: HTMLInputElement }): void { if (event.defaultPrevented || root.disabled()) return; root.handleInputFocus(index(), event); }, onBlur(event: FocusEvent & { currentTarget: HTMLInputElement }): void { if (event.defaultPrevented) return; root.handleInputBlur(event); }, onInput(event: InputEvent & { currentTarget: HTMLInputElement }): void { if (event.defaultPrevented || root.disabled() || root.readOnly()) return; const rawValue = event.currentTarget.value; const nextDigits = normalizeOTPValue( rawValue, root.length(), root.validationType(), root.sanitizeValue(), ); const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; if (didSanitize) { root.reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event), ); } const currentSlotValue = slotValue(); const currentValue = root.value(); const slotIndex = index(); if (nextDigits === "") { if (rawValue === "") { root.setValue( removeOTPCharacter(currentValue, slotIndex), createChangeEventDetails( REASONS.inputClear, event, ), ); } else { // The typed text sanitized away entirely (e.g. a letter in a // numeric field). Restore the slot's committed digit so the // rejected character doesn't linger in the DOM input — slot 0's // `maxlength` would otherwise let invalid chars pile up. When the // slot is empty this clears the input back to "". event.currentTarget.value = currentSlotValue; if (currentSlotValue !== "") event.currentTarget.select(); } return; } const nextValue = replaceOTPValue( currentValue, slotIndex, nextDigits, root.length(), root.validationType(), root.sanitizeValue(), ); const committedValue = root.setValue( nextValue, createChangeEventDetails( REASONS.inputChange, event, ), ); if (committedValue == null) { // The change was canceled, so the signal still holds the old // value. Restore the slot's committed digit ourselves rather // than leaving the rejected character for the `value` binding // to overwrite on its next run. event.currentTarget.value = currentSlotValue; return; } const nextInput = Math.min( slotIndex + nextDigits.length, root.length() - 1, ); root.queueFocusInput(nextInput, committedValue); }, onKeyDown( event: KeyboardEvent & { currentTarget: HTMLInputElement }, ): void { if (event.defaultPrevented || root.disabled()) return; const slotIndex = index(); const currentValue = root.value(); const currentSlotValue = slotValue(); if (event.key === "ArrowLeft") { event.preventDefault(); event.stopPropagation(); root.focusInput(Math.max(0, slotIndex - 1)); return; } if (event.key === "ArrowRight") { event.preventDefault(); event.stopPropagation(); root.focusInput(Math.min(root.length() - 1, slotIndex + 1)); return; } if (event.key === "Home") { event.preventDefault(); event.stopPropagation(); root.focusInput(0); return; } if (event.key === "End") { event.preventDefault(); event.stopPropagation(); root.focusInput(Math.max(currentValue.length - 1, 0)); return; } if (root.readOnly()) return; if (event.key === "Delete") { event.preventDefault(); event.stopPropagation(); const committed = root.setValue( removeOTPCharacter(currentValue, slotIndex), createChangeEventDetails( REASONS.keyboard, event, ), ); if (committed != null) { root.queueFocusInput(slotIndex, committed); } return; } const inputValue = event.currentTarget.value; const fullSelection = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === inputValue.length; if ( event.key.length === 1 && fullSelection && currentSlotValue === event.key ) { event.preventDefault(); event.stopPropagation(); root.focusInput(Math.min(root.length() - 1, slotIndex + 1)); return; } if (event.key === "Backspace") { event.preventDefault(); event.stopPropagation(); const deleteIndex = currentSlotValue === "" ? Math.max(0, slotIndex - 1) : slotIndex; const targetIndex = Math.max(0, slotIndex - 1); const committed = root.setValue( removeOTPCharacter(currentValue, deleteIndex), createChangeEventDetails( REASONS.keyboard, event, ), ); if (committed != null) { root.queueFocusInput(targetIndex, committed); } } }, onPaste(event: ClipboardEvent): void { if (event.defaultPrevented || root.disabled() || root.readOnly()) return; let rawValue = ""; try { rawValue = event.clipboardData?.getData("text/plain") ?? ""; } catch { return; } event.preventDefault(); const nextDigits = normalizeOTPValue( rawValue, root.length(), root.validationType(), root.sanitizeValue(), ); const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; if (didSanitize) { root.reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputPaste, event), ); } if (nextDigits === "") return; const slotIndex = index(); const committedValue = root.setValue( replaceOTPValue( root.value(), slotIndex, nextDigits, root.length(), root.validationType(), root.sanitizeValue(), ), createChangeEventDetails( REASONS.inputPaste, event, ), ); if (committedValue != null) { const nextInput = Math.min( slotIndex + nextDigits.length, root.length() - 1, ); root.queueFocusInput(nextInput, committedValue); } }, }; const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( internalSlot, seedAriaLabelledBy(() => (ariaLabel() != null ? "" : inheritedLabel())) ?? {}, ); merged = mergeRootProps(merged, seedAriaInvalid(() => root.invalid()) ?? {}); merged = mergeRootProps(merged, fieldStateAttrs(inputState)); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "input"); return ; }