@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,319 @@
|
|
|
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 interface SegmentedInputProps extends Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "size" | "pattern"> {
|
|
12
|
+
/** Number of segments */
|
|
13
|
+
length?: number;
|
|
14
|
+
/** Controlled value */
|
|
15
|
+
value?: string;
|
|
16
|
+
/** Default uncontrolled value */
|
|
17
|
+
defaultValue?: string;
|
|
18
|
+
/** Callback when value changes */
|
|
19
|
+
onChange?: (value: string) => void;
|
|
20
|
+
/** Callback when all segments are filled */
|
|
21
|
+
onComplete?: (value: string) => void;
|
|
22
|
+
/** Visual separator between segments */
|
|
23
|
+
separator?: React.ReactNode;
|
|
24
|
+
/** Position(s) where separator appears (after these 0-based indices) */
|
|
25
|
+
separatorIndices?: number[];
|
|
26
|
+
/** Whether to auto-focus first segment on mount */
|
|
27
|
+
autoFocus?: boolean;
|
|
28
|
+
/** Disabled state */
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Custom class for each segment */
|
|
31
|
+
segmentClassName?: string;
|
|
32
|
+
/** Custom class for the container */
|
|
33
|
+
containerClassName?: string;
|
|
34
|
+
/** Custom class for separator */
|
|
35
|
+
separatorClassName?: string;
|
|
36
|
+
/** Pattern regex for each character */
|
|
37
|
+
pattern?: RegExp;
|
|
38
|
+
/** Transform input character */
|
|
39
|
+
transform?: (char: string) => string;
|
|
40
|
+
/** Fluid mode — segments stretch to fill container */
|
|
41
|
+
fluid?: boolean;
|
|
42
|
+
/** Size variant */
|
|
43
|
+
size?: "sm" | "default" | "lg";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Utilities
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
function cleanValue(value: string, pattern: RegExp, transform?: (char: string) => string): string {
|
|
51
|
+
let cleaned = value.split("").filter((c) => pattern.test(c)).join("");
|
|
52
|
+
if (transform) {
|
|
53
|
+
cleaned = cleaned.split("").map(transform).join("");
|
|
54
|
+
}
|
|
55
|
+
return cleaned;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Component
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
const sizeVariants = {
|
|
63
|
+
sm: "h-8 w-8 text-sm",
|
|
64
|
+
default: "h-10 w-10 text-base",
|
|
65
|
+
lg: "h-14 w-14 text-2xl",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const SegmentedInput = React.forwardRef<HTMLDivElement, SegmentedInputProps>(
|
|
69
|
+
(
|
|
70
|
+
{
|
|
71
|
+
length = 6,
|
|
72
|
+
value: controlledValue,
|
|
73
|
+
defaultValue = "",
|
|
74
|
+
onChange,
|
|
75
|
+
onComplete,
|
|
76
|
+
separator,
|
|
77
|
+
separatorIndices,
|
|
78
|
+
autoFocus = true,
|
|
79
|
+
disabled = false,
|
|
80
|
+
segmentClassName,
|
|
81
|
+
containerClassName,
|
|
82
|
+
separatorClassName,
|
|
83
|
+
pattern = /[a-zA-Z0-9]/,
|
|
84
|
+
transform,
|
|
85
|
+
fluid = false,
|
|
86
|
+
size = "default",
|
|
87
|
+
name,
|
|
88
|
+
className,
|
|
89
|
+
...props
|
|
90
|
+
},
|
|
91
|
+
ref
|
|
92
|
+
) => {
|
|
93
|
+
const isControlled = controlledValue !== undefined;
|
|
94
|
+
const [internalValue, setInternalValue] = React.useState(defaultValue);
|
|
95
|
+
const value = isControlled ? controlledValue : internalValue;
|
|
96
|
+
|
|
97
|
+
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
|
|
98
|
+
const [activeIndex, setActiveIndex] = React.useState(-1);
|
|
99
|
+
|
|
100
|
+
const cleanedValue = React.useMemo(
|
|
101
|
+
() => cleanValue(value, pattern, transform),
|
|
102
|
+
[value, pattern, transform]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const updateValue = React.useCallback(
|
|
106
|
+
(newValue: string) => {
|
|
107
|
+
const cleaned = cleanValue(newValue, pattern, transform).slice(0, length);
|
|
108
|
+
if (!isControlled) {
|
|
109
|
+
setInternalValue(cleaned);
|
|
110
|
+
}
|
|
111
|
+
onChange?.(cleaned);
|
|
112
|
+
if (cleaned.length === length) {
|
|
113
|
+
onComplete?.(cleaned);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[isControlled, onChange, onComplete, length, pattern, transform]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const focusSegment = React.useCallback((index: number) => {
|
|
120
|
+
if (index >= 0 && index < length) {
|
|
121
|
+
inputRefs.current[index]?.focus();
|
|
122
|
+
inputRefs.current[index]?.select();
|
|
123
|
+
}
|
|
124
|
+
}, [length]);
|
|
125
|
+
|
|
126
|
+
const handleSegmentChange = React.useCallback(
|
|
127
|
+
(index: number, inputValue: string) => {
|
|
128
|
+
const char = inputValue.slice(-1);
|
|
129
|
+
if (!pattern.test(char)) return;
|
|
130
|
+
|
|
131
|
+
const transformed = transform ? transform(char) : char;
|
|
132
|
+
const newValue = cleanedValue.slice(0, index) + transformed + cleanedValue.slice(index + 1);
|
|
133
|
+
updateValue(newValue);
|
|
134
|
+
|
|
135
|
+
if (index < length - 1) {
|
|
136
|
+
focusSegment(index + 1);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
[cleanedValue, updateValue, focusSegment, length, pattern, transform]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const handleKeyDown = React.useCallback(
|
|
143
|
+
(index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
144
|
+
switch (event.key) {
|
|
145
|
+
case "ArrowLeft": {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
if (index > 0) focusSegment(index - 1);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "ArrowRight": {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
if (index < length - 1) focusSegment(index + 1);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "Backspace": {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
if (cleanedValue[index]) {
|
|
158
|
+
const newValue = cleanedValue.slice(0, index) + cleanedValue.slice(index + 1);
|
|
159
|
+
updateValue(newValue);
|
|
160
|
+
} else if (index > 0) {
|
|
161
|
+
focusSegment(index - 1);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "Delete": {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
const newValue = cleanedValue.slice(0, index) + cleanedValue.slice(index + 1);
|
|
168
|
+
updateValue(newValue);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "Home": {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
focusSegment(0);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "End": {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
focusSegment(length - 1);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
default: {
|
|
182
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
183
|
+
if (!pattern.test(event.key)) {
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Let onChange handle the input
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
[cleanedValue, focusSegment, length, pattern, updateValue]
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const handlePaste = React.useCallback(
|
|
196
|
+
(event: React.ClipboardEvent<HTMLInputElement>) => {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
const pasted = event.clipboardData.getData("text");
|
|
199
|
+
const cleaned = cleanValue(pasted, pattern, transform).slice(0, length);
|
|
200
|
+
updateValue(cleaned);
|
|
201
|
+
|
|
202
|
+
// Focus the next empty segment or the last one
|
|
203
|
+
const nextEmpty = cleaned.length < length ? cleaned.length : length - 1;
|
|
204
|
+
focusSegment(nextEmpty);
|
|
205
|
+
},
|
|
206
|
+
[length, pattern, transform, updateValue, focusSegment]
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const handleFocus = React.useCallback((index: number) => {
|
|
210
|
+
setActiveIndex(index);
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
const handleBlur = React.useCallback(() => {
|
|
214
|
+
setActiveIndex(-1);
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
// Auto-focus first segment on mount
|
|
218
|
+
React.useEffect(() => {
|
|
219
|
+
if (autoFocus && !disabled) {
|
|
220
|
+
const timer = setTimeout(() => focusSegment(0), 0);
|
|
221
|
+
return () => clearTimeout(timer);
|
|
222
|
+
}
|
|
223
|
+
}, [autoFocus, disabled, focusSegment]);
|
|
224
|
+
|
|
225
|
+
// Determine default separator positions (middle)
|
|
226
|
+
const defaultSeparatorIndices = React.useMemo(() => {
|
|
227
|
+
if (separatorIndices !== undefined) return separatorIndices;
|
|
228
|
+
if (!separator) return [];
|
|
229
|
+
const mid = Math.floor(length / 2);
|
|
230
|
+
return mid > 0 && mid < length ? [mid - 1] : [];
|
|
231
|
+
}, [separatorIndices, separator, length]);
|
|
232
|
+
|
|
233
|
+
// Hidden input for form integration
|
|
234
|
+
const isFormControl = React.useRef(false);
|
|
235
|
+
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
236
|
+
React.useImperativeHandle(ref, () => rootRef.current!);
|
|
237
|
+
|
|
238
|
+
React.useEffect(() => {
|
|
239
|
+
if (rootRef.current) {
|
|
240
|
+
isFormControl.current = !!rootRef.current.closest("form");
|
|
241
|
+
}
|
|
242
|
+
}, []);
|
|
243
|
+
|
|
244
|
+
const slots: React.ReactNode[] = [];
|
|
245
|
+
for (let i = 0; i < length; i++) {
|
|
246
|
+
const char = cleanedValue[i] || "";
|
|
247
|
+
const isActive = activeIndex === i;
|
|
248
|
+
|
|
249
|
+
slots.push(
|
|
250
|
+
<input
|
|
251
|
+
key={`segment-${i}`}
|
|
252
|
+
ref={(el) => { inputRefs.current[i] = el; }}
|
|
253
|
+
type="text"
|
|
254
|
+
inputMode="text"
|
|
255
|
+
maxLength={1}
|
|
256
|
+
disabled={disabled}
|
|
257
|
+
value={char}
|
|
258
|
+
data-active={isActive ? "" : undefined}
|
|
259
|
+
data-filled={char ? "" : undefined}
|
|
260
|
+
className={cn(
|
|
261
|
+
"relative flex items-center justify-center border-y border-r border-input text-center text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
|
262
|
+
"focus-visible:outline-none focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
|
263
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
264
|
+
fluid ? `flex-1 min-w-0 aspect-square ${sizeVariants[size]}` : sizeVariants[size],
|
|
265
|
+
segmentClassName
|
|
266
|
+
)}
|
|
267
|
+
onChange={(e) => handleSegmentChange(i, e.target.value)}
|
|
268
|
+
onKeyDown={(e) => handleKeyDown(i, e)}
|
|
269
|
+
onPaste={handlePaste}
|
|
270
|
+
onFocus={() => handleFocus(i)}
|
|
271
|
+
onBlur={handleBlur}
|
|
272
|
+
{...props}
|
|
273
|
+
/>
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Add separator after this index if specified
|
|
277
|
+
if (defaultSeparatorIndices.includes(i) && separator) {
|
|
278
|
+
slots.push(
|
|
279
|
+
<div
|
|
280
|
+
key={`separator-${i}`}
|
|
281
|
+
role="separator"
|
|
282
|
+
className={cn("flex items-center justify-center", separatorClassName)}
|
|
283
|
+
>
|
|
284
|
+
{separator}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<>
|
|
292
|
+
<div
|
|
293
|
+
ref={rootRef}
|
|
294
|
+
className={cn(
|
|
295
|
+
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
|
296
|
+
fluid && "w-full",
|
|
297
|
+
containerClassName
|
|
298
|
+
)}
|
|
299
|
+
>
|
|
300
|
+
<div className={cn("flex items-center", fluid && "w-full", className)}>
|
|
301
|
+
{slots}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
{isFormControl.current && name && (
|
|
305
|
+
<input
|
|
306
|
+
type="hidden"
|
|
307
|
+
name={name}
|
|
308
|
+
value={cleanedValue}
|
|
309
|
+
disabled={disabled}
|
|
310
|
+
/>
|
|
311
|
+
)}
|
|
312
|
+
</>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
SegmentedInput.displayName = "SegmentedInput";
|
|
318
|
+
|
|
319
|
+
export { SegmentedInput };
|