key-value

PreviousNext
Docs
diceuiui

Preview

Loading preview…
ui/key-value.tsx
"use client";

import { Slot } from "@radix-ui/react-slot";
import { PlusIcon, XIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/registry/default/components/visually-hidden-input";
import { useAsRef } from "@/registry/default/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/registry/default/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/registry/default/hooks/use-lazy-ref";

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 = React.ComponentRef<typeof KeyValue>;
type KeyInputElement = React.ComponentRef<typeof KeyValueKeyInput>;
type RemoveElement = React.ComponentRef<typeof KeyValueRemove>;
type AddElement = React.ComponentRef<typeof KeyValueAdd>;

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: <K extends keyof KeyValueState>(
    key: K,
    value: KeyValueState[K],
  ) => void;
  notify: () => void;
}

function useStore<T>(
  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<string, { key?: string; value?: string }>;
}

const StoreContext = React.createContext<Store | null>(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<KeyValueContextValue | null>(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<DivProps, "onPaste" | "defaultValue"> {
  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<RootElement | null>(
    null,
  );
  const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
  const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;

  const listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<KeyValueState>(() => ({
    value: valueProp ??
      defaultValue ?? [{ id: crypto.randomUUID(), key: "", value: "" }],
    focusedId: null,
    errors: {},
  }));
  const propsRef = useAsRef({ onValueChange });

  const store = React.useMemo<Store>(() => {
    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();
        }
      },
    };
  }, [listenersRef, stateRef, propsRef]);

  const value = useStore((state) => state.value, store);
  const errors = useStore((state) => state.errors, store);
  const isInvalid = Object.keys(errors).length > 0;

  useIsomorphicLayoutEffect(() => {
    if (valueProp !== undefined) {
      store.setState("value", valueProp);
    }
  }, [valueProp]);

  const contextValue = React.useMemo<KeyValueContextValue>(
    () => ({
      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 (
    <StoreContext.Provider value={store}>
      <KeyValueContext.Provider value={contextValue}>
        <RootPrimitive
          id={id}
          data-slot="key-value"
          data-disabled={disabled ? "" : undefined}
          data-invalid={isInvalid ? "" : undefined}
          data-readonly={readOnly ? "" : undefined}
          {...rootProps}
          ref={composedRef}
          className={cn("flex flex-col gap-2", className)}
        />
        {isFormControl && (
          <VisuallyHiddenInput
            type="hidden"
            control={formTrigger}
            name={name}
            value={value}
            disabled={disabled}
            readOnly={readOnly}
            required={required}
          />
        )}
      </KeyValueContext.Provider>
    </StoreContext.Provider>
  );
}

interface KeyValueListProps extends DivProps {
  orientation?: Orientation;
}

function KeyValueList(props: KeyValueListProps) {
  const { orientation = "vertical", asChild, className, ...listProps } = props;

  const value = useStore((state) => state.value);

  const ListPrimitive = asChild ? Slot : "div";

  return (
    <ListPrimitive
      role="list"
      aria-orientation={orientation}
      data-slot="key-value-list"
      data-orientation={orientation}
      {...listProps}
      className={cn(
        "flex",
        orientation === "vertical" ? "flex-col gap-2" : "flex-row gap-2",
        className,
      )}
    >
      {value.map((item) => {
        const children = React.Children.toArray(props.children);

        return (
          <KeyValueItemContext.Provider key={item.id} value={item}>
            {children}
          </KeyValueItemContext.Provider>
        );
      })}
    </ListPrimitive>
  );
}

const KeyValueItemContext = React.createContext<ItemData | null>(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 (
    <ItemPrimitive
      role="listitem"
      data-slot="key-value-item"
      data-highlighted={focusedId === itemData.id ? "" : undefined}
      {...itemProps}
      className={cn("flex items-start gap-2", className)}
    />
  );
}

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 = useAsRef({
    onChange: onChangeProp,
    onPaste: 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<KeyInputElement>) => {
      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, propsRef],
  );

  const onPaste = React.useCallback(
    (event: React.ClipboardEvent<KeyInputElement>) => {
      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, propsRef],
  );

  const KeyInputPrimitive = asChild ? Slot : Input;

  return (
    <KeyInputPrimitive
      aria-invalid={isInvalid}
      aria-describedby={
        isInvalid ? getErrorId(context.rootId, itemData.id, "key") : undefined
      }
      data-slot="key-value-key-input"
      autoCapitalize="off"
      autoComplete="off"
      autoCorrect="off"
      spellCheck="false"
      disabled={isDisabled}
      readOnly={isReadOnly}
      required={isRequired}
      placeholder={context.keyPlaceholder}
      {...keyInputProps}
      value={itemData.key}
      onChange={onChange}
      onPaste={onPaste}
    />
  );
}

interface KeyValueValueInputProps
  extends Omit<React.ComponentProps<"textarea">, "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 = useAsRef({
    onChange: 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<HTMLTextAreaElement>) => {
      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, propsRef],
  );

  const ValueInputPrimitive = asChild ? Slot : Textarea;

  return (
    <ValueInputPrimitive
      aria-invalid={isInvalid}
      aria-describedby={
        isInvalid ? getErrorId(context.rootId, itemData.id, "value") : undefined
      }
      data-slot="key-value-value-input"
      autoCapitalize="off"
      autoComplete="off"
      autoCorrect="off"
      spellCheck="false"
      disabled={isDisabled}
      readOnly={isReadOnly}
      required={isRequired}
      placeholder={context.valuePlaceholder}
      {...valueInputProps}
      className={cn(
        "field-sizing-content min-h-9 resize-none",
        maxRows && "overflow-y-auto",
        className,
      )}
      style={{
        ...style,
        ...(maxHeight && { maxHeight }),
      }}
      value={itemData.value}
      onChange={onChange}
    />
  );
}

interface KeyValueRemoveProps extends React.ComponentProps<typeof Button> {}

function KeyValueRemove(props: KeyValueRemoveProps) {
  const { onClick: onClickProp, children, ...removeProps } = props;

  const context = useKeyValueContext(REMOVE_NAME);
  const itemData = useKeyValueItemContext(REMOVE_NAME);
  const store = useStoreContext(REMOVE_NAME);

  const propsRef = useAsRef({
    onClick: onClickProp,
  });
  const value = useStore((state) => state.value);
  const isDisabled = context.disabled || value.length <= context.minItems;

  const onClick = React.useCallback(
    (event: React.MouseEvent<RemoveElement>) => {
      propsRef.current.onClick?.(event);

      const state = store.getState();
      if (state.value.length <= context.minItems) return;

      const itemToRemove = state.value.find((item) => item.id === itemData.id);
      if (!itemToRemove) return;

      const newValue = state.value.filter((item) => item.id !== itemData.id);
      const newErrors = { ...state.errors };
      delete newErrors[itemData.id];

      store.setState("value", newValue);
      store.setState("errors", newErrors);

      context.onRemove?.(itemToRemove);
    },
    [store, context, itemData.id, propsRef],
  );

  return (
    <Button
      type="button"
      data-slot="key-value-remove"
      variant="outline"
      size="icon"
      disabled={isDisabled}
      {...removeProps}
      onClick={onClick}
    >
      {children ?? <XIcon />}
    </Button>
  );
}

function KeyValueAdd(props: React.ComponentProps<typeof Button>) {
  const { onClick: onClickProp, children, ...addProps } = props;

  const context = useKeyValueContext(ADD_NAME);
  const store = useStoreContext(ADD_NAME);

  const propsRef = useAsRef({
    onClick: onClickProp,
  });
  const value = useStore((state) => state.value);
  const isDisabled =
    context.disabled ||
    (context.maxItems !== undefined && value.length >= context.maxItems);

  const onClick = React.useCallback(
    (event: React.MouseEvent<AddElement>) => {
      propsRef.current.onClick?.(event);

      const state = store.getState();
      if (
        context.maxItems !== undefined &&
        state.value.length >= context.maxItems
      ) {
        return;
      }

      const newItem: ItemData = {
        id: crypto.randomUUID(),
        key: "",
        value: "",
      };

      const newValue = [...state.value, newItem];
      store.setState("value", newValue);
      store.setState("focusedId", newItem.id);

      context.onAdd?.(newItem);
    },
    [store, context, propsRef],
  );

  return (
    <Button
      type="button"
      data-slot="key-value-add"
      variant="outline"
      disabled={isDisabled}
      {...addProps}
      onClick={onClick}
    >
      {children ?? (
        <>
          <PlusIcon />
          Add
        </>
      )}
    </Button>
  );
}

interface KeyValueErrorProps extends DivProps {
  field: Field;
}

function KeyValueError(props: KeyValueErrorProps) {
  const { field, asChild, className, ...errorProps } = props;

  const context = useKeyValueContext(ERROR_NAME);
  const itemData = useKeyValueItemContext(ERROR_NAME);

  const errors = useStore((state) => state.errors);
  const error = errors[itemData.id]?.[field];

  if (!error) return null;

  const ErrorPrimitive = asChild ? Slot : "span";

  return (
    <ErrorPrimitive
      id={getErrorId(context.rootId, itemData.id, field)}
      role="alert"
      {...errorProps}
      className={cn("font-medium text-destructive text-sm", className)}
    >
      {error}
    </ErrorPrimitive>
  );
}

export {
  KeyValue,
  KeyValueList,
  KeyValueItem,
  KeyValueKeyInput,
  KeyValueValueInput,
  KeyValueRemove,
  KeyValueAdd,
  KeyValueError,
  //
  useStore as useKeyValueStore,
  //
  type KeyValueProps,
  type ItemData as KeyValueItemData,
};

Installation

npx shadcn@latest add @diceui/key-value

Usage

import { KeyValue } from "@/components/ui/key-value"
<KeyValue />