@urbicon-ui/blocks 6.36.0 → 6.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { useBlocksI18n } from '../..';
4
+ import { getBlocksConfig, resolveSlotClasses } from '../../provider';
5
+ import { getTierContext } from '../../utils';
6
+ import type { PinInputProps } from './index';
7
+ import { pinInputVariants, type PinInputVariants } from './pin-input.variants';
8
+
9
+ const bt = useBlocksI18n();
10
+
11
+ let {
12
+ value = $bindable(''),
13
+ length = 6,
14
+ type = 'numeric',
15
+ mask = false,
16
+ placeholder = '',
17
+ uppercase = false,
18
+ autoFocus = false,
19
+ separator,
20
+ groupSize = 3,
21
+ tier,
22
+ variant = 'outlined',
23
+ size = 'md',
24
+ intent = 'default',
25
+ disabled = false,
26
+ readonly = false,
27
+ required = false,
28
+ label,
29
+ helper,
30
+ error,
31
+ onValueChange,
32
+ onComplete,
33
+ name,
34
+ class: className = '',
35
+ unstyled: unstyledProp = false,
36
+ slotClasses: slotClassesProp = {},
37
+ preset,
38
+ id: idProp,
39
+ 'aria-label': ariaLabel
40
+ }: PinInputProps = $props();
41
+
42
+ const tierCtx = getTierContext();
43
+ const effectiveTier = $derived(tier ?? tierCtx?.tier ?? 'modify');
44
+
45
+ const blocksConfig = getBlocksConfig();
46
+ const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
47
+
48
+ const propsId = $props.id();
49
+ const fieldId = $derived(idProp ?? `pininput-${propsId}`);
50
+ const labelId = $derived(`${fieldId}-label`);
51
+ const messageId = $derived(`${fieldId}-message`);
52
+ const describedBy = $derived(error || helper ? messageId : undefined);
53
+
54
+ // Allowed-character filter. `numeric` keeps digits; `alphanumeric` also keeps
55
+ // ASCII letters and (optionally) uppercases them. Applied to typed input,
56
+ // pasted text, and externally-set values alike, so the model never holds a
57
+ // character a cell could not have produced.
58
+ function sanitize(raw: string): string {
59
+ const re = type === 'numeric' ? /[^0-9]/g : /[^0-9a-zA-Z]/g;
60
+ const cleaned = raw.replace(re, '');
61
+ return uppercase && type !== 'numeric' ? cleaned.toUpperCase() : cleaned;
62
+ }
63
+
64
+ function toCells(raw: string): string[] {
65
+ const chars = sanitize(raw).slice(0, length).split('');
66
+ return Array.from({ length }, (_, i) => chars[i] ?? '');
67
+ }
68
+
69
+ // `cells` is the DOM source of truth; `value` (bindable) mirrors its join.
70
+ // Empty cells collapse in the join — fine for OTP, where the value is the
71
+ // sequence of entered characters, not a positional map.
72
+ let cells = $state<string[]>(toCells(value ?? ''));
73
+ let inputs = $state<(HTMLInputElement | undefined)[]>([]);
74
+ let wasComplete = $state(false);
75
+
76
+ // External value / length / filter changes re-seed the buffer. Writing back a
77
+ // normalized `value` keeps an out-of-range or unsanitized prop honest without
78
+ // firing onValueChange (the parent set it deliberately).
79
+ $effect(() => {
80
+ const normalized = toCells(value ?? '').join('');
81
+ if (normalized !== untrack(() => cells.join(''))) {
82
+ cells = toCells(normalized);
83
+ }
84
+ if (normalized !== (value ?? '')) {
85
+ value = normalized;
86
+ }
87
+ // Completeness must track the (possibly externally reset) value — a stale
88
+ // `true` after a parent-side clear would swallow the onComplete of the next
89
+ // single-emit fill (paste/autofill), the standard wrong-code retry flow.
90
+ wasComplete = normalized.length === length;
91
+ });
92
+
93
+ // Stable per-position keys — the index IS the cell identity, but a derived id
94
+ // keeps `{#each}` off a bare index key.
95
+ const cellKeys = $derived(Array.from({ length }, (_, i) => `${fieldId}-cell-${i}`));
96
+
97
+ const variantProps: PinInputVariants = $derived({
98
+ tier: effectiveTier,
99
+ variant,
100
+ size,
101
+ intent,
102
+ disabled: disabled || undefined,
103
+ readonly: readonly || undefined,
104
+ error: !!error || undefined,
105
+ required: required || undefined,
106
+ messageType: error ? 'error' : 'helper'
107
+ });
108
+
109
+ const styles = $derived(pinInputVariants(variantProps));
110
+
111
+ const slotClasses = $derived(
112
+ resolveSlotClasses(blocksConfig, 'PinInput', preset, variantProps, slotClassesProp)
113
+ );
114
+
115
+ function cellClass() {
116
+ return unstyled ? (slotClasses?.cell ?? '') : styles.cell({ class: slotClasses?.cell });
117
+ }
118
+
119
+ function focusCell(index: number) {
120
+ const el = inputs[index];
121
+ if (el) {
122
+ el.focus();
123
+ el.select();
124
+ }
125
+ }
126
+
127
+ function emit() {
128
+ const joined = cells.join('');
129
+ if (joined !== value) {
130
+ value = joined;
131
+ onValueChange?.(joined);
132
+ }
133
+ const complete = cells.every((c) => c !== '');
134
+ if (complete && !wasComplete) onComplete?.(joined);
135
+ wasComplete = complete;
136
+ }
137
+
138
+ // Distribute a run of characters across cells starting at `start` (typing more
139
+ // than one char, autofill, or paste). Focus lands on the cell after the last
140
+ // one written, or the final cell when the run fills to the end.
141
+ function fill(start: number, raw: string) {
142
+ const chars = sanitize(raw).split('');
143
+ if (chars.length === 0) return;
144
+ let idx = start;
145
+ for (const ch of chars) {
146
+ if (idx >= length) break;
147
+ cells[idx] = ch;
148
+ idx++;
149
+ }
150
+ emit();
151
+ focusCell(Math.min(idx, length - 1));
152
+ }
153
+
154
+ function handleInput(index: number, event: Event) {
155
+ const el = event.currentTarget as HTMLInputElement;
156
+ const sanitized = sanitize(el.value);
157
+ if (sanitized.length <= 1) {
158
+ const ch = sanitized;
159
+ // A rejected character (a letter in numeric mode) sanitizes to '' while the
160
+ // raw field is non-empty. Restore the existing digit instead of erasing it —
161
+ // a bad keystroke must not destroy a filled cell.
162
+ if (ch === '' && el.value !== '') {
163
+ el.value = cells[index];
164
+ return;
165
+ }
166
+ // Force the DOM to the sanitized single char so a rejected glyph can never
167
+ // linger in an otherwise-empty cell (the model may not have changed).
168
+ el.value = ch;
169
+ if (cells[index] !== ch) {
170
+ cells[index] = ch;
171
+ emit();
172
+ }
173
+ if (ch && index < length - 1) focusCell(index + 1);
174
+ } else {
175
+ fill(index, sanitized);
176
+ el.value = cells[index];
177
+ }
178
+ }
179
+
180
+ function handleKeydown(index: number, event: KeyboardEvent) {
181
+ if (disabled) return;
182
+ switch (event.key) {
183
+ case 'Backspace':
184
+ if (readonly) break;
185
+ event.preventDefault();
186
+ if (cells[index]) {
187
+ cells[index] = '';
188
+ if (inputs[index]) inputs[index]!.value = '';
189
+ emit();
190
+ } else if (index > 0) {
191
+ cells[index - 1] = '';
192
+ if (inputs[index - 1]) inputs[index - 1]!.value = '';
193
+ emit();
194
+ focusCell(index - 1);
195
+ }
196
+ break;
197
+ case 'Delete':
198
+ if (readonly) break;
199
+ event.preventDefault();
200
+ if (cells[index]) {
201
+ cells[index] = '';
202
+ if (inputs[index]) inputs[index]!.value = '';
203
+ emit();
204
+ }
205
+ break;
206
+ case 'ArrowLeft':
207
+ event.preventDefault();
208
+ if (index > 0) focusCell(index - 1);
209
+ break;
210
+ case 'ArrowRight':
211
+ event.preventDefault();
212
+ if (index < length - 1) focusCell(index + 1);
213
+ break;
214
+ case 'Home':
215
+ event.preventDefault();
216
+ focusCell(0);
217
+ break;
218
+ case 'End':
219
+ event.preventDefault();
220
+ focusCell(length - 1);
221
+ break;
222
+ }
223
+ }
224
+
225
+ function handlePaste(index: number, event: ClipboardEvent) {
226
+ event.preventDefault();
227
+ if (disabled || readonly) return;
228
+ fill(index, event.clipboardData?.getData('text') ?? '');
229
+ }
230
+
231
+ function handleFocus(event: FocusEvent) {
232
+ (event.currentTarget as HTMLInputElement).select();
233
+ }
234
+
235
+ // Focus the first empty cell on mount when requested. Runs once — autoFocus is
236
+ // stable — and untracks `cells` so a later edit does not re-steal focus.
237
+ $effect(() => {
238
+ if (!autoFocus) return;
239
+ untrack(() => {
240
+ const firstEmpty = cells.findIndex((c) => !c);
241
+ focusCell(firstEmpty === -1 ? length - 1 : firstEmpty);
242
+ });
243
+ });
244
+ </script>
245
+
246
+ <div
247
+ class={unstyled
248
+ ? [slotClasses?.root, className].filter(Boolean).join(' ')
249
+ : styles.root({ class: [slotClasses?.root, className] })}
250
+ >
251
+ {#if label}
252
+ <span
253
+ id={labelId}
254
+ class={unstyled ? (slotClasses?.label ?? '') : styles.label({ class: slotClasses?.label })}
255
+ >
256
+ {label}
257
+ </span>
258
+ {/if}
259
+
260
+ <div
261
+ role="group"
262
+ aria-labelledby={label ? labelId : undefined}
263
+ aria-label={label ? undefined : ariaLabel}
264
+ class={unstyled ? (slotClasses?.group ?? '') : styles.group({ class: slotClasses?.group })}
265
+ >
266
+ {#each cellKeys as key, i (key)}
267
+ {#if separator && i > 0 && i % groupSize === 0}
268
+ <span
269
+ aria-hidden="true"
270
+ class={unstyled
271
+ ? (slotClasses?.separator ?? '')
272
+ : styles.separator({ class: slotClasses?.separator })}
273
+ >
274
+ {separator}
275
+ </span>
276
+ {/if}
277
+ <input
278
+ bind:this={inputs[i]}
279
+ id={i === 0 ? fieldId : undefined}
280
+ value={cells[i]}
281
+ type={mask ? 'password' : 'text'}
282
+ inputmode={type === 'numeric' ? 'numeric' : 'text'}
283
+ autocomplete={i === 0 ? 'one-time-code' : 'off'}
284
+ pattern={type === 'numeric' ? '[0-9]*' : undefined}
285
+ maxlength="1"
286
+ placeholder={placeholder || undefined}
287
+ {disabled}
288
+ {readonly}
289
+ aria-label={bt('accessibility.pinInputCell', { index: i + 1, total: length })}
290
+ aria-invalid={error ? 'true' : undefined}
291
+ aria-describedby={describedBy}
292
+ class={cellClass()}
293
+ oninput={(e) => handleInput(i, e)}
294
+ onkeydown={(e) => handleKeydown(i, e)}
295
+ onpaste={(e) => handlePaste(i, e)}
296
+ onfocus={handleFocus}
297
+ />
298
+ {/each}
299
+ </div>
300
+
301
+ {#if error}
302
+ <div
303
+ id={messageId}
304
+ role="alert"
305
+ class={unstyled
306
+ ? (slotClasses?.message ?? '')
307
+ : styles.message({ class: slotClasses?.message })}
308
+ >
309
+ {error}
310
+ </div>
311
+ {:else if helper}
312
+ <div
313
+ id={messageId}
314
+ class={unstyled
315
+ ? (slotClasses?.message ?? '')
316
+ : styles.message({ class: slotClasses?.message })}
317
+ >
318
+ {helper}
319
+ </div>
320
+ {/if}
321
+
322
+ {#if name}
323
+ <input type="hidden" {name} {value} />
324
+ {/if}
325
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { PinInputProps } from './index.js';
2
+ declare const PinInput: import("svelte").Component<PinInputProps, {}, "value">;
3
+ type PinInput = ReturnType<typeof PinInput>;
4
+ export default PinInput;
@@ -0,0 +1,98 @@
1
+ import type { PinInputSlots, PinInputVariants } from './pin-input.variants.js';
2
+ /**
3
+ * @description Segmented one-time-code / PIN entry — a row of single-character
4
+ * cells with auto-advance, backspace-to-previous, paste-to-fill, and optional
5
+ * masking. Purpose-built for the 2FA/OTP flow the auth package's
6
+ * `TwoFactorManager` drives (pair it with `autoComplete="one-time-code"` for
7
+ * iOS SMS autofill). The value is the concatenated string; `onComplete` fires
8
+ * once every cell is filled.
9
+ *
10
+ * @tag form
11
+ * @related Input
12
+ * @related NumberInput
13
+ * @stability beta
14
+ *
15
+ * @example
16
+ * ```svelte
17
+ * <script>
18
+ * let code = $state('');
19
+ * </script>
20
+ * <PinInput bind:value={code} length={6} onComplete={(v) => verify(v)} />
21
+ * ```
22
+ *
23
+ * @example Masked, alphanumeric, grouped with a separator
24
+ * ```svelte
25
+ * <PinInput length={8} type="alphanumeric" mask separator="-" groupSize={4} />
26
+ * ```
27
+ */
28
+ export interface PinInputProps extends Omit<PinInputVariants, 'error'> {
29
+ /** Current value — the concatenated cell characters. Supports `bind:value`. */
30
+ value?: string;
31
+ /** Number of cells. @default 6 */
32
+ length?: number;
33
+ /**
34
+ * Allowed characters and keyboard hint. `numeric` accepts `0-9` and sets a
35
+ * numeric inputmode; `alphanumeric` also accepts `A-Z`/`a-z`. @default 'numeric'
36
+ */
37
+ type?: 'numeric' | 'alphanumeric';
38
+ /** Render each filled cell as a masked dot (password style). @default false */
39
+ mask?: boolean;
40
+ /** Placeholder character shown in every empty cell. @default '' */
41
+ placeholder?: string;
42
+ /**
43
+ * Uppercase alphanumeric input as it is entered — keeps a code like `ABCD`
44
+ * visually consistent regardless of caps lock. Ignored for `numeric`.
45
+ * @default false
46
+ */
47
+ uppercase?: boolean;
48
+ /** Focus the first empty cell on mount. @default false */
49
+ autoFocus?: boolean;
50
+ /**
51
+ * Render a separator between groups of `groupSize` cells (e.g. `123-456`).
52
+ * A string is shown verbatim; omit for no separator.
53
+ */
54
+ separator?: string;
55
+ /** Cells per group when `separator` is set. @default 3 */
56
+ groupSize?: number;
57
+ /** @default false */
58
+ disabled?: boolean;
59
+ /** @default false */
60
+ readonly?: boolean;
61
+ /** Adds a required asterisk to the label. @default false */
62
+ required?: boolean;
63
+ /** Group label rendered above the cells and linked via `aria-labelledby`. */
64
+ label?: string;
65
+ /** Helper text below the cells — hidden when `error` is present. */
66
+ helper?: string;
67
+ /**
68
+ * Error message below the cells. When set it overrides `helper`, colours the
69
+ * cells danger, and sets `aria-invalid` on every cell.
70
+ */
71
+ error?: string;
72
+ /** Fires after any change (typing, paste, backspace) with the full value. */
73
+ onValueChange?: (value: string) => void;
74
+ /** Fires once when the last empty cell is filled, with the complete value. */
75
+ onComplete?: (value: string) => void;
76
+ /** Shared `name` for a hidden input, for native form submission. */
77
+ name?: string;
78
+ /** Extra classes merged onto the root element. */
79
+ class?: string;
80
+ /** Remove all default tv() classes — only user-provided classes apply. */
81
+ unstyled?: boolean;
82
+ /**
83
+ * Per-slot class overrides merged with tv() styles. Slots: root (what `class`
84
+ * also targets) | label | group | cell | separator | message.
85
+ */
86
+ slotClasses?: Partial<Record<PinInputSlots, string>>;
87
+ /** Apply a named preset registered via `<BlocksProvider presets={{ PinInput: {...} }}>`. */
88
+ preset?: string;
89
+ /**
90
+ * Accessible name for the cell group when no visible `label` is set. Each
91
+ * cell additionally announces its position ("digit 2 of 6").
92
+ */
93
+ 'aria-label'?: string;
94
+ /** Root id; the cells derive their ids and ARIA wiring from it. */
95
+ id?: string;
96
+ }
97
+ export { default as PinInput } from './PinInput.svelte';
98
+ export { type PinInputVariants, pinInputVariants } from './pin-input.variants.js';
@@ -0,0 +1,2 @@
1
+ export { default as PinInput } from './PinInput.svelte';
2
+ export { pinInputVariants } from './pin-input.variants.js';
@@ -0,0 +1,96 @@
1
+ import { type SlotNames, type VariantProps } from '../../utils/variants.js';
2
+ export declare const pinInputVariants: ((props?: {
3
+ tier?: "commit" | "modify" | undefined;
4
+ variant?: "ghost" | "filled" | "outlined" | undefined;
5
+ size?: "sm" | "md" | "lg" | undefined;
6
+ intent?: "default" | "success" | "warning" | "danger" | undefined;
7
+ disabled?: boolean | undefined;
8
+ readonly?: boolean | undefined;
9
+ messageType?: "error" | "helper" | undefined;
10
+ error?: boolean | undefined;
11
+ required?: boolean | undefined;
12
+ } | undefined) => {
13
+ root: (props?: ({
14
+ tier?: "commit" | "modify" | undefined;
15
+ variant?: "ghost" | "filled" | "outlined" | undefined;
16
+ size?: "sm" | "md" | "lg" | undefined;
17
+ intent?: "default" | "success" | "warning" | "danger" | undefined;
18
+ disabled?: boolean | undefined;
19
+ readonly?: boolean | undefined;
20
+ messageType?: "error" | "helper" | undefined;
21
+ error?: boolean | undefined;
22
+ required?: boolean | undefined;
23
+ } & {
24
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
25
+ }) | undefined) => string;
26
+ label: (props?: ({
27
+ tier?: "commit" | "modify" | undefined;
28
+ variant?: "ghost" | "filled" | "outlined" | undefined;
29
+ size?: "sm" | "md" | "lg" | undefined;
30
+ intent?: "default" | "success" | "warning" | "danger" | undefined;
31
+ disabled?: boolean | undefined;
32
+ readonly?: boolean | undefined;
33
+ messageType?: "error" | "helper" | undefined;
34
+ error?: boolean | undefined;
35
+ required?: boolean | undefined;
36
+ } & {
37
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
38
+ }) | undefined) => string;
39
+ group: (props?: ({
40
+ tier?: "commit" | "modify" | undefined;
41
+ variant?: "ghost" | "filled" | "outlined" | undefined;
42
+ size?: "sm" | "md" | "lg" | undefined;
43
+ intent?: "default" | "success" | "warning" | "danger" | undefined;
44
+ disabled?: boolean | undefined;
45
+ readonly?: boolean | undefined;
46
+ messageType?: "error" | "helper" | undefined;
47
+ error?: boolean | undefined;
48
+ required?: boolean | undefined;
49
+ } & {
50
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
51
+ }) | undefined) => string;
52
+ cell: (props?: ({
53
+ tier?: "commit" | "modify" | undefined;
54
+ variant?: "ghost" | "filled" | "outlined" | undefined;
55
+ size?: "sm" | "md" | "lg" | undefined;
56
+ intent?: "default" | "success" | "warning" | "danger" | undefined;
57
+ disabled?: boolean | undefined;
58
+ readonly?: boolean | undefined;
59
+ messageType?: "error" | "helper" | undefined;
60
+ error?: boolean | undefined;
61
+ required?: boolean | undefined;
62
+ } & {
63
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
64
+ }) | undefined) => string;
65
+ separator: (props?: ({
66
+ tier?: "commit" | "modify" | undefined;
67
+ variant?: "ghost" | "filled" | "outlined" | undefined;
68
+ size?: "sm" | "md" | "lg" | undefined;
69
+ intent?: "default" | "success" | "warning" | "danger" | undefined;
70
+ disabled?: boolean | undefined;
71
+ readonly?: boolean | undefined;
72
+ messageType?: "error" | "helper" | undefined;
73
+ error?: boolean | undefined;
74
+ required?: boolean | undefined;
75
+ } & {
76
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
77
+ }) | undefined) => string;
78
+ message: (props?: ({
79
+ tier?: "commit" | "modify" | undefined;
80
+ variant?: "ghost" | "filled" | "outlined" | undefined;
81
+ size?: "sm" | "md" | "lg" | undefined;
82
+ intent?: "default" | "success" | "warning" | "danger" | undefined;
83
+ disabled?: boolean | undefined;
84
+ readonly?: boolean | undefined;
85
+ messageType?: "error" | "helper" | undefined;
86
+ error?: boolean | undefined;
87
+ required?: boolean | undefined;
88
+ } & {
89
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
90
+ }) | undefined) => string;
91
+ }) & {
92
+ readonly config: import("../../utils/variants.js").TVConfig;
93
+ };
94
+ export type PinInputVariants = VariantProps<typeof pinInputVariants>;
95
+ /** Slot names derived from the `tv()` config above — single source of truth for `slotClasses`. */
96
+ export type PinInputSlots = SlotNames<typeof pinInputVariants>;
@@ -0,0 +1,111 @@
1
+ import { tv } from '../../utils/variants.js';
2
+ export const pinInputVariants = tv({
3
+ slots: {
4
+ root: ['flex flex-col gap-1.5'],
5
+ label: ['block font-medium text-text-secondary text-sm'],
6
+ group: ['flex items-center'],
7
+ cell: [
8
+ 'box-border text-center font-medium tabular-nums caret-primary',
9
+ 'border text-text-primary bg-surface-base',
10
+ 'transition-[color,background-color,border-color,box-shadow] duration-[var(--blocks-duration-fast)] ease-out',
11
+ 'focus-visible:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:z-10',
12
+ 'hover:border-border-default',
13
+ 'disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-surface-subtle',
14
+ 'read-only:bg-surface-subtle read-only:cursor-default'
15
+ ],
16
+ separator: ['text-text-tertiary select-none'],
17
+ message: ['text-xs']
18
+ },
19
+ variants: {
20
+ // 3-tier semantic radius. Default `modify` (soft) — pin cells read as fields.
21
+ // Inherited from a wrapping <Toolbar tier="commit"> via TierContext.
22
+ tier: {
23
+ modify: { cell: 'rounded-modify' },
24
+ commit: { cell: 'rounded-commit' }
25
+ },
26
+ variant: {
27
+ outlined: { cell: 'border-border-subtle' },
28
+ filled: {
29
+ cell: 'bg-surface-interactive border-transparent hover:bg-surface-hover focus-visible:bg-surface-base'
30
+ },
31
+ ghost: {
32
+ cell: 'bg-transparent hover:bg-surface-subtle focus-visible:bg-surface-base focus-visible:border-border-subtle'
33
+ }
34
+ },
35
+ size: {
36
+ sm: {
37
+ // `pointer-coarse:text-base` floors the font to 16px on touch-primary
38
+ // devices — below 16px iOS Safari auto-zooms the field on focus.
39
+ cell: 'h-9 w-9 text-sm pointer-coarse:text-base',
40
+ group: 'gap-1.5',
41
+ separator: 'mx-0.5 text-sm'
42
+ },
43
+ md: {
44
+ cell: 'h-11 w-11 text-lg',
45
+ group: 'gap-2',
46
+ separator: 'mx-1 text-lg'
47
+ },
48
+ lg: {
49
+ cell: 'h-14 w-14 text-2xl',
50
+ group: 'gap-2.5',
51
+ separator: 'mx-1.5 text-2xl'
52
+ }
53
+ },
54
+ intent: {
55
+ default: {},
56
+ success: {
57
+ cell: 'border-success focus-visible:border-success focus-visible:ring-success/20'
58
+ },
59
+ warning: {
60
+ cell: 'border-warning focus-visible:border-warning focus-visible:ring-warning/20'
61
+ },
62
+ danger: {
63
+ cell: 'border-danger focus-visible:border-danger focus-visible:ring-danger/20'
64
+ }
65
+ },
66
+ disabled: {
67
+ true: {
68
+ cell: 'opacity-50 cursor-not-allowed bg-surface-disabled pointer-events-none',
69
+ label: 'text-text-disabled'
70
+ }
71
+ },
72
+ readonly: {
73
+ true: { cell: 'bg-surface-subtle cursor-default' }
74
+ },
75
+ // Declared BEFORE `error` so the error tone wins the message-color bucket
76
+ // in every call shape — `{ error: true }` alone must read red.
77
+ messageType: {
78
+ error: { message: 'text-danger' },
79
+ helper: { message: 'text-text-tertiary' }
80
+ },
81
+ error: {
82
+ true: {
83
+ cell: 'border-danger focus-visible:border-danger focus-visible:ring-danger/20',
84
+ message: 'text-danger'
85
+ }
86
+ },
87
+ required: {
88
+ true: { label: "after:content-['*'] after:ml-1 after:text-danger" }
89
+ }
90
+ },
91
+ compoundVariants: [
92
+ // Ghost keeps a transparent border at rest — even under an intent. The error
93
+ // state drops this so validation feedback (border-danger) stays visible.
94
+ {
95
+ variant: 'ghost',
96
+ error: false,
97
+ class: { cell: 'border-transparent' }
98
+ }
99
+ ],
100
+ defaultVariants: {
101
+ tier: 'modify',
102
+ variant: 'outlined',
103
+ size: 'md',
104
+ intent: 'default',
105
+ disabled: false,
106
+ readonly: false,
107
+ error: false,
108
+ required: false,
109
+ messageType: 'helper'
110
+ }
111
+ });