@zentauri-ui/zentauri-components 1.7.3 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,327 @@
1
+ "use client";
2
+
3
+ import {
4
+ type ClipboardEvent,
5
+ type KeyboardEvent,
6
+ useCallback,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+
13
+ import {
14
+ zuiOtpCellsBase,
15
+ zuiOtpErrorBase,
16
+ zuiOtpHintBase,
17
+ zuiOtpLabelBase,
18
+ zuiOtpRootBase,
19
+ zuiOtpSeparatorBase,
20
+ } from "../../design-system/otp-input";
21
+ import { cn } from "../../lib/utils";
22
+
23
+ import type { OTPInputAllowedCharacters, OTPInputProps } from "./types";
24
+ import { otpInputCellVariants } from "./variants";
25
+
26
+ function clampLength(length: number): number {
27
+ return Number.isFinite(length) ? Math.max(1, Math.min(12, length)) : 6;
28
+ }
29
+
30
+ function sanitizeValue(
31
+ value: string,
32
+ allowedCharacters: OTPInputAllowedCharacters,
33
+ maxLength: number,
34
+ ): string {
35
+ const pattern = allowedCharacters === "numeric" ? /[0-9]/g : /[a-zA-Z0-9]/g;
36
+ return (value.match(pattern) ?? []).join("").slice(0, maxLength);
37
+ }
38
+
39
+ function valueToCells(value: string, length: number): string[] {
40
+ return Array.from({ length }, (_, index) => {
41
+ const c = value[index] ?? "\x00";
42
+ return c === "\x00" ? "" : c;
43
+ });
44
+ }
45
+
46
+ function cellsToInternal(cells: string[], length: number): string {
47
+ return Array.from({ length }, (_, i) => cells[i] || "\x00").join("");
48
+ }
49
+
50
+ export function OTPInput(props: OTPInputProps) {
51
+ const {
52
+ allowedCharacters = "numeric",
53
+ appearance,
54
+ autoFocus,
55
+ cellClassName,
56
+ className,
57
+ defaultValue = "",
58
+ disabled,
59
+ errorMessage,
60
+ hint,
61
+ id,
62
+ label,
63
+ length = 6,
64
+ mask,
65
+ name,
66
+ onComplete,
67
+ onValueChange,
68
+ ref,
69
+ separatorEvery,
70
+ size,
71
+ value,
72
+ ...rest
73
+ } = props;
74
+ const generatedId = useId();
75
+ const rootId = id ?? generatedId;
76
+ const resolvedLength = clampLength(length);
77
+ const isControlled = value !== undefined;
78
+ const [uncontrolledValue, setUncontrolledValue] = useState(() => {
79
+ const clean = sanitizeValue(
80
+ defaultValue,
81
+ allowedCharacters,
82
+ resolvedLength,
83
+ );
84
+ return clean.padEnd(resolvedLength, "\x00");
85
+ });
86
+ const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
87
+ const cells = useMemo(
88
+ () =>
89
+ isControlled
90
+ ? valueToCells(
91
+ sanitizeValue(
92
+ value ?? "",
93
+ allowedCharacters,
94
+ resolvedLength,
95
+ ).padEnd(resolvedLength, "\x00"),
96
+ resolvedLength,
97
+ )
98
+ : Array.from({ length: resolvedLength }, (_, index) => {
99
+ const c = uncontrolledValue[index] ?? "\x00";
100
+ return c === "\x00" ? "" : sanitizeValue(c, allowedCharacters, 1);
101
+ }),
102
+ [allowedCharacters, isControlled, resolvedLength, uncontrolledValue, value],
103
+ );
104
+ const sanitizedValue = cells.filter(Boolean).join("");
105
+ const labelId = `${rootId}-label`;
106
+ const hintId = `${rootId}-hint`;
107
+ const errorId = `${rootId}-error`;
108
+ const describedBy = [
109
+ hint !== undefined ? hintId : undefined,
110
+ errorMessage !== undefined ? errorId : undefined,
111
+ ]
112
+ .filter(Boolean)
113
+ .join(" ");
114
+
115
+ const commitValue = useCallback(
116
+ (nextCells: string[]) => {
117
+ if (!isControlled) {
118
+ setUncontrolledValue(cellsToInternal(nextCells, resolvedLength));
119
+ }
120
+ const next = nextCells.filter(Boolean).join("").slice(0, resolvedLength);
121
+ onValueChange?.(next);
122
+ if (next.length === resolvedLength) {
123
+ onComplete?.(next);
124
+ }
125
+ },
126
+ [isControlled, onComplete, onValueChange, resolvedLength],
127
+ );
128
+
129
+ const focusCell = useCallback(
130
+ (index: number) => {
131
+ const target =
132
+ inputRefs.current[Math.max(0, Math.min(index, resolvedLength - 1))];
133
+ target?.focus();
134
+ target?.select();
135
+ },
136
+ [resolvedLength],
137
+ );
138
+
139
+ const updateAtIndex = useCallback(
140
+ (index: number, nextChars: string, isPaste = false) => {
141
+ let chars: string | undefined = sanitizeValue(
142
+ nextChars,
143
+ allowedCharacters,
144
+ resolvedLength,
145
+ );
146
+
147
+ // Detect single-char overwrite: browser gives "existingChar + typedChar"
148
+ if (
149
+ !isPaste &&
150
+ chars &&
151
+ chars.length === 2 &&
152
+ chars[0] === (cells[index] ?? "")
153
+ ) {
154
+ chars = chars[1];
155
+ }
156
+
157
+ if (!chars?.length || (!isPaste && chars === cells[index])) {
158
+ return;
159
+ }
160
+
161
+ const nextCells = [...cells];
162
+
163
+ chars.split("").forEach((char, offset) => {
164
+ const targetIndex = index + offset;
165
+ if (targetIndex < resolvedLength) {
166
+ nextCells[targetIndex] = char;
167
+ }
168
+ });
169
+
170
+ commitValue(nextCells);
171
+ focusCell(
172
+ Math.min(index + Math.max(chars.length, 1), resolvedLength - 1),
173
+ );
174
+ },
175
+ [allowedCharacters, cells, commitValue, focusCell, resolvedLength],
176
+ );
177
+
178
+ const clearAtIndex = useCallback(
179
+ (index: number) => {
180
+ const nextCells = [...cells];
181
+ nextCells[index] = "";
182
+ commitValue(nextCells);
183
+ },
184
+ [cells, commitValue],
185
+ );
186
+
187
+ const handlePaste = useCallback(
188
+ (event: ClipboardEvent<HTMLInputElement>, index: number) => {
189
+ event.preventDefault();
190
+ updateAtIndex(index, event.clipboardData.getData("text"), true);
191
+ },
192
+ [updateAtIndex],
193
+ );
194
+
195
+ const handleKeyDown = useCallback(
196
+ (event: KeyboardEvent<HTMLInputElement>, index: number) => {
197
+ if (event.key === "Backspace") {
198
+ event.preventDefault();
199
+ if (cells[index]) {
200
+ clearAtIndex(index);
201
+ return;
202
+ }
203
+ clearAtIndex(Math.max(index - 1, 0));
204
+ focusCell(index - 1);
205
+ return;
206
+ }
207
+
208
+ if (event.key === "Delete") {
209
+ event.preventDefault();
210
+ clearAtIndex(index);
211
+ return;
212
+ }
213
+
214
+ if (event.key === "ArrowLeft") {
215
+ event.preventDefault();
216
+ focusCell(index - 1);
217
+ return;
218
+ }
219
+
220
+ if (event.key === "ArrowRight") {
221
+ event.preventDefault();
222
+ focusCell(index + 1);
223
+ return;
224
+ }
225
+
226
+ if (event.key === "Home") {
227
+ event.preventDefault();
228
+ focusCell(0);
229
+ return;
230
+ }
231
+
232
+ if (event.key === "End") {
233
+ event.preventDefault();
234
+ focusCell(resolvedLength - 1);
235
+ }
236
+ },
237
+ [cells, clearAtIndex, focusCell, resolvedLength],
238
+ );
239
+
240
+ return (
241
+ <div
242
+ ref={ref}
243
+ id={rootId}
244
+ role="group"
245
+ aria-labelledby={label !== undefined ? labelId : undefined}
246
+ aria-describedby={describedBy || undefined}
247
+ aria-invalid={errorMessage !== undefined ? true : undefined}
248
+ className={cn(zuiOtpRootBase, className)}
249
+ data-disabled={disabled ? "true" : undefined}
250
+ data-slot="otp-input"
251
+ {...rest}
252
+ >
253
+ {label !== undefined && (
254
+ <p id={labelId} className={zuiOtpLabelBase}>
255
+ {label}
256
+ </p>
257
+ )}
258
+ {hint !== undefined && (
259
+ <p id={hintId} className={zuiOtpHintBase}>
260
+ {hint}
261
+ </p>
262
+ )}
263
+ <div className={zuiOtpCellsBase} data-slot="otp-input-cells">
264
+ {cells.map((char, index) => (
265
+ <span
266
+ key={`${rootId}-${index}`}
267
+ className="contents"
268
+ data-slot="otp-input-cell-wrapper"
269
+ >
270
+ <input
271
+ ref={(node) => {
272
+ inputRefs.current[index] = node;
273
+ }}
274
+ aria-label={`Digit ${index + 1} of ${resolvedLength}`}
275
+ autoComplete={index === 0 ? "one-time-code" : "off"}
276
+ autoFocus={autoFocus && index === 0}
277
+ className={cn(
278
+ otpInputCellVariants({ appearance, size }),
279
+ cellClassName,
280
+ )}
281
+ data-slot="otp-input-cell"
282
+ disabled={disabled}
283
+ inputMode={allowedCharacters === "numeric" ? "numeric" : "text"}
284
+ maxLength={resolvedLength}
285
+ onChange={(event) =>
286
+ updateAtIndex(index, event.currentTarget.value, false)
287
+ }
288
+ onFocus={(event) => event.currentTarget.select()}
289
+ onKeyDown={(event) => handleKeyDown(event, index)}
290
+ onPaste={(event) => handlePaste(event, index)}
291
+ pattern={
292
+ allowedCharacters === "numeric" ? "[0-9]*" : "[A-Za-z0-9]*"
293
+ }
294
+ type={mask ? "password" : "text"}
295
+ value={char}
296
+ />
297
+ {separatorEvery &&
298
+ separatorEvery > 0 &&
299
+ index < resolvedLength - 1 &&
300
+ (index + 1) % separatorEvery === 0 && (
301
+ <span
302
+ aria-hidden="true"
303
+ className={zuiOtpSeparatorBase}
304
+ data-slot="otp-input-separator"
305
+ />
306
+ )}
307
+ </span>
308
+ ))}
309
+ </div>
310
+ {name !== undefined && (
311
+ <input
312
+ type="hidden"
313
+ name={name}
314
+ value={sanitizedValue}
315
+ disabled={disabled}
316
+ />
317
+ )}
318
+ {errorMessage !== undefined && (
319
+ <p id={errorId} className={zuiOtpErrorBase}>
320
+ {errorMessage}
321
+ </p>
322
+ )}
323
+ </div>
324
+ );
325
+ }
326
+
327
+ OTPInput.displayName = "OTPInput";
@@ -0,0 +1,32 @@
1
+ import type { VariantProps } from "class-variance-authority";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+
4
+ import type { otpInputCellVariants } from "./variants";
5
+
6
+ export type OTPInputAllowedCharacters = "numeric" | "alphanumeric";
7
+
8
+ export type OTPInputCellVariantProps = VariantProps<
9
+ typeof otpInputCellVariants
10
+ >;
11
+
12
+ export type OTPInputProps = OTPInputCellVariantProps &
13
+ Omit<
14
+ ComponentPropsWithRef<"div">,
15
+ "defaultValue" | "dir" | "onChange" | "children"
16
+ > & {
17
+ allowedCharacters?: OTPInputAllowedCharacters;
18
+ autoFocus?: boolean;
19
+ cellClassName?: string;
20
+ defaultValue?: string;
21
+ disabled?: boolean;
22
+ errorMessage?: ReactNode;
23
+ hint?: ReactNode;
24
+ label?: ReactNode;
25
+ length?: number;
26
+ mask?: boolean;
27
+ name?: string;
28
+ onComplete?: (value: string) => void;
29
+ onValueChange?: (value: string) => void;
30
+ separatorEvery?: number;
31
+ value?: string;
32
+ };
@@ -0,0 +1,18 @@
1
+ import { cva } from "class-variance-authority";
2
+
3
+ import {
4
+ zuiOtpAppearances,
5
+ zuiOtpCellBase,
6
+ zuiOtpSizes,
7
+ } from "../../design-system/otp-input";
8
+
9
+ export const otpInputCellVariants = cva(zuiOtpCellBase, {
10
+ variants: {
11
+ appearance: zuiOtpAppearances,
12
+ size: zuiOtpSizes,
13
+ },
14
+ defaultVariants: {
15
+ appearance: "default",
16
+ size: "md",
17
+ },
18
+ });