@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.
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,896 @@
1
+ "use client"
2
+
3
+ import { X } from "lucide-react";
4
+ import * as React from "react";
5
+
6
+ import { cn } from "../../../lib/utils";
7
+
8
+ // =============================================================================
9
+ // Types
10
+ // =============================================================================
11
+
12
+ export type TagValue = string;
13
+
14
+ export interface TagsInputRootProps
15
+ extends Omit<
16
+ React.ComponentPropsWithoutRef<"div">,
17
+ "value" | "defaultValue" | "onChange" | "children"
18
+ > {
19
+ /** Controlled array of tag values. */
20
+ value?: TagValue[];
21
+ /** Initial array of tag values when uncontrolled. */
22
+ defaultValue?: TagValue[];
23
+ /** Callback when tag values change. */
24
+ onChange?: (value: TagValue[]) => void;
25
+ /** Validate a tag before adding. Return false to reject. */
26
+ onValidate?: (value: TagValue) => boolean;
27
+ /** Callback when a tag is rejected (duplicate or invalid). */
28
+ onTagInvalid?: (value: TagValue) => void;
29
+ /** Function to convert a tag value to its display string. */
30
+ displayValue?: (value: TagValue) => string;
31
+ /** Add tags on paste (split by delimiter). @default false */
32
+ addOnPaste?: boolean;
33
+ /** Add tags on Tab key. @default false */
34
+ addOnTab?: boolean;
35
+ /** Disable the entire input. @default false */
36
+ disabled?: boolean;
37
+ /** Allow editing existing tags. @default false */
38
+ editable?: boolean;
39
+ /** Wrap focus from last to first. @default false */
40
+ loop?: boolean;
41
+ /** Behavior on blur: "add" | "clear" | undefined. @default undefined */
42
+ blurBehavior?: "add" | "clear";
43
+ /** Delimiter for paste splitting. @default "," */
44
+ delimiter?: string;
45
+ /** Max number of tags. @default Infinity */
46
+ max?: number;
47
+ /** Read-only. @default false */
48
+ readOnly?: boolean;
49
+ /** Required in form. @default false */
50
+ required?: boolean;
51
+ /** Form field name. */
52
+ name?: string;
53
+ /** Unique id. */
54
+ id?: string;
55
+ /** Children or render prop. */
56
+ children?: React.ReactNode | ((context: { value: TagValue[] }) => React.ReactNode);
57
+ }
58
+
59
+ export interface TagsInputInputProps
60
+ extends Omit<React.ComponentPropsWithoutRef<"input">, "value" | "defaultValue"> {}
61
+
62
+ export interface TagsInputItemProps extends React.ComponentPropsWithoutRef<"div"> {
63
+ /** The value of the item. */
64
+ value: TagValue;
65
+ /** Whether the item is disabled. */
66
+ disabled?: boolean;
67
+ }
68
+
69
+ export interface TagsInputItemTextProps
70
+ extends React.ComponentPropsWithoutRef<"span"> {}
71
+
72
+ export interface TagsInputItemDeleteProps
73
+ extends React.ComponentPropsWithoutRef<"button"> {}
74
+
75
+ // =============================================================================
76
+ // Context
77
+ // =============================================================================
78
+
79
+ interface TagsInputContextValue {
80
+ value: TagValue[];
81
+ onValueChange: (value: TagValue[]) => void;
82
+ onItemAdd: (textValue: string, options?: { viaPaste?: boolean }) => boolean;
83
+ onItemRemove: (index: number) => void;
84
+ onItemUpdate: (index: number, newTextValue: string) => void;
85
+ onInputKeydown: (event: React.KeyboardEvent) => void;
86
+ highlightedIndex: number | null;
87
+ setHighlightedIndex: (index: number | null) => void;
88
+ editingIndex: number | null;
89
+ setEditingIndex: (index: number | null) => void;
90
+ displayValue: (value: TagValue) => string;
91
+ onItemLeave: () => void;
92
+ inputRef: React.RefObject<HTMLInputElement | null>;
93
+ addOnPaste: boolean;
94
+ addOnTab: boolean;
95
+ delimiter: string;
96
+ disabled: boolean;
97
+ editable: boolean;
98
+ isInvalidInput: boolean;
99
+ loop: boolean;
100
+ readOnly: boolean;
101
+ blurBehavior: "add" | "clear" | undefined;
102
+ max: number;
103
+ id: string;
104
+ inputId: string;
105
+ labelId: string;
106
+ }
107
+
108
+ const TagsInputContext = React.createContext<TagsInputContextValue | null>(null);
109
+
110
+ function useTagsInput(componentName: string) {
111
+ const context = React.useContext(TagsInputContext);
112
+ if (!context) {
113
+ throw new Error(`${componentName} must be used within <TagsInput>`);
114
+ }
115
+ return context;
116
+ }
117
+
118
+ interface TagsInputItemContextValue {
119
+ id: string;
120
+ value: TagValue;
121
+ index: number;
122
+ isHighlighted: boolean;
123
+ isEditing: boolean;
124
+ disabled?: boolean;
125
+ textId: string;
126
+ displayValue: string;
127
+ }
128
+
129
+ const TagsInputItemContext = React.createContext<TagsInputItemContextValue | null>(null);
130
+
131
+ function useTagsInputItem(componentName: string) {
132
+ const context = React.useContext(TagsInputItemContext);
133
+ if (!context) {
134
+ throw new Error(`${componentName} must be used within <TagsInputItem>`);
135
+ }
136
+ return context;
137
+ }
138
+
139
+ // =============================================================================
140
+ // Root
141
+ // =============================================================================
142
+
143
+ const DATA_ITEM_ATTR = "data-tags-input-item";
144
+
145
+ const TagsInput = React.forwardRef<HTMLDivElement, TagsInputRootProps>(
146
+ (props, ref) => {
147
+ const {
148
+ value: valueProp,
149
+ defaultValue,
150
+ onChange,
151
+ onValidate,
152
+ onTagInvalid,
153
+ displayValue = (value: TagValue) => value.toString(),
154
+ addOnPaste = false,
155
+ addOnTab = false,
156
+ disabled = false,
157
+ editable = false,
158
+ loop = false,
159
+ blurBehavior,
160
+ delimiter = ",",
161
+ max = Number.POSITIVE_INFINITY,
162
+ readOnly = false,
163
+ required = false,
164
+ name,
165
+ children,
166
+ id: idProp,
167
+ className,
168
+ ...rootProps
169
+ } = props;
170
+
171
+ const [value = [], setValue] = React.useState<TagValue[] | undefined>(defaultValue);
172
+ const resolvedValue = valueProp !== undefined ? valueProp : (value ?? []);
173
+
174
+ const [highlightedIndex, setHighlightedIndex] = React.useState<number | null>(null);
175
+ const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
176
+ const [isInvalidInput, setIsInvalidInput] = React.useState(false);
177
+ const collectionRef = React.useRef<HTMLDivElement>(null);
178
+ const inputRef = React.useRef<HTMLInputElement>(null);
179
+
180
+ const id = idProp ?? React.useId();
181
+ const inputId = React.useId();
182
+ const labelId = React.useId();
183
+
184
+ const isFormControl = React.useRef(false);
185
+ React.useEffect(() => {
186
+ if (collectionRef.current) {
187
+ isFormControl.current = !!collectionRef.current.closest("form");
188
+ }
189
+ }, []);
190
+
191
+ const getEnabledItems = React.useCallback(() => {
192
+ if (!collectionRef.current) return [];
193
+ return Array.from(collectionRef.current.querySelectorAll(`[${DATA_ITEM_ATTR}]`));
194
+ }, []);
195
+
196
+ const onItemAdd = React.useCallback(
197
+ (textValue: string, options?: { viaPaste?: boolean }) => {
198
+ if (disabled || readOnly) return false;
199
+
200
+ if (addOnPaste && options?.viaPaste) {
201
+ const splitValues = textValue
202
+ .split(delimiter)
203
+ .map((v) => v.trim())
204
+ .filter(Boolean);
205
+
206
+ if (resolvedValue.length + splitValues.length > max && max > 0) {
207
+ onTagInvalid?.(textValue);
208
+ return false;
209
+ }
210
+
211
+ const newValues = [...new Set(splitValues.filter((v) => !resolvedValue.includes(v)))];
212
+ const validValues = newValues.filter((v) => !onValidate || onValidate(v));
213
+
214
+ if (validValues.length === 0) return false;
215
+
216
+ const nextValue = [...resolvedValue, ...validValues];
217
+ if (valueProp === undefined) setValue(nextValue);
218
+ onChange?.(nextValue);
219
+ return true;
220
+ }
221
+
222
+ if (resolvedValue.length >= max && max > 0) {
223
+ onTagInvalid?.(textValue);
224
+ return false;
225
+ }
226
+
227
+ const trimmedValue = textValue.trim();
228
+ if (onValidate && !onValidate(trimmedValue)) {
229
+ setIsInvalidInput(true);
230
+ onTagInvalid?.(trimmedValue);
231
+ return false;
232
+ }
233
+
234
+ const exists = resolvedValue.some((v) => v === trimmedValue);
235
+ if (exists) {
236
+ setIsInvalidInput(true);
237
+ onTagInvalid?.(trimmedValue);
238
+ return true;
239
+ }
240
+
241
+ const nextValue = [...resolvedValue, trimmedValue];
242
+ if (valueProp === undefined) setValue(nextValue);
243
+ onChange?.(nextValue);
244
+ setHighlightedIndex(null);
245
+ setEditingIndex(null);
246
+ setIsInvalidInput(false);
247
+ return true;
248
+ },
249
+ [resolvedValue, max, addOnPaste, delimiter, onChange, onTagInvalid, onValidate, disabled, readOnly, valueProp]
250
+ );
251
+
252
+ const onItemUpdate = React.useCallback(
253
+ (index: number, newTextValue: string) => {
254
+ if (disabled || readOnly) return;
255
+ if (index === -1) return;
256
+
257
+ const trimmedValue = newTextValue.trim();
258
+ const exists = resolvedValue.some((v, i) => i !== index && v === trimmedValue);
259
+ if (exists) {
260
+ setIsInvalidInput(true);
261
+ onTagInvalid?.(trimmedValue);
262
+ return;
263
+ }
264
+
265
+ if (onValidate && !onValidate(trimmedValue)) {
266
+ setIsInvalidInput(true);
267
+ onTagInvalid?.(trimmedValue);
268
+ return;
269
+ }
270
+
271
+ const nextValue = [...resolvedValue];
272
+ nextValue[index] = displayValue(trimmedValue);
273
+ if (valueProp === undefined) setValue(nextValue);
274
+ onChange?.(nextValue);
275
+ setHighlightedIndex(index);
276
+ setEditingIndex(null);
277
+ setIsInvalidInput(false);
278
+
279
+ requestAnimationFrame(() => inputRef.current?.focus());
280
+ },
281
+ [resolvedValue, onChange, displayValue, onTagInvalid, onValidate, disabled, readOnly, valueProp]
282
+ );
283
+
284
+ const onItemRemove = React.useCallback(
285
+ (index: number) => {
286
+ if (disabled || readOnly) return;
287
+ if (index === -1) return;
288
+
289
+ const nextValue = [...resolvedValue];
290
+ nextValue.splice(index, 1);
291
+ if (valueProp === undefined) setValue(nextValue);
292
+ onChange?.(nextValue);
293
+ setHighlightedIndex(null);
294
+ setEditingIndex(null);
295
+ inputRef.current?.focus();
296
+ },
297
+ [resolvedValue, onChange, disabled, readOnly, valueProp]
298
+ );
299
+
300
+ const onItemLeave = React.useCallback(() => {
301
+ setHighlightedIndex(null);
302
+ setEditingIndex(null);
303
+ inputRef.current?.focus();
304
+ }, []);
305
+
306
+ const findNextEnabledIndex = React.useCallback(
307
+ (currentIndex: number | null, direction: "next" | "prev"): number | null => {
308
+ const enabledItems = getEnabledItems();
309
+ const enabledIndices = enabledItems.map((_, index) => index);
310
+ if (enabledIndices.length === 0) return null;
311
+
312
+ if (currentIndex === null) {
313
+ return direction === "prev"
314
+ ? (enabledIndices[enabledIndices.length - 1] ?? null)
315
+ : (enabledIndices[0] ?? null);
316
+ }
317
+
318
+ const currentEnabledIndex = enabledIndices.indexOf(currentIndex);
319
+ if (direction === "next") {
320
+ return currentEnabledIndex >= enabledIndices.length - 1
321
+ ? loop
322
+ ? (enabledIndices[0] ?? null)
323
+ : null
324
+ : (enabledIndices[currentEnabledIndex + 1] ?? null);
325
+ }
326
+
327
+ return currentEnabledIndex <= 0
328
+ ? loop
329
+ ? (enabledIndices[enabledIndices.length - 1] ?? null)
330
+ : null
331
+ : (enabledIndices[currentEnabledIndex - 1] ?? null);
332
+ },
333
+ [getEnabledItems, loop]
334
+ );
335
+
336
+ const onInputKeydown = React.useCallback(
337
+ (event: React.KeyboardEvent) => {
338
+ const target = event.target;
339
+ if (!(target instanceof HTMLInputElement)) return;
340
+
341
+ const isArrowLeft = event.key === "ArrowLeft";
342
+ const isArrowRight = event.key === "ArrowRight";
343
+
344
+ if (target.value && target.selectionStart !== 0) {
345
+ setHighlightedIndex(null);
346
+ setEditingIndex(null);
347
+ return;
348
+ }
349
+
350
+ switch (event.key) {
351
+ case "Delete":
352
+ case "Backspace": {
353
+ if (target.selectionStart !== 0 || target.selectionEnd !== 0) break;
354
+ if (highlightedIndex !== null) {
355
+ const newIndex = findNextEnabledIndex(highlightedIndex, "prev");
356
+ onItemRemove(highlightedIndex);
357
+ setHighlightedIndex(newIndex);
358
+ event.preventDefault();
359
+ } else if (event.key === "Backspace" && resolvedValue.length > 0) {
360
+ const lastIndex = findNextEnabledIndex(null, "prev");
361
+ setHighlightedIndex(lastIndex);
362
+ event.preventDefault();
363
+ }
364
+ break;
365
+ }
366
+ case "Enter": {
367
+ if (highlightedIndex !== null && editable && !disabled) {
368
+ setEditingIndex(highlightedIndex);
369
+ event.preventDefault();
370
+ }
371
+ break;
372
+ }
373
+ case "ArrowLeft":
374
+ case "ArrowRight": {
375
+ if (target.selectionStart === 0 && isArrowLeft && highlightedIndex === null && resolvedValue.length > 0) {
376
+ const lastIndex = findNextEnabledIndex(null, "prev");
377
+ setHighlightedIndex(lastIndex);
378
+ event.preventDefault();
379
+ } else if (highlightedIndex !== null) {
380
+ const nextIndex = findNextEnabledIndex(highlightedIndex, isArrowLeft ? "prev" : "next");
381
+ if (nextIndex !== null) {
382
+ setHighlightedIndex(nextIndex);
383
+ event.preventDefault();
384
+ } else if (isArrowRight) {
385
+ setHighlightedIndex(null);
386
+ requestAnimationFrame(() => target.setSelectionRange(0, 0));
387
+ }
388
+ }
389
+ break;
390
+ }
391
+ case "Home": {
392
+ if (highlightedIndex !== null) {
393
+ const firstIndex = findNextEnabledIndex(null, "next");
394
+ setHighlightedIndex(firstIndex);
395
+ event.preventDefault();
396
+ }
397
+ break;
398
+ }
399
+ case "End": {
400
+ if (highlightedIndex !== null) {
401
+ const lastIndex = findNextEnabledIndex(null, "prev");
402
+ setHighlightedIndex(lastIndex);
403
+ event.preventDefault();
404
+ }
405
+ break;
406
+ }
407
+ case "Escape": {
408
+ if (highlightedIndex !== null) setHighlightedIndex(null);
409
+ if (editingIndex !== null) setEditingIndex(null);
410
+ requestAnimationFrame(() => target.setSelectionRange(0, 0));
411
+ break;
412
+ }
413
+ }
414
+ },
415
+ [resolvedValue, highlightedIndex, editingIndex, onItemRemove, findNextEnabledIndex, editable, disabled]
416
+ );
417
+
418
+ const getIsClickedInEmptyRoot = React.useCallback(
419
+ (target: HTMLElement) => {
420
+ return (
421
+ collectionRef.current?.contains(target) &&
422
+ !target.hasAttribute(DATA_ITEM_ATTR) &&
423
+ target.tagName !== "INPUT"
424
+ );
425
+ },
426
+ []
427
+ );
428
+
429
+ return (
430
+ <TagsInputContext.Provider
431
+ value={{
432
+ value: resolvedValue,
433
+ onValueChange: onChange ?? (() => {}),
434
+ onItemAdd,
435
+ onItemRemove,
436
+ onItemUpdate,
437
+ onInputKeydown,
438
+ highlightedIndex,
439
+ setHighlightedIndex,
440
+ editingIndex,
441
+ setEditingIndex,
442
+ displayValue,
443
+ onItemLeave,
444
+ inputRef,
445
+ isInvalidInput,
446
+ addOnPaste,
447
+ addOnTab,
448
+ disabled,
449
+ editable,
450
+ loop,
451
+ readOnly,
452
+ blurBehavior,
453
+ delimiter,
454
+ max,
455
+ id,
456
+ inputId,
457
+ labelId,
458
+ }}
459
+ >
460
+ <div
461
+ id={id}
462
+ ref={(node) => {
463
+ collectionRef.current = node;
464
+ if (typeof ref === "function") {
465
+ ref(node);
466
+ } else if (ref) {
467
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
468
+ }
469
+ }}
470
+ data-disabled={disabled ? "" : undefined}
471
+ data-invalid={isInvalidInput ? "" : undefined}
472
+ data-readonly={readOnly ? "" : undefined}
473
+ className={cn(
474
+ "flex flex-wrap items-center gap-1.5 rounded-[var(--radius)] border border-input bg-transparent px-2 py-1.5 text-sm shadow-sm transition-colors",
475
+ "focus-within:ring-1 focus-within:ring-ring",
476
+ disabled && "cursor-not-allowed opacity-50",
477
+ className
478
+ )}
479
+ onClick={(event) => {
480
+ rootProps.onClick?.(event);
481
+ const target = event.target;
482
+ if (!(target instanceof HTMLElement)) return;
483
+ if (
484
+ getIsClickedInEmptyRoot(target) &&
485
+ document.activeElement !== inputRef.current
486
+ ) {
487
+ event.currentTarget.focus();
488
+ inputRef.current?.focus();
489
+ }
490
+ }}
491
+ onMouseDown={(event) => {
492
+ rootProps.onMouseDown?.(event);
493
+ const target = event.target;
494
+ if (!(target instanceof HTMLElement)) return;
495
+ if (getIsClickedInEmptyRoot(target)) {
496
+ event.preventDefault();
497
+ }
498
+ }}
499
+ onBlur={(event) => {
500
+ rootProps.onBlur?.(event);
501
+ if (
502
+ event.relatedTarget !== inputRef.current &&
503
+ !collectionRef.current?.contains(event.relatedTarget as Node)
504
+ ) {
505
+ requestAnimationFrame(() => setHighlightedIndex(null));
506
+ }
507
+ }}
508
+ {...rootProps}
509
+ >
510
+ {typeof children === "function" ? children({ value: resolvedValue }) : children}
511
+ {isFormControl.current && name && (
512
+ <input type="hidden" name={name} value={resolvedValue.join(delimiter)} disabled={disabled} required={required} />
513
+ )}
514
+ </div>
515
+ </TagsInputContext.Provider>
516
+ );
517
+ }
518
+ );
519
+
520
+ TagsInput.displayName = "TagsInput";
521
+
522
+ // =============================================================================
523
+ // Input
524
+ // =============================================================================
525
+
526
+ const TagsInputInput = React.forwardRef<HTMLInputElement, TagsInputInputProps>(
527
+ (props, ref) => {
528
+ const { autoFocus, ...inputProps } = props;
529
+ const context = useTagsInput("TagsInputInput");
530
+
531
+ const onCustomKeydown = React.useCallback(
532
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
533
+ if (event.defaultPrevented) return;
534
+ const value = event.currentTarget.value;
535
+ if (!value) return;
536
+ const isAdded = context.onItemAdd(value);
537
+ if (isAdded) {
538
+ event.currentTarget.value = "";
539
+ context.setHighlightedIndex(null);
540
+ }
541
+ event.preventDefault();
542
+ },
543
+ [context.onItemAdd, context.setHighlightedIndex]
544
+ );
545
+
546
+ const onTab = React.useCallback(
547
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
548
+ if (!context.addOnTab) return;
549
+ onCustomKeydown(event);
550
+ },
551
+ [context.addOnTab, onCustomKeydown]
552
+ );
553
+
554
+ React.useEffect(() => {
555
+ if (!autoFocus) return;
556
+ const id = requestAnimationFrame(() => context.inputRef.current?.focus());
557
+ return () => cancelAnimationFrame(id);
558
+ }, [autoFocus, context.inputRef]);
559
+
560
+ const composedRef = React.useCallback(
561
+ (node: HTMLInputElement | null) => {
562
+ context.inputRef.current = node;
563
+ if (typeof ref === "function") {
564
+ ref(node);
565
+ } else if (ref) {
566
+ (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
567
+ }
568
+ },
569
+ [ref, context.inputRef]
570
+ );
571
+
572
+ return (
573
+ <input
574
+ type="text"
575
+ id={context.inputId}
576
+ autoCapitalize="off"
577
+ autoComplete="off"
578
+ autoCorrect="off"
579
+ spellCheck="false"
580
+ autoFocus={autoFocus}
581
+ aria-labelledby={context.labelId}
582
+ aria-readonly={context.readOnly}
583
+ data-invalid={context.isInvalidInput ? "" : undefined}
584
+ disabled={context.disabled}
585
+ readOnly={context.readOnly}
586
+ {...inputProps}
587
+ ref={composedRef}
588
+ className={cn(
589
+ "flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 min-w-[60px]",
590
+ inputProps.className
591
+ )}
592
+ onBlur={(event) => {
593
+ inputProps.onBlur?.(event);
594
+ if (context.readOnly) return;
595
+ if (context.blurBehavior === "add") {
596
+ const val = event.target.value;
597
+ if (val) {
598
+ const isAdded = context.onItemAdd(val);
599
+ if (isAdded) event.target.value = "";
600
+ }
601
+ }
602
+ if (context.blurBehavior === "clear") {
603
+ event.target.value = "";
604
+ }
605
+ }}
606
+ onChange={(event) => {
607
+ inputProps.onChange?.(event);
608
+ if (context.readOnly) return;
609
+ const target = event.target;
610
+ if (!(target instanceof HTMLInputElement)) return;
611
+ const delimiter = context.delimiter;
612
+ if (delimiter === target.value.slice(-1)) {
613
+ const val = target.value.slice(0, -1);
614
+ target.value = "";
615
+ if (val) {
616
+ context.onItemAdd(val);
617
+ context.setHighlightedIndex(null);
618
+ }
619
+ }
620
+ }}
621
+ onKeyDown={(event) => {
622
+ inputProps.onKeyDown?.(event);
623
+ if (context.readOnly) return;
624
+ if (event.key === "Enter") onCustomKeydown(event);
625
+ if (event.key === "Tab") onTab(event);
626
+ context.onInputKeydown(event);
627
+ if (event.key.length === 1) context.setHighlightedIndex(null);
628
+ }}
629
+ onPaste={(event) => {
630
+ inputProps.onPaste?.(event);
631
+ if (context.readOnly) return;
632
+ if (context.addOnPaste) {
633
+ event.preventDefault();
634
+ const val = event.clipboardData.getData("text");
635
+ context.onItemAdd(val, { viaPaste: true });
636
+ context.setHighlightedIndex(null);
637
+ }
638
+ }}
639
+ />
640
+ );
641
+ }
642
+ );
643
+
644
+ TagsInputInput.displayName = "TagsInputInput";
645
+
646
+ // =============================================================================
647
+ // Item
648
+ // =============================================================================
649
+
650
+ const TagsInputItem = React.forwardRef<HTMLDivElement, TagsInputItemProps>(
651
+ (props, ref) => {
652
+ const { value, disabled: itemDisabledProp, ...itemProps } = props;
653
+ const pointerTypeRef = React.useRef<React.PointerEvent["pointerType"]>("touch");
654
+ const context = useTagsInput("TagsInputItem");
655
+ const id = React.useId();
656
+ const textId = `${id}text`;
657
+ const index = context.value.indexOf(value);
658
+ const isHighlighted = index === context.highlightedIndex;
659
+ const isEditing = index === context.editingIndex;
660
+ const itemDisabled = itemDisabledProp || context.disabled;
661
+ const displayValue = context.displayValue(value);
662
+
663
+ const onItemSelect = React.useCallback(() => {
664
+ context.setHighlightedIndex(index);
665
+ context.inputRef.current?.focus();
666
+ }, [context.setHighlightedIndex, context.inputRef, index]);
667
+
668
+ return (
669
+ <TagsInputItemContext.Provider
670
+ value={{
671
+ id,
672
+ value,
673
+ index,
674
+ isHighlighted,
675
+ isEditing,
676
+ disabled: itemDisabled,
677
+ textId,
678
+ displayValue,
679
+ }}
680
+ >
681
+ <div
682
+ id={id}
683
+ ref={ref}
684
+ aria-labelledby={textId}
685
+ aria-current={isHighlighted}
686
+ aria-disabled={itemDisabled}
687
+ {...{ [DATA_ITEM_ATTR]: "" }}
688
+ data-state={isHighlighted ? "active" : "inactive"}
689
+ data-highlighted={isHighlighted ? "" : undefined}
690
+ data-editing={isEditing ? "" : undefined}
691
+ data-editable={context.editable ? "" : undefined}
692
+ data-disabled={itemDisabled ? "" : undefined}
693
+ className={cn(
694
+ "inline-flex items-center gap-1 rounded-[var(--radius)] border bg-secondary px-2 py-0.5 text-sm text-secondary-foreground transition-colors",
695
+ isHighlighted && "ring-1 ring-ring",
696
+ itemDisabled && "opacity-50 cursor-not-allowed",
697
+ itemProps.className
698
+ )}
699
+ onClick={(event) => {
700
+ itemProps.onClick?.(event);
701
+ event.stopPropagation();
702
+ if (!isEditing && pointerTypeRef.current !== "mouse") {
703
+ onItemSelect();
704
+ }
705
+ }}
706
+ onDoubleClick={() => {
707
+ itemProps.onDoubleClick?.(undefined as unknown as React.MouseEvent<HTMLDivElement>);
708
+ if (context.editable && !itemDisabled) {
709
+ requestAnimationFrame(() => context.setEditingIndex(index));
710
+ }
711
+ }}
712
+ onPointerUp={() => {
713
+ itemProps.onPointerUp?.(undefined as unknown as React.PointerEvent<HTMLDivElement>);
714
+ if (pointerTypeRef.current === "mouse") onItemSelect();
715
+ }}
716
+ onPointerDown={(event) => {
717
+ itemProps.onPointerDown?.(event);
718
+ pointerTypeRef.current = event.pointerType;
719
+ }}
720
+ onPointerMove={(event) => {
721
+ itemProps.onPointerMove?.(event);
722
+ pointerTypeRef.current = event.pointerType;
723
+ if (itemDisabledProp) {
724
+ context.onItemLeave();
725
+ } else if (pointerTypeRef.current === "mouse") {
726
+ event.currentTarget.focus({ preventScroll: true });
727
+ }
728
+ }}
729
+ onPointerLeave={(event) => {
730
+ itemProps.onPointerLeave?.(event);
731
+ if (event.currentTarget === document.activeElement) {
732
+ context.onItemLeave();
733
+ }
734
+ }}
735
+ {...itemProps}
736
+ />
737
+ </TagsInputItemContext.Provider>
738
+ );
739
+ }
740
+ );
741
+
742
+ TagsInputItem.displayName = "TagsInputItem";
743
+
744
+ // =============================================================================
745
+ // ItemText
746
+ // =============================================================================
747
+
748
+ function TagsInputEditableItemText() {
749
+ const context = useTagsInput("TagsInputEditableItemText");
750
+ const itemContext = useTagsInputItem("TagsInputEditableItemText");
751
+ const [editValue, setEditValue] = React.useState(itemContext.displayValue);
752
+
753
+ const onBlur = React.useCallback(() => {
754
+ setEditValue(itemContext.displayValue);
755
+ context.setEditingIndex(null);
756
+ }, [context.setEditingIndex, itemContext.displayValue]);
757
+
758
+ const onChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
759
+ const target = event.target;
760
+ target.style.width = "0";
761
+ target.style.width = `${target.scrollWidth + 4}px`;
762
+ setEditValue(event.target.value);
763
+ }, []);
764
+
765
+ const onFocus = React.useCallback((event: React.FocusEvent<HTMLInputElement>) => {
766
+ event.target.select();
767
+ event.target.style.width = "0";
768
+ event.target.style.width = `${event.target.scrollWidth + 4}px`;
769
+ }, []);
770
+
771
+ const onKeyDown = React.useCallback(
772
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
773
+ if (event.key === "Enter") {
774
+ const index = context.value.indexOf(itemContext.value);
775
+ context.onItemUpdate(index, editValue);
776
+ } else if (event.key === "Escape") {
777
+ setEditValue(itemContext.displayValue);
778
+ context.setEditingIndex(null);
779
+ context.setHighlightedIndex(itemContext.index);
780
+ context.inputRef.current?.focus();
781
+ }
782
+ event.stopPropagation();
783
+ },
784
+ [
785
+ context.value,
786
+ context.onItemUpdate,
787
+ context.setEditingIndex,
788
+ itemContext.displayValue,
789
+ editValue,
790
+ itemContext.value,
791
+ context.setHighlightedIndex,
792
+ itemContext.index,
793
+ context.inputRef,
794
+ ]
795
+ );
796
+
797
+ return (
798
+ <input
799
+ type="text"
800
+ autoCapitalize="off"
801
+ autoComplete="off"
802
+ autoCorrect="off"
803
+ spellCheck="false"
804
+ autoFocus
805
+ aria-describedby={itemContext.textId}
806
+ value={editValue}
807
+ onChange={onChange}
808
+ onKeyDown={onKeyDown}
809
+ onFocus={onFocus}
810
+ onBlur={onBlur}
811
+ className="bg-transparent outline-none min-w-[1ch]"
812
+ style={{
813
+ font: "inherit",
814
+ color: "inherit",
815
+ padding: 0,
816
+ border: "none",
817
+ }}
818
+ />
819
+ );
820
+ }
821
+
822
+ const TagsInputItemText = React.forwardRef<HTMLSpanElement, TagsInputItemTextProps>(
823
+ (props, ref) => {
824
+ const { children, ...itemTextProps } = props;
825
+ const context = useTagsInput("TagsInputItemText");
826
+ const itemContext = useTagsInputItem("TagsInputItemText");
827
+
828
+ if (itemContext.isEditing && context.editable && !itemContext.disabled) {
829
+ return <TagsInputEditableItemText />;
830
+ }
831
+
832
+ return (
833
+ <span id={itemContext.textId} {...itemTextProps} ref={ref}>
834
+ {children ?? itemContext.displayValue}
835
+ </span>
836
+ );
837
+ }
838
+ );
839
+
840
+ TagsInputItemText.displayName = "TagsInputItemText";
841
+
842
+ // =============================================================================
843
+ // ItemDelete
844
+ // =============================================================================
845
+
846
+ const TagsInputItemDelete = React.forwardRef<HTMLButtonElement, TagsInputItemDeleteProps>(
847
+ (props, ref) => {
848
+ const context = useTagsInput("TagsInputItemDelete");
849
+ const itemContext = useTagsInputItem("TagsInputItemDelete");
850
+ const disabled = itemContext.disabled || context.disabled;
851
+
852
+ if (itemContext.isEditing) return null;
853
+
854
+ return (
855
+ <button
856
+ type="button"
857
+ ref={ref}
858
+ tabIndex={disabled ? undefined : -1}
859
+ aria-labelledby={itemContext.textId}
860
+ aria-controls={itemContext.id}
861
+ aria-current={itemContext.isHighlighted}
862
+ data-state={itemContext.isHighlighted ? "active" : "inactive"}
863
+ data-disabled={disabled ? "" : undefined}
864
+ className={cn(
865
+ "inline-flex items-center justify-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
866
+ disabled && "pointer-events-none opacity-50",
867
+ props.className
868
+ )}
869
+ onClick={(event) => {
870
+ props.onClick?.(event);
871
+ if (disabled) return;
872
+ const index = context.value.indexOf(itemContext.value);
873
+ context.onItemRemove(index);
874
+ }}
875
+ {...props}
876
+ >
877
+ {props.children ?? <X className="h-3 w-3" />}
878
+ </button>
879
+ );
880
+ }
881
+ );
882
+
883
+ TagsInputItemDelete.displayName = "TagsInputItemDelete";
884
+
885
+ // =============================================================================
886
+ // Exports
887
+ // =============================================================================
888
+
889
+ export {
890
+ TagsInput,
891
+ TagsInputInput,
892
+ TagsInputItem,
893
+ TagsInputItemText,
894
+ TagsInputItemDelete,
895
+ };
896
+