diff --git a/src/uix/eidos/components/pin-input/README.md b/src/uix/eidos/components/pin-input/README.md new file mode 100644 index 000000000..0d7a3d3af --- /dev/null +++ b/src/uix/eidos/components/pin-input/README.md @@ -0,0 +1,135 @@ +# `` — eidos + +Multi-cell OTP / verification code input. A transparent +absolutely-positioned `` captures every keystroke and paste; +visible bordered cells follow the hidden input's caret. Field-aware +(disabled / readonly / required / invalid OR-merge with the enclosing +Field.Provider). + +## Usage + +```svelte + + + console.log('filled:', value)} +> + {#snippet children({ cells })} + {#each cells as cell, i} + + {/each} + {/snippet} + +``` + +## Parts + +| Part | Notes | +|---|---| +| `PinInput` (root) | Provider; container + hidden capture input. Exposes `{ cells, isFocused, isHovering, value }` via `children` snippet. | +| `Cell` | One visible cell per character position. Receives `cell` (`PinInputCellData`) and `index` from the snippet. | + +## Eidos-layer props + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `size` | `'sm' \| 'md' \| 'lg' \| 'xl'` (responsive) | `'md'` | Cell dimensions + glyph size. | + +## Headless props (forwarded to soma) + +`value`, `onValueChange`, `onComplete`, `length`, `type`, `pattern`, +`pasteTransformer`, `mask`, `placeholder`, `disabled`, `readonly`, +`required`, `invalid`, `name`, `autocomplete`, `inputmode`, +`aria-label`. See `src/uix/soma/components/pin-input/types.ts`. + +## Cell data-* (emitted by morfo) + +| Attr | When | +|---|---| +| `data-active` | The cell holds the caret (focused + about to be typed in). | +| `data-filled` | The cell has a character. | +| `data-disabled` | Cascaded from the provider's disabled state. | + +## Passive justification + +Passive **at the morfo level** — pin-input's morfo declares parts +(Provider, Cell, Input) but no `events` array. The user-perceived +interactions (typing, paste, completion) are mediated by the hidden +``'s native events; sema-side perceptual feedback piggy-backs +on the `commit` family base when the consumer hooks `onComplete`. + +The component IS interactive from the user's perspective. "Passive" +here is a contract-layer classification, not a UX one — same logic +as `` (delegates events to the underlying primitive). + +## Baseline + +WAI-ARIA Textbox pattern: + + +The OTP-input UX has no formal ARIA pattern. Soma follows the +de-facto behaviour established by Radix-style implementations and +the iOS / Android native code-input controls: + +- Single hidden `` (not one input per cell) so paste, IME and + autofill (`one-time-code`) work naturally. +- Paste distributes across cells via the morfo's + `pasteTransformer` hook. +- Backspace clears the previous cell when the current one is empty. +- Numeric `type` sets `inputmode='numeric'` + `autocomplete='one-time-code'` + so iOS / Android offer the SMS-autofill banner. + +## Comparativa + +| Lib | Single hidden input | Mask | Paste distribute | Type presets | Custom `pattern` | Snippet cells | +|---|---|---|---|---|---|---| +| **Radix Primitives** | n/a (not provided) | — | — | — | — | — | +| **Bits UI** | ✓ | ✓ | ✓ | numeric/alphanumeric/alphabetic | ✓ | ✓ | +| **Ark UI** | input per cell | ✓ | ✓ | numeric/alphanumeric | — | — | +| **Chakra v3** | input per cell | ✓ | ✓ | numeric/alphanumeric | — | — | +| **shadcn/ui (`input-otp`)** | ✓ | ✓ | ✓ | numeric (default) | ✓ | ✓ | +| **Eidos (this)** | ✓ | ✓ | ✓ | numeric / alphanumeric / alphabetic / custom | ✓ | ✓ | + +Aligned with the shadcn / Bits family (single hidden input). Ark and +Chakra use one input per cell which works but loses paste / IME +quality. + +## Decisiones + +- **Snippet-driven Cell rendering.** Forces the consumer to iterate + `cells` and render `` per index. Auto-rendering + would hide the per-index `index` arg which downstream consumers + use for analytics / focus targeting. +- **Cell consumes `data-active` for the focus ring**, not a literal + ``. Only the hidden input is focused; the visible + cells receive a synthetic focus ring via `data-active` to match + the caret position. +- **No `variant` knob.** Pin inputs are a singular visual pattern + (bordered boxes); variants like outline-only / soft / ghost noise + the contract. Consumers can recolor cells via `data-color` if + needed. +- **Size as t-shirt** (`sm` / `md` / `lg` / `xl`). Matches the rest + of UIX's input scale. + +## Gaps + +| Gap | Disposición | Detalle | +| --- | --- | --- | +| Group separator (e.g. `123-456`) | **diferir** | Soma's `pasteTransformer` strips dashes; rendering a visible separator between groups (Bits' `` part) isn't in the morfo. Add when a real consumer needs it. | +| Animated caret transition between cells | **diferir** | Soma emits `data-active` instantly; a transitioned bar could be a nice polish. | +| `data-color` cascade | **diferir** | Coloured cells (success on complete, danger on invalid) would propagate from the root's `data-color`. Add when the consumer pattern stabilises. | + +## Reference + +- Bits UI: +- Ark UI: +- Chakra v3: +- shadcn/ui (`input-otp`): + diff --git a/src/uix/eidos/components/pin-input/index.ts b/src/uix/eidos/components/pin-input/index.ts new file mode 100644 index 000000000..15cb0a477 --- /dev/null +++ b/src/uix/eidos/components/pin-input/index.ts @@ -0,0 +1,34 @@ +// PinInput — eidos compound API. +// +// import { PinInput } from '$uix/eidos/components/pin-input'; +// +// +// {#snippet children({ cells })} +// {#each cells as cell, i} +// +// {/each} +// {/snippet} +// +import PinInputComponent from './pin-input.svelte'; +import Cell from './pin-input-cell.svelte'; + +type PinInputNamespace = typeof PinInputComponent & { + Cell: typeof Cell; +}; + +const PinInput = PinInputComponent as PinInputNamespace; +PinInput.Cell = Cell; + +export { PinInput }; + +export default PinInput; + +export type { + PinInputProps, + PinInputCellProps as CellProps, + PinInputSize, + PinInputCellData, + PinInputCellSnippetProps, + PinInputProviderSnippetProps, + PinInputType +} from './types'; diff --git a/src/uix/eidos/components/pin-input/pin-input-cell.svelte b/src/uix/eidos/components/pin-input/pin-input-cell.svelte new file mode 100644 index 000000000..277335e93 --- /dev/null +++ b/src/uix/eidos/components/pin-input/pin-input-cell.svelte @@ -0,0 +1,18 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/pin-input/pin-input.css b/src/uix/eidos/components/pin-input/pin-input.css new file mode 100644 index 000000000..af2b449e0 --- /dev/null +++ b/src/uix/eidos/components/pin-input/pin-input.css @@ -0,0 +1,95 @@ +/* + * PinInput recipe. + * + * [data-pin-input] → flex row container (also hosts the + * hidden capture input as a sibling) + * [data-pin-input-cell] → visible character cell + * + * Soma owns the hidden input and emits per-cell data: + * + * [data-pin-input-cell][data-active] → focused cell receiving input + * [data-pin-input-cell][data-filled] → cell has a character + * [data-pin-input-cell][data-disabled] → cell disabled + * + * The recipe paints bordered boxes with a focus ring on the active + * cell and a subtle bg lift on filled cells. The hidden input keeps + * its inline absolute positioning from soma. + */ + +[data-pin-input] { + display: inline-flex; + align-items: center; + gap: var(--pin-input-gap, var(--space-2)); + font-family: var(--style-label-font-family, var(--font-ui)); +} + +[data-pin-input][data-disabled] { + opacity: var(--pin-input-disabled-opacity, 0.55); + pointer-events: none; +} + +/* ── Cell ─────────────────────────────────────────────────────────── */ + +[data-pin-input-cell] { + --_pin-cell-size: var(--pin-input-cell-size-md, 2.5rem); + --_pin-cell-font-size: var(--pin-input-cell-font-size-md, var(--font-size-lg)); + --_pin-cell-radius: var(--pin-input-cell-radius, var(--radius-md)); + + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--_pin-cell-size); + block-size: var(--_pin-cell-size); + border: var(--pin-input-cell-border-width, 1px) solid + var(--pin-input-cell-border-color, var(--color-border-default)); + border-radius: var(--_pin-cell-radius); + background: var(--pin-input-cell-bg, var(--color-surface-default)); + color: var(--color-content-primary); + font-size: var(--_pin-cell-font-size); + font-weight: var(--pin-input-cell-font-weight, 500); + font-variant-numeric: tabular-nums; + line-height: 1; /* literal: cells are single-glyph, line-height collapse */ + user-select: none; + transition: + border-color var(--duration-fast) var(--ease-default), + background var(--duration-fast) var(--ease-default), + box-shadow var(--duration-fast) var(--ease-default); +} + +[data-pin-input-cell][data-filled] { + background: var(--pin-input-cell-filled-bg, var(--color-primary-element)); + border-color: var(--pin-input-cell-filled-border, var(--color-primary-border)); +} + +[data-pin-input-cell][data-active] { + border-color: var(--pin-input-cell-active-border, var(--color-primary-solid)); + box-shadow: var(--focus-ring); +} + +[data-pin-input-cell][data-disabled] { + opacity: var(--pin-input-disabled-opacity, 0.55); +} + +/* Invalid (Field-aware). Soma OR-merges `invalid` with the enclosing + * Field's state; both surface as `data-invalid` on the container which + * we cascade to cells. */ +[data-pin-input][data-invalid] [data-pin-input-cell] { + border-color: var(--pin-input-cell-invalid-border, var(--color-threat-solid)); +} + +/* ── Size cascade ─────────────────────────────────────────────────── */ + +[data-pin-input][data-size='sm'] [data-pin-input-cell] { + --_pin-cell-size: var(--pin-input-cell-size-sm, 2rem); + --_pin-cell-font-size: var(--pin-input-cell-font-size-sm, var(--font-size-md)); +} + +[data-pin-input][data-size='lg'] [data-pin-input-cell] { + --_pin-cell-size: var(--pin-input-cell-size-lg, 3rem); + --_pin-cell-font-size: var(--pin-input-cell-font-size-lg, var(--font-size-xl)); +} + +[data-pin-input][data-size='xl'] [data-pin-input-cell] { + --_pin-cell-size: var(--pin-input-cell-size-xl, 3.5rem); + --_pin-cell-font-size: var(--pin-input-cell-font-size-xl, var(--font-size-xxl)); +} diff --git a/src/uix/eidos/components/pin-input/pin-input.svelte b/src/uix/eidos/components/pin-input/pin-input.svelte new file mode 100644 index 000000000..3bda29da9 --- /dev/null +++ b/src/uix/eidos/components/pin-input/pin-input.svelte @@ -0,0 +1,27 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/pin-input/types.ts b/src/uix/eidos/components/pin-input/types.ts new file mode 100644 index 000000000..1992b6d7b --- /dev/null +++ b/src/uix/eidos/components/pin-input/types.ts @@ -0,0 +1,44 @@ +import type { + ProviderProps, + CellProps, + PinInputCellData, + PinInputCellSnippetProps, + PinInputProviderSnippetProps, + PinInputType +} from '$soma/components/pin-input'; +import type { ResponsiveProp, Size } from '$uix/eidos/lib/types'; + +/** + * Eidos `` — visual wrapper over the multi-cell OTP / code + * input headless primitive. Two parts: + * + * + * {#snippet children({ cells })} + * {#each cells as cell, i} + * + * {/each} + * {/snippet} + * + * + * Soma renders a transparent absolutely-positioned `` that + * captures every keystroke + paste; consumers iterate the `cells` + * snippet arg and render the visible boxes. Eidos paints the cell + * chrome (border, focus ring, filled bg, fake caret). + */ + +/** Visual sizing — t-shirt scale narrowed to the steps the recipe maps. */ +export type PinInputSize = Extract; + +export type PinInputProps = ProviderProps & { + /** Cell dimensions. @default 'md' */ + size?: ResponsiveProp; +}; + +export type PinInputCellProps = CellProps; + +export type { + PinInputCellData, + PinInputCellSnippetProps, + PinInputProviderSnippetProps, + PinInputType +}; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 6461f3ad1..e5ba34a35 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -93,6 +93,7 @@ @import './components/alert-dialog/alert-dialog.css'; @import './components/dropdown-menu/dropdown-menu.css'; @import './components/context-menu/context-menu.css'; +@import './components/pin-input/pin-input.css'; @import './components/drawer/drawer.css'; @import './components/field/field.css'; @import './components/form/form.css'; diff --git a/src/uix/morfo/components/pin-input.ts b/src/uix/morfo/components/pin-input.ts index 1e4ffc389..618e0429c 100644 --- a/src/uix/morfo/components/pin-input.ts +++ b/src/uix/morfo/components/pin-input.ts @@ -4,7 +4,11 @@ import { v } from '../types'; export const pinInputMorfo = { name: 'PinInput', kebab: 'pin-input', - scope: ['soma'], + // Eidos wrapper added 2026-05-22 (`src/uix/eidos/components/pin-input/`). + // Sound for commit-fill (when the input reaches its length) comes from + // the `commit` family base — no per-component cascade needed. + scope: ['soma', 'sema', 'eidos'], + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/', texts: { label: '#?components.pin-input.label|Pin Input', input: '#?components.pin-input.input|Verification code' diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 6e4720acf..513255852 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -162,6 +162,7 @@ { slug: '/uix/components/stepper', label: 'Stepper' }, { slug: '/uix/components/tag-group', label: 'Tag group' }, { slug: '/uix/components/tags-input', label: 'Tags input' }, + { slug: '/uix/components/pin-input', label: 'Pin input' }, { slug: '/uix/components/file-upload', label: 'File upload' } ] }, diff --git a/web/routes/uix/components/pin-input/+page.svelte b/web/routes/uix/components/pin-input/+page.svelte new file mode 100644 index 000000000..18cd84145 --- /dev/null +++ b/web/routes/uix/components/pin-input/+page.svelte @@ -0,0 +1,427 @@ + + +
+
+
Form · PinInput
+

PinInput

+

+ Multi-cell OTP / verification code input. Single hidden + <input> captures every keystroke + paste so + autofill (autocomplete="one-time-code"), IME and + distribute-on-paste all work naturally. + soma owns the capture + pipeline; eidos paints the bordered cells with the focus ring + tracking the caret via data-active. +

+
+ + length{length} + + + type{type} + + + value{value || '—'} + + {#if lastComplete} + + completed{lastComplete} + + {/if} +
+
+ +
+
+ + {#snippet children({ cells })} + {#each cells as cell, i (i)} + + {/each} + {/snippet} + +
+
+ trace + {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} + {#if trace[0]} + · + last + {trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)} + {/if} +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+ +
+ soma props · shape + alphabet +
+
+ + + + + + +
+ +
+ eidos props · cell shape +
+
+ +
+ +
+
+ soma + headless · hidden capture input + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · sized bordered cells + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultNotes
value somastring''Bindable code value.
length somanumber6Number of cells.
type soma'numeric' | 'alphanumeric' | 'alphabetic' | 'custom''numeric'Alphabet preset → drives pattern + inputmode + autocomplete defaults.
pattern somastring | RegExp—Custom accept regex (overrides type).
mask somabooleanfalseShow • instead of the real char.
placeholder somastring''Glyph shown in empty cells.
onComplete soma(value: string) => void—Fired once when value.length === length.
size eidos'sm' | 'md' | 'lg' | 'xl''md'Cell dimensions + glyph size.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+
+ + + + {#each partsList as part} + {@const partAny = part as unknown as Record} + + + + + + {/each} + +
KebabRoleMarker
{partAny.kebab}{partAny.role ?? '—'}{partAny.marker ?? '—'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ PinInput emits {events.length} events. onComplete + fires when the value reaches the target length. +

+
+ + + + {#each events as action} + {@const sem = action.semantic} + + + + + + + {/each} + +
NameFamilyVerbSequence
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ pin-input.css styles the bordered cell. The + focus ring follows the caret via data-active on + the cell — the only actually-focused element is the hidden + input, but the visible cell receives the synthetic ring. +

+
+ + + + + + + + + + +
SelectorPurpose
[data-pin-input]Inline flex row.
[data-pin-input-cell]Square bordered cell.
[data-pin-input-cell][data-active]Focus ring + primary border.
[data-pin-input-cell][data-filled]Subtle bg lift on filled cells.
[data-pin-input][data-invalid] [data-pin-input-cell]Threat-toned border.
[data-pin-input][data-size='lg']Cell + glyph scale token swap.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

A11y

+

+ No formal ARIA pattern for OTP inputs. PinInput follows the + de-facto convention: a single hidden + <input> with + autocomplete="one-time-code" (when + type="numeric") so iOS / Android surface the + SMS-autofill banner. +

+
+ + + + + + + + + + +
KeyEffect
Any characterInsert at caret; advance to next cell.
BackspaceClear current cell; if empty, clear + focus previous.
DeleteClear current cell; stay.
ArrowLeft / ArrowRightMove caret one cell.
Home / EndCaret to first / last cell.
PasteDistribute across cells (via pasteTransformer if set).
+
+
+ {/if} +