@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,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
|
+
}
|