@djangocfg/ui-core 2.1.412 → 2.1.415
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/package.json +4 -4
- package/src/components/data/avatar-group/index.tsx +224 -0
- package/src/components/data/badge-overflow/index.tsx +259 -0
- package/src/components/data/circular-progress/index.tsx +358 -0
- package/src/components/data/relative-time-card/index.tsx +191 -0
- package/src/components/data/stat/index.tsx +140 -0
- package/src/components/data/status/index.tsx +80 -0
- package/src/components/effects/GlowBackground.tsx +9 -1
- package/src/components/effects/swap/index.tsx +289 -0
- package/src/components/feedback/banner/index.tsx +693 -0
- package/src/components/forms/checkbox-group/index.tsx +243 -0
- package/src/components/forms/editable/index.tsx +420 -0
- package/src/components/forms/input-otp/index.tsx +12 -3
- package/src/components/forms/mask-input/index.tsx +466 -0
- package/src/components/forms/otp/index.tsx +12 -8
- package/src/components/forms/segmented-input/index.tsx +319 -0
- package/src/components/forms/tags-input/index.tsx +896 -0
- package/src/components/forms/time-picker/index.tsx +285 -0
- package/src/components/index.ts +51 -0
- package/src/components/layout/key-value/index.tsx +884 -0
- package/src/components/layout/stack/index.tsx +349 -0
- package/src/components/navigation/context-menu/index.tsx +9 -6
- package/src/components/navigation/stepper/index.tsx +1307 -0
- package/src/components/select/multi-select-pro-async.tsx +11 -2
- package/src/components/select/multi-select-pro.tsx +11 -2
- package/src/components/specialized/presence/index.tsx +181 -0
- package/src/components/specialized/primitive/index.tsx +83 -0
- package/src/components/specialized/visually-hidden/index.tsx +19 -0
- package/src/components/specialized/visually-hidden-input/index.tsx +99 -0
- package/src/hooks/dom/index.ts +4 -0
- package/src/hooks/dom/useFormReset.ts +49 -0
- package/src/hooks/dom/useLayoutEffect.ts +16 -0
- package/src/hooks/dom/useSize.ts +57 -0
- package/src/hooks/state/index.ts +4 -0
- package/src/hooks/state/useCallbackRef.ts +25 -0
- package/src/hooks/state/usePrevious.ts +20 -0
- package/src/hooks/state/useStateMachine.ts +29 -0
- package/src/lib/compose-event-handlers.ts +22 -0
- package/src/lib/compose-refs.ts +65 -0
- package/src/lib/create-context.tsx +62 -0
- package/src/lib/get-element-ref.ts +33 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/styles.ts +103 -0
- package/src/styles/README.md +43 -0
- package/src/styles/palette/utils.ts +15 -5
- package/src/styles/utilities/animations.css +135 -0
- package/src/styles/utilities/display.css +62 -0
- package/src/styles/utilities/glass.css +57 -0
- package/src/styles/utilities/marquee.css +69 -0
- package/src/styles/utilities/step.css +25 -0
- package/src/styles/utilities.css +6 -259
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../../lib/utils";
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
export type MaskToken =
|
|
12
|
+
| "0" // digit
|
|
13
|
+
| "a" // letter
|
|
14
|
+
| "*" // alphanumeric
|
|
15
|
+
| string; // literal
|
|
16
|
+
|
|
17
|
+
export interface MaskDefinition {
|
|
18
|
+
/** Character that represents this token in the mask pattern */
|
|
19
|
+
token: MaskToken;
|
|
20
|
+
/** Regex to validate the character at this position */
|
|
21
|
+
pattern: RegExp;
|
|
22
|
+
/** Whether this token is optional */
|
|
23
|
+
optional?: boolean;
|
|
24
|
+
/** Transform function for the character */
|
|
25
|
+
transform?: (char: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MaskInputProps extends Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "onBeforeInput"> {
|
|
29
|
+
onBeforeInput?: (event: React.FormEvent<HTMLInputElement> & { data?: string }) => void;
|
|
30
|
+
/** Mask pattern, e.g. "(000) 000-0000" or "00/00/0000" */
|
|
31
|
+
mask: string;
|
|
32
|
+
/** Custom mask definitions. Defaults to digit-only. */
|
|
33
|
+
definitions?: Record<string, MaskDefinition>;
|
|
34
|
+
/** Controlled value (raw, unmasked) */
|
|
35
|
+
value?: string;
|
|
36
|
+
/** Default uncontrolled value (raw, unmasked) */
|
|
37
|
+
defaultValue?: string;
|
|
38
|
+
/** Callback when raw value changes */
|
|
39
|
+
onChange?: (value: string) => void;
|
|
40
|
+
/** Character used for empty mask positions */
|
|
41
|
+
maskChar?: string;
|
|
42
|
+
/** Whether to always show the mask */
|
|
43
|
+
alwaysShowMask?: boolean;
|
|
44
|
+
/** Whether to clean the value on blur if incomplete */
|
|
45
|
+
cleanOnBlur?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Default Definitions
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
const defaultDefinitions: Record<string, MaskDefinition> = {
|
|
53
|
+
"0": { token: "0", pattern: /\d/ },
|
|
54
|
+
"a": { token: "a", pattern: /[a-zA-Z]/, transform: (c) => c.toLowerCase() },
|
|
55
|
+
"A": { token: "A", pattern: /[a-zA-Z]/, transform: (c) => c.toUpperCase() },
|
|
56
|
+
"*": { token: "*", pattern: /[a-zA-Z0-9]/ },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// Utilities
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
function parseMask(
|
|
64
|
+
mask: string,
|
|
65
|
+
definitions: Record<string, MaskDefinition>
|
|
66
|
+
): Array<{ type: "literal"; char: string } | { type: "token"; def: MaskDefinition }> {
|
|
67
|
+
const result: ReturnType<typeof parseMask> = [];
|
|
68
|
+
for (const char of mask) {
|
|
69
|
+
const def = definitions[char];
|
|
70
|
+
if (def) {
|
|
71
|
+
result.push({ type: "token", def });
|
|
72
|
+
} else {
|
|
73
|
+
result.push({ type: "literal", char });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function applyMask(
|
|
80
|
+
rawValue: string,
|
|
81
|
+
maskParts: ReturnType<typeof parseMask>,
|
|
82
|
+
maskChar: string
|
|
83
|
+
): { masked: string; raw: string; complete: boolean } {
|
|
84
|
+
let rawIndex = 0;
|
|
85
|
+
let masked = "";
|
|
86
|
+
let raw = "";
|
|
87
|
+
let complete = true;
|
|
88
|
+
|
|
89
|
+
for (const part of maskParts) {
|
|
90
|
+
if (part.type === "literal") {
|
|
91
|
+
masked += part.char;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (rawIndex < rawValue.length) {
|
|
96
|
+
const char = rawValue[rawIndex];
|
|
97
|
+
if (part.def.pattern.test(char)) {
|
|
98
|
+
const transformed = part.def.transform ? part.def.transform(char) : char;
|
|
99
|
+
masked += transformed;
|
|
100
|
+
raw += transformed;
|
|
101
|
+
rawIndex++;
|
|
102
|
+
} else {
|
|
103
|
+
// Invalid char for this position — skip it
|
|
104
|
+
rawIndex++;
|
|
105
|
+
// Re-process this mask position with next raw char
|
|
106
|
+
const nextChar = rawValue[rawIndex];
|
|
107
|
+
if (nextChar && part.def.pattern.test(nextChar)) {
|
|
108
|
+
const transformed = part.def.transform ? part.def.transform(nextChar) : nextChar;
|
|
109
|
+
masked += transformed;
|
|
110
|
+
raw += transformed;
|
|
111
|
+
rawIndex++;
|
|
112
|
+
} else {
|
|
113
|
+
masked += maskChar;
|
|
114
|
+
complete = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
masked += maskChar;
|
|
119
|
+
complete = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { masked, raw, complete };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractRaw(value: string, maskParts: ReturnType<typeof parseMask>): string {
|
|
127
|
+
let raw = "";
|
|
128
|
+
let valueIndex = 0;
|
|
129
|
+
for (const part of maskParts) {
|
|
130
|
+
if (valueIndex >= value.length) break;
|
|
131
|
+
if (part.type === "literal") {
|
|
132
|
+
if (value[valueIndex] === part.char) {
|
|
133
|
+
valueIndex++;
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const char = value[valueIndex];
|
|
138
|
+
if (part.def.pattern.test(char)) {
|
|
139
|
+
raw += part.def.transform ? part.def.transform(char) : char;
|
|
140
|
+
}
|
|
141
|
+
valueIndex++;
|
|
142
|
+
}
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getNextTokenIndex(
|
|
147
|
+
maskParts: ReturnType<typeof parseMask>,
|
|
148
|
+
currentIndex: number,
|
|
149
|
+
direction: 1 | -1
|
|
150
|
+
): number {
|
|
151
|
+
let index = currentIndex + direction;
|
|
152
|
+
while (index >= 0 && index < maskParts.length) {
|
|
153
|
+
if (maskParts[index].type === "token") return index;
|
|
154
|
+
index += direction;
|
|
155
|
+
}
|
|
156
|
+
return direction === 1 ? maskParts.length : -1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// =============================================================================
|
|
160
|
+
// Component
|
|
161
|
+
// =============================================================================
|
|
162
|
+
|
|
163
|
+
const MaskInput = React.forwardRef<HTMLInputElement, MaskInputProps>(
|
|
164
|
+
(
|
|
165
|
+
{
|
|
166
|
+
mask,
|
|
167
|
+
definitions = defaultDefinitions,
|
|
168
|
+
value: controlledValue,
|
|
169
|
+
defaultValue = "",
|
|
170
|
+
onChange,
|
|
171
|
+
maskChar = "_",
|
|
172
|
+
alwaysShowMask = false,
|
|
173
|
+
cleanOnBlur = false,
|
|
174
|
+
className,
|
|
175
|
+
onFocus,
|
|
176
|
+
onBlur,
|
|
177
|
+
onKeyDown,
|
|
178
|
+
onBeforeInput,
|
|
179
|
+
...props
|
|
180
|
+
},
|
|
181
|
+
ref
|
|
182
|
+
) => {
|
|
183
|
+
const maskParts = React.useMemo(() => parseMask(mask, definitions), [mask, definitions]);
|
|
184
|
+
const isControlled = controlledValue !== undefined;
|
|
185
|
+
|
|
186
|
+
const [internalRaw, setInternalRaw] = React.useState(defaultValue);
|
|
187
|
+
const rawValue = isControlled ? controlledValue : internalRaw;
|
|
188
|
+
|
|
189
|
+
const { masked: maskedValue } = React.useMemo(
|
|
190
|
+
() => applyMask(rawValue, maskParts, maskChar),
|
|
191
|
+
[rawValue, maskParts, maskChar]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
195
|
+
const composedRef = React.useMemo(() => {
|
|
196
|
+
return (node: HTMLInputElement | null) => {
|
|
197
|
+
inputRef.current = node;
|
|
198
|
+
if (typeof ref === "function") {
|
|
199
|
+
ref(node);
|
|
200
|
+
} else if (ref) {
|
|
201
|
+
(ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}, [ref]);
|
|
205
|
+
|
|
206
|
+
const [isFocused, setIsFocused] = React.useState(false);
|
|
207
|
+
const displayValue = alwaysShowMask || isFocused ? maskedValue : rawValue || "";
|
|
208
|
+
|
|
209
|
+
// Controlled inputs reset caret to end on every value commit, so all
|
|
210
|
+
// caret moves (focus + every keystroke that rewrites the value) defer
|
|
211
|
+
// through this ref and run from useLayoutEffect after the render.
|
|
212
|
+
const pendingCaretRef = React.useRef<number | null>(null);
|
|
213
|
+
|
|
214
|
+
const updateValue = React.useCallback(
|
|
215
|
+
(newRaw: string) => {
|
|
216
|
+
if (!isControlled) {
|
|
217
|
+
setInternalRaw(newRaw);
|
|
218
|
+
}
|
|
219
|
+
onChange?.(newRaw);
|
|
220
|
+
},
|
|
221
|
+
[isControlled, onChange]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const handleBeforeInput = React.useCallback(
|
|
225
|
+
(event: React.FormEvent<HTMLInputElement> & { data?: string }) => {
|
|
226
|
+
onBeforeInput?.(event);
|
|
227
|
+
if (event.defaultPrevented) return;
|
|
228
|
+
|
|
229
|
+
const input = event.currentTarget;
|
|
230
|
+
const data = event.data;
|
|
231
|
+
if (!data) return;
|
|
232
|
+
|
|
233
|
+
const start = input.selectionStart ?? 0;
|
|
234
|
+
const end = input.selectionEnd ?? 0;
|
|
235
|
+
|
|
236
|
+
// Determine which mask position we're at
|
|
237
|
+
let maskPos = 0;
|
|
238
|
+
let charCount = 0;
|
|
239
|
+
for (let i = 0; i < maskParts.length && charCount < start; i++) {
|
|
240
|
+
if (maskParts[i].type === "token") {
|
|
241
|
+
charCount++;
|
|
242
|
+
}
|
|
243
|
+
maskPos = i + 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Find next token position
|
|
247
|
+
const nextToken = getNextTokenIndex(maskParts, maskPos - 1, 1);
|
|
248
|
+
if (nextToken === -1 || nextToken >= maskParts.length) {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const part = maskParts[nextToken];
|
|
254
|
+
if (part.type !== "token") {
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!part.def.pattern.test(data)) {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
[maskParts, onBeforeInput]
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const handleKeyDown = React.useCallback(
|
|
268
|
+
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
269
|
+
onKeyDown?.(event);
|
|
270
|
+
if (event.defaultPrevented) return;
|
|
271
|
+
|
|
272
|
+
const input = event.currentTarget;
|
|
273
|
+
const start = input.selectionStart ?? 0;
|
|
274
|
+
const end = input.selectionEnd ?? 0;
|
|
275
|
+
|
|
276
|
+
if (event.key === "Backspace") {
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
if (start !== end) {
|
|
279
|
+
const before = input.value.slice(0, start);
|
|
280
|
+
const after = input.value.slice(end);
|
|
281
|
+
const newRaw = extractRaw(before + after, maskParts);
|
|
282
|
+
let pos = start;
|
|
283
|
+
while (pos > 0 && maskParts[pos - 1]?.type === "literal") pos--;
|
|
284
|
+
pendingCaretRef.current = pos;
|
|
285
|
+
updateValue(newRaw);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (start === 0) return;
|
|
290
|
+
|
|
291
|
+
let pos = start - 1;
|
|
292
|
+
while (pos >= 0 && maskParts[pos]?.type === "literal") pos--;
|
|
293
|
+
if (pos < 0) return;
|
|
294
|
+
|
|
295
|
+
const currentRaw = extractRaw(input.value, maskParts);
|
|
296
|
+
let rawIndex = 0;
|
|
297
|
+
for (let i = 0; i < pos; i++) {
|
|
298
|
+
if (maskParts[i].type === "token") rawIndex++;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const newRaw = currentRaw.slice(0, rawIndex) + currentRaw.slice(rawIndex + 1);
|
|
302
|
+
pendingCaretRef.current = pos;
|
|
303
|
+
updateValue(newRaw);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (event.key === "Delete") {
|
|
308
|
+
event.preventDefault();
|
|
309
|
+
if (start !== end) {
|
|
310
|
+
const before = input.value.slice(0, start);
|
|
311
|
+
const after = input.value.slice(end);
|
|
312
|
+
const newRaw = extractRaw(before + after, maskParts);
|
|
313
|
+
pendingCaretRef.current = start;
|
|
314
|
+
updateValue(newRaw);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const currentRaw = extractRaw(input.value, maskParts);
|
|
319
|
+
let rawIndex = 0;
|
|
320
|
+
for (let i = 0; i < start; i++) {
|
|
321
|
+
if (maskParts[i].type === "token") rawIndex++;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (rawIndex >= currentRaw.length) return;
|
|
325
|
+
|
|
326
|
+
const newRaw = currentRaw.slice(0, rawIndex) + currentRaw.slice(rawIndex + 1);
|
|
327
|
+
pendingCaretRef.current = start;
|
|
328
|
+
updateValue(newRaw);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
333
|
+
// Let default happen but skip over literals
|
|
334
|
+
requestAnimationFrame(() => {
|
|
335
|
+
const newStart = input.selectionStart ?? 0;
|
|
336
|
+
const direction = event.key === "ArrowLeft" ? -1 : 1;
|
|
337
|
+
let pos = newStart;
|
|
338
|
+
if (direction === -1) {
|
|
339
|
+
pos--;
|
|
340
|
+
while (pos >= 0 && maskParts[pos]?.type === "literal") pos--;
|
|
341
|
+
if (pos >= 0) {
|
|
342
|
+
input.setSelectionRange(pos + 1, pos + 1);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
while (pos < maskParts.length && maskParts[pos]?.type === "literal") pos++;
|
|
346
|
+
input.setSelectionRange(pos, pos);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
const char = event.key;
|
|
355
|
+
|
|
356
|
+
const nextToken = getNextTokenIndex(maskParts, start - 1, 1);
|
|
357
|
+
if (nextToken === -1 || nextToken >= maskParts.length) return;
|
|
358
|
+
|
|
359
|
+
const part = maskParts[nextToken];
|
|
360
|
+
if (part.type !== "token") return;
|
|
361
|
+
if (!part.def.pattern.test(char)) return;
|
|
362
|
+
|
|
363
|
+
const currentRaw = extractRaw(input.value, maskParts);
|
|
364
|
+
let rawIndex = 0;
|
|
365
|
+
for (let i = 0; i < nextToken; i++) {
|
|
366
|
+
if (maskParts[i].type === "token") rawIndex++;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const transformed = part.def.transform ? part.def.transform(char) : char;
|
|
370
|
+
const newRaw = currentRaw.slice(0, rawIndex) + transformed + currentRaw.slice(rawIndex + 1);
|
|
371
|
+
|
|
372
|
+
let nextPos = nextToken + 1;
|
|
373
|
+
while (nextPos < maskParts.length && maskParts[nextPos]?.type === "literal") nextPos++;
|
|
374
|
+
pendingCaretRef.current = nextPos;
|
|
375
|
+
|
|
376
|
+
updateValue(newRaw);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
[maskParts, maskChar, onKeyDown, updateValue]
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const computeNextEmptyCaret = React.useCallback(() => {
|
|
383
|
+
const filledCount = rawValue.length;
|
|
384
|
+
let tokenSeen = 0;
|
|
385
|
+
for (let i = 0; i < maskParts.length; i++) {
|
|
386
|
+
if (maskParts[i].type === "token") {
|
|
387
|
+
if (tokenSeen === filledCount) return i;
|
|
388
|
+
tokenSeen++;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return maskParts.length;
|
|
392
|
+
}, [maskParts, rawValue]);
|
|
393
|
+
|
|
394
|
+
const handleFocus = React.useCallback(
|
|
395
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
396
|
+
setIsFocused(true);
|
|
397
|
+
onFocus?.(event);
|
|
398
|
+
// setIsFocused triggers re-render to masked display. The caret
|
|
399
|
+
// must be placed AFTER that render committed, otherwise the
|
|
400
|
+
// browser defaults to end-of-string. useLayoutEffect below does
|
|
401
|
+
// the placement; we just record where to go.
|
|
402
|
+
pendingCaretRef.current = computeNextEmptyCaret();
|
|
403
|
+
},
|
|
404
|
+
[onFocus, computeNextEmptyCaret]
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
React.useLayoutEffect(() => {
|
|
408
|
+
if (pendingCaretRef.current == null) return;
|
|
409
|
+
const input = inputRef.current;
|
|
410
|
+
if (!input) return;
|
|
411
|
+
const pos = pendingCaretRef.current;
|
|
412
|
+
pendingCaretRef.current = null;
|
|
413
|
+
input.setSelectionRange(pos, pos);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const handleBlur = React.useCallback(
|
|
417
|
+
(event: React.FocusEvent<HTMLInputElement>) => {
|
|
418
|
+
setIsFocused(false);
|
|
419
|
+
onBlur?.(event);
|
|
420
|
+
if (cleanOnBlur) {
|
|
421
|
+
const { complete, raw } = applyMask(rawValue, maskParts, maskChar);
|
|
422
|
+
if (!complete) {
|
|
423
|
+
updateValue("");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
[cleanOnBlur, rawValue, maskParts, maskChar, onBlur, updateValue]
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const handleChange = React.useCallback(
|
|
431
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
432
|
+
// Extract raw from whatever the user managed to input
|
|
433
|
+
const newRaw = extractRaw(event.target.value, maskParts);
|
|
434
|
+
updateValue(newRaw);
|
|
435
|
+
},
|
|
436
|
+
[maskParts, updateValue]
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<input
|
|
441
|
+
type="text"
|
|
442
|
+
inputMode="numeric"
|
|
443
|
+
autoComplete="off"
|
|
444
|
+
autoCorrect="off"
|
|
445
|
+
autoCapitalize="off"
|
|
446
|
+
spellCheck="false"
|
|
447
|
+
{...props}
|
|
448
|
+
ref={composedRef}
|
|
449
|
+
value={displayValue}
|
|
450
|
+
className={cn(
|
|
451
|
+
"flex h-10 w-full rounded-[var(--radius)] border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
452
|
+
className
|
|
453
|
+
)}
|
|
454
|
+
onChange={handleChange}
|
|
455
|
+
onFocus={handleFocus}
|
|
456
|
+
onBlur={handleBlur}
|
|
457
|
+
onKeyDown={handleKeyDown}
|
|
458
|
+
onBeforeInput={handleBeforeInput}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
MaskInput.displayName = "MaskInput";
|
|
465
|
+
|
|
466
|
+
export { MaskInput };
|
|
@@ -17,15 +17,17 @@ import type { SmartOTPProps } from './types'
|
|
|
17
17
|
* In fixed mode they set explicit w/h dimensions.
|
|
18
18
|
*/
|
|
19
19
|
const sizeVariants = {
|
|
20
|
-
sm: 'h-
|
|
21
|
-
default: 'h-
|
|
22
|
-
lg: 'h-14 w-14 text-
|
|
20
|
+
sm: 'h-10 w-10 text-base',
|
|
21
|
+
default: 'h-12 w-12 text-lg',
|
|
22
|
+
lg: 'h-14 w-14 text-xl',
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Fluid mode: width is fed by flex-1, height by aspect-square. We only
|
|
26
|
+
// need to size the text inside the box.
|
|
25
27
|
const sizeTextVariants = {
|
|
26
|
-
sm: 'text-
|
|
27
|
-
default: 'text-
|
|
28
|
-
lg: 'text-
|
|
28
|
+
sm: 'text-base',
|
|
29
|
+
default: 'text-lg',
|
|
30
|
+
lg: 'text-xl',
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -103,7 +105,9 @@ export const OTPInput = React.forwardRef<
|
|
|
103
105
|
ref
|
|
104
106
|
) => {
|
|
105
107
|
const isMobile = useIsMobile()
|
|
106
|
-
|
|
108
|
+
// Vercel-tuned default — 48×48 even on desktop. Caller can still
|
|
109
|
+
// pass size="lg" for marketing surfaces.
|
|
110
|
+
const resolvedSize = size ?? (isMobile ? 'default' : 'default')
|
|
107
111
|
|
|
108
112
|
const {
|
|
109
113
|
value: otpValue,
|
|
@@ -183,7 +187,7 @@ export const OTPInput = React.forwardRef<
|
|
|
183
187
|
onPaste={pasteHandler}
|
|
184
188
|
{...props}
|
|
185
189
|
>
|
|
186
|
-
<InputOTPGroup className={cn(fluid && 'w-full')}>{slots}</InputOTPGroup>
|
|
190
|
+
<InputOTPGroup className={cn('gap-2', fluid && 'w-full')}>{slots}</InputOTPGroup>
|
|
187
191
|
</InputOTP>
|
|
188
192
|
)
|
|
189
193
|
}
|