@djangocfg/ui-core 2.1.412 → 2.1.413

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/components/data/avatar-group/index.tsx +224 -0
  3. package/src/components/data/badge-overflow/index.tsx +259 -0
  4. package/src/components/data/circular-progress/index.tsx +358 -0
  5. package/src/components/data/relative-time-card/index.tsx +191 -0
  6. package/src/components/data/stat/index.tsx +140 -0
  7. package/src/components/data/status/index.tsx +80 -0
  8. package/src/components/effects/GlowBackground.tsx +9 -1
  9. package/src/components/effects/swap/index.tsx +289 -0
  10. package/src/components/feedback/banner/index.tsx +693 -0
  11. package/src/components/forms/checkbox-group/index.tsx +243 -0
  12. package/src/components/forms/editable/index.tsx +420 -0
  13. package/src/components/forms/input-otp/index.tsx +12 -3
  14. package/src/components/forms/mask-input/index.tsx +466 -0
  15. package/src/components/forms/otp/index.tsx +12 -8
  16. package/src/components/forms/segmented-input/index.tsx +319 -0
  17. package/src/components/forms/tags-input/index.tsx +896 -0
  18. package/src/components/forms/time-picker/index.tsx +285 -0
  19. package/src/components/index.ts +51 -0
  20. package/src/components/layout/key-value/index.tsx +884 -0
  21. package/src/components/layout/stack/index.tsx +349 -0
  22. package/src/components/navigation/context-menu/index.tsx +9 -6
  23. package/src/components/navigation/stepper/index.tsx +1307 -0
  24. package/src/components/select/multi-select-pro-async.tsx +11 -2
  25. package/src/components/select/multi-select-pro.tsx +11 -2
  26. package/src/components/specialized/presence/index.tsx +181 -0
  27. package/src/components/specialized/primitive/index.tsx +83 -0
  28. package/src/components/specialized/visually-hidden/index.tsx +19 -0
  29. package/src/components/specialized/visually-hidden-input/index.tsx +99 -0
  30. package/src/hooks/dom/index.ts +4 -0
  31. package/src/hooks/dom/useFormReset.ts +49 -0
  32. package/src/hooks/dom/useLayoutEffect.ts +16 -0
  33. package/src/hooks/dom/useSize.ts +57 -0
  34. package/src/hooks/state/index.ts +4 -0
  35. package/src/hooks/state/useCallbackRef.ts +25 -0
  36. package/src/hooks/state/usePrevious.ts +20 -0
  37. package/src/hooks/state/useStateMachine.ts +29 -0
  38. package/src/lib/compose-event-handlers.ts +22 -0
  39. package/src/lib/compose-refs.ts +65 -0
  40. package/src/lib/create-context.tsx +62 -0
  41. package/src/lib/get-element-ref.ts +33 -0
  42. package/src/lib/index.ts +5 -0
  43. package/src/lib/styles.ts +103 -0
  44. package/src/styles/README.md +43 -0
  45. package/src/styles/palette/utils.ts +15 -5
  46. package/src/styles/utilities/animations.css +135 -0
  47. package/src/styles/utilities/display.css +62 -0
  48. package/src/styles/utilities/glass.css +57 -0
  49. package/src/styles/utilities/marquee.css +69 -0
  50. package/src/styles/utilities/step.css +25 -0
  51. 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-8 w-8 text-sm',
21
- default: 'h-10 w-10 text-base',
22
- lg: 'h-14 w-14 text-2xl',
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-sm',
27
- default: 'text-base',
28
- lg: 'text-2xl',
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
- const resolvedSize = size ?? (isMobile ? 'default' : 'lg')
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
  }