"use client" import { PlusIcon, XIcon } from "lucide-react" import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cn } from "../../../lib/utils" import { Button } from "../../forms/button" import { Input } from "../../forms/input" import { Textarea } from "../../forms/textarea" const ROOT_NAME = "KeyValue" const LIST_NAME = "KeyValueList" const ITEM_NAME = "KeyValueItem" const KEY_INPUT_NAME = "KeyValueKeyInput" const VALUE_INPUT_NAME = "KeyValueValueInput" const REMOVE_NAME = "KeyValueRemove" const ADD_NAME = "KeyValueAdd" const ERROR_NAME = "KeyValueError" type Orientation = "vertical" | "horizontal" type Field = "key" | "value" interface DivProps extends React.ComponentProps<"div"> { asChild?: boolean } type RootElement = HTMLDivElement type KeyInputElement = HTMLInputElement type RemoveElement = HTMLButtonElement type AddElement = HTMLButtonElement function getErrorId(rootId: string, itemId: string, field: Field) { return `${rootId}-${itemId}-${field}-error` } function removeQuotes(string: string, shouldStrip: boolean): string { if (!shouldStrip) return string const trimmed = string.trim() if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1) } return trimmed } interface Store { subscribe: (callback: () => void) => () => void getState: () => KeyValueState setState: ( key: K, value: KeyValueState[K], ) => void notify: () => void } function useStore( selector: (state: KeyValueState) => T, ogStore?: Store | null, ): T { const contextStore = React.useContext(StoreContext) const store = ogStore ?? contextStore if (!store) { throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``) } const getSnapshot = React.useCallback( () => selector(store.getState()), [store, selector], ) return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot) } interface ItemData { id: string key: string value: string } interface KeyValueState { value: ItemData[] focusedId: string | null errors: Record } const StoreContext = React.createContext(null) function useStoreContext(consumerName: string) { const context = React.useContext(StoreContext) if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``) } return context } interface KeyValueContextValue { onPaste?: (event: ClipboardEvent, items: ItemData[]) => void onAdd?: (value: ItemData) => void onRemove?: (value: ItemData) => void onKeyValidate?: (key: string, value: ItemData[]) => string | undefined onValueValidate?: ( value: string, key: string, items: ItemData[], ) => string | undefined rootId: string maxItems?: number minItems: number keyPlaceholder: string valuePlaceholder: string allowDuplicateKeys: boolean enablePaste: boolean trim: boolean stripQuotes: boolean disabled: boolean readOnly: boolean required: boolean } const KeyValueContext = React.createContext(null) function useKeyValueContext(consumerName: string) { const context = React.useContext(KeyValueContext) if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``) } return context } interface KeyValueProps extends Omit { id?: string defaultValue?: ItemData[] value?: ItemData[] onValueChange?: (value: ItemData[]) => void maxItems?: number minItems?: number keyPlaceholder?: string valuePlaceholder?: string name?: string allowDuplicateKeys?: boolean enablePaste?: boolean trim?: boolean stripQuotes?: boolean disabled?: boolean readOnly?: boolean required?: boolean onPaste?: (event: ClipboardEvent, items: ItemData[]) => void onAdd?: (value: ItemData) => void onRemove?: (value: ItemData) => void onKeyValidate?: (key: string, value: ItemData[]) => string | undefined onValueValidate?: ( value: string, key: string, items: ItemData[], ) => string | undefined } function KeyValue(props: KeyValueProps) { const { value: valueProp, defaultValue, onValueChange, onPaste, onAdd, onRemove, onKeyValidate, onValueValidate, maxItems, minItems = 0, keyPlaceholder = "Key", valuePlaceholder = "Value", allowDuplicateKeys = false, asChild, enablePaste = true, trim = true, stripQuotes = true, disabled = false, readOnly = false, required = false, className, id, name, ref, ...rootProps } = props const instanceId = React.useId() const rootId = id ?? instanceId const [formTrigger, setFormTrigger] = React.useState( null, ) const composedRef = React.useCallback( (node: RootElement | null) => { setFormTrigger(node) if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } }, [ref], ) const isFormControl = formTrigger ? !!formTrigger.closest("form") : true const listenersRef = React.useRef void>>(new Set()) const stateRef = React.useRef({ value: valueProp ?? defaultValue ?? [{ id: crypto.randomUUID(), key: "", value: "" }], focusedId: null, errors: {}, }) const propsRef = React.useRef({ onValueChange }) React.useEffect(() => { propsRef.current = { onValueChange } }, [onValueChange]) const store = React.useMemo(() => { return { subscribe: (cb) => { listenersRef.current.add(cb) return () => listenersRef.current.delete(cb) }, getState: () => stateRef.current, setState: (key, val) => { if (Object.is(stateRef.current[key], val)) return if (key === "value" && Array.isArray(val)) { stateRef.current.value = val as ItemData[] propsRef.current.onValueChange?.(val as ItemData[]) } else { stateRef.current[key] = val } store.notify() }, notify: () => { for (const cb of listenersRef.current) { cb() } }, } }, []) const value = useStore((state) => state.value, store) const errors = useStore((state) => state.errors, store) const isInvalid = Object.keys(errors).length > 0 React.useEffect(() => { if (valueProp !== undefined) { store.setState("value", valueProp) } }, [valueProp, store]) const contextValue = React.useMemo( () => ({ onPaste, onAdd, onRemove, onKeyValidate, onValueValidate, rootId, maxItems, minItems, keyPlaceholder, valuePlaceholder, allowDuplicateKeys, enablePaste, trim, stripQuotes, disabled, readOnly, required, }), [ onPaste, onAdd, onRemove, onKeyValidate, onValueValidate, rootId, disabled, readOnly, required, maxItems, minItems, keyPlaceholder, valuePlaceholder, allowDuplicateKeys, enablePaste, trim, stripQuotes, ], ) const RootPrimitive = asChild ? Slot : "div" return ( {isFormControl && name && ( )} ) } interface KeyValueListProps extends DivProps { orientation?: Orientation } function KeyValueList(props: KeyValueListProps) { const { orientation = "vertical", asChild, className, children, ...listProps } = props const value = useStore((state) => state.value) const ListPrimitive = asChild ? Slot : "div" return ( {value.map((item) => { const childrenArray = React.Children.toArray(children) return ( {childrenArray} ) })} ) } const KeyValueItemContext = React.createContext(null) function useKeyValueItemContext(consumerName: string) { const context = React.useContext(KeyValueItemContext) if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${LIST_NAME}\``) } return context } interface KeyValueItemProps extends React.ComponentProps<"div"> { asChild?: boolean } function KeyValueItem(props: KeyValueItemProps) { const { asChild, className, ...itemProps } = props const itemData = useKeyValueItemContext(ITEM_NAME) const focusedId = useStore((state) => state.focusedId) const ItemPrimitive = asChild ? Slot : "div" return ( ) } interface KeyValueKeyInputProps extends React.ComponentProps<"input"> { asChild?: boolean } function KeyValueKeyInput(props: KeyValueKeyInputProps) { const { onChange: onChangeProp, onPaste: onPasteProp, asChild, disabled, readOnly, required, ...keyInputProps } = props const context = useKeyValueContext(KEY_INPUT_NAME) const itemData = useKeyValueItemContext(KEY_INPUT_NAME) const store = useStoreContext(KEY_INPUT_NAME) const errors = useStore((state) => state.errors) const propsRef = React.useRef({ onChange: onChangeProp, onPaste: onPasteProp, }) React.useEffect(() => { propsRef.current = { onChange: onChangeProp, onPaste: onPasteProp } }, [onChangeProp, onPasteProp]) const isDisabled = disabled || context.disabled const isReadOnly = readOnly || context.readOnly const isRequired = required || context.required const isInvalid = errors[itemData.id]?.key !== undefined const onChange = React.useCallback( (event: React.ChangeEvent) => { const state = store.getState() const newValue = state.value.map((item) => { if (item.id !== itemData.id) return item const updated = { ...item, key: event.target.value } if (context.trim) updated.key = updated.key.trim() return updated }) store.setState("value", newValue) const updatedItemData = newValue.find((item) => item.id === itemData.id) if (updatedItemData) { const errors: { key?: string; value?: string } = {} if (context.onKeyValidate) { const keyError = context.onKeyValidate(updatedItemData.key, newValue) if (keyError) errors.key = keyError } if (!context.allowDuplicateKeys) { const duplicateKey = newValue.find( (item) => item.id !== updatedItemData.id && item.key === updatedItemData.key && updatedItemData.key !== "", ) if (duplicateKey) { errors.key = "Duplicate key" } } if (context.onValueValidate) { const valueError = context.onValueValidate( updatedItemData.value, updatedItemData.key, newValue, ) if (valueError) errors.value = valueError } const newErrorsState = { ...state.errors } if (Object.keys(errors).length > 0) { newErrorsState[itemData.id] = errors } else { delete newErrorsState[itemData.id] } store.setState("errors", newErrorsState) } propsRef.current.onChange?.(event) }, [store, itemData.id, context], ) const onPaste = React.useCallback( (event: React.ClipboardEvent) => { if (!context.enablePaste) return propsRef.current.onPaste?.(event) if (event.defaultPrevented) return const content = event.clipboardData.getData("text") const lines = content.split(/\r?\n/).filter((line) => line.trim()) if (lines.length > 1) { event.preventDefault() const parsed: ItemData[] = [] for (const line of lines) { let key = "" let value = "" if (line.includes("=")) { const parts = line.split("=") key = parts[0]?.trim() ?? "" value = removeQuotes( parts.slice(1).join("=").trim(), context.stripQuotes, ) } else if (line.includes(":")) { const parts = line.split(":") key = parts[0]?.trim() ?? "" value = removeQuotes( parts.slice(1).join(":").trim(), context.stripQuotes, ) } else if (/\s{2,}|\t/.test(line)) { const parts = line.split(/\s{2,}|\t/) key = parts[0]?.trim() ?? "" value = removeQuotes( parts.slice(1).join(" ").trim(), context.stripQuotes, ) } if (key) { parsed.push({ id: crypto.randomUUID(), key, value }) } } if (parsed.length > 0) { const state = store.getState() const currentIndex = state.value.findIndex( (item) => item.id === itemData.id, ) let newValue: ItemData[] if (itemData.key === "" && itemData.value === "") { newValue = [ ...state.value.slice(0, currentIndex), ...parsed, ...state.value.slice(currentIndex + 1), ] } else { newValue = [ ...state.value.slice(0, currentIndex + 1), ...parsed, ...state.value.slice(currentIndex + 1), ] } if (context.maxItems !== undefined) { newValue = newValue.slice(0, context.maxItems) } store.setState("value", newValue) if (context.onPaste) { context.onPaste( event.nativeEvent as unknown as ClipboardEvent, parsed, ) } } } }, [context, store, itemData], ) return ( ) } interface KeyValueValueInputProps extends Omit, "rows"> { maxRows?: number asChild?: boolean } function KeyValueValueInput(props: KeyValueValueInputProps) { const { onChange: onChangeProp, asChild, disabled, readOnly, required, className, maxRows, style, ...valueInputProps } = props const context = useKeyValueContext(VALUE_INPUT_NAME) const itemData = useKeyValueItemContext(VALUE_INPUT_NAME) const store = useStoreContext(VALUE_INPUT_NAME) const errors = useStore((state) => state.errors) const propsRef = React.useRef({ onChange: onChangeProp, }) React.useEffect(() => { propsRef.current = { onChange: onChangeProp } }, [onChangeProp]) const isDisabled = disabled || context.disabled const isReadOnly = readOnly || context.readOnly const isRequired = required || context.required const isInvalid = errors[itemData.id]?.value !== undefined const maxHeight = maxRows ? `calc(${maxRows} * 1.5em + 1rem)` : undefined const onChange = React.useCallback( (event: React.ChangeEvent) => { propsRef.current.onChange?.(event) const state = store.getState() const newValue = state.value.map((item) => { if (item.id !== itemData.id) return item const updated = { ...item, value: event.target.value } if (context.trim) updated.value = updated.value.trim() return updated }) store.setState("value", newValue) const updatedItemData = newValue.find((item) => item.id === itemData.id) if (updatedItemData) { const errors: { key?: string; value?: string } = {} if (context.onKeyValidate) { const keyError = context.onKeyValidate(updatedItemData.key, newValue) if (keyError) errors.key = keyError } if (!context.allowDuplicateKeys) { const duplicateKey = newValue.find( (item) => item.id !== updatedItemData.id && item.key === updatedItemData.key && updatedItemData.key !== "", ) if (duplicateKey) { errors.key = "Duplicate key" } } if (context.onValueValidate) { const valueError = context.onValueValidate( updatedItemData.value, updatedItemData.key, newValue, ) if (valueError) errors.value = valueError } const newErrorsState = { ...state.errors } if (Object.keys(errors).length > 0) { newErrorsState[itemData.id] = errors } else { delete newErrorsState[itemData.id] } store.setState("errors", newErrorsState) } }, [store, itemData.id, context], ) return (