@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +45 -0
- package/src/AccessibleIcon.ts +26 -0
- package/src/Accordion.ts +282 -0
- package/src/AlertDialog.ts +110 -0
- package/src/Arrow.ts +21 -0
- package/src/AspectRatio.ts +34 -0
- package/src/Avatar.ts +152 -0
- package/src/Checkbox.ts +325 -0
- package/src/Collapsible.ts +170 -0
- package/src/ContextMenu.ts +303 -0
- package/src/Dialog.ts +308 -0
- package/src/DismissableLayer.ts +413 -0
- package/src/DropdownMenu.ts +275 -0
- package/src/FocusScope.ts +266 -0
- package/src/Form.ts +621 -0
- package/src/HoverCard.ts +318 -0
- package/src/Label.ts +25 -0
- package/src/Menu.ts +1057 -0
- package/src/Menubar.ts +572 -0
- package/src/NavigationMenu.ts +1236 -0
- package/src/OneTimePasswordField.ts +977 -0
- package/src/PasswordToggleField.ts +495 -0
- package/src/Popover.ts +354 -0
- package/src/Popper.ts +401 -0
- package/src/Portal.ts +19 -0
- package/src/Presence.ts +225 -0
- package/src/Primitive.ts +53 -0
- package/src/Progress.ts +92 -0
- package/src/Radio.ts +262 -0
- package/src/RadioGroup.ts +212 -0
- package/src/RovingFocusGroup.ts +259 -0
- package/src/ScrollArea.ts +966 -0
- package/src/Select.ts +1901 -0
- package/src/Separator.ts +36 -0
- package/src/Slider.ts +745 -0
- package/src/Slot.ts +105 -0
- package/src/Switch.ts +278 -0
- package/src/Tabs.ts +177 -0
- package/src/Toast.ts +923 -0
- package/src/Toggle.ts +31 -0
- package/src/ToggleGroup.ts +157 -0
- package/src/Toolbar.ts +130 -0
- package/src/Tooltip.ts +669 -0
- package/src/VisuallyHidden.ts +29 -0
- package/src/collection.ts +97 -0
- package/src/compose-event-handlers.ts +15 -0
- package/src/compose-refs.ts +53 -0
- package/src/context.ts +109 -0
- package/src/direction.ts +19 -0
- package/src/focus-guards.ts +56 -0
- package/src/index.ts +54 -0
- package/src/internal.ts +42 -0
- package/src/scroll-lock.ts +57 -0
- package/src/use-callback-ref.ts +28 -0
- package/src/use-effect-event.ts +36 -0
- package/src/use-is-hydrated.ts +23 -0
- package/src/use-previous.ts +28 -0
- package/src/use-size.ts +57 -0
- package/src/useControllableState.ts +78 -0
- 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
|
+
};
|