@djangocfg/ui-core 2.1.411 → 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 (52) 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/select/select.tsx +13 -3
  27. package/src/components/specialized/presence/index.tsx +181 -0
  28. package/src/components/specialized/primitive/index.tsx +83 -0
  29. package/src/components/specialized/visually-hidden/index.tsx +19 -0
  30. package/src/components/specialized/visually-hidden-input/index.tsx +99 -0
  31. package/src/hooks/dom/index.ts +4 -0
  32. package/src/hooks/dom/useFormReset.ts +49 -0
  33. package/src/hooks/dom/useLayoutEffect.ts +16 -0
  34. package/src/hooks/dom/useSize.ts +57 -0
  35. package/src/hooks/state/index.ts +4 -0
  36. package/src/hooks/state/useCallbackRef.ts +25 -0
  37. package/src/hooks/state/usePrevious.ts +20 -0
  38. package/src/hooks/state/useStateMachine.ts +29 -0
  39. package/src/lib/compose-event-handlers.ts +22 -0
  40. package/src/lib/compose-refs.ts +65 -0
  41. package/src/lib/create-context.tsx +62 -0
  42. package/src/lib/get-element-ref.ts +33 -0
  43. package/src/lib/index.ts +5 -0
  44. package/src/lib/styles.ts +103 -0
  45. package/src/styles/README.md +43 -0
  46. package/src/styles/palette/utils.ts +15 -5
  47. package/src/styles/utilities/animations.css +135 -0
  48. package/src/styles/utilities/display.css +62 -0
  49. package/src/styles/utilities/glass.css +57 -0
  50. package/src/styles/utilities/marquee.css +69 -0
  51. package/src/styles/utilities/step.css +25 -0
  52. package/src/styles/utilities.css +6 -259
@@ -0,0 +1,884 @@
1
+ "use client"
2
+
3
+ import { PlusIcon, XIcon } from "lucide-react"
4
+ import * as React from "react"
5
+ import { Slot } from "@radix-ui/react-slot"
6
+
7
+ import { cn } from "../../../lib/utils"
8
+ import { Button } from "../../forms/button"
9
+ import { Input } from "../../forms/input"
10
+ import { Textarea } from "../../forms/textarea"
11
+
12
+ const ROOT_NAME = "KeyValue"
13
+ const LIST_NAME = "KeyValueList"
14
+ const ITEM_NAME = "KeyValueItem"
15
+ const KEY_INPUT_NAME = "KeyValueKeyInput"
16
+ const VALUE_INPUT_NAME = "KeyValueValueInput"
17
+ const REMOVE_NAME = "KeyValueRemove"
18
+ const ADD_NAME = "KeyValueAdd"
19
+ const ERROR_NAME = "KeyValueError"
20
+
21
+ type Orientation = "vertical" | "horizontal"
22
+ type Field = "key" | "value"
23
+
24
+ interface DivProps extends React.ComponentProps<"div"> {
25
+ asChild?: boolean
26
+ }
27
+
28
+ type RootElement = HTMLDivElement
29
+ type KeyInputElement = HTMLInputElement
30
+ type RemoveElement = HTMLButtonElement
31
+ type AddElement = HTMLButtonElement
32
+
33
+ function getErrorId(rootId: string, itemId: string, field: Field) {
34
+ return `${rootId}-${itemId}-${field}-error`
35
+ }
36
+
37
+ function removeQuotes(string: string, shouldStrip: boolean): string {
38
+ if (!shouldStrip) return string
39
+
40
+ const trimmed = string.trim()
41
+ if (
42
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
43
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
44
+ ) {
45
+ return trimmed.slice(1, -1)
46
+ }
47
+ return trimmed
48
+ }
49
+
50
+ interface Store {
51
+ subscribe: (callback: () => void) => () => void
52
+ getState: () => KeyValueState
53
+ setState: <K extends keyof KeyValueState>(
54
+ key: K,
55
+ value: KeyValueState[K],
56
+ ) => void
57
+ notify: () => void
58
+ }
59
+
60
+ function useStore<T>(
61
+ selector: (state: KeyValueState) => T,
62
+ ogStore?: Store | null,
63
+ ): T {
64
+ const contextStore = React.useContext(StoreContext)
65
+
66
+ const store = ogStore ?? contextStore
67
+
68
+ if (!store) {
69
+ throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``)
70
+ }
71
+
72
+ const getSnapshot = React.useCallback(
73
+ () => selector(store.getState()),
74
+ [store, selector],
75
+ )
76
+
77
+ return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
78
+ }
79
+
80
+ interface ItemData {
81
+ id: string
82
+ key: string
83
+ value: string
84
+ }
85
+
86
+ interface KeyValueState {
87
+ value: ItemData[]
88
+ focusedId: string | null
89
+ errors: Record<string, { key?: string; value?: string }>
90
+ }
91
+
92
+ const StoreContext = React.createContext<Store | null>(null)
93
+
94
+ function useStoreContext(consumerName: string) {
95
+ const context = React.useContext(StoreContext)
96
+ if (!context) {
97
+ throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``)
98
+ }
99
+ return context
100
+ }
101
+
102
+ interface KeyValueContextValue {
103
+ onPaste?: (event: ClipboardEvent, items: ItemData[]) => void
104
+ onAdd?: (value: ItemData) => void
105
+ onRemove?: (value: ItemData) => void
106
+ onKeyValidate?: (key: string, value: ItemData[]) => string | undefined
107
+ onValueValidate?: (
108
+ value: string,
109
+ key: string,
110
+ items: ItemData[],
111
+ ) => string | undefined
112
+ rootId: string
113
+ maxItems?: number
114
+ minItems: number
115
+ keyPlaceholder: string
116
+ valuePlaceholder: string
117
+ allowDuplicateKeys: boolean
118
+ enablePaste: boolean
119
+ trim: boolean
120
+ stripQuotes: boolean
121
+ disabled: boolean
122
+ readOnly: boolean
123
+ required: boolean
124
+ }
125
+
126
+ const KeyValueContext = React.createContext<KeyValueContextValue | null>(null)
127
+
128
+ function useKeyValueContext(consumerName: string) {
129
+ const context = React.useContext(KeyValueContext)
130
+ if (!context) {
131
+ throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``)
132
+ }
133
+ return context
134
+ }
135
+
136
+ interface KeyValueProps extends Omit<DivProps, "onPaste" | "defaultValue"> {
137
+ id?: string
138
+ defaultValue?: ItemData[]
139
+ value?: ItemData[]
140
+ onValueChange?: (value: ItemData[]) => void
141
+ maxItems?: number
142
+ minItems?: number
143
+ keyPlaceholder?: string
144
+ valuePlaceholder?: string
145
+ name?: string
146
+ allowDuplicateKeys?: boolean
147
+ enablePaste?: boolean
148
+ trim?: boolean
149
+ stripQuotes?: boolean
150
+ disabled?: boolean
151
+ readOnly?: boolean
152
+ required?: boolean
153
+ onPaste?: (event: ClipboardEvent, items: ItemData[]) => void
154
+ onAdd?: (value: ItemData) => void
155
+ onRemove?: (value: ItemData) => void
156
+ onKeyValidate?: (key: string, value: ItemData[]) => string | undefined
157
+ onValueValidate?: (
158
+ value: string,
159
+ key: string,
160
+ items: ItemData[],
161
+ ) => string | undefined
162
+ }
163
+
164
+ function KeyValue(props: KeyValueProps) {
165
+ const {
166
+ value: valueProp,
167
+ defaultValue,
168
+ onValueChange,
169
+ onPaste,
170
+ onAdd,
171
+ onRemove,
172
+ onKeyValidate,
173
+ onValueValidate,
174
+ maxItems,
175
+ minItems = 0,
176
+ keyPlaceholder = "Key",
177
+ valuePlaceholder = "Value",
178
+ allowDuplicateKeys = false,
179
+ asChild,
180
+ enablePaste = true,
181
+ trim = true,
182
+ stripQuotes = true,
183
+ disabled = false,
184
+ readOnly = false,
185
+ required = false,
186
+ className,
187
+ id,
188
+ name,
189
+ ref,
190
+ ...rootProps
191
+ } = props
192
+
193
+ const instanceId = React.useId()
194
+ const rootId = id ?? instanceId
195
+
196
+ const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
197
+ null,
198
+ )
199
+ const composedRef = React.useCallback(
200
+ (node: RootElement | null) => {
201
+ setFormTrigger(node)
202
+ if (typeof ref === "function") {
203
+ ref(node)
204
+ } else if (ref) {
205
+ ref.current = node
206
+ }
207
+ },
208
+ [ref],
209
+ )
210
+ const isFormControl = formTrigger ? !!formTrigger.closest("form") : true
211
+
212
+ const listenersRef = React.useRef<Set<() => void>>(new Set())
213
+ const stateRef = React.useRef<KeyValueState>({
214
+ value: valueProp ??
215
+ defaultValue ?? [{ id: crypto.randomUUID(), key: "", value: "" }],
216
+ focusedId: null,
217
+ errors: {},
218
+ })
219
+ const propsRef = React.useRef({ onValueChange })
220
+
221
+ React.useEffect(() => {
222
+ propsRef.current = { onValueChange }
223
+ }, [onValueChange])
224
+
225
+ const store = React.useMemo<Store>(() => {
226
+ return {
227
+ subscribe: (cb) => {
228
+ listenersRef.current.add(cb)
229
+ return () => listenersRef.current.delete(cb)
230
+ },
231
+ getState: () => stateRef.current,
232
+ setState: (key, val) => {
233
+ if (Object.is(stateRef.current[key], val)) return
234
+
235
+ if (key === "value" && Array.isArray(val)) {
236
+ stateRef.current.value = val as ItemData[]
237
+ propsRef.current.onValueChange?.(val as ItemData[])
238
+ } else {
239
+ stateRef.current[key] = val
240
+ }
241
+
242
+ store.notify()
243
+ },
244
+ notify: () => {
245
+ for (const cb of listenersRef.current) {
246
+ cb()
247
+ }
248
+ },
249
+ }
250
+ }, [])
251
+
252
+ const value = useStore((state) => state.value, store)
253
+ const errors = useStore((state) => state.errors, store)
254
+ const isInvalid = Object.keys(errors).length > 0
255
+
256
+ React.useEffect(() => {
257
+ if (valueProp !== undefined) {
258
+ store.setState("value", valueProp)
259
+ }
260
+ }, [valueProp, store])
261
+
262
+ const contextValue = React.useMemo<KeyValueContextValue>(
263
+ () => ({
264
+ onPaste,
265
+ onAdd,
266
+ onRemove,
267
+ onKeyValidate,
268
+ onValueValidate,
269
+ rootId,
270
+ maxItems,
271
+ minItems,
272
+ keyPlaceholder,
273
+ valuePlaceholder,
274
+ allowDuplicateKeys,
275
+ enablePaste,
276
+ trim,
277
+ stripQuotes,
278
+ disabled,
279
+ readOnly,
280
+ required,
281
+ }),
282
+ [
283
+ onPaste,
284
+ onAdd,
285
+ onRemove,
286
+ onKeyValidate,
287
+ onValueValidate,
288
+ rootId,
289
+ disabled,
290
+ readOnly,
291
+ required,
292
+ maxItems,
293
+ minItems,
294
+ keyPlaceholder,
295
+ valuePlaceholder,
296
+ allowDuplicateKeys,
297
+ enablePaste,
298
+ trim,
299
+ stripQuotes,
300
+ ],
301
+ )
302
+
303
+ const RootPrimitive = asChild ? Slot : "div"
304
+
305
+ return (
306
+ <StoreContext.Provider value={store}>
307
+ <KeyValueContext.Provider value={contextValue}>
308
+ <RootPrimitive
309
+ id={id}
310
+ data-slot="key-value"
311
+ data-disabled={disabled ? "" : undefined}
312
+ data-invalid={isInvalid ? "" : undefined}
313
+ data-readonly={readOnly ? "" : undefined}
314
+ {...rootProps}
315
+ ref={composedRef}
316
+ className={cn("flex flex-col gap-2", className)}
317
+ />
318
+ {isFormControl && name && (
319
+ <input
320
+ type="hidden"
321
+ name={name}
322
+ value={JSON.stringify(value)}
323
+ disabled={disabled}
324
+ readOnly={readOnly}
325
+ required={required}
326
+ />
327
+ )}
328
+ </KeyValueContext.Provider>
329
+ </StoreContext.Provider>
330
+ )
331
+ }
332
+
333
+ interface KeyValueListProps extends DivProps {
334
+ orientation?: Orientation
335
+ }
336
+
337
+ function KeyValueList(props: KeyValueListProps) {
338
+ const { orientation = "vertical", asChild, className, children, ...listProps } = props
339
+
340
+ const value = useStore((state) => state.value)
341
+
342
+ const ListPrimitive = asChild ? Slot : "div"
343
+
344
+ return (
345
+ <ListPrimitive
346
+ role="list"
347
+ aria-orientation={orientation}
348
+ data-slot="key-value-list"
349
+ data-orientation={orientation}
350
+ {...listProps}
351
+ className={cn(
352
+ "flex",
353
+ orientation === "vertical" ? "flex-col gap-2" : "flex-row gap-2",
354
+ className,
355
+ )}
356
+ >
357
+ {value.map((item) => {
358
+ const childrenArray = React.Children.toArray(children)
359
+
360
+ return (
361
+ <KeyValueItemContext.Provider key={item.id} value={item}>
362
+ {childrenArray}
363
+ </KeyValueItemContext.Provider>
364
+ )
365
+ })}
366
+ </ListPrimitive>
367
+ )
368
+ }
369
+
370
+ const KeyValueItemContext = React.createContext<ItemData | null>(null)
371
+
372
+ function useKeyValueItemContext(consumerName: string) {
373
+ const context = React.useContext(KeyValueItemContext)
374
+ if (!context) {
375
+ throw new Error(`\`${consumerName}\` must be used within \`${LIST_NAME}\``)
376
+ }
377
+ return context
378
+ }
379
+
380
+ interface KeyValueItemProps extends React.ComponentProps<"div"> {
381
+ asChild?: boolean
382
+ }
383
+
384
+ function KeyValueItem(props: KeyValueItemProps) {
385
+ const { asChild, className, ...itemProps } = props
386
+ const itemData = useKeyValueItemContext(ITEM_NAME)
387
+
388
+ const focusedId = useStore((state) => state.focusedId)
389
+
390
+ const ItemPrimitive = asChild ? Slot : "div"
391
+
392
+ return (
393
+ <ItemPrimitive
394
+ role="listitem"
395
+ data-slot="key-value-item"
396
+ data-highlighted={focusedId === itemData.id ? "" : undefined}
397
+ {...itemProps}
398
+ className={cn("flex items-start gap-2", className)}
399
+ />
400
+ )
401
+ }
402
+
403
+ interface KeyValueKeyInputProps extends React.ComponentProps<"input"> {
404
+ asChild?: boolean
405
+ }
406
+
407
+ function KeyValueKeyInput(props: KeyValueKeyInputProps) {
408
+ const {
409
+ onChange: onChangeProp,
410
+ onPaste: onPasteProp,
411
+ asChild,
412
+ disabled,
413
+ readOnly,
414
+ required,
415
+ ...keyInputProps
416
+ } = props
417
+
418
+ const context = useKeyValueContext(KEY_INPUT_NAME)
419
+ const itemData = useKeyValueItemContext(KEY_INPUT_NAME)
420
+ const store = useStoreContext(KEY_INPUT_NAME)
421
+ const errors = useStore((state) => state.errors)
422
+
423
+ const propsRef = React.useRef({
424
+ onChange: onChangeProp,
425
+ onPaste: onPasteProp,
426
+ })
427
+
428
+ React.useEffect(() => {
429
+ propsRef.current = { onChange: onChangeProp, onPaste: onPasteProp }
430
+ }, [onChangeProp, onPasteProp])
431
+
432
+ const isDisabled = disabled || context.disabled
433
+ const isReadOnly = readOnly || context.readOnly
434
+ const isRequired = required || context.required
435
+ const isInvalid = errors[itemData.id]?.key !== undefined
436
+
437
+ const onChange = React.useCallback(
438
+ (event: React.ChangeEvent<KeyInputElement>) => {
439
+ const state = store.getState()
440
+ const newValue = state.value.map((item) => {
441
+ if (item.id !== itemData.id) return item
442
+ const updated = { ...item, key: event.target.value }
443
+ if (context.trim) updated.key = updated.key.trim()
444
+ return updated
445
+ })
446
+
447
+ store.setState("value", newValue)
448
+
449
+ const updatedItemData = newValue.find((item) => item.id === itemData.id)
450
+ if (updatedItemData) {
451
+ const errors: { key?: string; value?: string } = {}
452
+
453
+ if (context.onKeyValidate) {
454
+ const keyError = context.onKeyValidate(updatedItemData.key, newValue)
455
+ if (keyError) errors.key = keyError
456
+ }
457
+
458
+ if (!context.allowDuplicateKeys) {
459
+ const duplicateKey = newValue.find(
460
+ (item) =>
461
+ item.id !== updatedItemData.id &&
462
+ item.key === updatedItemData.key &&
463
+ updatedItemData.key !== "",
464
+ )
465
+ if (duplicateKey) {
466
+ errors.key = "Duplicate key"
467
+ }
468
+ }
469
+
470
+ if (context.onValueValidate) {
471
+ const valueError = context.onValueValidate(
472
+ updatedItemData.value,
473
+ updatedItemData.key,
474
+ newValue,
475
+ )
476
+ if (valueError) errors.value = valueError
477
+ }
478
+
479
+ const newErrorsState = { ...state.errors }
480
+ if (Object.keys(errors).length > 0) {
481
+ newErrorsState[itemData.id] = errors
482
+ } else {
483
+ delete newErrorsState[itemData.id]
484
+ }
485
+ store.setState("errors", newErrorsState)
486
+ }
487
+
488
+ propsRef.current.onChange?.(event)
489
+ },
490
+ [store, itemData.id, context],
491
+ )
492
+
493
+ const onPaste = React.useCallback(
494
+ (event: React.ClipboardEvent<KeyInputElement>) => {
495
+ if (!context.enablePaste) return
496
+
497
+ propsRef.current.onPaste?.(event)
498
+ if (event.defaultPrevented) return
499
+
500
+ const content = event.clipboardData.getData("text")
501
+ const lines = content.split(/\r?\n/).filter((line) => line.trim())
502
+
503
+ if (lines.length > 1) {
504
+ event.preventDefault()
505
+
506
+ const parsed: ItemData[] = []
507
+
508
+ for (const line of lines) {
509
+ let key = ""
510
+ let value = ""
511
+
512
+ if (line.includes("=")) {
513
+ const parts = line.split("=")
514
+ key = parts[0]?.trim() ?? ""
515
+ value = removeQuotes(
516
+ parts.slice(1).join("=").trim(),
517
+ context.stripQuotes,
518
+ )
519
+ } else if (line.includes(":")) {
520
+ const parts = line.split(":")
521
+ key = parts[0]?.trim() ?? ""
522
+ value = removeQuotes(
523
+ parts.slice(1).join(":").trim(),
524
+ context.stripQuotes,
525
+ )
526
+ } else if (/\s{2,}|\t/.test(line)) {
527
+ const parts = line.split(/\s{2,}|\t/)
528
+ key = parts[0]?.trim() ?? ""
529
+ value = removeQuotes(
530
+ parts.slice(1).join(" ").trim(),
531
+ context.stripQuotes,
532
+ )
533
+ }
534
+
535
+ if (key) {
536
+ parsed.push({ id: crypto.randomUUID(), key, value })
537
+ }
538
+ }
539
+
540
+ if (parsed.length > 0) {
541
+ const state = store.getState()
542
+ const currentIndex = state.value.findIndex(
543
+ (item) => item.id === itemData.id,
544
+ )
545
+
546
+ let newValue: ItemData[]
547
+ if (itemData.key === "" && itemData.value === "") {
548
+ newValue = [
549
+ ...state.value.slice(0, currentIndex),
550
+ ...parsed,
551
+ ...state.value.slice(currentIndex + 1),
552
+ ]
553
+ } else {
554
+ newValue = [
555
+ ...state.value.slice(0, currentIndex + 1),
556
+ ...parsed,
557
+ ...state.value.slice(currentIndex + 1),
558
+ ]
559
+ }
560
+
561
+ if (context.maxItems !== undefined) {
562
+ newValue = newValue.slice(0, context.maxItems)
563
+ }
564
+
565
+ store.setState("value", newValue)
566
+
567
+ if (context.onPaste) {
568
+ context.onPaste(
569
+ event.nativeEvent as unknown as ClipboardEvent,
570
+ parsed,
571
+ )
572
+ }
573
+ }
574
+ }
575
+ },
576
+ [context, store, itemData],
577
+ )
578
+
579
+ return (
580
+ <Input
581
+ aria-invalid={isInvalid}
582
+ aria-describedby={
583
+ isInvalid ? getErrorId(context.rootId, itemData.id, "key") : undefined
584
+ }
585
+ data-slot="key-value-key-input"
586
+ autoCapitalize="off"
587
+ autoComplete="off"
588
+ autoCorrect="off"
589
+ spellCheck="false"
590
+ disabled={isDisabled}
591
+ readOnly={isReadOnly}
592
+ required={isRequired}
593
+ placeholder={context.keyPlaceholder}
594
+ {...keyInputProps}
595
+ value={itemData.key}
596
+ onChange={onChange}
597
+ onPaste={onPaste}
598
+ />
599
+ )
600
+ }
601
+
602
+ interface KeyValueValueInputProps
603
+ extends Omit<React.ComponentProps<"textarea">, "rows"> {
604
+ maxRows?: number
605
+ asChild?: boolean
606
+ }
607
+
608
+ function KeyValueValueInput(props: KeyValueValueInputProps) {
609
+ const {
610
+ onChange: onChangeProp,
611
+ asChild,
612
+ disabled,
613
+ readOnly,
614
+ required,
615
+ className,
616
+ maxRows,
617
+ style,
618
+ ...valueInputProps
619
+ } = props
620
+
621
+ const context = useKeyValueContext(VALUE_INPUT_NAME)
622
+ const itemData = useKeyValueItemContext(VALUE_INPUT_NAME)
623
+ const store = useStoreContext(VALUE_INPUT_NAME)
624
+ const errors = useStore((state) => state.errors)
625
+
626
+ const propsRef = React.useRef({
627
+ onChange: onChangeProp,
628
+ })
629
+
630
+ React.useEffect(() => {
631
+ propsRef.current = { onChange: onChangeProp }
632
+ }, [onChangeProp])
633
+
634
+ const isDisabled = disabled || context.disabled
635
+ const isReadOnly = readOnly || context.readOnly
636
+ const isRequired = required || context.required
637
+ const isInvalid = errors[itemData.id]?.value !== undefined
638
+ const maxHeight = maxRows ? `calc(${maxRows} * 1.5em + 1rem)` : undefined
639
+
640
+ const onChange = React.useCallback(
641
+ (event: React.ChangeEvent<HTMLTextAreaElement>) => {
642
+ propsRef.current.onChange?.(event)
643
+
644
+ const state = store.getState()
645
+ const newValue = state.value.map((item) => {
646
+ if (item.id !== itemData.id) return item
647
+ const updated = { ...item, value: event.target.value }
648
+ if (context.trim) updated.value = updated.value.trim()
649
+ return updated
650
+ })
651
+
652
+ store.setState("value", newValue)
653
+
654
+ const updatedItemData = newValue.find((item) => item.id === itemData.id)
655
+ if (updatedItemData) {
656
+ const errors: { key?: string; value?: string } = {}
657
+
658
+ if (context.onKeyValidate) {
659
+ const keyError = context.onKeyValidate(updatedItemData.key, newValue)
660
+ if (keyError) errors.key = keyError
661
+ }
662
+
663
+ if (!context.allowDuplicateKeys) {
664
+ const duplicateKey = newValue.find(
665
+ (item) =>
666
+ item.id !== updatedItemData.id &&
667
+ item.key === updatedItemData.key &&
668
+ updatedItemData.key !== "",
669
+ )
670
+ if (duplicateKey) {
671
+ errors.key = "Duplicate key"
672
+ }
673
+ }
674
+
675
+ if (context.onValueValidate) {
676
+ const valueError = context.onValueValidate(
677
+ updatedItemData.value,
678
+ updatedItemData.key,
679
+ newValue,
680
+ )
681
+ if (valueError) errors.value = valueError
682
+ }
683
+
684
+ const newErrorsState = { ...state.errors }
685
+ if (Object.keys(errors).length > 0) {
686
+ newErrorsState[itemData.id] = errors
687
+ } else {
688
+ delete newErrorsState[itemData.id]
689
+ }
690
+ store.setState("errors", newErrorsState)
691
+ }
692
+ },
693
+ [store, itemData.id, context],
694
+ )
695
+
696
+ return (
697
+ <Textarea
698
+ aria-invalid={isInvalid}
699
+ aria-describedby={
700
+ isInvalid ? getErrorId(context.rootId, itemData.id, "value") : undefined
701
+ }
702
+ data-slot="key-value-value-input"
703
+ autoCapitalize="off"
704
+ autoComplete="off"
705
+ autoCorrect="off"
706
+ spellCheck="false"
707
+ disabled={isDisabled}
708
+ readOnly={isReadOnly}
709
+ required={isRequired}
710
+ placeholder={context.valuePlaceholder}
711
+ {...valueInputProps}
712
+ className={cn(
713
+ "field-sizing-content min-h-9 resize-none",
714
+ maxRows && "overflow-y-auto",
715
+ className,
716
+ )}
717
+ style={{
718
+ ...style,
719
+ ...(maxHeight && { maxHeight }),
720
+ }}
721
+ value={itemData.value}
722
+ onChange={onChange}
723
+ />
724
+ )
725
+ }
726
+
727
+ interface KeyValueRemoveProps extends React.ComponentProps<typeof Button> {}
728
+
729
+ function KeyValueRemove(props: KeyValueRemoveProps) {
730
+ const { onClick: onClickProp, children, ...removeProps } = props
731
+
732
+ const context = useKeyValueContext(REMOVE_NAME)
733
+ const itemData = useKeyValueItemContext(REMOVE_NAME)
734
+ const store = useStoreContext(REMOVE_NAME)
735
+
736
+ const propsRef = React.useRef({ onClick: onClickProp })
737
+ React.useEffect(() => {
738
+ propsRef.current = { onClick: onClickProp }
739
+ }, [onClickProp])
740
+
741
+ const value = useStore((state) => state.value)
742
+ const isDisabled = context.disabled || value.length <= context.minItems
743
+
744
+ const onClick = React.useCallback(
745
+ (event: React.MouseEvent<RemoveElement>) => {
746
+ propsRef.current.onClick?.(event)
747
+
748
+ const state = store.getState()
749
+ if (state.value.length <= context.minItems) return
750
+
751
+ const itemToRemove = state.value.find((item) => item.id === itemData.id)
752
+ if (!itemToRemove) return
753
+
754
+ const newValue = state.value.filter((item) => item.id !== itemData.id)
755
+ const newErrors = { ...state.errors }
756
+ delete newErrors[itemData.id]
757
+
758
+ store.setState("value", newValue)
759
+ store.setState("errors", newErrors)
760
+
761
+ context.onRemove?.(itemToRemove)
762
+ },
763
+ [store, context, itemData.id],
764
+ )
765
+
766
+ return (
767
+ <Button
768
+ type="button"
769
+ data-slot="key-value-remove"
770
+ variant="outline"
771
+ size="icon"
772
+ disabled={isDisabled}
773
+ {...removeProps}
774
+ onClick={onClick}
775
+ >
776
+ {children ?? <XIcon />}
777
+ </Button>
778
+ )
779
+ }
780
+
781
+ function KeyValueAdd(props: React.ComponentProps<typeof Button>) {
782
+ const { onClick: onClickProp, children, ...addProps } = props
783
+
784
+ const context = useKeyValueContext(ADD_NAME)
785
+ const store = useStoreContext(ADD_NAME)
786
+
787
+ const propsRef = React.useRef({ onClick: onClickProp })
788
+ React.useEffect(() => {
789
+ propsRef.current = { onClick: onClickProp }
790
+ }, [onClickProp])
791
+
792
+ const value = useStore((state) => state.value)
793
+ const isDisabled =
794
+ context.disabled ||
795
+ (context.maxItems !== undefined && value.length >= context.maxItems)
796
+
797
+ const onClick = React.useCallback(
798
+ (event: React.MouseEvent<AddElement>) => {
799
+ propsRef.current.onClick?.(event)
800
+
801
+ const state = store.getState()
802
+ if (
803
+ context.maxItems !== undefined &&
804
+ state.value.length >= context.maxItems
805
+ ) {
806
+ return
807
+ }
808
+
809
+ const newItem: ItemData = {
810
+ id: crypto.randomUUID(),
811
+ key: "",
812
+ value: "",
813
+ }
814
+
815
+ const newValue = [...state.value, newItem]
816
+ store.setState("value", newValue)
817
+ store.setState("focusedId", newItem.id)
818
+
819
+ context.onAdd?.(newItem)
820
+ },
821
+ [store, context],
822
+ )
823
+
824
+ return (
825
+ <Button
826
+ type="button"
827
+ data-slot="key-value-add"
828
+ variant="outline"
829
+ disabled={isDisabled}
830
+ {...addProps}
831
+ onClick={onClick}
832
+ >
833
+ {children ?? (
834
+ <>
835
+ <PlusIcon />
836
+ Add
837
+ </>
838
+ )}
839
+ </Button>
840
+ )
841
+ }
842
+
843
+ interface KeyValueErrorProps extends DivProps {
844
+ field: Field
845
+ }
846
+
847
+ function KeyValueError(props: KeyValueErrorProps) {
848
+ const { field, asChild, className, ...errorProps } = props
849
+
850
+ const context = useKeyValueContext(ERROR_NAME)
851
+ const itemData = useKeyValueItemContext(ERROR_NAME)
852
+
853
+ const errors = useStore((state) => state.errors)
854
+ const error = errors[itemData.id]?.[field]
855
+
856
+ if (!error) return null
857
+
858
+ const ErrorPrimitive = asChild ? Slot : "span"
859
+
860
+ return (
861
+ <ErrorPrimitive
862
+ id={getErrorId(context.rootId, itemData.id, field)}
863
+ role="alert"
864
+ {...errorProps}
865
+ className={cn("font-medium text-destructive text-sm", className)}
866
+ >
867
+ {error}
868
+ </ErrorPrimitive>
869
+ )
870
+ }
871
+
872
+ export {
873
+ type ItemData as KeyValueItemData,
874
+ KeyValue,
875
+ KeyValueAdd,
876
+ KeyValueError,
877
+ KeyValueItem,
878
+ KeyValueKeyInput,
879
+ KeyValueList,
880
+ type KeyValueProps,
881
+ KeyValueRemove,
882
+ KeyValueValueInput,
883
+ useStore as useKeyValueStore,
884
+ }