Skip to content

Otp — live demo

This is the real @rozie-ui/otp-vue package running on this page (VitePress is itself a Vue app). Type a code, paste one in (it distributes across the cells), backspace through it, or arrow between cells — then watch the two-way bound value update and the @complete readout fire when the last cell fills. The same Otp component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit. It's built on native <input> cells with no engine and no required CSS — the platform input behaviour and a tokenised skin all ship inside the component.

value is two-way bound with v-model:value — the readout updates the instant you edit, and a consumer write flows back in. The assembled code is always a contiguous string; flip :mask="true" to render the cells as password dots, set type to 'numeric' / 'alphanumeric' / 'text' to change the allowed characters and the mobile keyboard, and listen to @complete to auto-submit. The Numeric instance's buttons drive the imperative handle (clear(), focus()) grabbed through Vue's ref. See the full API for every prop, event, and handle verb, plus theming and keyboard reference.

What ships for each framework

You author the component once as a .rozie file:

html
<!--
  Otp.rozie — a headless, WAI-ARIA accessible one-time-code / PIN input.

  A pure-Rozie family (NO third-party engine) in the spirit of captcha: it fills
  a real cross-framework need (every auth UI re-implements a segmented code input)
  with zero engine dependency. The platform IS the engine: N native <input>
  cells, browser focus, keyboard, paste, and `autocomplete="one-time-code"` SMS
  autofill come for free. Rozie owns the author-side API, the two-way binding, the
  sanitize/distribute logic, focus choreography, and the token-themed skin.

  The branchy write model (sanitize/distribute + emit-transition detection)
  lives in `./internal/otpWrite.ts` (the date-picker `buildMonthGrid`
  precedent): `onInput` and `onPaste` both collapse onto ONE `planWrite`
  distribution routine that clamps the write position to the current fill
  point and distributes every sanitized character from there — this single
  path serves single-char typing, SMS autofill, swipe, and IME commit alike,
  so a click past the fill point never leaves a hole and a multi-char write is
  never truncated. `commitValue`'s emits are gated by `planEmits`: `change` on
  an actual value transition, `complete` only on the not-full → full
  transition (never on an in-place edit of an already-full code, never at
  `length: 0`).

  CONTROLLED, NO LOCAL STATE: the assembled code string IS `value` (the sole
  model:true prop → Angular ControlValueAccessor; an OTP IS a form control). Each
  cell's displayed char is derived from `value` (`cells()`), so there is no draft
  to keep in <data> and no value↔cells echo guard to maintain. Entry is
  sequential (left→right); `value` is always a contiguous string — the standard
  OTP UX, and what lets us stay fully controlled (a middle-gap state, which no
  real verification flow produces, is intentionally not modeled).

  FOCUS without per-cell refs: focus choreography reads `$refs.root` (ONE
  container ref) and walks `root.querySelectorAll('input')` — read only in
  $onMount / event handlers / $expose verbs (all post-mount → ROZ123-safe), and
  it works inside Lit's shadow root because the container lives there too. No
  attempt to ref each r-for cell (which has no clean cross-target story).

  Authoring notes (collision classes — see the authoring playbook):
    - The focus verb is `focus` — a DELIBERATE override of the inherited
      HTMLElement.focus on the Lit custom element (ROZ137 WARNS, accepted; the
      public focus() handle is intended). Same choice slider made; listbox took
      the other branch (`focusControl`). The accepted warn is documented in every
      leaf README. `clear` is collision-safe (not a host-element member).
    - Every write funnels through ONE `commitValue(next)` so its `$emit` prop-
      destructure hoists exactly once on React (Phase 46 also dedups, but the
      single-funnel pattern is kept for clarity — the slider precedent).
    - Handler params are LEFT UNTYPED so they neutralize to `any`; reading
      `e.target.value` / `e.clipboardData` then typechecks across all six strict
      leaves (the global-filter idiom). Never annotate them.
    - `cells()` is a PLAIN function (not `$computed`): it is called from the
      r-for AND read inside handlers, and a $computed is a value on React but an
      accessor on Solid — aliasing/calling it diverges. Keep derived collections
      that script logic touches as plain functions called `()` uniformly.

  Consumer example:

    <Otp r-model:value="$data.code" :length="6" type="numeric"
         ariaLabel="Verification code" @complete="onComplete" />
-->

<rozie name="Otp">

<props>
{
  // The assembled code (two-way). As the sole model:true prop it drives the
  // Angular ControlValueAccessor — an Otp IS a form control. Always a contiguous
  // string of 0..length chars.
  value: {
    type: String,
    default: '',
    model: true,
    docs: {
      description:
        'The assembled one-time code (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so an Otp **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Always a contiguous string of `0..length` characters; Otp writes the new code back on every edit (type, paste, backspace).',
      example: '<Otp r-model:value="code" :length="6" type="numeric" ariaLabel="Verification code" />',
    },
  },

  // Number of cells.
  length: {
    type: Number,
    default: 6,
    docs: {
      description: 'Number of input cells to render.',
    },
  },

  // Allowed-character class + mobile keyboard hint:
  //   'numeric'      → digits only,  inputmode="numeric"
  //   'alphanumeric' → [A-Za-z0-9],  inputmode="text"
  //   'text'         → any non-space, inputmode="text"
  type: {
    type: String,
    default: 'numeric',
    docs: {
      description:
        "Allowed-character class plus the mobile keyboard hint: `'numeric'` permits digits only and sets `inputmode=\"numeric\"`; `'alphanumeric'` permits `[A-Za-z0-9]` with `inputmode=\"text\"`; `'text'` permits any non-space character with `inputmode=\"text\"`. Characters that fail the test are rejected on type and filtered on paste.",
    },
  },

  // Render cells as masked dots (password) — for sensitive codes.
  mask: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Render the cells as masked dots (`type="password"`) for sensitive codes, while keeping the same keyboard and ARIA behavior.',
    },
  },

  // Focus the first empty cell on mount.
  autoFocus: {
    type: Boolean,
    default: false,
    docs: {
      description: 'Focus the first empty cell on mount.',
    },
  },

  // Disable every cell.
  disabled: {
    type: Boolean,
    default: false,
    docs: {
      description: 'Disable every cell. Also sets the Angular `ControlValueAccessor` disabled state.',
    },
  },

  // Per-cell placeholder character (e.g. '•' or '0').
  placeholder: {
    type: String,
    default: '',
    docs: {
      description: "Per-cell placeholder character shown in empty cells (e.g. `'•'` or `'0'`).",
    },
  },

  // Accessible name for the whole group (role="group"). Each cell also gets an
  // ordinal aria-label ("Digit 1 of 6").
  ariaLabel: {
    type: String,
    default: null,
    docs: {
      description:
        'Accessible name for the whole group (`role="group"`, applied as `aria-label`). Each cell additionally gets an ordinal `aria-label` (`"Digit 1 of 6"`).',
    },
  },
}
</props>

<script lang="ts">
import { firstEmptyIndex as firstEmpty, isAllowedChar, planEmits, planWrite } from './internal/otpWrite'

// ---- derived view (plain functions, uniform ×6) ------------------------
// The current code, normalized to a string.
const code = () => (typeof $props.value === 'string' ? $props.value : '')

// The cells to render: one { i, ch } per position, ch derived from `value`.
// A plain function (called in the r-for and from handlers) — never $computed.
const cells = () => {
  const v = code()
  const out = []
  for (let i = 0; i < $props.length; i++) out.push({ i, ch: v[i] || '' })
  return out
}

// Allowed-character test for the configured `type`. Thin wrapper over the
// pure write model in ./internal/otpWrite (vendored into every leaf).
const allowChar = (ch) => isAllowedChar($props.type, ch)

// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
const firstEmptyIndex = () => firstEmpty(code(), $props.length)

// ---- focus choreography (container ref, post-mount only) ----------------
// Read $refs.root only here / in $onMount / in $expose verbs (all post-mount →
// ROZ123-safe). querySelectorAll reaches the cells inside Lit's shadow root too.
const focusIndex = (idx) => {
  let i = idx
  if (i < 0) i = 0
  if (i >= $props.length) i = $props.length - 1
  const root = $refs.root
  if (!root) return
  const inputs = root.querySelectorAll('input')
  const el = inputs[i]
  if (el) {
    el.focus()
    if (el.select) el.select()
  }
}

// ---- write funnel (single $emit site) ----------------------------------
// Capture `prev` BEFORE the model write (code() reads $props.value, which has
// not yet round-tripped) — this is what makes the transition detection
// stateless and keeps the documented "CONTROLLED, NO LOCAL STATE" invariant
// (do NOT add a `wasFull` flag to <data>). The model write stays
// unconditional (idempotent, keeps the controlled contract); only the emits
// are gated: `change` on an actual value transition, `complete` only on the
// not-full -> full transition (never on an in-place edit of an already-full
// code, never at length: 0 — see ./internal/otpWrite.planEmits).
const commitValue = (raw) => {
  const prev = code()
  const next = String(raw).slice(0, $props.length)
  const emits = planEmits(prev, next, $props.length)
  $model.value = next
  if (emits.change) $emit('change', { value: next })
  if (emits.complete) $emit('complete', { value: next })
}

// ---- input handler -----------------------------------------------------
// One path for every input shape (single char, autofill, swipe, IME commit —
// see ./internal/otpWrite.planWrite): sanitize + distribute from the write
// position, clamped to the current fill point so a click past the fill point
// never leaves a hole. An invalid / empty-after-sanitize write is rejected by
// restoring the cell's DOM value directly (a no-op model write may not
// re-render on React, so reset the element instead).
const onInput = (i, e) => {
  const raw = e && e.target ? e.target.value : ''
  if (raw === '') {
    const cur = code()
    commitValue(cur.slice(0, i) + cur.slice(i + 1))
    return
  }
  const plan = planWrite(code(), $props.length, $props.type, i, raw)
  if (!plan) {
    if (e && e.target) e.target.value = code()[i] || ''
    return
  }
  commitValue(plan.next)
  // Restore the originating cell's DOM value from the derived model. Required
  // for the multi-char/autofill case: the element currently holds the WHOLE
  // pasted/autofilled string, and when the committed value happens to equal the
  // previous value the framework re-render is a no-op (the same reason the
  // invalid-char branch above resets the element directly). Without this the
  // first cell keeps rendering the whole autofilled string.
  if (e && e.target) e.target.value = plan.next[i] || ''
  focusIndex(plan.focus)
}

// ---- keyboard ----------------------------------------------------------
// Backspace deletes the current char (or the previous one when the cell is
// already empty) and moves focus accordingly; arrows / Home / End navigate.
const onKeydown = (i, e) => {
  const key = e ? e.key : ''
  const cur = code()
  if (key === 'Backspace') {
    if (e) e.preventDefault()
    if (cur[i]) {
      commitValue(cur.slice(0, i) + cur.slice(i + 1))
    } else if (i > 0) {
      commitValue(cur.slice(0, i - 1) + cur.slice(i))
      focusIndex(i - 1)
    }
  } else if (key === 'ArrowLeft') {
    if (e) e.preventDefault()
    focusIndex(i - 1)
  } else if (key === 'ArrowRight') {
    if (e) e.preventDefault()
    focusIndex(i + 1)
  } else if (key === 'Home') {
    if (e) e.preventDefault()
    focusIndex(0)
  } else if (key === 'End') {
    if (e) e.preventDefault()
    focusIndex($props.length - 1)
  }
}

// ---- paste (collapses onto the same distribution routine as onInput) ---
const onPaste = (i, e) => {
  if (e) e.preventDefault()
  const text = (e && e.clipboardData && e.clipboardData.getData('text')) || ''
  const plan = planWrite(code(), $props.length, $props.type, i, text)
  if (!plan) return
  commitValue(plan.next)
  focusIndex(plan.focus)
}

// Select the cell's content on focus so a keystroke overwrites it. Correct
// for Tab and programmatic focus.
const onFocus = (e) => {
  if (e && e.target && e.target.select) e.target.select()
}

// A mouse/touch focus places the caret on mouseup, collapsing onFocus's
// select(); with maxlength="1" a filled cell then REJECTS the next keystroke.
// Re-select on pointerup (after caret placement) so click-then-type overwrites,
// matching the Tab-focus behavior.
const onPointerUp = (e) => {
  if (e && e.target && e.target.select) e.target.select()
}

// ---- per-cell attribute helpers ----------------------------------------
const cellType = () => ($props.mask ? 'password' : 'text')
// NOTE: named `cellInputMode`, NOT `inputMode` — a bare `inputMode` member
// collides with the inherited `HTMLElement.inputMode: string` on the Lit custom
// element (a hard TS2416/TS1238, unlike the warn-only `focus` override). The
// `cell`-prefix keeps it collision-safe across all six strict-typecheck leaves.
const cellInputMode = () => ($props.type === 'numeric' ? 'numeric' : 'text')
const cellAriaLabel = (i) => 'Digit ' + (i + 1) + ' of ' + $props.length
const cellAutocomplete = (i) => (i === 0 ? 'one-time-code' : 'off')

// ---- lifecycle + imperative handle -------------------------------------
$onMount(() => {
  if ($props.autoFocus) focusIndex(firstEmptyIndex())
})

// focus() — focus the first empty cell. DELIBERATELY overrides HTMLElement.focus
// on Lit (ROZ137 warn, accepted). clear() — reset the code and focus the first
// cell. Both read $refs in a post-mount handle call (ROZ123-safe).
const focus = () => focusIndex(firstEmptyIndex())
const clear = () => {
  commitValue('')
  focusIndex(0)
}

$expose({ focus, clear })
</script>

<template>
<div
  class="rozie-otp"
  ref="root"
  role="group"
  :aria-label="$props.ariaLabel"
  :class="{ 'rozie-otp--disabled': $props.disabled }"
>
  <input
    r-for="cell in cells()"
    :key="cell.i"
    class="rozie-otp-cell"
    :type="cellType()"
    :inputmode="cellInputMode()"
    maxlength="1"
    autocapitalize="off"
    autocorrect="off"
    spellcheck="false"
    :autocomplete="cellAutocomplete(cell.i)"
    :value="cell.ch"
    :placeholder="$props.placeholder"
    :disabled="!!$props.disabled"
    :aria-label="cellAriaLabel(cell.i)"
    :data-filled="cell.ch ? 'true' : null"
    @input="onInput(cell.i, $event)"
    @keydown="onKeydown(cell.i, $event)"
    @paste="onPaste(cell.i, $event)"
    @focus="onFocus($event)"
    @pointerup="onPointerUp($event)"
  />
</div>
</template>

<style>
/*
  Fully token-driven (mirrors slider/themes): EVERY visual value is a
  `var(--rozie-otp-*, <fallback>)`, so the component renders with zero config yet
  is completely re-skinnable by setting tokens at any ancestor scope. The shipped
  themes/*.css presets map these tokens onto shadcn/Radix, Material 3, Bootstrap 5.
*/
.rozie-otp {
  display: inline-flex;
  gap: var(--rozie-otp-gap, 0.5rem);
  font: var(--rozie-otp-font, inherit);
}
.rozie-otp-cell {
  box-sizing: border-box;
  width: var(--rozie-otp-cell-size, 2.75rem);
  height: var(--rozie-otp-cell-size, 2.75rem);
  padding: 0;
  text-align: center;
  font-size: var(--rozie-otp-font-size, 1.25rem);
  font-weight: var(--rozie-otp-font-weight, 600);
  color: var(--rozie-otp-color, inherit);
  background: var(--rozie-otp-bg, #fff);
  border: var(--rozie-otp-border-width, 1px) solid var(--rozie-otp-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-otp-radius, 0.5rem);
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
  caret-color: var(--rozie-otp-accent, #0066cc);
}
.rozie-otp-cell::placeholder {
  color: var(--rozie-otp-placeholder-color, rgba(0, 0, 0, 0.3));
}
.rozie-otp-cell[data-filled='true'] {
  border-color: var(--rozie-otp-filled-border-color, var(--rozie-otp-accent, #0066cc));
}
.rozie-otp-cell:focus {
  border-color: var(--rozie-otp-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-otp-focus-ring-width, 3px) var(--rozie-otp-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-otp--disabled .rozie-otp-cell {
  cursor: not-allowed;
  opacity: var(--rozie-otp-disabled-opacity, 0.55);
  background: var(--rozie-otp-disabled-bg, rgba(0, 0, 0, 0.04));
}
</style>

</rozie>

…and Rozie compiles it to six framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/otp-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { clsx, rozieAttr, useControllableState } from '@rozie/runtime-react';
import './Otp.css';
import { firstEmptyIndex as firstEmpty, isAllowedChar, planEmits, planWrite } from './internal/otpWrite';

// ---- derived view (plain functions, uniform ×6) ------------------------
// The current code, normalized to a string.

interface OtpProps {
  /**
   * The assembled one-time code (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so an Otp **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Always a contiguous string of `0..length` characters; Otp writes the new code back on every edit (type, paste, backspace).
   * @example
   * <Otp r-model:value="code" :length="6" type="numeric" ariaLabel="Verification code" />
   */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /**
   * Number of input cells to render.
   */
  length?: number;
  /**
   * Allowed-character class plus the mobile keyboard hint: `'numeric'` permits digits only and sets `inputmode="numeric"`; `'alphanumeric'` permits `[A-Za-z0-9]` with `inputmode="text"`; `'text'` permits any non-space character with `inputmode="text"`. Characters that fail the test are rejected on type and filtered on paste.
   */
  type?: string;
  /**
   * Render the cells as masked dots (`type="password"`) for sensitive codes, while keeping the same keyboard and ARIA behavior.
   */
  mask?: boolean;
  /**
   * Focus the first empty cell on mount.
   */
  autoFocus?: boolean;
  /**
   * Disable every cell. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Per-cell placeholder character shown in empty cells (e.g. `'•'` or `'0'`).
   */
  placeholder?: string;
  /**
   * Accessible name for the whole group (`role="group"`, applied as `aria-label`). Each cell additionally gets an ordinal `aria-label` (`"Digit 1 of 6"`).
   */
  ariaLabel?: (string) | null;
  onChange?: (...args: any[]) => void;
  onComplete?: (...args: any[]) => void;
}

export interface OtpHandle {
  focus: (...args: any[]) => any;
  clear: (...args: any[]) => any;
}

const Otp = forwardRef<OtpHandle, OtpProps>(function Otp(_props: OtpProps, ref): JSX.Element {
  const props: Omit<OtpProps, 'length' | 'type' | 'mask' | 'autoFocus' | 'disabled' | 'placeholder' | 'ariaLabel'> & { length: number; type: string; mask: boolean; autoFocus: boolean; disabled: boolean; placeholder: string; ariaLabel: (string) | null } = {
    ..._props,
    length: _props.length ?? 6,
    type: _props.type ?? 'numeric',
    mask: _props.mask ?? false,
    autoFocus: _props.autoFocus ?? false,
    disabled: _props.disabled ?? false,
    placeholder: _props.placeholder ?? '',
    ariaLabel: _props.ariaLabel ?? null,
  };
  const attrs: Record<string, unknown> = (() => {
    const { value, length, type, mask, autoFocus, disabled, placeholder, ariaLabel, defaultValue, onValueChange, onChange, onComplete, ...rest } = _props as OtpProps & Record<string, unknown>;
    void value; void length; void type; void mask; void autoFocus; void disabled; void placeholder; void ariaLabel; void defaultValue; void onValueChange; void onChange; void onComplete;
    return rest;
  })();
  const [value, setValue] = useControllableState({
    value: props.value,
    defaultValue: props.defaultValue ?? '',
    onValueChange: props.onValueChange,
  });
  const _autoFocusRef = useRef(props.autoFocus);
  _autoFocusRef.current = props.autoFocus;
  const root = useRef<HTMLDivElement | null>(null);

  function code() {
    return typeof value === 'string' ? value : '';
  }
  function cells() {
    const v = code();
    const out = [];
    for (let i = 0; i < props.length; i++) out.push({
      i,
      ch: v[i] || ''
    });
    return out;
  }
  function allowChar(ch: any) {
    return isAllowedChar(props.type, ch);
  }
  const firstEmptyIndex = useCallback(() => firstEmpty(code(), props.length), [code, props.length]);
  const focusIndex = useCallback((idx: any) => {
    let i = idx;
    if (i < 0) i = 0;
    if (i >= props.length) i = props.length - 1;
    const root$local = root.current;
    if (!root$local) return;
    const inputs = root$local.querySelectorAll('input');
    const el = inputs[i];
    if (el) {
      el.focus();
      if (el.select) el.select();
    }
  }, [props.length]);
  function commitValue(raw: any) {
    const prev = code();
    const next = String(raw).slice(0, props.length);
    const emits = planEmits(prev, next, props.length);
    setValue(next);
    if (emits.change) props.onChange && props.onChange({
      value: next
    });
    if (emits.complete) props.onComplete && props.onComplete({
      value: next
    });
  }
  const onInput = useCallback((i: any, e: any) => {
    const raw = e && e.target ? e.target.value : '';
    if (raw === '') {
      const cur = code();
      commitValue(cur.slice(0, i) + cur.slice(i + 1));
      return;
    }
    const plan = planWrite(code(), props.length, props.type, i, raw);
    if (!plan) {
      if (e && e.target) e.target.value = code()[i] || '';
      return;
    }
    commitValue(plan.next);
    // Restore the originating cell's DOM value from the derived model. Required
    // for the multi-char/autofill case: the element currently holds the WHOLE
    // pasted/autofilled string, and when the committed value happens to equal the
    // previous value the framework re-render is a no-op (the same reason the
    // invalid-char branch above resets the element directly). Without this the
    // first cell keeps rendering the whole autofilled string.
    if (e && e.target) e.target.value = plan.next[i] || '';
    focusIndex(plan.focus);
  }, [code, commitValue, focusIndex, props.length, props.type]);
  const onKeydown = useCallback((i: any, e: any) => {
    const key = e ? e.key : '';
    const cur = code();
    if (key === 'Backspace') {
      if (e) e.preventDefault();
      if (cur[i]) {
        commitValue(cur.slice(0, i) + cur.slice(i + 1));
      } else if (i > 0) {
        commitValue(cur.slice(0, i - 1) + cur.slice(i));
        focusIndex(i - 1);
      }
    } else if (key === 'ArrowLeft') {
      if (e) e.preventDefault();
      focusIndex(i - 1);
    } else if (key === 'ArrowRight') {
      if (e) e.preventDefault();
      focusIndex(i + 1);
    } else if (key === 'Home') {
      if (e) e.preventDefault();
      focusIndex(0);
    } else if (key === 'End') {
      if (e) e.preventDefault();
      focusIndex(props.length - 1);
    }
  }, [code, commitValue, focusIndex, props.length]);
  const onPaste = useCallback((i: any, e: any) => {
    if (e) e.preventDefault();
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const plan = planWrite(code(), props.length, props.type, i, text);
    if (!plan) return;
    commitValue(plan.next);
    focusIndex(plan.focus);
  }, [code, commitValue, focusIndex, props.length, props.type]);
  const onFocus = useCallback((e: any) => {
    if (e && e.target && e.target.select) e.target.select();
  }, []);
  const onPointerUp = useCallback((e: any) => {
    if (e && e.target && e.target.select) e.target.select();
  }, []);
  function cellType() {
    return props.mask ? 'password' : 'text';
  }
  function cellInputMode() {
    return props.type === 'numeric' ? 'numeric' : 'text';
  }
  function cellAriaLabel(i: any) {
    return 'Digit ' + (i + 1) + ' of ' + props.length;
  }
  function cellAutocomplete(i: any) {
    return i === 0 ? 'one-time-code' : 'off';
  }
  function focus() {
    return focusIndex(firstEmptyIndex());
  }
  function clear() {
    commitValue('');
    focusIndex(0);
  }

  const _firstEmptyIndexRef = useRef(firstEmptyIndex);
  _firstEmptyIndexRef.current = firstEmptyIndex;
  const _focusIndexRef = useRef(focusIndex);
  _focusIndexRef.current = focusIndex;
  useEffect(() => {
    if (_autoFocusRef.current) _focusIndexRef.current(_firstEmptyIndexRef.current());
  }, []);

  const _rozieExposeRef = useRef({ focus, clear });
  _rozieExposeRef.current = { focus, clear };
  useImperativeHandle(ref, () => ({ focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args) }), []);

  return (
    <>
    <div ref={root} role="group" aria-label={rozieAttr(props.ariaLabel)} {...attrs} className={clsx(clsx("rozie-otp", { "rozie-otp--disabled": props.disabled }), (attrs.className as string | undefined))} data-rozie-s-8267d52a="">
      {cells().map((cell) => <input key={cell.i} className={"rozie-otp-cell"} type={rozieAttr(cellType())} inputMode={rozieAttr(cellInputMode())} maxLength={1} autoCapitalize="off" autoCorrect="off" spellCheck="false" autoComplete={rozieAttr(cellAutocomplete(cell.i))} value={cell.ch} placeholder={props.placeholder} disabled={!!props.disabled} aria-label={rozieAttr(cellAriaLabel(cell.i))} data-filled={rozieAttr(cell.ch ? 'true' : undefined)} onInput={($event) => { onInput(cell.i, $event); }} onKeyDown={($event) => { onKeydown(cell.i, $event); }} onPaste={($event) => { onPaste(cell.i, $event); }} onFocus={($event) => { onFocus($event); }} onPointerUp={($event) => { onPointerUp($event); }} data-rozie-s-8267d52a="" />)}
    </div>
    </>
  );
});
export default Otp;
vue
<template>

<div :class="['rozie-otp', { 'rozie-otp--disabled': props.disabled }]" ref="rootRef" role="group" :aria-label="props.ariaLabel" v-bind="$attrs">
  <input v-for="cell in cells()" :key="cell.i" class="rozie-otp-cell" :type="cellType()" :inputmode="cellInputMode()" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" :autocomplete="cellAutocomplete(cell.i)" :value="cell.ch" :placeholder="props.placeholder" :disabled="!!props.disabled" :aria-label="cellAriaLabel(cell.i)" :data-filled="cell.ch ? 'true' : undefined" @input="onInput(cell.i, $event)" @keydown="onKeydown(cell.i, $event)" @paste="onPaste(cell.i, $event)" @focus="onFocus($event)" @pointerup="onPointerUp($event)" />
</div>

</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * Number of input cells to render.
     */
    length?: number;
    /**
     * Allowed-character class plus the mobile keyboard hint: `'numeric'` permits digits only and sets `inputmode="numeric"`; `'alphanumeric'` permits `[A-Za-z0-9]` with `inputmode="text"`; `'text'` permits any non-space character with `inputmode="text"`. Characters that fail the test are rejected on type and filtered on paste.
     */
    type?: string;
    /**
     * Render the cells as masked dots (`type="password"`) for sensitive codes, while keeping the same keyboard and ARIA behavior.
     */
    mask?: boolean;
    /**
     * Focus the first empty cell on mount.
     */
    autoFocus?: boolean;
    /**
     * Disable every cell. Also sets the Angular `ControlValueAccessor` disabled state.
     */
    disabled?: boolean;
    /**
     * Per-cell placeholder character shown in empty cells (e.g. `'•'` or `'0'`).
     */
    placeholder?: string;
    /**
     * Accessible name for the whole group (`role="group"`, applied as `aria-label`). Each cell additionally gets an ordinal `aria-label` (`"Digit 1 of 6"`).
     */
    ariaLabel?: string | null;
  }>(),
  { length: 6, type: 'numeric', mask: false, autoFocus: false, disabled: false, placeholder: '', ariaLabel: null }
);

/**
 * The assembled one-time code (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so an Otp **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Always a contiguous string of `0..length` characters; Otp writes the new code back on every edit (type, paste, backspace).
 * @example
 * <Otp r-model:value="code" :length="6" type="numeric" ariaLabel="Verification code" />
 */
const value = defineModel<string>('value', { default: '' });

const emit = defineEmits<{
  change: [...args: any[]];
  complete: [...args: any[]];
}>();

const rootRef = ref<HTMLElement>();

import { firstEmptyIndex as firstEmpty, isAllowedChar, planEmits, planWrite } from './internal/otpWrite';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current code, normalized to a string.
const code = () => typeof value.value === 'string' ? value.value : '';
// The cells to render: one { i, ch } per position, ch derived from `value`.
// A plain function (called in the r-for and from handlers) — never $computed.
const cells = () => {
  const v = code();
  const out = [];
  for (let i = 0; i < props.length; i++) out.push({
    i,
    ch: v[i] || ''
  });
  return out;
};
// Allowed-character test for the configured `type`. Thin wrapper over the
// pure write model in ./internal/otpWrite (vendored into every leaf).
const allowChar = (ch: any) => isAllowedChar(props.type, ch);
// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
const firstEmptyIndex = () => firstEmpty(code(), props.length);
// ---- focus choreography (container ref, post-mount only) ----------------
// Read $refs.root only here / in $onMount / in $expose verbs (all post-mount →
// ROZ123-safe). querySelectorAll reaches the cells inside Lit's shadow root too.
const focusIndex = (idx: any) => {
  let i = idx;
  if (i < 0) i = 0;
  if (i >= props.length) i = props.length - 1;
  const root = rootRef.value;
  if (!root) return;
  const inputs = root.querySelectorAll('input');
  const el = inputs[i];
  if (el) {
    el.focus();
    if (el.select) el.select();
  }
};
// ---- write funnel (single $emit site) ----------------------------------
// Capture `prev` BEFORE the model write (code() reads $props.value, which has
// not yet round-tripped) — this is what makes the transition detection
// stateless and keeps the documented "CONTROLLED, NO LOCAL STATE" invariant
// (do NOT add a `wasFull` flag to <data>). The model write stays
// unconditional (idempotent, keeps the controlled contract); only the emits
// are gated: `change` on an actual value transition, `complete` only on the
// not-full -> full transition (never on an in-place edit of an already-full
// code, never at length: 0 — see ./internal/otpWrite.planEmits).
const commitValue = (raw: any) => {
  const prev = code();
  const next = String(raw).slice(0, props.length);
  const emits = planEmits(prev, next, props.length);
  value.value = next;
  if (emits.change) emit('change', {
    value: next
  });
  if (emits.complete) emit('complete', {
    value: next
  });
};
// ---- input handler -----------------------------------------------------
// One path for every input shape (single char, autofill, swipe, IME commit —
// see ./internal/otpWrite.planWrite): sanitize + distribute from the write
// position, clamped to the current fill point so a click past the fill point
// never leaves a hole. An invalid / empty-after-sanitize write is rejected by
// restoring the cell's DOM value directly (a no-op model write may not
// re-render on React, so reset the element instead).
const onInput = (i: any, e: any) => {
  const raw = e && e.target ? e.target.value : '';
  if (raw === '') {
    const cur = code();
    commitValue(cur.slice(0, i) + cur.slice(i + 1));
    return;
  }
  const plan = planWrite(code(), props.length, props.type, i, raw);
  if (!plan) {
    if (e && e.target) e.target.value = code()[i] || '';
    return;
  }
  commitValue(plan.next);
  // Restore the originating cell's DOM value from the derived model. Required
  // for the multi-char/autofill case: the element currently holds the WHOLE
  // pasted/autofilled string, and when the committed value happens to equal the
  // previous value the framework re-render is a no-op (the same reason the
  // invalid-char branch above resets the element directly). Without this the
  // first cell keeps rendering the whole autofilled string.
  if (e && e.target) e.target.value = plan.next[i] || '';
  focusIndex(plan.focus);
};
// ---- keyboard ----------------------------------------------------------
// Backspace deletes the current char (or the previous one when the cell is
// already empty) and moves focus accordingly; arrows / Home / End navigate.
const onKeydown = (i: any, e: any) => {
  const key = e ? e.key : '';
  const cur = code();
  if (key === 'Backspace') {
    if (e) e.preventDefault();
    if (cur[i]) {
      commitValue(cur.slice(0, i) + cur.slice(i + 1));
    } else if (i > 0) {
      commitValue(cur.slice(0, i - 1) + cur.slice(i));
      focusIndex(i - 1);
    }
  } else if (key === 'ArrowLeft') {
    if (e) e.preventDefault();
    focusIndex(i - 1);
  } else if (key === 'ArrowRight') {
    if (e) e.preventDefault();
    focusIndex(i + 1);
  } else if (key === 'Home') {
    if (e) e.preventDefault();
    focusIndex(0);
  } else if (key === 'End') {
    if (e) e.preventDefault();
    focusIndex(props.length - 1);
  }
};
// ---- paste (collapses onto the same distribution routine as onInput) ---
const onPaste = (i: any, e: any) => {
  if (e) e.preventDefault();
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const plan = planWrite(code(), props.length, props.type, i, text);
  if (!plan) return;
  commitValue(plan.next);
  focusIndex(plan.focus);
};
// Select the cell's content on focus so a keystroke overwrites it. Correct
// for Tab and programmatic focus.
const onFocus = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};
// A mouse/touch focus places the caret on mouseup, collapsing onFocus's
// select(); with maxlength="1" a filled cell then REJECTS the next keystroke.
// Re-select on pointerup (after caret placement) so click-then-type overwrites,
// matching the Tab-focus behavior.
const onPointerUp = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};
// ---- per-cell attribute helpers ----------------------------------------
const cellType = () => props.mask ? 'password' : 'text';
// NOTE: named `cellInputMode`, NOT `inputMode` — a bare `inputMode` member
// collides with the inherited `HTMLElement.inputMode: string` on the Lit custom
// element (a hard TS2416/TS1238, unlike the warn-only `focus` override). The
// `cell`-prefix keeps it collision-safe across all six strict-typecheck leaves.
const cellInputMode = () => props.type === 'numeric' ? 'numeric' : 'text';
const cellAriaLabel = (i: any) => 'Digit ' + (i + 1) + ' of ' + props.length;
const cellAutocomplete = (i: any) => i === 0 ? 'one-time-code' : 'off';

// ---- lifecycle + imperative handle -------------------------------------
// focus() — focus the first empty cell. DELIBERATELY overrides HTMLElement.focus
// on Lit (ROZ137 warn, accepted). clear() — reset the code and focus the first
// cell. Both read $refs in a post-mount handle call (ROZ123-safe).
const focus = () => focusIndex(firstEmptyIndex());
const clear = () => {
  commitValue('');
  focusIndex(0);
};

onMounted(() => {
  if (props.autoFocus) focusIndex(firstEmptyIndex());
});

defineExpose({ focus, clear });
</script>

<style scoped>
.rozie-otp {
  display: inline-flex;
  gap: var(--rozie-otp-gap, 0.5rem);
  font: var(--rozie-otp-font, inherit);
}
.rozie-otp-cell {
  box-sizing: border-box;
  width: var(--rozie-otp-cell-size, 2.75rem);
  height: var(--rozie-otp-cell-size, 2.75rem);
  padding: 0;
  text-align: center;
  font-size: var(--rozie-otp-font-size, 1.25rem);
  font-weight: var(--rozie-otp-font-weight, 600);
  color: var(--rozie-otp-color, inherit);
  background: var(--rozie-otp-bg, #fff);
  border: var(--rozie-otp-border-width, 1px) solid var(--rozie-otp-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-otp-radius, 0.5rem);
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
  caret-color: var(--rozie-otp-accent, #0066cc);
}
.rozie-otp-cell::placeholder {
  color: var(--rozie-otp-placeholder-color, rgba(0, 0, 0, 0.3));
}
.rozie-otp-cell[data-filled='true'] {
  border-color: var(--rozie-otp-filled-border-color, var(--rozie-otp-accent, #0066cc));
}
.rozie-otp-cell:focus {
  border-color: var(--rozie-otp-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-otp-focus-ring-width, 3px) var(--rozie-otp-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-otp--disabled .rozie-otp-cell {
  cursor: not-allowed;
  opacity: var(--rozie-otp-disabled-opacity, 0.55);
  background: var(--rozie-otp-disabled-bg, rgba(0, 0, 0, 0.04));
}
</style>
svelte
<script lang="ts">
import { applyListeners, rozieAttr } from '@rozie/runtime-svelte';

import { onMount } from 'svelte';

interface Props {
  /**
   * The assembled one-time code (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so an Otp **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Always a contiguous string of `0..length` characters; Otp writes the new code back on every edit (type, paste, backspace).
   * @example
   * <Otp r-model:value="code" :length="6" type="numeric" ariaLabel="Verification code" />
   */
  value?: string;
  /**
   * Number of input cells to render.
   */
  length?: number;
  /**
   * Allowed-character class plus the mobile keyboard hint: `'numeric'` permits digits only and sets `inputmode="numeric"`; `'alphanumeric'` permits `[A-Za-z0-9]` with `inputmode="text"`; `'text'` permits any non-space character with `inputmode="text"`. Characters that fail the test are rejected on type and filtered on paste.
   */
  type?: string;
  /**
   * Render the cells as masked dots (`type="password"`) for sensitive codes, while keeping the same keyboard and ARIA behavior.
   */
  mask?: boolean;
  /**
   * Focus the first empty cell on mount.
   */
  autoFocus?: boolean;
  /**
   * Disable every cell. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Per-cell placeholder character shown in empty cells (e.g. `'•'` or `'0'`).
   */
  placeholder?: string;
  /**
   * Accessible name for the whole group (`role="group"`, applied as `aria-label`). Each cell additionally gets an ordinal `aria-label` (`"Digit 1 of 6"`).
   */
  ariaLabel?: (string) | null;
  onchange?: (...args: unknown[]) => void;
  oncomplete?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let {
  value = $bindable(''),
  length = 6,
  type = 'numeric',
  mask = false,
  autoFocus = false,
  disabled = false,
  placeholder = '',
  ariaLabel = null,
  onchange,
  oncomplete,
  ...__rozieAttrs
}: Props = $props();

let root = $state<HTMLElement | undefined>(undefined);

import { firstEmptyIndex as firstEmpty, isAllowedChar, planEmits, planWrite } from './internal/otpWrite';
// ---- derived view (plain functions, uniform ×6) ------------------------
// The current code, normalized to a string.
const code = () => typeof value === 'string' ? value : '';
// The cells to render: one { i, ch } per position, ch derived from `value`.
// A plain function (called in the r-for and from handlers) — never $computed.
const cells = () => {
  const v = code();
  const out = [];
  for (let i = 0; i < length; i++) out.push({
    i,
    ch: v[i] || ''
  });
  return out;
};
// Allowed-character test for the configured `type`. Thin wrapper over the
// pure write model in ./internal/otpWrite (vendored into every leaf).
const allowChar = (ch: any) => isAllowedChar(type, ch);
// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
const firstEmptyIndex = () => firstEmpty(code(), length);
// ---- focus choreography (container ref, post-mount only) ----------------
// Read $refs.root only here / in $onMount / in $expose verbs (all post-mount →
// ROZ123-safe). querySelectorAll reaches the cells inside Lit's shadow root too.
const focusIndex = (idx: any) => {
  let i = idx;
  if (i < 0) i = 0;
  if (i >= length) i = length - 1;
  const root$local = root;
  if (!root$local) return;
  const inputs = root$local.querySelectorAll('input');
  const el = inputs[i];
  if (el) {
    el.focus();
    if (el.select) el.select();
  }
};
// ---- write funnel (single $emit site) ----------------------------------
// Capture `prev` BEFORE the model write (code() reads $props.value, which has
// not yet round-tripped) — this is what makes the transition detection
// stateless and keeps the documented "CONTROLLED, NO LOCAL STATE" invariant
// (do NOT add a `wasFull` flag to <data>). The model write stays
// unconditional (idempotent, keeps the controlled contract); only the emits
// are gated: `change` on an actual value transition, `complete` only on the
// not-full -> full transition (never on an in-place edit of an already-full
// code, never at length: 0 — see ./internal/otpWrite.planEmits).
const commitValue = (raw: any) => {
  const prev = code();
  const next = String(raw).slice(0, length);
  const emits = planEmits(prev, next, length);
  value = next;
  if (emits.change) onchange?.({
    value: next
  });
  if (emits.complete) oncomplete?.({
    value: next
  });
};
// ---- input handler -----------------------------------------------------
// One path for every input shape (single char, autofill, swipe, IME commit —
// see ./internal/otpWrite.planWrite): sanitize + distribute from the write
// position, clamped to the current fill point so a click past the fill point
// never leaves a hole. An invalid / empty-after-sanitize write is rejected by
// restoring the cell's DOM value directly (a no-op model write may not
// re-render on React, so reset the element instead).
const onInput = (i: any, e: any) => {
  const raw = e && e.target ? e.target.value : '';
  if (raw === '') {
    const cur = code();
    commitValue(cur.slice(0, i) + cur.slice(i + 1));
    return;
  }
  const plan = planWrite(code(), length, type, i, raw);
  if (!plan) {
    if (e && e.target) e.target.value = code()[i] || '';
    return;
  }
  commitValue(plan.next);
  // Restore the originating cell's DOM value from the derived model. Required
  // for the multi-char/autofill case: the element currently holds the WHOLE
  // pasted/autofilled string, and when the committed value happens to equal the
  // previous value the framework re-render is a no-op (the same reason the
  // invalid-char branch above resets the element directly). Without this the
  // first cell keeps rendering the whole autofilled string.
  if (e && e.target) e.target.value = plan.next[i] || '';
  focusIndex(plan.focus);
};
// ---- keyboard ----------------------------------------------------------
// Backspace deletes the current char (or the previous one when the cell is
// already empty) and moves focus accordingly; arrows / Home / End navigate.
const onKeydown = (i: any, e: any) => {
  const key = e ? e.key : '';
  const cur = code();
  if (key === 'Backspace') {
    if (e) e.preventDefault();
    if (cur[i]) {
      commitValue(cur.slice(0, i) + cur.slice(i + 1));
    } else if (i > 0) {
      commitValue(cur.slice(0, i - 1) + cur.slice(i));
      focusIndex(i - 1);
    }
  } else if (key === 'ArrowLeft') {
    if (e) e.preventDefault();
    focusIndex(i - 1);
  } else if (key === 'ArrowRight') {
    if (e) e.preventDefault();
    focusIndex(i + 1);
  } else if (key === 'Home') {
    if (e) e.preventDefault();
    focusIndex(0);
  } else if (key === 'End') {
    if (e) e.preventDefault();
    focusIndex(length - 1);
  }
};
// ---- paste (collapses onto the same distribution routine as onInput) ---
const onPaste = (i: any, e: any) => {
  if (e) e.preventDefault();
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const plan = planWrite(code(), length, type, i, text);
  if (!plan) return;
  commitValue(plan.next);
  focusIndex(plan.focus);
};
// Select the cell's content on focus so a keystroke overwrites it. Correct
// for Tab and programmatic focus.
const onFocus = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};
// A mouse/touch focus places the caret on mouseup, collapsing onFocus's
// select(); with maxlength="1" a filled cell then REJECTS the next keystroke.
// Re-select on pointerup (after caret placement) so click-then-type overwrites,
// matching the Tab-focus behavior.
const onPointerUp = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};
// ---- per-cell attribute helpers ----------------------------------------
const cellType = () => mask ? 'password' : 'text';
// NOTE: named `cellInputMode`, NOT `inputMode` — a bare `inputMode` member
// collides with the inherited `HTMLElement.inputMode: string` on the Lit custom
// element (a hard TS2416/TS1238, unlike the warn-only `focus` override). The
// `cell`-prefix keeps it collision-safe across all six strict-typecheck leaves.
const cellInputMode = () => type === 'numeric' ? 'numeric' : 'text';
const cellAriaLabel = (i: any) => 'Digit ' + (i + 1) + ' of ' + length;
const cellAutocomplete = (i: any) => i === 0 ? 'one-time-code' : 'off';

// ---- lifecycle + imperative handle -------------------------------------
// focus() — focus the first empty cell. DELIBERATELY overrides HTMLElement.focus
// on Lit (ROZ137 warn, accepted). clear() — reset the code and focus the first
// cell. Both read $refs in a post-mount handle call (ROZ123-safe).
export const focus = () => focusIndex(firstEmptyIndex());
export const clear = () => {
  commitValue('');
  focusIndex(0);
};

onMount(() => {
  if (autoFocus) focusIndex(firstEmptyIndex());
});
</script>

<div bind:this={root} role="group" aria-label={ariaLabel} {...__rozieAttrs} class={["rozie-otp", { 'rozie-otp--disabled': disabled }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-8267d52a>{#each cells() as cell (cell.i)}<input class="rozie-otp-cell" type={rozieAttr(cellType())} inputmode={rozieAttr(cellInputMode())} maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete={rozieAttr(cellAutocomplete(cell.i))} value={rozieAttr(cell.ch)} placeholder={placeholder} disabled={!!disabled} aria-label={rozieAttr(cellAriaLabel(cell.i))} data-filled={rozieAttr(cell.ch ? 'true' : null)} oninput={($event) => { onInput(cell.i, $event); }} onkeydown={($event) => { onKeydown(cell.i, $event); }} onpaste={($event) => { onPaste(cell.i, $event); }} onfocus={($event) => { onFocus($event); }} onpointerup={($event) => { onPointerUp($event); }} data-rozie-s-8267d52a />{/each}</div>

<style>
:global {
  .rozie-otp[data-rozie-s-8267d52a] {
    display: inline-flex;
    gap: var(--rozie-otp-gap, 0.5rem);
    font: var(--rozie-otp-font, inherit);
  }
  .rozie-otp-cell[data-rozie-s-8267d52a] {
    box-sizing: border-box;
    width: var(--rozie-otp-cell-size, 2.75rem);
    height: var(--rozie-otp-cell-size, 2.75rem);
    padding: 0;
    text-align: center;
    font-size: var(--rozie-otp-font-size, 1.25rem);
    font-weight: var(--rozie-otp-font-weight, 600);
    color: var(--rozie-otp-color, inherit);
    background: var(--rozie-otp-bg, #fff);
    border: var(--rozie-otp-border-width, 1px) solid var(--rozie-otp-border-color, rgba(0, 0, 0, 0.25));
    border-radius: var(--rozie-otp-radius, 0.5rem);
    outline: none;
    transition: border-color 0.15s, box-shadow 0.15s;
    caret-color: var(--rozie-otp-accent, #0066cc);
  }
  .rozie-otp-cell[data-rozie-s-8267d52a]::placeholder {
    color: var(--rozie-otp-placeholder-color, rgba(0, 0, 0, 0.3));
  }
  .rozie-otp-cell[data-filled='true'][data-rozie-s-8267d52a] {
    border-color: var(--rozie-otp-filled-border-color, var(--rozie-otp-accent, #0066cc));
  }
  .rozie-otp-cell[data-rozie-s-8267d52a]:focus {
    border-color: var(--rozie-otp-accent, #0066cc);
    box-shadow: 0 0 0 var(--rozie-otp-focus-ring-width, 3px) var(--rozie-otp-focus-ring-color, rgba(0, 102, 204, 0.25));
  }
  .rozie-otp--disabled[data-rozie-s-8267d52a] .rozie-otp-cell[data-rozie-s-8267d52a] {
    cursor: not-allowed;
    opacity: var(--rozie-otp-disabled-opacity, 0.55);
    background: var(--rozie-otp-disabled-bg, rgba(0, 0, 0, 0.04));
  }
}
</style>
ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgClass } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';

import { firstEmptyIndex as firstEmpty, isAllowedChar, planEmits, planWrite } from './internal/otpWrite';

// ---- derived view (plain functions, uniform ×6) ------------------------
// The current code, normalized to a string.

@Component({
  selector: 'rozie-otp',
  standalone: true,
  imports: [NgClass],
  template: `

    <div class="rozie-otp" [ngClass]="{ 'rozie-otp--disabled': (disabled() || this.__rozieCvaDisabled()) }" #root role="group" [attr.aria-label]="rozieAttr(ariaLabel())" #rozieSpread_0 #rozieListenersTarget_1>
      @for (cell of cells(); track cell.i) {
    <input class="rozie-otp-cell" [attr.type]="rozieAttr(cellType())" [attr.inputmode]="rozieAttr(cellInputMode())" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" [attr.autocomplete]="rozieAttr(cellAutocomplete(cell.i))" [value]="cell.ch" [placeholder]="placeholder()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="rozieAttr(cellAriaLabel(cell.i))" [attr.data-filled]="rozieAttr(cell.ch ? 'true' : null)" (input)="onInput(cell.i, $event)" (keydown)="onKeydown(cell.i, $event)" (paste)="onPaste(cell.i, $event)" (focus)="onFocus($event)" (pointerup)="onPointerUp($event)" />
    }
    </div>

  `,
  styles: [`
    :host(rozie-otp) { display: contents; }
    .rozie-otp {
      display: inline-flex;
      gap: var(--rozie-otp-gap, 0.5rem);
      font: var(--rozie-otp-font, inherit);
    }
    .rozie-otp-cell {
      box-sizing: border-box;
      width: var(--rozie-otp-cell-size, 2.75rem);
      height: var(--rozie-otp-cell-size, 2.75rem);
      padding: 0;
      text-align: center;
      font-size: var(--rozie-otp-font-size, 1.25rem);
      font-weight: var(--rozie-otp-font-weight, 600);
      color: var(--rozie-otp-color, inherit);
      background: var(--rozie-otp-bg, #fff);
      border: var(--rozie-otp-border-width, 1px) solid var(--rozie-otp-border-color, rgba(0, 0, 0, 0.25));
      border-radius: var(--rozie-otp-radius, 0.5rem);
      outline: none;
      transition: border-color 0.15s, box-shadow 0.15s;
      caret-color: var(--rozie-otp-accent, #0066cc);
    }
    .rozie-otp-cell::placeholder {
      color: var(--rozie-otp-placeholder-color, rgba(0, 0, 0, 0.3));
    }
    .rozie-otp-cell[data-filled='true'] {
      border-color: var(--rozie-otp-filled-border-color, var(--rozie-otp-accent, #0066cc));
    }
    .rozie-otp-cell:focus {
      border-color: var(--rozie-otp-accent, #0066cc);
      box-shadow: 0 0 0 var(--rozie-otp-focus-ring-width, 3px) var(--rozie-otp-focus-ring-color, rgba(0, 102, 204, 0.25));
    }
    .rozie-otp--disabled .rozie-otp-cell {
      cursor: not-allowed;
      opacity: var(--rozie-otp-disabled-opacity, 0.55);
      background: var(--rozie-otp-disabled-bg, rgba(0, 0, 0, 0.04));
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => Otp),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Otp {
  /**
   * The assembled one-time code (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so an Otp **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Always a contiguous string of `0..length` characters; Otp writes the new code back on every edit (type, paste, backspace).
   * @example
   * <Otp r-model:value="code" :length="6" type="numeric" ariaLabel="Verification code" />
   */
  value = model<string>('');
  /**
   * Number of input cells to render.
   */
  length = input<number>(6);
  /**
   * Allowed-character class plus the mobile keyboard hint: `'numeric'` permits digits only and sets `inputmode="numeric"`; `'alphanumeric'` permits `[A-Za-z0-9]` with `inputmode="text"`; `'text'` permits any non-space character with `inputmode="text"`. Characters that fail the test are rejected on type and filtered on paste.
   */
  type = input<string>('numeric');
  /**
   * Render the cells as masked dots (`type="password"`) for sensitive codes, while keeping the same keyboard and ARIA behavior.
   */
  mask = input<boolean>(false);
  /**
   * Focus the first empty cell on mount.
   */
  autoFocus = input<boolean>(false);
  /**
   * Disable every cell. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled = input<boolean>(false);
  /**
   * Per-cell placeholder character shown in empty cells (e.g. `'•'` or `'0'`).
   */
  placeholder = input<string>('');
  /**
   * Accessible name for the whole group (`role="group"`, applied as `aria-label`). Each cell additionally gets an ordinal `aria-label` (`"Digit 1 of 6"`).
   */
  ariaLabel = input<(string) | null>(null);
  root = viewChild<ElementRef<HTMLDivElement>>('root');
  change = output<unknown>();
  complete = output<unknown>();

  ngAfterViewInit() {
    if (this.autoFocus()) this.focusIndex(this.firstEmptyIndex());
  }

  code = () => typeof this.value() === 'string' ? this.value() : '';
  cells = () => {
    const v = this.code();
    const out = [];
    for (let i = 0; i < this.length(); i++) out.push({
      i,
      ch: v[i] || ''
    });
    return out;
  };
  allowChar = (ch: any) => isAllowedChar(this.type(), ch);
  firstEmptyIndex = () => firstEmpty(this.code(), this.length());
  focusIndex = (idx: any) => {
    const __length = this.length();
    let i = idx;
    if (i < 0) i = 0;
    if (i >= __length) i = __length - 1;
    const root = this.root()?.nativeElement;
    if (!root) return;
    const inputs = root.querySelectorAll('input');
    const el = inputs[i];
    if (el) {
      el.focus();
      if (el.select) el.select();
    }
  };
  commitValue = (raw: any) => {
    const __length = this.length();
    const prev = this.code();
    const next = String(raw).slice(0, __length);
    const emits = planEmits(prev, next, __length);
    this.value.set(next), this.__rozieCvaOnChange(next);
    if (emits.change) this.change.emit({
      value: next
    });
    if (emits.complete) this.complete.emit({
      value: next
    });
  };
  onInput = (i: any, e: any) => {
    const raw = e && e.target ? e.target.value : '';
    if (raw === '') {
      const cur = this.code();
      this.commitValue(cur.slice(0, i) + cur.slice(i + 1));
      return;
    }
    const plan = planWrite(this.code(), this.length(), this.type(), i, raw);
    if (!plan) {
      if (e && e.target) e.target.value = this.code()[i] || '';
      return;
    }
    this.commitValue(plan.next);
    // Restore the originating cell's DOM value from the derived model. Required
    // for the multi-char/autofill case: the element currently holds the WHOLE
    // pasted/autofilled string, and when the committed value happens to equal the
    // previous value the framework re-render is a no-op (the same reason the
    // invalid-char branch above resets the element directly). Without this the
    // first cell keeps rendering the whole autofilled string.
    if (e && e.target) e.target.value = plan.next[i] || '';
    this.focusIndex(plan.focus);
  };
  onKeydown = (i: any, e: any) => {
    const key = e ? e.key : '';
    const cur = this.code();
    if (key === 'Backspace') {
      if (e) e.preventDefault();
      if (cur[i]) {
        this.commitValue(cur.slice(0, i) + cur.slice(i + 1));
      } else if (i > 0) {
        this.commitValue(cur.slice(0, i - 1) + cur.slice(i));
        this.focusIndex(i - 1);
      }
    } else if (key === 'ArrowLeft') {
      if (e) e.preventDefault();
      this.focusIndex(i - 1);
    } else if (key === 'ArrowRight') {
      if (e) e.preventDefault();
      this.focusIndex(i + 1);
    } else if (key === 'Home') {
      if (e) e.preventDefault();
      this.focusIndex(0);
    } else if (key === 'End') {
      if (e) e.preventDefault();
      this.focusIndex(this.length() - 1);
    }
  };
  onPaste = (i: any, e: any) => {
    if (e) e.preventDefault();
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const plan = planWrite(this.code(), this.length(), this.type(), i, text);
    if (!plan) return;
    this.commitValue(plan.next);
    this.focusIndex(plan.focus);
  };
  onFocus = (e: any) => {
    if (e && e.target && e.target.select) e.target.select();
  };
  onPointerUp = (e: any) => {
    if (e && e.target && e.target.select) e.target.select();
  };
  cellType = () => this.mask() ? 'password' : 'text';
  cellInputMode = () => this.type() === 'numeric' ? 'numeric' : 'text';
  cellAriaLabel = (i: any) => 'Digit ' + (i + 1) + ' of ' + this.length();
  cellAutocomplete = (i: any) => i === 0 ? 'one-time-code' : 'off';
  focus = () => this.focusIndex(this.firstEmptyIndex());
  clear = () => {
    this.commitValue('');
    this.focusIndex(0);
  };

  private __rozieCvaOnChange: (v: string) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: string | null): void {
    this.value.set(v ?? '');
  }
  registerOnChange(fn: (v: string) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default Otp;
tsx
import type { JSX } from 'solid-js';
import { mergeProps, onMount, splitProps } from 'solid-js';
import { Key } from '@solid-primitives/keyed';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass } from '@rozie/runtime-solid';
import { firstEmptyIndex as firstEmpty, isAllowedChar, planEmits, planWrite } from './internal/otpWrite';

// ---- derived view (plain functions, uniform ×6) ------------------------
// The current code, normalized to a string.

__rozieInjectStyle('Otp-8267d52a', `.rozie-otp[data-rozie-s-8267d52a] {
  display: inline-flex;
  gap: var(--rozie-otp-gap, 0.5rem);
  font: var(--rozie-otp-font, inherit);
}
.rozie-otp-cell[data-rozie-s-8267d52a] {
  box-sizing: border-box;
  width: var(--rozie-otp-cell-size, 2.75rem);
  height: var(--rozie-otp-cell-size, 2.75rem);
  padding: 0;
  text-align: center;
  font-size: var(--rozie-otp-font-size, 1.25rem);
  font-weight: var(--rozie-otp-font-weight, 600);
  color: var(--rozie-otp-color, inherit);
  background: var(--rozie-otp-bg, #fff);
  border: var(--rozie-otp-border-width, 1px) solid var(--rozie-otp-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-otp-radius, 0.5rem);
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
  caret-color: var(--rozie-otp-accent, #0066cc);
}
.rozie-otp-cell[data-rozie-s-8267d52a]::placeholder {
  color: var(--rozie-otp-placeholder-color, rgba(0, 0, 0, 0.3));
}
.rozie-otp-cell[data-filled='true'][data-rozie-s-8267d52a] {
  border-color: var(--rozie-otp-filled-border-color, var(--rozie-otp-accent, #0066cc));
}
.rozie-otp-cell[data-rozie-s-8267d52a]:focus {
  border-color: var(--rozie-otp-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-otp-focus-ring-width, 3px) var(--rozie-otp-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-otp--disabled[data-rozie-s-8267d52a] .rozie-otp-cell[data-rozie-s-8267d52a] {
  cursor: not-allowed;
  opacity: var(--rozie-otp-disabled-opacity, 0.55);
  background: var(--rozie-otp-disabled-bg, rgba(0, 0, 0, 0.04));
}`);

interface OtpProps {
  /**
   * The assembled one-time code (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so an Otp **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Always a contiguous string of `0..length` characters; Otp writes the new code back on every edit (type, paste, backspace).
   * @example
   * <Otp r-model:value="code" :length="6" type="numeric" ariaLabel="Verification code" />
   */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /**
   * Number of input cells to render.
   */
  length?: number;
  /**
   * Allowed-character class plus the mobile keyboard hint: `'numeric'` permits digits only and sets `inputmode="numeric"`; `'alphanumeric'` permits `[A-Za-z0-9]` with `inputmode="text"`; `'text'` permits any non-space character with `inputmode="text"`. Characters that fail the test are rejected on type and filtered on paste.
   */
  type?: string;
  /**
   * Render the cells as masked dots (`type="password"`) for sensitive codes, while keeping the same keyboard and ARIA behavior.
   */
  mask?: boolean;
  /**
   * Focus the first empty cell on mount.
   */
  autoFocus?: boolean;
  /**
   * Disable every cell. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Per-cell placeholder character shown in empty cells (e.g. `'•'` or `'0'`).
   */
  placeholder?: string;
  /**
   * Accessible name for the whole group (`role="group"`, applied as `aria-label`). Each cell additionally gets an ordinal `aria-label` (`"Digit 1 of 6"`).
   */
  ariaLabel?: (string) | null;
  onChange?: (...args: unknown[]) => void;
  onComplete?: (...args: unknown[]) => void;
  ref?: (h: OtpHandle) => void;
}

export interface OtpHandle {
  focus: (...args: any[]) => any;
  clear: (...args: any[]) => any;
}

export default function Otp(_props: OtpProps): JSX.Element {
  const _merged = mergeProps({ length: 6, type: 'numeric', mask: false, autoFocus: false, disabled: false, placeholder: '', ariaLabel: null }, _props);
  const [local, attrs] = splitProps(_merged, ['value', 'length', 'type', 'mask', 'autoFocus', 'disabled', 'placeholder', 'ariaLabel', 'ref', 'onChange', 'onComplete']);
  onMount(() => { local.ref?.({ focus, clear }); });

  const [value, setValue] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'value', '');
  onMount(() => {
    if (local.autoFocus) focusIndex(firstEmptyIndex());
  });
  let rootRef: HTMLElement | null = null;

  // ---- derived view (plain functions, uniform ×6) ------------------------
  // The current code, normalized to a string.
  function code() {
    return typeof value() === 'string' ? value() : '';
  }

  // The cells to render: one { i, ch } per position, ch derived from `value`.
  // A plain function (called in the r-for and from handlers) — never $computed.
  function cells() {
    const v = code();
    const out = [];
    for (let i = 0; i < local.length; i++) out.push({
      i,
      ch: v[i] || ''
    });
    return out;
  }

  // Allowed-character test for the configured `type`. Thin wrapper over the
  // pure write model in ./internal/otpWrite (vendored into every leaf).
  function allowChar(ch: any) {
    return isAllowedChar(local.type, ch);
  }

  // The cell that should receive focus for new input: the first empty position
  // (clamped to the last cell when full).
  function firstEmptyIndex() {
    return firstEmpty(code(), local.length);
  }

  // ---- focus choreography (container ref, post-mount only) ----------------
  // Read $refs.root only here / in $onMount / in $expose verbs (all post-mount →
  // ROZ123-safe). querySelectorAll reaches the cells inside Lit's shadow root too.
  function focusIndex(idx: any) {
    let i = idx;
    if (i < 0) i = 0;
    if (i >= local.length) i = local.length - 1;
    const root = rootRef;
    if (!root) return;
    const inputs = root.querySelectorAll('input');
    const el = inputs[i];
    if (el) {
      el.focus();
      if (el.select) el.select();
    }
  }

  // ---- write funnel (single $emit site) ----------------------------------
  // Capture `prev` BEFORE the model write (code() reads $props.value, which has
  // not yet round-tripped) — this is what makes the transition detection
  // stateless and keeps the documented "CONTROLLED, NO LOCAL STATE" invariant
  // (do NOT add a `wasFull` flag to <data>). The model write stays
  // unconditional (idempotent, keeps the controlled contract); only the emits
  // are gated: `change` on an actual value transition, `complete` only on the
  // not-full -> full transition (never on an in-place edit of an already-full
  // code, never at length: 0 — see ./internal/otpWrite.planEmits).
  function commitValue(raw: any) {
    const prev = code();
    const next = String(raw).slice(0, local.length);
    const emits = planEmits(prev, next, local.length);
    setValue(next);
    if (emits.change) _props.onChange?.({
      value: next
    });
    if (emits.complete) _props.onComplete?.({
      value: next
    });
  }

  // ---- input handler -----------------------------------------------------
  // One path for every input shape (single char, autofill, swipe, IME commit —
  // see ./internal/otpWrite.planWrite): sanitize + distribute from the write
  // position, clamped to the current fill point so a click past the fill point
  // never leaves a hole. An invalid / empty-after-sanitize write is rejected by
  // restoring the cell's DOM value directly (a no-op model write may not
  // re-render on React, so reset the element instead).
  function onInput(i: any, e: any) {
    const raw = e && e.target ? e.target.value : '';
    if (raw === '') {
      const cur = code();
      commitValue(cur.slice(0, i) + cur.slice(i + 1));
      return;
    }
    const plan = planWrite(code(), local.length, local.type, i, raw);
    if (!plan) {
      if (e && e.target) e.target.value = code()[i] || '';
      return;
    }
    commitValue(plan.next);
    // Restore the originating cell's DOM value from the derived model. Required
    // for the multi-char/autofill case: the element currently holds the WHOLE
    // pasted/autofilled string, and when the committed value happens to equal the
    // previous value the framework re-render is a no-op (the same reason the
    // invalid-char branch above resets the element directly). Without this the
    // first cell keeps rendering the whole autofilled string.
    if (e && e.target) e.target.value = plan.next[i] || '';
    focusIndex(plan.focus);
  }

  // ---- keyboard ----------------------------------------------------------
  // Backspace deletes the current char (or the previous one when the cell is
  // already empty) and moves focus accordingly; arrows / Home / End navigate.
  function onKeydown(i: any, e: any) {
    const key = e ? e.key : '';
    const cur = code();
    if (key === 'Backspace') {
      if (e) e.preventDefault();
      if (cur[i]) {
        commitValue(cur.slice(0, i) + cur.slice(i + 1));
      } else if (i > 0) {
        commitValue(cur.slice(0, i - 1) + cur.slice(i));
        focusIndex(i - 1);
      }
    } else if (key === 'ArrowLeft') {
      if (e) e.preventDefault();
      focusIndex(i - 1);
    } else if (key === 'ArrowRight') {
      if (e) e.preventDefault();
      focusIndex(i + 1);
    } else if (key === 'Home') {
      if (e) e.preventDefault();
      focusIndex(0);
    } else if (key === 'End') {
      if (e) e.preventDefault();
      focusIndex(local.length - 1);
    }
  }

  // ---- paste (collapses onto the same distribution routine as onInput) ---
  function onPaste(i: any, e: any) {
    if (e) e.preventDefault();
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const plan = planWrite(code(), local.length, local.type, i, text);
    if (!plan) return;
    commitValue(plan.next);
    focusIndex(plan.focus);
  }

  // Select the cell's content on focus so a keystroke overwrites it. Correct
  // for Tab and programmatic focus.
  function onFocus(e: any) {
    if (e && e.target && e.target.select) e.target.select();
  }

  // A mouse/touch focus places the caret on mouseup, collapsing onFocus's
  // select(); with maxlength="1" a filled cell then REJECTS the next keystroke.
  // Re-select on pointerup (after caret placement) so click-then-type overwrites,
  // matching the Tab-focus behavior.
  function onPointerUp(e: any) {
    if (e && e.target && e.target.select) e.target.select();
  }

  // ---- per-cell attribute helpers ----------------------------------------
  function cellType() {
    return local.mask ? 'password' : 'text';
  }
  // NOTE: named `cellInputMode`, NOT `inputMode` — a bare `inputMode` member
  // collides with the inherited `HTMLElement.inputMode: string` on the Lit custom
  // element (a hard TS2416/TS1238, unlike the warn-only `focus` override). The
  // `cell`-prefix keeps it collision-safe across all six strict-typecheck leaves.
  function cellInputMode() {
    return local.type === 'numeric' ? 'numeric' : 'text';
  }
  function cellAriaLabel(i: any) {
    return 'Digit ' + (i + 1) + ' of ' + local.length;
  }
  function cellAutocomplete(i: any) {
    return i === 0 ? 'one-time-code' : 'off';
  }

  // ---- lifecycle + imperative handle -------------------------------------

  // focus() — focus the first empty cell. DELIBERATELY overrides HTMLElement.focus
  // on Lit (ROZ137 warn, accepted). clear() — reset the code and focus the first
  // cell. Both read $refs in a post-mount handle call (ROZ123-safe).
  function focus() {
    return focusIndex(firstEmptyIndex());
  }
  function clear() {
    commitValue('');
    focusIndex(0);
  }

  return (
    <>
    <div ref={(el) => { rootRef = el as HTMLElement; }} role="group" aria-label={rozieAttr(local.ariaLabel)} {...attrs} class={"rozie-otp" + " " + rozieClass({ 'rozie-otp--disabled': local.disabled }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-8267d52a="">
      <Key each={cells() as readonly any[]} by={(cell) => cell.i}>{(cell) => <input autocapitalize="off" autocorrect="off" spellcheck="false" aria-label={rozieAttr(cellAriaLabel(cell().i))} data-filled={rozieAttr(cell().ch ? 'true' : null)} class={"rozie-otp-cell"} type={rozieAttr(cellType())} inputMode={rozieAttr(cellInputMode())} maxLength={1} autocomplete={rozieAttr(cellAutocomplete(cell().i))} value={cell().ch} placeholder={local.placeholder} disabled={!!local.disabled} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInput(cell().i, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeydown(cell().i, $event); }} onPaste={($event: ClipboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onPaste(cell().i, $event); }} onFocus={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onFocus($event); }} onPointerUp={($event: PointerEvent & { currentTarget: HTMLInputElement; target: Element }) => { onPointerUp($event); }} data-rozie-s-8267d52a="" />}</Key>
    </div>
    </>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
import { firstEmptyIndex as firstEmpty, isAllowedChar, planEmits, planWrite } from './internal/otpWrite';

// ---- derived view (plain functions, uniform ×6) ------------------------
// The current code, normalized to a string.

@customElement('rozie-otp')
export default class Otp extends SignalWatcher(LitElement) {
  static styles = css`
:host{display:contents}
.rozie-otp[data-rozie-s-8267d52a] {
  display: inline-flex;
  gap: var(--rozie-otp-gap, 0.5rem);
  font: var(--rozie-otp-font, inherit);
}
.rozie-otp-cell[data-rozie-s-8267d52a] {
  box-sizing: border-box;
  width: var(--rozie-otp-cell-size, 2.75rem);
  height: var(--rozie-otp-cell-size, 2.75rem);
  padding: 0;
  text-align: center;
  font-size: var(--rozie-otp-font-size, 1.25rem);
  font-weight: var(--rozie-otp-font-weight, 600);
  color: var(--rozie-otp-color, inherit);
  background: var(--rozie-otp-bg, #fff);
  border: var(--rozie-otp-border-width, 1px) solid var(--rozie-otp-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-otp-radius, 0.5rem);
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
  caret-color: var(--rozie-otp-accent, #0066cc);
}
.rozie-otp-cell[data-rozie-s-8267d52a]::placeholder {
  color: var(--rozie-otp-placeholder-color, rgba(0, 0, 0, 0.3));
}
.rozie-otp-cell[data-filled='true'][data-rozie-s-8267d52a] {
  border-color: var(--rozie-otp-filled-border-color, var(--rozie-otp-accent, #0066cc));
}
.rozie-otp-cell[data-rozie-s-8267d52a]:focus {
  border-color: var(--rozie-otp-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-otp-focus-ring-width, 3px) var(--rozie-otp-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-otp--disabled[data-rozie-s-8267d52a] .rozie-otp-cell[data-rozie-s-8267d52a] {
  cursor: not-allowed;
  opacity: var(--rozie-otp-disabled-opacity, 0.55);
  background: var(--rozie-otp-disabled-bg, rgba(0, 0, 0, 0.04));
}
`;

  /**
   * The assembled one-time code (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so an Otp **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). Always a contiguous string of `0..length` characters; Otp writes the new code back on every edit (type, paste, backspace).
   * @example
   * <Otp r-model:value="code" :length="6" type="numeric" ariaLabel="Verification code" />
   */
  @property({ type: String, attribute: 'value' }) _value_attr: string = '';
  private _valueControllable = createLitControllableProperty<string>({ host: this, eventName: 'value-change', defaultValue: '', initialControlledValue: undefined });
  /**
   * Number of input cells to render.
   */
  @property({ type: Number, reflect: true }) length: number = 6;
  /**
   * Allowed-character class plus the mobile keyboard hint: `'numeric'` permits digits only and sets `inputmode="numeric"`; `'alphanumeric'` permits `[A-Za-z0-9]` with `inputmode="text"`; `'text'` permits any non-space character with `inputmode="text"`. Characters that fail the test are rejected on type and filtered on paste.
   */
  @property({ type: String, reflect: true }) type: string = 'numeric';
  /**
   * Render the cells as masked dots (`type="password"`) for sensitive codes, while keeping the same keyboard and ARIA behavior.
   */
  @property({ type: Boolean, reflect: true }) mask: boolean = false;
  /**
   * Focus the first empty cell on mount.
   */
  @property({ type: Boolean, reflect: true }) autoFocus: boolean = false;
  /**
   * Disable every cell. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  @property({ type: Boolean, reflect: true }) disabled: boolean = false;
  /**
   * Per-cell placeholder character shown in empty cells (e.g. `'•'` or `'0'`).
   */
  @property({ type: String, reflect: true }) placeholder: string = '';
  /**
   * Accessible name for the whole group (`role="group"`, applied as `aria-label`). Each cell additionally gets an ordinal `aria-label` (`"Digit 1 of 6"`).
   */
  @property({ type: String, reflect: true }) ariaLabel: string | null = null;
  @query('[data-rozie-ref="root"]') private _refRoot!: HTMLElement;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  firstUpdated(): void {
    if (this.autoFocus) this.focusIndex(this.firstEmptyIndex());
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'value') this._valueControllable.notifyAttributeChange(value as unknown as string);
  }

  render() {
    return html`
<div class="${Object.entries({ "rozie-otp": true, 'rozie-otp--disabled': this.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="group" aria-label=${rozieAttr(this.ariaLabel)} ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="root" data-rozie-s-8267d52a>
  ${repeat<any>(this.cells(), (cell, _idx) => cell.i, (cell, _idx) => html`<input class="rozie-otp-cell" type=${rozieAttr(this.cellType())} inputmode=${rozieAttr(this.cellInputMode())} maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete=${rozieAttr(this.cellAutocomplete(cell.i))} .value=${cell.ch} placeholder=${this.placeholder} ?disabled=${!!this.disabled} aria-label=${rozieAttr(this.cellAriaLabel(cell.i))} data-filled=${rozieAttr(cell.ch ? 'true' : null)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInput(cell.i, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown(cell.i, $event); }} @paste=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onPaste(cell.i, $event); }} @focus=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onFocus($event); }} @pointerup=${($event: PointerEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onPointerUp($event); }} data-rozie-s-8267d52a />`)}
</div>
`;
  }

  code = () => typeof this.value === 'string' ? this.value : '';

  cells = () => {
  const v = this.code();
  const out = [];
  for (let i = 0; i < this.length; i++) out.push({
    i,
    ch: v[i] || ''
  });
  return out;
};

  allowChar = (ch: any) => isAllowedChar(this.type, ch);

  firstEmptyIndex = () => firstEmpty(this.code(), this.length);

  focusIndex = (idx: any) => {
  let i = idx;
  if (i < 0) i = 0;
  if (i >= this.length) i = this.length - 1;
  const root = this._refRoot;
  if (!root) return;
  const inputs = root.querySelectorAll('input');
  const el = inputs[i];
  if (el) {
    el.focus();
    if (el.select) el.select();
  }
};

  commitValue = (raw: any) => {
  const prev = this.code();
  const next = String(raw).slice(0, this.length);
  const emits = planEmits(prev, next, this.length);
  this._valueControllable.write(next);
  if (emits.change) this.dispatchEvent(new CustomEvent("change", {
    detail: {
      value: next
    },
    bubbles: true,
    composed: true
  }));
  if (emits.complete) this.dispatchEvent(new CustomEvent("complete", {
    detail: {
      value: next
    },
    bubbles: true,
    composed: true
  }));
};

  onInput = (i: any, e: any) => {
  const raw = e && e.target ? e.target.value : '';
  if (raw === '') {
    const cur = this.code();
    this.commitValue(cur.slice(0, i) + cur.slice(i + 1));
    return;
  }
  const plan = planWrite(this.code(), this.length, this.type, i, raw);
  if (!plan) {
    if (e && e.target) e.target.value = this.code()[i] || '';
    return;
  }
  this.commitValue(plan.next);
  // Restore the originating cell's DOM value from the derived model. Required
  // for the multi-char/autofill case: the element currently holds the WHOLE
  // pasted/autofilled string, and when the committed value happens to equal the
  // previous value the framework re-render is a no-op (the same reason the
  // invalid-char branch above resets the element directly). Without this the
  // first cell keeps rendering the whole autofilled string.
  if (e && e.target) e.target.value = plan.next[i] || '';
  this.focusIndex(plan.focus);
};

  onKeydown = (i: any, e: any) => {
  const key = e ? e.key : '';
  const cur = this.code();
  if (key === 'Backspace') {
    if (e) e.preventDefault();
    if (cur[i]) {
      this.commitValue(cur.slice(0, i) + cur.slice(i + 1));
    } else if (i > 0) {
      this.commitValue(cur.slice(0, i - 1) + cur.slice(i));
      this.focusIndex(i - 1);
    }
  } else if (key === 'ArrowLeft') {
    if (e) e.preventDefault();
    this.focusIndex(i - 1);
  } else if (key === 'ArrowRight') {
    if (e) e.preventDefault();
    this.focusIndex(i + 1);
  } else if (key === 'Home') {
    if (e) e.preventDefault();
    this.focusIndex(0);
  } else if (key === 'End') {
    if (e) e.preventDefault();
    this.focusIndex(this.length - 1);
  }
};

  onPaste = (i: any, e: any) => {
  if (e) e.preventDefault();
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const plan = planWrite(this.code(), this.length, this.type, i, text);
  if (!plan) return;
  this.commitValue(plan.next);
  this.focusIndex(plan.focus);
};

  onFocus = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};

  onPointerUp = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};

  cellType = () => this.mask ? 'password' : 'text';

  cellInputMode = () => this.type === 'numeric' ? 'numeric' : 'text';

  cellAriaLabel = (i: any) => 'Digit ' + (i + 1) + ' of ' + this.length;

  cellAutocomplete = (i: any) => i === 0 ? 'one-time-code' : 'off';

  focus = () => this.focusIndex(this.firstEmptyIndex());

  clear = () => {
  this.commitValue('');
  this.focusIndex(0);
};

  get value(): string { return this._valueControllable.read(); }
  set value(v: string) { this._valueControllable.notifyPropertyWrite(v); }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   *
   * command-palette-per-level-virtual / portal-through-portal cluster —
   * `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
   * attribute, never a consumer prop) so a parent-assigned `ref=` on this
   * component's own host tag can never clobber this component's OWN
   * internal `data-rozie-ref` ref markers via fallthrough re-application.
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['data-rozie-ref', 'value', 'length', 'type', 'mask', 'auto-focus', 'autofocus', 'disabled', 'placeholder', 'aria-label', 'arialabel']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Each is a real component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same props, same change / complete events, same two-way value, same imperative handle — identical on every target, built on native <input> cells with no third-party engine behind it.

See also

Pre-1.0 — APIs may change between minor versions.