@arun-dev/headless 4.18.0 → 4.19.0

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.
@@ -167,12 +167,20 @@ function useOtpInputRootContext(part) {
167
167
  }
168
168
  return context;
169
169
  }
170
+ var GAP = " ";
170
171
  function sanitize(text, validationType) {
171
172
  return validationType === "numeric" ? text.replace(/\D/g, "") : text.replace(/[^A-Za-z0-9]/g, "");
172
173
  }
174
+ function normalize(text, validationType, length) {
175
+ const allowed = validationType === "numeric" ? /[^\d ]/g : /[^A-Za-z0-9 ]/g;
176
+ return text.replace(allowed, "").slice(0, length).trimEnd();
177
+ }
178
+ function isComplete(value, length) {
179
+ return value.length === length && !value.includes(GAP);
180
+ }
173
181
  function otpInputDataAttributes({ value, length, disabled }) {
174
182
  return {
175
- "data-complete": value.length === length ? "" : void 0,
183
+ "data-complete": isComplete(value, length) ? "" : void 0,
176
184
  "data-disabled": disabled ? "" : void 0
177
185
  };
178
186
  }
@@ -183,6 +191,7 @@ function OtpInputRoot({
183
191
  value: valueProp,
184
192
  defaultValue,
185
193
  onValueChange,
194
+ onComplete,
186
195
  length = 6,
187
196
  validationType = "numeric",
188
197
  disabled = false,
@@ -199,12 +208,13 @@ function OtpInputRoot({
199
208
  });
200
209
  const commit = (0, import_react4.useCallback)(
201
210
  (next) => {
202
- const clean = sanitize(next, validationType).slice(0, length);
211
+ const clean = normalize(next, validationType, length);
203
212
  if (clean === value) return;
204
213
  setValue(clean);
205
214
  onValueChange?.(clean);
215
+ if (isComplete(clean, length)) onComplete?.(clean);
206
216
  },
207
- [validationType, length, value, setValue, onValueChange]
217
+ [validationType, length, value, setValue, onValueChange, onComplete]
208
218
  );
209
219
  const [selection, setSelection] = (0, import_react4.useState)(null);
210
220
  const inputRef = (0, import_react4.useRef)(null);
@@ -267,6 +277,7 @@ function OtpInputRoot({
267
277
  }
268
278
 
269
279
  // src/otp-input/OtpInputInput.tsx
280
+ var import_react5 = require("react");
270
281
  function OtpInputInput({ render, ...rest }) {
271
282
  const { value, length, disabled, validationType, setSelection, commit, inputRef, slotAt } = useOtpInputRootContext("Input");
272
283
  function select(input, index) {
@@ -279,6 +290,48 @@ function OtpInputInput({ render, ...rest }) {
279
290
  function report(input) {
280
291
  setSelection({ start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 });
281
292
  }
293
+ const pendingCaret = (0, import_react5.useRef)(null);
294
+ (0, import_react5.useLayoutEffect)(() => {
295
+ const input = inputRef.current;
296
+ const at = pendingCaret.current;
297
+ pendingCaret.current = null;
298
+ if (input && at !== null && input.ownerDocument.activeElement === input) select(input, at);
299
+ });
300
+ function edit(next, caret) {
301
+ pendingCaret.current = caret;
302
+ commit(next);
303
+ }
304
+ const cleared = (index) => value.slice(0, index) + GAP + value.slice(index + 1);
305
+ function onDelete(event, input) {
306
+ const start = input.selectionStart ?? 0;
307
+ const end = input.selectionEnd ?? start;
308
+ event.preventDefault();
309
+ if (end > start + 1) {
310
+ edit(value.slice(0, start) + GAP.repeat(end - start) + value.slice(end), start);
311
+ } else if (end === start + 1 && value.charAt(start) !== GAP) {
312
+ edit(cleared(start), start);
313
+ } else if (event.inputType.includes("Backward") && start > 0) {
314
+ edit(cleared(start - 1), start - 1);
315
+ }
316
+ }
317
+ const beforeInput = (0, import_react5.useRef)(() => {
318
+ });
319
+ beforeInput.current = (event) => {
320
+ const input = event.currentTarget;
321
+ if (event.inputType.startsWith("delete") && event.inputType !== "deleteByCut") {
322
+ onDelete(event, input);
323
+ } else if (event.inputType === "insertText") {
324
+ const typed = event.data ?? "";
325
+ if (typed.length > 0 && sanitize(typed, validationType) !== typed) event.preventDefault();
326
+ }
327
+ };
328
+ (0, import_react5.useEffect)(() => {
329
+ const input = inputRef.current;
330
+ if (!input) return;
331
+ const listener = (event) => beforeInput.current(event);
332
+ input.addEventListener("beforeinput", listener);
333
+ return () => input.removeEventListener("beforeinput", listener);
334
+ }, [inputRef]);
282
335
  function syncSelection(event) {
283
336
  const input = event.currentTarget;
284
337
  const start = input.selectionStart ?? 0;
@@ -301,12 +354,6 @@ function OtpInputInput({ render, ...rest }) {
301
354
  maxLength: length,
302
355
  pattern: validationType === "numeric" ? `\\d{${length}}` : `[A-Za-z0-9]{${length}}`,
303
356
  disabled: disabled || void 0,
304
- onBeforeInput(event) {
305
- const typed = event.data;
306
- if (typed && typed.length === 1 && !sanitize(typed, validationType)) {
307
- event.preventDefault();
308
- }
309
- },
310
357
  onChange(event) {
311
358
  commit(event.currentTarget.value);
312
359
  syncSelection(event);
@@ -317,12 +364,16 @@ function OtpInputInput({ render, ...rest }) {
317
364
  if (!pasted) return;
318
365
  const input = event.currentTarget;
319
366
  if (pasted.length >= length) {
320
- commit(pasted);
367
+ edit(pasted, length);
321
368
  return;
322
369
  }
323
370
  const start = input.selectionStart ?? value.length;
324
371
  const end = input.selectionEnd ?? start;
325
- commit(value.slice(0, start) + pasted + value.slice(end));
372
+ const reach = start + pasted.length;
373
+ edit(
374
+ value.slice(0, start) + pasted + GAP.repeat(Math.max(0, end - reach)) + value.slice(Math.max(end, reach)),
375
+ reach
376
+ );
326
377
  },
327
378
  onKeyDown(event) {
328
379
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
@@ -333,13 +384,13 @@ function OtpInputInput({ render, ...rest }) {
333
384
  select(input, event.key === "ArrowLeft" ? at - 1 : at + 1);
334
385
  },
335
386
  onClick(event) {
336
- if (event.detail > 1) return;
337
387
  const index = slotAt(event.clientX);
338
388
  if (index !== null) select(event.currentTarget, index);
339
389
  },
340
390
  onSelect: syncSelection,
341
391
  onFocus(event) {
342
- select(event.currentTarget, event.currentTarget.value.length);
392
+ const gap = value.indexOf(GAP);
393
+ select(event.currentTarget, gap === -1 ? value.length : gap);
343
394
  },
344
395
  onBlur() {
345
396
  setSelection(null);
@@ -350,15 +401,15 @@ function OtpInputInput({ render, ...rest }) {
350
401
  }
351
402
 
352
403
  // src/otp-input/OtpInputSlot.tsx
353
- var import_react5 = require("react");
404
+ var import_react6 = require("react");
354
405
  function OtpInputSlot({ index, className, children, render, ...rest }) {
355
406
  const { value, length, disabled, selection, registerSlot } = useOtpInputRootContext("Slot");
356
- const char = value.charAt(index);
407
+ const char = value.charAt(index) === GAP ? "" : value.charAt(index);
357
408
  let active = false;
358
409
  if (selection) {
359
410
  active = selection.start === selection.end ? index === Math.min(selection.start, length - 1) : index >= selection.start && index < selection.end;
360
411
  }
361
- const ref = (0, import_react5.useCallback)(
412
+ const ref = (0, import_react6.useCallback)(
362
413
  (element) => registerSlot(index, element),
363
414
  [registerSlot, index]
364
415
  );
@@ -20,8 +20,18 @@ type OtpInputRootOwnProps = {
20
20
  value?: string;
21
21
  /** Initial value when uncontrolled. Read once, at mount. */
22
22
  defaultValue?: string;
23
- /** Called with the code after every change, partial or complete. */
23
+ /**
24
+ * Called with the code after every change, partial or complete. A box emptied before the end
25
+ * is a space — `"123 56"` — so check completeness with a pattern such as `/^\d{6}$/`, not
26
+ * the length alone.
27
+ */
24
28
  onValueChange?: (value: string) => void;
29
+ /**
30
+ * Called with the code after a change that leaves it complete: every slot filled, no gap.
31
+ * Typing the last digit, pasting a whole code and filling an emptied slot all count; a change
32
+ * that leaves a slot empty does not. Use it to verify as soon as the code is in.
33
+ */
34
+ onComplete?: (value: string) => void;
25
35
  /** How many characters the code has, and so how many slots it shows. Defaults to 6. */
26
36
  length?: number;
27
37
  /**
@@ -48,7 +58,7 @@ type OtpInputRootProps = OtpInputRootOwnProps & Omit<ComponentPropsWithRef<'div'
48
58
  * Holds the value through `useControlled`. The input reports changes, and the Root keeps only
49
59
  * the characters `validationType` allows, up to `length`.
50
60
  */
51
- declare function OtpInputRoot({ value: valueProp, defaultValue, onValueChange, length, validationType, disabled, className, children, render, ...rest }: OtpInputRootProps): react.JSX.Element;
61
+ declare function OtpInputRoot({ value: valueProp, defaultValue, onValueChange, onComplete, length, validationType, disabled, className, children, render, ...rest }: OtpInputRootProps): react.JSX.Element;
52
62
 
53
63
  type OtpInputInputOwnProps = {
54
64
  /** Element to render instead of the default `<input>`. Props and ref are merged onto it. */
@@ -66,7 +76,11 @@ type OtpInputInputProps = OtpInputInputOwnProps & Omit<ComponentPropsWithRef<'in
66
76
  *
67
77
  * The caret always covers one character, so typing replaces the character in the active slot
68
78
  * and moves on, and the last slot of a full code is replaced rather than refused. Left and
69
- * Right move one slot; Backspace and Delete are the platform's.
79
+ * Right move one slot.
80
+ *
81
+ * Delete and Backspace empty a slot in place: the characters after it keep their slots, and
82
+ * the caret stays, so the next character fills the same slot. Backspace on an empty slot
83
+ * empties the one before it; Delete on an empty slot does nothing.
70
84
  *
71
85
  * Carries `maxLength` and a `pattern` of exactly `length` allowed characters, so the browser's
72
86
  * own validation reports a code that is too short. Both can be overridden.
@@ -20,8 +20,18 @@ type OtpInputRootOwnProps = {
20
20
  value?: string;
21
21
  /** Initial value when uncontrolled. Read once, at mount. */
22
22
  defaultValue?: string;
23
- /** Called with the code after every change, partial or complete. */
23
+ /**
24
+ * Called with the code after every change, partial or complete. A box emptied before the end
25
+ * is a space — `"123 56"` — so check completeness with a pattern such as `/^\d{6}$/`, not
26
+ * the length alone.
27
+ */
24
28
  onValueChange?: (value: string) => void;
29
+ /**
30
+ * Called with the code after a change that leaves it complete: every slot filled, no gap.
31
+ * Typing the last digit, pasting a whole code and filling an emptied slot all count; a change
32
+ * that leaves a slot empty does not. Use it to verify as soon as the code is in.
33
+ */
34
+ onComplete?: (value: string) => void;
25
35
  /** How many characters the code has, and so how many slots it shows. Defaults to 6. */
26
36
  length?: number;
27
37
  /**
@@ -48,7 +58,7 @@ type OtpInputRootProps = OtpInputRootOwnProps & Omit<ComponentPropsWithRef<'div'
48
58
  * Holds the value through `useControlled`. The input reports changes, and the Root keeps only
49
59
  * the characters `validationType` allows, up to `length`.
50
60
  */
51
- declare function OtpInputRoot({ value: valueProp, defaultValue, onValueChange, length, validationType, disabled, className, children, render, ...rest }: OtpInputRootProps): react.JSX.Element;
61
+ declare function OtpInputRoot({ value: valueProp, defaultValue, onValueChange, onComplete, length, validationType, disabled, className, children, render, ...rest }: OtpInputRootProps): react.JSX.Element;
52
62
 
53
63
  type OtpInputInputOwnProps = {
54
64
  /** Element to render instead of the default `<input>`. Props and ref are merged onto it. */
@@ -66,7 +76,11 @@ type OtpInputInputProps = OtpInputInputOwnProps & Omit<ComponentPropsWithRef<'in
66
76
  *
67
77
  * The caret always covers one character, so typing replaces the character in the active slot
68
78
  * and moves on, and the last slot of a full code is replaced rather than refused. Left and
69
- * Right move one slot; Backspace and Delete are the platform's.
79
+ * Right move one slot.
80
+ *
81
+ * Delete and Backspace empty a slot in place: the characters after it keep their slots, and
82
+ * the caret stays, so the next character fills the same slot. Backspace on an empty slot
83
+ * empties the one before it; Delete on an empty slot does nothing.
70
84
  *
71
85
  * Carries `maxLength` and a `pattern` of exactly `length` allowed characters, so the browser's
72
86
  * own validation reports a code that is too short. Both can be overridden.
@@ -27,12 +27,20 @@ function useOtpInputRootContext(part) {
27
27
  }
28
28
  return context;
29
29
  }
30
+ var GAP = " ";
30
31
  function sanitize(text, validationType) {
31
32
  return validationType === "numeric" ? text.replace(/\D/g, "") : text.replace(/[^A-Za-z0-9]/g, "");
32
33
  }
34
+ function normalize(text, validationType, length) {
35
+ const allowed = validationType === "numeric" ? /[^\d ]/g : /[^A-Za-z0-9 ]/g;
36
+ return text.replace(allowed, "").slice(0, length).trimEnd();
37
+ }
38
+ function isComplete(value, length) {
39
+ return value.length === length && !value.includes(GAP);
40
+ }
33
41
  function otpInputDataAttributes({ value, length, disabled }) {
34
42
  return {
35
- "data-complete": value.length === length ? "" : void 0,
43
+ "data-complete": isComplete(value, length) ? "" : void 0,
36
44
  "data-disabled": disabled ? "" : void 0
37
45
  };
38
46
  }
@@ -43,6 +51,7 @@ function OtpInputRoot({
43
51
  value: valueProp,
44
52
  defaultValue,
45
53
  onValueChange,
54
+ onComplete,
46
55
  length = 6,
47
56
  validationType = "numeric",
48
57
  disabled = false,
@@ -59,12 +68,13 @@ function OtpInputRoot({
59
68
  });
60
69
  const commit = useCallback(
61
70
  (next) => {
62
- const clean = sanitize(next, validationType).slice(0, length);
71
+ const clean = normalize(next, validationType, length);
63
72
  if (clean === value) return;
64
73
  setValue(clean);
65
74
  onValueChange?.(clean);
75
+ if (isComplete(clean, length)) onComplete?.(clean);
66
76
  },
67
- [validationType, length, value, setValue, onValueChange]
77
+ [validationType, length, value, setValue, onValueChange, onComplete]
68
78
  );
69
79
  const [selection, setSelection] = useState(null);
70
80
  const inputRef = useRef(null);
@@ -127,6 +137,7 @@ function OtpInputRoot({
127
137
  }
128
138
 
129
139
  // src/otp-input/OtpInputInput.tsx
140
+ import { useEffect as useEffect2, useLayoutEffect, useRef as useRef2 } from "react";
130
141
  function OtpInputInput({ render, ...rest }) {
131
142
  const { value, length, disabled, validationType, setSelection, commit, inputRef, slotAt } = useOtpInputRootContext("Input");
132
143
  function select(input, index) {
@@ -139,6 +150,48 @@ function OtpInputInput({ render, ...rest }) {
139
150
  function report(input) {
140
151
  setSelection({ start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 });
141
152
  }
153
+ const pendingCaret = useRef2(null);
154
+ useLayoutEffect(() => {
155
+ const input = inputRef.current;
156
+ const at = pendingCaret.current;
157
+ pendingCaret.current = null;
158
+ if (input && at !== null && input.ownerDocument.activeElement === input) select(input, at);
159
+ });
160
+ function edit(next, caret) {
161
+ pendingCaret.current = caret;
162
+ commit(next);
163
+ }
164
+ const cleared = (index) => value.slice(0, index) + GAP + value.slice(index + 1);
165
+ function onDelete(event, input) {
166
+ const start = input.selectionStart ?? 0;
167
+ const end = input.selectionEnd ?? start;
168
+ event.preventDefault();
169
+ if (end > start + 1) {
170
+ edit(value.slice(0, start) + GAP.repeat(end - start) + value.slice(end), start);
171
+ } else if (end === start + 1 && value.charAt(start) !== GAP) {
172
+ edit(cleared(start), start);
173
+ } else if (event.inputType.includes("Backward") && start > 0) {
174
+ edit(cleared(start - 1), start - 1);
175
+ }
176
+ }
177
+ const beforeInput = useRef2(() => {
178
+ });
179
+ beforeInput.current = (event) => {
180
+ const input = event.currentTarget;
181
+ if (event.inputType.startsWith("delete") && event.inputType !== "deleteByCut") {
182
+ onDelete(event, input);
183
+ } else if (event.inputType === "insertText") {
184
+ const typed = event.data ?? "";
185
+ if (typed.length > 0 && sanitize(typed, validationType) !== typed) event.preventDefault();
186
+ }
187
+ };
188
+ useEffect2(() => {
189
+ const input = inputRef.current;
190
+ if (!input) return;
191
+ const listener = (event) => beforeInput.current(event);
192
+ input.addEventListener("beforeinput", listener);
193
+ return () => input.removeEventListener("beforeinput", listener);
194
+ }, [inputRef]);
142
195
  function syncSelection(event) {
143
196
  const input = event.currentTarget;
144
197
  const start = input.selectionStart ?? 0;
@@ -161,12 +214,6 @@ function OtpInputInput({ render, ...rest }) {
161
214
  maxLength: length,
162
215
  pattern: validationType === "numeric" ? `\\d{${length}}` : `[A-Za-z0-9]{${length}}`,
163
216
  disabled: disabled || void 0,
164
- onBeforeInput(event) {
165
- const typed = event.data;
166
- if (typed && typed.length === 1 && !sanitize(typed, validationType)) {
167
- event.preventDefault();
168
- }
169
- },
170
217
  onChange(event) {
171
218
  commit(event.currentTarget.value);
172
219
  syncSelection(event);
@@ -177,12 +224,16 @@ function OtpInputInput({ render, ...rest }) {
177
224
  if (!pasted) return;
178
225
  const input = event.currentTarget;
179
226
  if (pasted.length >= length) {
180
- commit(pasted);
227
+ edit(pasted, length);
181
228
  return;
182
229
  }
183
230
  const start = input.selectionStart ?? value.length;
184
231
  const end = input.selectionEnd ?? start;
185
- commit(value.slice(0, start) + pasted + value.slice(end));
232
+ const reach = start + pasted.length;
233
+ edit(
234
+ value.slice(0, start) + pasted + GAP.repeat(Math.max(0, end - reach)) + value.slice(Math.max(end, reach)),
235
+ reach
236
+ );
186
237
  },
187
238
  onKeyDown(event) {
188
239
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
@@ -193,13 +244,13 @@ function OtpInputInput({ render, ...rest }) {
193
244
  select(input, event.key === "ArrowLeft" ? at - 1 : at + 1);
194
245
  },
195
246
  onClick(event) {
196
- if (event.detail > 1) return;
197
247
  const index = slotAt(event.clientX);
198
248
  if (index !== null) select(event.currentTarget, index);
199
249
  },
200
250
  onSelect: syncSelection,
201
251
  onFocus(event) {
202
- select(event.currentTarget, event.currentTarget.value.length);
252
+ const gap = value.indexOf(GAP);
253
+ select(event.currentTarget, gap === -1 ? value.length : gap);
203
254
  },
204
255
  onBlur() {
205
256
  setSelection(null);
@@ -213,7 +264,7 @@ function OtpInputInput({ render, ...rest }) {
213
264
  import { useCallback as useCallback2 } from "react";
214
265
  function OtpInputSlot({ index, className, children, render, ...rest }) {
215
266
  const { value, length, disabled, selection, registerSlot } = useOtpInputRootContext("Slot");
216
- const char = value.charAt(index);
267
+ const char = value.charAt(index) === GAP ? "" : value.charAt(index);
217
268
  let active = false;
218
269
  if (selection) {
219
270
  active = selection.start === selection.end ? index === Math.min(selection.start, length - 1) : index >= selection.start && index < selection.end;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arun-dev/headless",
3
- "version": "4.18.0",
3
+ "version": "4.19.0",
4
4
  "description": "Unstyled React behaviour primitives — render engine, controlled/uncontrolled state, and data-* state projection. Ships no CSS and no class names.",
5
5
  "license": "MIT",
6
6
  "type": "module",