@octanejs/radix 0.1.2

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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. package/src/useId.ts +15 -0
@@ -0,0 +1,977 @@
1
+ // Ported from @radix-ui/react-one-time-password-field (source:
2
+ // .radix-primitives/packages/react/one-time-password-field/src/one-time-password-field.tsx).
3
+ // A one-time-password / verification-code field: per-character `Input` cells inside a
4
+ // `Root` that owns the combined value (useControllableState), a RovingFocusGroup across
5
+ // the cells (single tab stop, arrow keys move focus), a `HiddenInput` carrying the
6
+ // joined value for forms, and paste/keyboard orchestration — typing advances focus,
7
+ // Backspace clears + retreats, paste distributes characters, per-cell validation via
8
+ // `validationType` (numeric/alpha/alphanumeric patterns), `autoSubmit` submits the
9
+ // associated form when all cells fill, and sanitization on every input.
10
+ //
11
+ // octane adaptations (all established in prior ports; see docs/react-parity-migration-plan.md):
12
+ // - React's `onChange` on a TEXT INPUT is the native `input` event (as in Form.ts) — the
13
+ // source's separate `onInput` (password-manager multi-char detection) and `onChange`
14
+ // handlers both fire from the SAME native `input` event here. We bind ONE octane
15
+ // `onInput` that runs the source's input logic then its change logic, each composed
16
+ // with the corresponding user prop via `composeEventHandlers` (React fires them in that
17
+ // order for a native `input` event). The user's `onChange` prop is therefore folded
18
+ // into the `input` binding and NOT bound to native `change` (native change = commit).
19
+ // - React's CONTROLLED `value={char}` cell has no octane equivalent (octane inputs are
20
+ // uncontrolled and native): the `value` prop renders as the native attribute, a layout
21
+ // effect imperatively syncs the DOM `.value` property whenever the cell's char state
22
+ // changes, and the composed input handler ends with React's controlled-value
23
+ // REASSERTION (restore `.value` to the state char when they diverge — e.g. an invalid
24
+ // character that produced no state change).
25
+ // - The source's `unstable_createCollection` (state-backed, `.at`/`.from`/`.size`/
26
+ // `.indexOf`) has no shared octane port (the shared collection.ts is the legacy API,
27
+ // which registers items in passive effects and never re-renders consumers). Since the
28
+ // new collection's order IS DOM order, we derive an equivalent live collection API from
29
+ // the DOM (`input[data-radix-otp-input]` under the Root element — the source stamps
30
+ // that attribute on every cell) and thread it to `Input` through context (standing in
31
+ // for the source's `useCollection(scope)`). `from()` clamps to the first/last member
32
+ // exactly like the source's OrderedDict.from. The source's registration re-render (its
33
+ // collection is React state, so a cell mounting/unmounting re-renders Root + every
34
+ // Input) is recreated with a `collectionVersion` counter in context that each Input
35
+ // bumps from a layout effect on mount/unmount. `locateForm` reads the first input
36
+ // lazily inside the callback instead of memoizing a render-time snapshot — equivalent,
37
+ // since by the time any caller runs (effects/event handlers) the cells are in the DOM.
38
+ // - `flushSync` comes from 'octane' (source: react-dom).
39
+ // - `spellCheck={false}` → the string 'false' (octane removes false-valued attributes;
40
+ // React renders spellcheck="false"), `autoFocus={false}` → omitted.
41
+ // - No forwardRef: `ref: forwardedRef` is destructured from props (React-19 style).
42
+ // - `@radix-ui/number`'s `clamp` is inlined.
43
+ // - Skipped: the source's dev-only TODO warnings (repo policy: functional outcomes only).
44
+ import {
45
+ createElement,
46
+ flushSync,
47
+ useCallback,
48
+ useEffect,
49
+ useLayoutEffect,
50
+ useMemo,
51
+ useRef,
52
+ useState,
53
+ } from 'octane';
54
+
55
+ import { composeEventHandlers } from './compose-event-handlers';
56
+ import { useComposedRefs } from './compose-refs';
57
+ import { createContextScope } from './context';
58
+ import { useDirection } from './direction';
59
+ import { S, subSlot } from './internal';
60
+ import { Primitive } from './Primitive';
61
+ import * as RovingFocusGroup from './RovingFocusGroup';
62
+ import { createRovingFocusGroupScope } from './RovingFocusGroup';
63
+ import { useControllableState } from './useControllableState';
64
+ import { useIsHydrated } from './use-is-hydrated';
65
+
66
+ export type InputValidationType = 'alpha' | 'numeric' | 'alphanumeric' | 'none';
67
+
68
+ const INPUT_VALIDATION_MAP: Record<
69
+ InputValidationType,
70
+ { type: InputValidationType; regexp: RegExp; pattern: string; inputMode: string } | null
71
+ > = {
72
+ numeric: {
73
+ type: 'numeric',
74
+ regexp: /[^\d]/g,
75
+ pattern: '\\d{1}',
76
+ inputMode: 'numeric',
77
+ },
78
+ alpha: {
79
+ type: 'alpha',
80
+ regexp: /[^a-zA-Z]/g,
81
+ pattern: '[a-zA-Z]{1}',
82
+ inputMode: 'text',
83
+ },
84
+ alphanumeric: {
85
+ type: 'alphanumeric',
86
+ regexp: /[^a-zA-Z0-9]/g,
87
+ pattern: '[a-zA-Z0-9]{1}',
88
+ inputMode: 'text',
89
+ },
90
+ none: null,
91
+ };
92
+
93
+ /* -------------------------------------------------------------------------------------------------
94
+ * OneTimePasswordField context
95
+ * -----------------------------------------------------------------------------------------------*/
96
+
97
+ type KeyboardActionDetails =
98
+ | {
99
+ type: 'keydown';
100
+ key: 'Backspace' | 'Delete' | 'Clear' | 'Char';
101
+ metaKey: boolean;
102
+ ctrlKey: boolean;
103
+ }
104
+ | { type: 'cut' }
105
+ | { type: 'autocomplete-paste' };
106
+
107
+ type UpdateAction =
108
+ | { type: 'SET_CHAR'; char: string; index: number; event: Event }
109
+ | { type: 'CLEAR_CHAR'; index: number; reason: 'Backspace' | 'Delete' | 'Cut' }
110
+ | { type: 'CLEAR'; reason: 'Reset' | 'Backspace' | 'Delete' | 'Clear' }
111
+ | { type: 'PASTE'; value: string };
112
+ type Dispatcher = (action: UpdateAction) => void;
113
+
114
+ // The live DOM-derived stand-in for the source's `unstable_createCollection` state (see
115
+ // file header). Order is DOM order — exactly what the source's collection resolves to.
116
+ interface CollectionApi {
117
+ readonly size: number;
118
+ at(index: number): { element: HTMLInputElement } | undefined;
119
+ from(element: HTMLElement, offset: number): { element: HTMLInputElement } | undefined;
120
+ indexOf(element: HTMLElement): number;
121
+ }
122
+
123
+ interface OneTimePasswordFieldContextValue {
124
+ attemptSubmit: () => void;
125
+ autoComplete: 'off' | 'one-time-code';
126
+ autoFocus: boolean;
127
+ collection: CollectionApi;
128
+ disabled: boolean;
129
+ dispatch: Dispatcher;
130
+ form: string | undefined;
131
+ hiddenInputRef: { current: HTMLInputElement | null };
132
+ isHydrated: boolean;
133
+ name: string | undefined;
134
+ orientation: 'horizontal' | 'vertical';
135
+ placeholder: string | undefined;
136
+ readOnly: boolean;
137
+ // octane adaptation (see file header): cell mount/unmount registration. `registerInput`
138
+ // bumps `collectionVersion`, whose presence in the context value re-renders every
139
+ // consumer (standing in for the source's state-backed collection re-render).
140
+ collectionVersion: number;
141
+ registerInput: () => () => void;
142
+ type: 'password' | 'text';
143
+ userActionRef: { current: KeyboardActionDetails | null };
144
+ validationType: InputValidationType;
145
+ value: string[];
146
+ sanitizeValue: (arg: string | string[]) => string[];
147
+ }
148
+
149
+ const ONE_TIME_PASSWORD_FIELD_NAME = 'OneTimePasswordField';
150
+ const [createOneTimePasswordFieldContext, createOneTimePasswordFieldScope] = createContextScope(
151
+ ONE_TIME_PASSWORD_FIELD_NAME,
152
+ [createRovingFocusGroupScope],
153
+ );
154
+ export { createOneTimePasswordFieldScope };
155
+ const useRovingFocusGroupScope = createRovingFocusGroupScope();
156
+
157
+ const [OneTimePasswordFieldContext, useOneTimePasswordFieldContext] =
158
+ createOneTimePasswordFieldContext<OneTimePasswordFieldContextValue>(ONE_TIME_PASSWORD_FIELD_NAME);
159
+
160
+ const OTP_INPUT_ATTR = 'data-radix-otp-input';
161
+
162
+ function createCollectionApi(getRoot: () => HTMLElement | null): CollectionApi {
163
+ const elements = (): HTMLInputElement[] => {
164
+ const root = getRoot();
165
+ if (!root) return [];
166
+ return Array.from(root.querySelectorAll<HTMLInputElement>(`input[${OTP_INPUT_ATTR}]`));
167
+ };
168
+ return {
169
+ get size(): number {
170
+ return elements().length;
171
+ },
172
+ at(index: number) {
173
+ const element = elements().at(index);
174
+ return element ? { element } : undefined;
175
+ },
176
+ from(element: HTMLElement, offset: number) {
177
+ const els = elements();
178
+ const index = els.indexOf(element as HTMLInputElement);
179
+ if (index === -1) return undefined;
180
+ // Clamp to the ends like the source's OrderedDict.from (ordered-dictionary.ts):
181
+ // for a member element this never walks off the collection, so boundary cells
182
+ // resolve to themselves (focusInput then re-selects the already-focused cell).
183
+ let dest = index + offset;
184
+ if (dest < 0) dest = 0;
185
+ if (dest >= els.length) dest = els.length - 1;
186
+ const target = els[dest];
187
+ return target ? { element: target } : undefined;
188
+ },
189
+ indexOf(element: HTMLElement) {
190
+ return elements().indexOf(element as HTMLInputElement);
191
+ },
192
+ };
193
+ }
194
+
195
+ /* -------------------------------------------------------------------------------------------------
196
+ * OneTimePasswordField (Root)
197
+ * -----------------------------------------------------------------------------------------------*/
198
+
199
+ export function OneTimePasswordField(props: any): any {
200
+ const slot = S('OneTimePasswordField.Root');
201
+ const {
202
+ __scopeOneTimePasswordField,
203
+ defaultValue,
204
+ value: valueProp,
205
+ onValueChange,
206
+ autoSubmit = false,
207
+ children,
208
+ onPaste,
209
+ onAutoSubmit,
210
+ disabled = false,
211
+ readOnly = false,
212
+ autoComplete = 'one-time-code',
213
+ autoFocus = false,
214
+ form,
215
+ name,
216
+ placeholder,
217
+ type = 'text',
218
+ // TODO (source): Change default to vertical when inputs use vertical writing mode
219
+ orientation = 'horizontal',
220
+ dir,
221
+ validationType = 'numeric',
222
+ sanitizeValue: sanitizeValueProp,
223
+ ref: forwardedRef,
224
+ ...domProps
225
+ } = props ?? {};
226
+
227
+ const rovingFocusGroupScope = useRovingFocusGroupScope(
228
+ __scopeOneTimePasswordField,
229
+ subSlot(slot, 'rfs'),
230
+ );
231
+ const direction = useDirection(dir);
232
+
233
+ const rootRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'root'));
234
+ // octane adaptation: live DOM-derived collection (see file header).
235
+ const collection = useMemo(
236
+ () => createCollectionApi(() => rootRef.current),
237
+ [],
238
+ subSlot(slot, 'coll'),
239
+ );
240
+ // octane adaptation: the source's collection is React STATE — each ItemSlot
241
+ // registration sets it, re-rendering the Root and (via context) every Input, so
242
+ // index-derived output (aria-label 'of N', maxLength, data-radix-index,
243
+ // focusable/active) refreshes when cells mount/unmount. Recreate that with a version
244
+ // counter: every Input bumps it from a layout effect on mount and again in its
245
+ // cleanup; the version rides in the context value (invalidating the scoped Provider's
246
+ // memo), so membership changes re-render Root + all Inputs. Order stays DOM-derived.
247
+ const [collectionVersion, setCollectionVersion] = useState(0, subSlot(slot, 'collVer'));
248
+ const registerInput = useCallback(
249
+ () => {
250
+ setCollectionVersion((v: number) => v + 1);
251
+ return () => setCollectionVersion((v: number) => v + 1);
252
+ },
253
+ [],
254
+ subSlot(slot, 'register'),
255
+ );
256
+
257
+ const validation = INPUT_VALIDATION_MAP[validationType as InputValidationType] ?? null;
258
+
259
+ const sanitizeValue = useCallback(
260
+ (value: string | string[]) => {
261
+ let str: string;
262
+ if (Array.isArray(value)) {
263
+ str = value.map(removeWhitespace).join('');
264
+ } else {
265
+ str = removeWhitespace(value);
266
+ }
267
+
268
+ if (validation) {
269
+ // global regexp is stateful, so we clone it for each call
270
+ const regexp = new RegExp(validation.regexp);
271
+ str = str.replace(regexp, '');
272
+ } else if (sanitizeValueProp) {
273
+ str = sanitizeValueProp(str);
274
+ }
275
+
276
+ return str.split('');
277
+ },
278
+ [validation, sanitizeValueProp],
279
+ subSlot(slot, 'sanitize'),
280
+ );
281
+
282
+ const controlledValue = useMemo(
283
+ () => {
284
+ return valueProp != null ? sanitizeValue(valueProp) : undefined;
285
+ },
286
+ [valueProp, sanitizeValue],
287
+ subSlot(slot, 'controlled'),
288
+ );
289
+
290
+ const handleValueChange = useCallback(
291
+ (value: string[]) => onValueChange?.(value.join('')),
292
+ [onValueChange],
293
+ subSlot(slot, 'onChange'),
294
+ );
295
+ const [value, setValue] = useControllableState<string[]>(
296
+ {
297
+ prop: controlledValue,
298
+ defaultProp: defaultValue != null ? sanitizeValue(defaultValue) : [],
299
+ onChange: handleValueChange,
300
+ },
301
+ subSlot(slot, 'value'),
302
+ );
303
+
304
+ // Use a ref so dispatch always reads the latest value without needing value in its
305
+ // dependency array (which would change its identity every keystroke and cause
306
+ // cascading effect re-runs).
307
+ const latestValueRef = useRef(value, subSlot(slot, 'latest'));
308
+ latestValueRef.current = value;
309
+
310
+ // Update function *specifically* for event handlers.
311
+ const dispatch = useCallback<Dispatcher>(
312
+ (action: UpdateAction) => {
313
+ const value = latestValueRef.current;
314
+ switch (action.type) {
315
+ case 'SET_CHAR': {
316
+ const { index, char } = action;
317
+ const currentTarget = collection.at(index)?.element;
318
+ if (value[index] === char) {
319
+ const next = currentTarget && collection.from(currentTarget, 1)?.element;
320
+ focusInput(next);
321
+ return;
322
+ }
323
+
324
+ // empty values should be handled in the CLEAR_CHAR action
325
+ if (char === '') {
326
+ return;
327
+ }
328
+
329
+ if (validation) {
330
+ const regexp = new RegExp(validation.regexp);
331
+ const clean = char.replace(regexp, '');
332
+ if (clean !== char) {
333
+ // not valid; ignore
334
+ return;
335
+ }
336
+ }
337
+
338
+ // no more space
339
+ if (value.length >= collection.size) {
340
+ // replace current value; move to next input
341
+ const newValue = [...value];
342
+ newValue[index] = char;
343
+ flushSync(() => setValue(newValue));
344
+ const next = currentTarget && collection.from(currentTarget, 1)?.element;
345
+ focusInput(next);
346
+ return;
347
+ }
348
+
349
+ const newValue = [...value];
350
+ newValue[index] = char;
351
+
352
+ const lastElement = collection.at(-1)?.element;
353
+ flushSync(() => setValue(newValue));
354
+ if (currentTarget !== lastElement) {
355
+ const next = currentTarget && collection.from(currentTarget, 1)?.element;
356
+ focusInput(next);
357
+ } else {
358
+ currentTarget?.select();
359
+ }
360
+ return;
361
+ }
362
+
363
+ case 'CLEAR_CHAR': {
364
+ const { index, reason } = action;
365
+ if (!value[index]) {
366
+ return;
367
+ }
368
+
369
+ const newValue = value.filter((_, i) => i !== index);
370
+ const currentTarget = collection.at(index)?.element;
371
+ const previous = currentTarget && collection.from(currentTarget, -1)?.element;
372
+
373
+ flushSync(() => setValue(newValue));
374
+ if (reason === 'Backspace') {
375
+ focusInput(previous);
376
+ } else if (reason === 'Delete' || reason === 'Cut') {
377
+ focusInput(currentTarget);
378
+ }
379
+ return;
380
+ }
381
+
382
+ case 'CLEAR': {
383
+ if (value.length === 0) {
384
+ return;
385
+ }
386
+
387
+ if (action.reason === 'Backspace' || action.reason === 'Delete') {
388
+ flushSync(() => setValue([]));
389
+ focusInput(collection.at(0)?.element);
390
+ } else {
391
+ setValue([]);
392
+ }
393
+ return;
394
+ }
395
+
396
+ case 'PASTE': {
397
+ const { value: pastedValue } = action;
398
+ const sanitizedValue = sanitizeValue(pastedValue);
399
+ if (!sanitizedValue) {
400
+ return;
401
+ }
402
+
403
+ const value = sanitizedValue.slice(0, collection.size);
404
+
405
+ flushSync(() => setValue(value));
406
+ focusInput(collection.at(value.length - 1)?.element);
407
+ return;
408
+ }
409
+ }
410
+ },
411
+ [collection, sanitizeValue, setValue, validation],
412
+ subSlot(slot, 'dispatch'),
413
+ );
414
+
415
+ // re-validate when the validation type changes
416
+ const validationTypeRef = useRef(validation, subSlot(slot, 'vtype'));
417
+ useEffect(
418
+ () => {
419
+ if (!validation) {
420
+ return;
421
+ }
422
+
423
+ if (validationTypeRef.current?.type !== validation.type) {
424
+ validationTypeRef.current = validation;
425
+ setValue(sanitizeValue(value.join('')));
426
+ }
427
+ },
428
+ [sanitizeValue, setValue, validation, value],
429
+ subSlot(slot, 'e:revalidate'),
430
+ );
431
+
432
+ const hiddenInputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'hidden'));
433
+
434
+ const userActionRef = useRef<KeyboardActionDetails | null>(null, subSlot(slot, 'action'));
435
+ const composedRefs = useComposedRefs(forwardedRef, rootRef, subSlot(slot, 'refs'));
436
+
437
+ const locateForm = useCallback(
438
+ () => {
439
+ let formElement: HTMLFormElement | null | undefined;
440
+ if (form) {
441
+ const associatedElement = (rootRef.current?.ownerDocument ?? document).getElementById(form);
442
+ if (isFormElement(associatedElement)) {
443
+ formElement = associatedElement;
444
+ }
445
+ } else if (hiddenInputRef.current) {
446
+ formElement = hiddenInputRef.current.form;
447
+ } else {
448
+ // octane adaptation: read the first input lazily from the live collection
449
+ // (equivalent to the source's render-time `collection.at(0)` snapshot; the
450
+ // lazy read is always at least as fresh — see file header).
451
+ const firstInput = collection.at(0)?.element;
452
+ if (firstInput) {
453
+ formElement = firstInput.form;
454
+ }
455
+ }
456
+
457
+ return formElement ?? null;
458
+ },
459
+ [form, collection],
460
+ subSlot(slot, 'locate'),
461
+ );
462
+
463
+ const attemptSubmit = useCallback(
464
+ () => {
465
+ const formElement = locateForm();
466
+ formElement?.requestSubmit();
467
+ },
468
+ [locateForm],
469
+ subSlot(slot, 'submit'),
470
+ );
471
+
472
+ useEffect(
473
+ () => {
474
+ const form = locateForm();
475
+ if (form) {
476
+ const reset = () => dispatch({ type: 'CLEAR', reason: 'Reset' });
477
+ form.addEventListener('reset', reset);
478
+ return () => form.removeEventListener('reset', reset);
479
+ }
480
+ },
481
+ [dispatch, locateForm],
482
+ subSlot(slot, 'e:reset'),
483
+ );
484
+
485
+ const currentValue = value.join('');
486
+ const valueRef = useRef(currentValue, subSlot(slot, 'valueRef'));
487
+ const length = collection.size;
488
+ useEffect(
489
+ () => {
490
+ const previousValue = valueRef.current;
491
+ valueRef.current = currentValue;
492
+ if (previousValue === currentValue) {
493
+ return;
494
+ }
495
+
496
+ if (autoSubmit && value.every((char) => char !== '') && value.length === length) {
497
+ onAutoSubmit?.(value.join(''));
498
+ attemptSubmit();
499
+ }
500
+ },
501
+ [attemptSubmit, autoSubmit, currentValue, length, onAutoSubmit, value],
502
+ subSlot(slot, 'e:autosubmit'),
503
+ );
504
+ const isHydrated = useIsHydrated(subSlot(slot, 'hydrated'));
505
+
506
+ return createElement(OneTimePasswordFieldContext, {
507
+ scope: __scopeOneTimePasswordField,
508
+ value,
509
+ attemptSubmit,
510
+ collection,
511
+ collectionVersion,
512
+ registerInput,
513
+ disabled,
514
+ readOnly,
515
+ autoComplete,
516
+ autoFocus,
517
+ form,
518
+ name,
519
+ placeholder,
520
+ type,
521
+ hiddenInputRef,
522
+ userActionRef,
523
+ dispatch,
524
+ validationType,
525
+ orientation,
526
+ isHydrated,
527
+ sanitizeValue,
528
+ children: createElement(RovingFocusGroup.Root, {
529
+ asChild: true,
530
+ ...rovingFocusGroupScope,
531
+ orientation,
532
+ dir: direction,
533
+ children: createElement(Primitive.div, {
534
+ ...domProps,
535
+ role: 'group',
536
+ ref: composedRefs,
537
+ onPaste: composeEventHandlers(onPaste, (event: ClipboardEvent) => {
538
+ event.preventDefault();
539
+ const pastedValue = event.clipboardData?.getData('text/plain') ?? '';
540
+ dispatch({ type: 'PASTE', value: pastedValue });
541
+ }),
542
+ children,
543
+ }),
544
+ }),
545
+ });
546
+ }
547
+
548
+ /* -------------------------------------------------------------------------------------------------
549
+ * OneTimePasswordFieldHiddenInput
550
+ * -----------------------------------------------------------------------------------------------*/
551
+
552
+ export function OneTimePasswordFieldHiddenInput(props: any): any {
553
+ const slot = S('OneTimePasswordField.HiddenInput');
554
+ const { __scopeOneTimePasswordField, ref: forwardedRef, ...hiddenProps } = props ?? {};
555
+ const { value, hiddenInputRef, name } = useOneTimePasswordFieldContext(
556
+ 'OneTimePasswordFieldHiddenInput',
557
+ __scopeOneTimePasswordField,
558
+ );
559
+ const ref = useComposedRefs(hiddenInputRef, forwardedRef, subSlot(slot, 'refs'));
560
+ return createElement('input', {
561
+ ref,
562
+ name,
563
+ value: value.join('').trim(),
564
+ autoComplete: 'off',
565
+ autoCapitalize: 'off',
566
+ autoCorrect: 'off',
567
+ autoSave: 'off',
568
+ // octane: pass the string 'false' — a false-valued attribute would be removed,
569
+ // but React renders spellcheck="false".
570
+ spellCheck: 'false',
571
+ ...hiddenProps,
572
+ type: 'hidden',
573
+ readOnly: true,
574
+ });
575
+ }
576
+
577
+ /* -------------------------------------------------------------------------------------------------
578
+ * OneTimePasswordFieldInput
579
+ * -----------------------------------------------------------------------------------------------*/
580
+
581
+ export function OneTimePasswordFieldInput(props: any): any {
582
+ const slot = S('OneTimePasswordField.Input');
583
+ const {
584
+ __scopeOneTimePasswordField,
585
+ onInvalidChange,
586
+ index: indexProp,
587
+ // props users should pass on the Root instead (the source strips + TODO-warns)
588
+ value: _value,
589
+ defaultValue: _defaultValue,
590
+ disabled: _disabled,
591
+ readOnly: _readOnly,
592
+ autoComplete: _autoComplete,
593
+ autoFocus: _autoFocus,
594
+ form: _form,
595
+ name: _name,
596
+ placeholder: _placeholder,
597
+ type: _type,
598
+ // octane adaptation: React's onChange on a text input is the native `input` event —
599
+ // both user handlers fold into the single composed onInput binding below, so they
600
+ // must not stay in domProps (a native `change` binding would mean something else).
601
+ onInput: onInputProp,
602
+ onChange: onChangeProp,
603
+ ref: forwardedRef,
604
+ ...domProps
605
+ } = props ?? {};
606
+
607
+ const context = useOneTimePasswordFieldContext(
608
+ 'OneTimePasswordFieldInput',
609
+ __scopeOneTimePasswordField,
610
+ );
611
+ const { dispatch, userActionRef, validationType, isHydrated, disabled, collection } = context;
612
+ const registerInput: () => () => void = context.registerInput;
613
+ // octane adaptation: registration bump (see Root) — mounting/unmounting ANY cell
614
+ // re-renders every cell so its index-derived render output stays fresh, matching the
615
+ // source's state-backed collection. Layout effect: runs after this input is in the
616
+ // DOM (mount) and its cleanup schedules a re-render after removal (unmount).
617
+ useLayoutEffect(() => registerInput(), [registerInput], subSlot(slot, 'e:register'));
618
+ const rovingFocusGroupScope = useRovingFocusGroupScope(
619
+ __scopeOneTimePasswordField,
620
+ subSlot(slot, 'rfs'),
621
+ );
622
+
623
+ const inputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'input'));
624
+ const [element, setElement] = useState<HTMLInputElement | null>(null, subSlot(slot, 'element'));
625
+
626
+ const index: number = indexProp ?? (element ? collection.indexOf(element) : -1);
627
+ const canSetPlaceholder = indexProp != null || isHydrated;
628
+ let placeholder: string | undefined;
629
+ if (canSetPlaceholder && context.placeholder && context.value.length === 0) {
630
+ // only set placeholder after hydration to prevent flickering when indices are
631
+ // re-calculated
632
+ placeholder = context.placeholder[index];
633
+ }
634
+
635
+ const composedInputRef = useComposedRefs(
636
+ forwardedRef,
637
+ inputRef,
638
+ setElement,
639
+ subSlot(slot, 'refs'),
640
+ );
641
+ const char = context.value[index] ?? '';
642
+ // Latest char for the post-handler controlled-value reassertion (octane adaptation).
643
+ const charRef = useRef(char, subSlot(slot, 'char'));
644
+ charRef.current = char;
645
+
646
+ // octane adaptation: React's controlled `value={char}` → imperative `.value` property
647
+ // sync when the cell's state char changes (the attribute alone can't update a dirty
648
+ // input).
649
+ useLayoutEffect(
650
+ () => {
651
+ const input = inputRef.current;
652
+ if (input && input.value !== char) {
653
+ input.value = char;
654
+ }
655
+ },
656
+ [char],
657
+ subSlot(slot, 'e:value'),
658
+ );
659
+
660
+ const keyboardActionTimeoutRef = useRef<number | null>(null, subSlot(slot, 'timeout'));
661
+ useEffect(
662
+ () => {
663
+ return () => {
664
+ if (keyboardActionTimeoutRef.current) {
665
+ window.clearTimeout(keyboardActionTimeoutRef.current);
666
+ }
667
+ };
668
+ },
669
+ [],
670
+ subSlot(slot, 'e:timeout'),
671
+ );
672
+
673
+ const totalValue = context.value.join('').trim();
674
+ const lastSelectableIndex = clamp(totalValue.length, [0, collection.size - 1]);
675
+ const isFocusable = index <= lastSelectableIndex;
676
+
677
+ const validation = INPUT_VALIDATION_MAP[validationType as InputValidationType] ?? undefined;
678
+
679
+ // Source `onInput` logic: password managers may try to insert the whole code into a
680
+ // single input, in which case form validation would fail to prevent additional input.
681
+ // Handle this the same as if a user were pasting a value.
682
+ const handleInput = (event: Event) => {
683
+ const value = (event.currentTarget as HTMLInputElement).value;
684
+ if (value.length > 1) {
685
+ event.preventDefault();
686
+ userActionRef.current = { type: 'autocomplete-paste' };
687
+ dispatch({ type: 'PASTE', value });
688
+ keyboardActionTimeoutRef.current = window.setTimeout(() => {
689
+ userActionRef.current = null;
690
+ }, 10);
691
+ }
692
+ };
693
+
694
+ // Source `onChange` logic (fires from the same native `input` event in octane).
695
+ const handleChange = (event: Event) => {
696
+ const target = event.target as HTMLInputElement;
697
+ const value = target.value;
698
+ event.preventDefault();
699
+ const action = userActionRef.current;
700
+ userActionRef.current = null;
701
+
702
+ if (action) {
703
+ switch (action.type) {
704
+ case 'cut':
705
+ // TODO (source): do we want to assume the user wants to clear the entire
706
+ // value here and copy the code to the clipboard instead of just the value
707
+ // of the given input?
708
+ dispatch({ type: 'CLEAR_CHAR', index, reason: 'Cut' });
709
+ return;
710
+ case 'autocomplete-paste':
711
+ // the PASTE handler will already set the value and focus the final input;
712
+ // we want to skip focusing the wrong element if the browser fires another
713
+ // change for the first input. This sometimes happens during autocomplete.
714
+ return;
715
+ case 'keydown': {
716
+ if (action.key === 'Char') {
717
+ // update resulting from a keydown event that set a value directly.
718
+ // Ignore.
719
+ return;
720
+ }
721
+
722
+ const isClearing = action.key === 'Backspace' && (action.metaKey || action.ctrlKey);
723
+ if (action.key === 'Clear' || isClearing) {
724
+ dispatch({ type: 'CLEAR', reason: 'Backspace' });
725
+ } else {
726
+ dispatch({ type: 'CLEAR_CHAR', index, reason: action.key });
727
+ }
728
+ return;
729
+ }
730
+ default:
731
+ return;
732
+ }
733
+ }
734
+
735
+ // Only update the value if it matches the input pattern
736
+ if (target.validity.valid) {
737
+ if (value === '') {
738
+ let reason: 'Backspace' | 'Delete' | 'Cut' = 'Backspace';
739
+ const inputType = (event as InputEvent).inputType;
740
+ if (inputType === 'deleteContentBackward') {
741
+ reason = 'Backspace';
742
+ } else if (inputType === 'deleteByCut') {
743
+ reason = 'Cut';
744
+ }
745
+ dispatch({ type: 'CLEAR_CHAR', index, reason });
746
+ } else {
747
+ dispatch({ type: 'SET_CHAR', char: value, index, event });
748
+ }
749
+ } else {
750
+ onInvalidChange?.(target.value);
751
+ requestAnimationFrame(() => {
752
+ if (target.ownerDocument.activeElement === target) {
753
+ target.select();
754
+ }
755
+ });
756
+ }
757
+ };
758
+
759
+ return createElement(RovingFocusGroup.Item, {
760
+ ...rovingFocusGroupScope,
761
+ asChild: true,
762
+ focusable: !context.disabled && isFocusable,
763
+ active: index === lastSelectableIndex,
764
+ children: ({
765
+ hasTabStop,
766
+ isCurrentTabStop,
767
+ }: {
768
+ hasTabStop: boolean;
769
+ isCurrentTabStop: boolean;
770
+ }) => {
771
+ const supportsAutoComplete = hasTabStop ? isCurrentTabStop : index === 0;
772
+ return createElement(Primitive.input, {
773
+ ref: composedInputRef,
774
+ type: context.type,
775
+ disabled,
776
+ 'aria-label': `Character ${index + 1} of ${collection.size}`,
777
+ autoComplete: supportsAutoComplete ? context.autoComplete : 'off',
778
+ 'data-1p-ignore': supportsAutoComplete ? undefined : 'true',
779
+ 'data-lpignore': supportsAutoComplete ? undefined : 'true',
780
+ 'data-protonpass-ignore': supportsAutoComplete ? undefined : 'true',
781
+ 'data-bwignore': supportsAutoComplete ? undefined : 'true',
782
+ inputMode: validation?.inputMode,
783
+ maxLength: supportsAutoComplete ? collection.size : 1,
784
+ pattern: validation?.pattern,
785
+ readOnly: context.readOnly,
786
+ value: char,
787
+ placeholder,
788
+ [OTP_INPUT_ATTR]: '',
789
+ 'data-radix-index': index,
790
+ ...domProps,
791
+ onFocus: composeEventHandlers(props?.onFocus, (event: FocusEvent) => {
792
+ (event.currentTarget as HTMLInputElement).select();
793
+ }),
794
+ onCut: composeEventHandlers(props?.onCut, (event: ClipboardEvent) => {
795
+ const currentValue = (event.currentTarget as HTMLInputElement).value;
796
+ if (currentValue !== '') {
797
+ // In this case the value will be cleared, but we don't want to set it
798
+ // directly because the user may want to prevent default behavior in the
799
+ // change handler. The userActionRef will be set temporarily so the
800
+ // change handler can behave correctly in response to the action.
801
+ userActionRef.current = { type: 'cut' };
802
+ // Set a short timeout to clear the action tracker after the change
803
+ // handler has had time to complete.
804
+ keyboardActionTimeoutRef.current = window.setTimeout(() => {
805
+ userActionRef.current = null;
806
+ }, 10);
807
+ }
808
+ }),
809
+ // octane adaptation: ONE native `input` binding runs the source's onInput
810
+ // logic then its onChange logic (React's order for a native input event),
811
+ // then re-asserts the controlled value (React's controlled-input
812
+ // restoration, which runs regardless of handler outcomes).
813
+ onInput: (event: Event) => {
814
+ composeEventHandlers(onInputProp, handleInput)(event);
815
+ composeEventHandlers(onChangeProp, handleChange)(event);
816
+ const input = inputRef.current;
817
+ if (input && input.value !== charRef.current) {
818
+ input.value = charRef.current;
819
+ }
820
+ },
821
+ onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
822
+ switch (event.key) {
823
+ case 'Clear':
824
+ case 'Delete':
825
+ case 'Backspace': {
826
+ const currentTarget = event.currentTarget as HTMLInputElement;
827
+ const currentValue = currentTarget.value;
828
+ // if current value is empty, no change event will fire
829
+ if (currentValue === '') {
830
+ // if the user presses delete when there is no value, noop
831
+ if (event.key === 'Delete') return;
832
+
833
+ const isClearing = event.key === 'Clear' || event.metaKey || event.ctrlKey;
834
+ if (isClearing) {
835
+ dispatch({ type: 'CLEAR', reason: 'Backspace' });
836
+ } else {
837
+ const element = currentTarget;
838
+ requestAnimationFrame(() => {
839
+ focusInput(collection.from(element, -1)?.element);
840
+ });
841
+ }
842
+ } else {
843
+ // In this case the value will be cleared, but we don't want to set
844
+ // it directly because the user may want to prevent default behavior
845
+ // in the change handler. The userActionRef is set temporarily so
846
+ // the change handler can behave correctly in response to the key
847
+ // vs. clearing the value by setting state externally.
848
+ userActionRef.current = {
849
+ type: 'keydown',
850
+ key: event.key as 'Backspace' | 'Delete' | 'Clear',
851
+ metaKey: event.metaKey,
852
+ ctrlKey: event.ctrlKey,
853
+ };
854
+ // Set a short timeout to clear the action tracker after the change
855
+ // handler has had time to complete.
856
+ keyboardActionTimeoutRef.current = window.setTimeout(() => {
857
+ userActionRef.current = null;
858
+ }, 10);
859
+ }
860
+
861
+ return;
862
+ }
863
+ case 'Enter': {
864
+ event.preventDefault();
865
+ context.attemptSubmit();
866
+ return;
867
+ }
868
+ case 'ArrowDown':
869
+ case 'ArrowUp': {
870
+ if (context.orientation === 'horizontal') {
871
+ // in horizontal orientation, up/down would de-select the input
872
+ // instead of moving focus
873
+ event.preventDefault();
874
+ }
875
+ return;
876
+ }
877
+ // TODO (source): Handle left/right arrow keys in vertical writing mode
878
+ default: {
879
+ const currentTarget = event.currentTarget as HTMLInputElement;
880
+ if (currentTarget.value === event.key) {
881
+ // if current value is same as the key press, no change event will
882
+ // fire. Focus the next input.
883
+ event.preventDefault();
884
+ focusInput(collection.from(currentTarget, 1)?.element);
885
+ return;
886
+ } else if (
887
+ // input already has a value, but...
888
+ currentTarget.value &&
889
+ // the value is not selected
890
+ !(
891
+ currentTarget.selectionStart === 0 &&
892
+ currentTarget.selectionEnd != null &&
893
+ currentTarget.selectionEnd > 0
894
+ )
895
+ ) {
896
+ const attemptedValue = event.key;
897
+ if (event.key.length > 1 || event.key === ' ') {
898
+ // not a character; do nothing
899
+ return;
900
+ } else {
901
+ // user is attempting to enter a character, but the input will
902
+ // not update by default since it's limited to a single
903
+ // character.
904
+ const nextInput = collection.from(currentTarget, 1)?.element;
905
+ const lastInput = collection.at(-1)?.element;
906
+ if (nextInput !== lastInput && currentTarget !== lastInput) {
907
+ // if selection is before the value, set the value of the
908
+ // current input. Otherwise set the value of the next input.
909
+ if (currentTarget.selectionStart === 0) {
910
+ dispatch({ type: 'SET_CHAR', char: attemptedValue, index, event });
911
+ } else {
912
+ dispatch({
913
+ type: 'SET_CHAR',
914
+ char: attemptedValue,
915
+ index: index + 1,
916
+ event,
917
+ });
918
+ }
919
+
920
+ userActionRef.current = {
921
+ type: 'keydown',
922
+ key: 'Char',
923
+ metaKey: event.metaKey,
924
+ ctrlKey: event.ctrlKey,
925
+ };
926
+ keyboardActionTimeoutRef.current = window.setTimeout(() => {
927
+ userActionRef.current = null;
928
+ }, 10);
929
+ }
930
+ }
931
+ }
932
+ }
933
+ }
934
+ }),
935
+ onPointerDown: composeEventHandlers(props?.onPointerDown, (event: PointerEvent) => {
936
+ event.preventDefault();
937
+ const indexToFocus = Math.min(index, lastSelectableIndex);
938
+ const element = collection.at(indexToFocus)?.element;
939
+ focusInput(element);
940
+ }),
941
+ });
942
+ },
943
+ });
944
+ }
945
+
946
+ /* -----------------------------------------------------------------------------------------------*/
947
+
948
+ function isFormElement(element: Element | null | undefined): element is HTMLFormElement {
949
+ return element?.tagName === 'FORM';
950
+ }
951
+
952
+ function removeWhitespace(value: string): string {
953
+ return value.replace(/\s/g, '');
954
+ }
955
+
956
+ function focusInput(element: HTMLInputElement | null | undefined): void {
957
+ if (!element) return;
958
+ if (element.ownerDocument.activeElement === element) {
959
+ // if the element is already focused, select the value in the next animation frame
960
+ window.requestAnimationFrame(() => {
961
+ element.select?.();
962
+ });
963
+ } else {
964
+ element.focus();
965
+ }
966
+ }
967
+
968
+ // Inlined from @radix-ui/number.
969
+ function clamp(value: number, [min, max]: [number, number]): number {
970
+ return Math.min(max, Math.max(min, value));
971
+ }
972
+
973
+ export {
974
+ OneTimePasswordField as Root,
975
+ OneTimePasswordFieldInput as Input,
976
+ OneTimePasswordFieldHiddenInput as HiddenInput,
977
+ };