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. Everything below is driven by the same Otp.rozie source that compiles to all six frameworks, 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.

One source, six outputs

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.

  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">
// ---- 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`.
const allowChar = (ch) => {
  if (!ch) return false
  if ($props.type === 'numeric') return /[0-9]/.test(ch)
  if ($props.type === 'alphanumeric') return /[a-zA-Z0-9]/.test(ch)
  return /\S/.test(ch)
}

// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
const firstEmptyIndex = () => {
  const len = code().length
  return len >= $props.length ? $props.length - 1 : len
}

// ---- 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) ----------------------------------
// Clamp to length, write the model, emit change, and emit complete when every
// cell is filled (a contiguous full string has length === $props.length).
const commitValue = (raw) => {
  const next = String(raw).slice(0, $props.length)
  $model.value = next
  $emit('change', { value: next })
  if (next.length === $props.length) $emit('complete', { value: next })
}

// ---- input handler -----------------------------------------------------
// Take the LAST char typed (handles overwriting a filled cell), sanitize, splice
// it into the contiguous string at this position, advance focus. An invalid char
// 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 ch = raw.slice(-1)
  if (!allowChar(ch)) {
    if (e && e.target) e.target.value = code()[i] || ''
    return
  }
  const cur = code()
  commitValue(cur.slice(0, i) + ch + cur.slice(i + 1))
  focusIndex(i + 1)
}

// ---- 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 (distribute across cells from this position) ----------------
const onPaste = (i, e) => {
  if (e) e.preventDefault()
  const text = (e && e.clipboardData && e.clipboardData.getData('text')) || ''
  const chars = text.split('').filter(allowChar)
  if (!chars.length) return
  const arr = code().split('')
  for (let k = 0; k < chars.length && i + k < $props.length; k++) arr[i + k] = chars[k]
  commitValue(arr.join(''))
  const landed = i + chars.length
  focusIndex(landed >= $props.length ? $props.length - 1 : landed)
}

// Select the cell's content on focus so a keystroke overwrites it.
const onFocus = (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"
    :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)"
  />
</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 idiomatic, 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';

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, ...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;
    return rest;
  })();
  const [value, setValue] = useControllableState({
    value: props.value,
    defaultValue: props.defaultValue ?? '',
    onValueChange: props.onValueChange,
  });
  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) {
    if (!ch) return false;
    if (props.type === 'numeric') return /[0-9]/.test(ch);
    if (props.type === 'alphanumeric') return /[a-zA-Z0-9]/.test(ch);
    return /\S/.test(ch);
  }
  const firstEmptyIndex = useCallback(() => {
    const len = code().length;
    return len >= props.length ? props.length - 1 : len;
  }, [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 next = String(raw).slice(0, props.length);
    setValue(next);
    props.onChange && props.onChange({
      value: next
    });
    if (next.length === props.length) 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 ch = raw.slice(-1);
    if (!allowChar(ch)) {
      if (e && e.target) e.target.value = code()[i] || '';
      return;
    }
    const cur = code();
    commitValue(cur.slice(0, i) + ch + cur.slice(i + 1));
    focusIndex(i + 1);
  }, [allowChar, code, commitValue, focusIndex]);
  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 chars = text.split('').filter(allowChar);
    if (!chars.length) return;
    const arr = code().split('');
    for (let k = 0; k < chars.length && i + k < props.length; k++) arr[i + k] = chars[k];
    commitValue(arr.join(''));
    const landed = i + chars.length;
    focusIndex(landed >= props.length ? props.length - 1 : landed);
  }, [allowChar, code, commitValue, focusIndex, props.length]);
  const onFocus = 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);
  }

  useEffect(() => {
    if (props.autoFocus) focusIndex(firstEmptyIndex());
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  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" 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); }} 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" :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)" />
</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>();

// ---- 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.
// 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`.
// Allowed-character test for the configured `type`.
const allowChar = (ch: any) => {
  if (!ch) return false;
  if (props.type === 'numeric') return /[0-9]/.test(ch);
  if (props.type === 'alphanumeric') return /[a-zA-Z0-9]/.test(ch);
  return /\S/.test(ch);
};

// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
const firstEmptyIndex = () => {
  const len = code().length;
  return len >= props.length ? props.length - 1 : len;
};

// ---- 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.
// ---- 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) ----------------------------------
// Clamp to length, write the model, emit change, and emit complete when every
// cell is filled (a contiguous full string has length === $props.length).
// ---- write funnel (single $emit site) ----------------------------------
// Clamp to length, write the model, emit change, and emit complete when every
// cell is filled (a contiguous full string has length === $props.length).
const commitValue = (raw: any) => {
  const next = String(raw).slice(0, props.length);
  value.value = next;
  emit('change', {
    value: next
  });
  if (next.length === props.length) emit('complete', {
    value: next
  });
};

// ---- input handler -----------------------------------------------------
// Take the LAST char typed (handles overwriting a filled cell), sanitize, splice
// it into the contiguous string at this position, advance focus. An invalid char
// 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).
// ---- input handler -----------------------------------------------------
// Take the LAST char typed (handles overwriting a filled cell), sanitize, splice
// it into the contiguous string at this position, advance focus. An invalid char
// 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 ch = raw.slice(-1);
  if (!allowChar(ch)) {
    if (e && e.target) e.target.value = code()[i] || '';
    return;
  }
  const cur = code();
  commitValue(cur.slice(0, i) + ch + cur.slice(i + 1));
  focusIndex(i + 1);
};

// ---- keyboard ----------------------------------------------------------
// Backspace deletes the current char (or the previous one when the cell is
// already empty) and moves focus accordingly; arrows / Home / End navigate.
// ---- 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 (distribute across cells from this position) ----------------
// ---- paste (distribute across cells from this position) ----------------
const onPaste = (i: any, e: any) => {
  if (e) e.preventDefault();
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const chars = text.split('').filter(allowChar);
  if (!chars.length) return;
  const arr = code().split('');
  for (let k = 0; k < chars.length && i + k < props.length; k++) arr[i + k] = chars[k];
  commitValue(arr.join(''));
  const landed = i + chars.length;
  focusIndex(landed >= props.length ? props.length - 1 : landed);
};

// Select the cell's content on focus so a keystroke overwrites it.
// Select the cell's content on focus so a keystroke overwrites it.
const onFocus = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};

// ---- per-cell attribute helpers ----------------------------------------
// ---- 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.
// 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);

// ---- 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.
// 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`.
// Allowed-character test for the configured `type`.
const allowChar = (ch: any) => {
  if (!ch) return false;
  if (type === 'numeric') return /[0-9]/.test(ch);
  if (type === 'alphanumeric') return /[a-zA-Z0-9]/.test(ch);
  return /\S/.test(ch);
};

// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
// The cell that should receive focus for new input: the first empty position
// (clamped to the last cell when full).
const firstEmptyIndex = () => {
  const len = code().length;
  return len >= length ? length - 1 : len;
};

// ---- 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.
// ---- 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) ----------------------------------
// Clamp to length, write the model, emit change, and emit complete when every
// cell is filled (a contiguous full string has length === $props.length).
// ---- write funnel (single $emit site) ----------------------------------
// Clamp to length, write the model, emit change, and emit complete when every
// cell is filled (a contiguous full string has length === $props.length).
const commitValue = (raw: any) => {
  const next = String(raw).slice(0, length);
  value = next;
  onchange?.({
    value: next
  });
  if (next.length === length) oncomplete?.({
    value: next
  });
};

// ---- input handler -----------------------------------------------------
// Take the LAST char typed (handles overwriting a filled cell), sanitize, splice
// it into the contiguous string at this position, advance focus. An invalid char
// 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).
// ---- input handler -----------------------------------------------------
// Take the LAST char typed (handles overwriting a filled cell), sanitize, splice
// it into the contiguous string at this position, advance focus. An invalid char
// 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 ch = raw.slice(-1);
  if (!allowChar(ch)) {
    if (e && e.target) e.target.value = code()[i] || '';
    return;
  }
  const cur = code();
  commitValue(cur.slice(0, i) + ch + cur.slice(i + 1));
  focusIndex(i + 1);
};

// ---- keyboard ----------------------------------------------------------
// Backspace deletes the current char (or the previous one when the cell is
// already empty) and moves focus accordingly; arrows / Home / End navigate.
// ---- 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 (distribute across cells from this position) ----------------
// ---- paste (distribute across cells from this position) ----------------
const onPaste = (i: any, e: any) => {
  if (e) e.preventDefault();
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const chars = text.split('').filter(allowChar);
  if (!chars.length) return;
  const arr = code().split('');
  for (let k = 0; k < chars.length && i + k < length; k++) arr[i + k] = chars[k];
  commitValue(arr.join(''));
  const landed = i + chars.length;
  focusIndex(landed >= length ? length - 1 : landed);
};

// Select the cell's content on focus so a keystroke overwrites it.
// Select the cell's content on focus so a keystroke overwrites it.
const onFocus = (e: any) => {
  if (e && e.target && e.target.select) e.target.select();
};

// ---- per-cell attribute helpers ----------------------------------------
// ---- 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.
// 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" 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); }} 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';

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

@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]="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" [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)" />
    }
    </div>

  `,
  styles: [`
    .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) => {
    const __type = this.type();
    if (!ch) return false;
    if (__type === 'numeric') return /[0-9]/.test(ch);
    if (__type === 'alphanumeric') return /[a-zA-Z0-9]/.test(ch);
    return /\S/.test(ch);
  };
  firstEmptyIndex = () => {
    const __length = this.length();
    const len = this.code().length;
    return len >= __length ? __length - 1 : len;
  };
  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 next = String(raw).slice(0, __length);
    this.value.set(next), this.__rozieCvaOnChange(next);
    this.change.emit({
      value: next
    });
    if (next.length === __length) 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 ch = raw.slice(-1);
    if (!this.allowChar(ch)) {
      if (e && e.target) e.target.value = this.code()[i] || '';
      return;
    }
    const cur = this.code();
    this.commitValue(cur.slice(0, i) + ch + cur.slice(i + 1));
    this.focusIndex(i + 1);
  };
  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) => {
    const __length = this.length();
    if (e) e.preventDefault();
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const chars = text.split('').filter(this.allowChar);
    if (!chars.length) return;
    const arr = this.code().split('');
    for (let k = 0; k < chars.length && i + k < __length; k++) arr[i + k] = chars[k];
    this.commitValue(arr.join(''));
    const landed = i + chars.length;
    this.focusIndex(landed >= __length ? __length - 1 : landed);
  };
  onFocus = (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 = (() => {
    const renderer = inject(Renderer2);
    const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
    const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
    const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
    const parseClassTokens = (value: unknown): string[] => {
      if (typeof value !== 'string') return [];
      const out: string[] = [];
      for (const tok of value.split(/\s+/)) {
        if (tok.length > 0) out.push(tok);
      }
      return out;
    };
    const parseStyleDecls = (value: unknown): Array<[string, string]> => {
      if (typeof value !== 'string') return [];
      const out: Array<[string, string]> = [];
      for (const decl of value.split(';')) {
        const colon = decl.indexOf(':');
        if (colon < 0) continue;
        const prop = decl.slice(0, colon).trim();
        const val = decl.slice(colon + 1).trim();
        if (prop.length > 0) out.push([prop, val]);
      }
      return out;
    };
    const applyClassMerge = (el: HTMLElement, value: unknown) => {
      const next = parseClassTokens(value);
      const prev = prevClassTokensByElement.get(el) ?? [];
      const nextSet = new Set(next);
      for (const tok of prev) {
        if (!nextSet.has(tok)) el.classList.remove(tok);
      }
      for (const tok of next) el.classList.add(tok);
      prevClassTokensByElement.set(el, next);
    };
    const applyStyleMerge = (el: HTMLElement, value: unknown) => {
      const next = parseStyleDecls(value);
      const prev = prevStylePropsByElement.get(el) ?? [];
      const nextProps = next.map(([p]) => p);
      const nextSet = new Set(nextProps);
      for (const prop of prev) {
        if (!nextSet.has(prop)) el.style.removeProperty(prop);
      }
      for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
      prevStylePropsByElement.set(el, nextProps);
    };
    return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
      const safeObj: Record<string, unknown> = obj ?? {};
      const prevKeys = prevKeysByElement.get(el) ?? [];
      for (const k of prevKeys) {
        if (k === 'class' || k === 'style') continue;
        if (!(k in safeObj)) renderer.removeAttribute(el, k);
      }
      if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
        applyClassMerge(el, '');
      }
      if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
        applyStyleMerge(el, '');
      }
      for (const [k, v] of Object.entries(safeObj)) {
        if (k === 'class') {
          applyClassMerge(el, v);
        } else if (k === 'style') {
          applyStyleMerge(el, v);
        } else if (v === null || v === false) {
          renderer.removeAttribute(el, k);
        } else {
          renderer.setAttribute(el, k, String(v));
        }
      }
      prevKeysByElement.set(el, Object.keys(safeObj));
    };
  })();

  private __rozieGetHostAttrs = (() => {
    const host = inject(ElementRef);
    return () => {
      const el = host.nativeElement as HTMLElement;
      const out: Record<string, unknown> = {};
      for (const a of Array.from(el.attributes)) out[a.name] = a.value;
      return out;
    };
  })();

  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 { For, mergeProps, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass } from '@rozie/runtime-solid';

__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']);
  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`.
  function allowChar(ch: any) {
    if (!ch) return false;
    if (local.type === 'numeric') return /[0-9]/.test(ch);
    if (local.type === 'alphanumeric') return /[a-zA-Z0-9]/.test(ch);
    return /\S/.test(ch);
  }

  // The cell that should receive focus for new input: the first empty position
  // (clamped to the last cell when full).
  function firstEmptyIndex() {
    const len = code().length;
    return len >= local.length ? local.length - 1 : len;
  }

  // ---- 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) ----------------------------------
  // Clamp to length, write the model, emit change, and emit complete when every
  // cell is filled (a contiguous full string has length === $props.length).
  function commitValue(raw: any) {
    const next = String(raw).slice(0, local.length);
    setValue(next);
    _props.onChange?.({
      value: next
    });
    if (next.length === local.length) _props.onComplete?.({
      value: next
    });
  }

  // ---- input handler -----------------------------------------------------
  // Take the LAST char typed (handles overwriting a filled cell), sanitize, splice
  // it into the contiguous string at this position, advance focus. An invalid char
  // 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 ch = raw.slice(-1);
    if (!allowChar(ch)) {
      if (e && e.target) e.target.value = code()[i] || '';
      return;
    }
    const cur = code();
    commitValue(cur.slice(0, i) + ch + cur.slice(i + 1));
    focusIndex(i + 1);
  }

  // ---- 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 (distribute across cells from this position) ----------------
  function onPaste(i: any, e: any) {
    if (e) e.preventDefault();
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const chars = text.split('').filter(allowChar);
    if (!chars.length) return;
    const arr = code().split('');
    for (let k = 0; k < chars.length && i + k < local.length; k++) arr[i + k] = chars[k];
    commitValue(arr.join(''));
    const landed = i + chars.length;
    focusIndex(landed >= local.length ? local.length - 1 : landed);
  }

  // Select the cell's content on focus so a keystroke overwrites it.
  function onFocus(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="">
      <For each={cells()}>{(cell) => <input autocapitalize="off" 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) => { onInput(cell.i, $event); }} onKeyDown={($event) => { onKeydown(cell.i, $event); }} onPaste={($event) => { onPaste(cell.i, $event); }} onFocus={($event) => { onFocus($event); }} data-rozie-s-8267d52a="" />}</For>
    </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';

@customElement('rozie-otp')
export default class Otp extends SignalWatcher(LitElement) {
  static styles = css`
.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=${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" key=${rozieAttr(cell.i)} type=${rozieAttr(this.cellType())} inputmode=${rozieAttr(this.cellInputMode())} maxlength="1" autocapitalize="off" 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: Event) => { this.onInput(cell.i, $event); }} @keydown=${($event: Event) => { this.onKeydown(cell.i, $event); }} @paste=${($event: Event) => { this.onPaste(cell.i, $event); }} @focus=${($event: Event) => { this.onFocus($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) => {
  if (!ch) return false;
  if (this.type === 'numeric') return /[0-9]/.test(ch);
  if (this.type === 'alphanumeric') return /[a-zA-Z0-9]/.test(ch);
  return /\S/.test(ch);
};

  firstEmptyIndex = () => {
  const len = this.code().length;
  return len >= this.length ? this.length - 1 : len;
};

  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 next = String(raw).slice(0, this.length);
  this._valueControllable.write(next);
  this.dispatchEvent(new CustomEvent("change", {
    detail: {
      value: next
    },
    bubbles: true,
    composed: true
  }));
  if (next.length === this.length) 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 ch = raw.slice(-1);
  if (!this.allowChar(ch)) {
    if (e && e.target) e.target.value = this.code()[i] || '';
    return;
  }
  const cur = this.code();
  this.commitValue(cur.slice(0, i) + ch + cur.slice(i + 1));
  this.focusIndex(i + 1);
};

  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 chars = text.split('').filter(this.allowChar);
  if (!chars.length) return;
  const arr = this.code().split('');
  for (let k = 0; k < chars.length && i + k < this.length; k++) arr[i + k] = chars[k];
  this.commitValue(arr.join(''));
  const landed = i + chars.length;
  this.focusIndex(landed >= this.length ? this.length - 1 : landed);
};

  onFocus = (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).
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['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, idiomatic 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 — all from the one source above, built on native <input> cells with no third-party engine behind it.

See also

Pre-v1.0 — internal monorepo.