{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "key-value-list",
  "type": "registry:ui",
  "title": "Key-Value List",
  "description": "Details-panel pairs with copy, in-place edit and a wash that marks what changed.",
  "categories": [
    "tables"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/key-value-list.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport { useCopy } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUpRight, Copy, Loader, Pencil } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype ListContext = { divided: boolean };\nconst Ctx = createContext<ListContext>({ divided: true });\n\n/* -------------------------------------------------------------------------------------------------\n * List\n * -----------------------------------------------------------------------------------------------*/\n\nexport type KeyValueListProps = React.ComponentProps<\"dl\"> & {\n  /** Hairlines between rows. */\n  divided?: boolean;\n  /** Width of the label column. Under a 384px container, labels stack above values instead. */\n  labelWidth?: number;\n};\n\nexport function KeyValueList({ divided = true, labelWidth = 128, className, style, children, ...rest }: KeyValueListProps) {\n  return (\n    <Ctx.Provider value={{ divided }}>\n      <dl\n        style={{ \"--kv-label\": `${labelWidth}px`, ...style } as React.CSSProperties}\n        className={cn(\"@container flex w-full min-w-0 flex-col text-[13px]\", className)}\n        {...rest}\n      >\n        {children}\n      </dl>\n    </Ctx.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Row\n * -----------------------------------------------------------------------------------------------*/\n\nexport type KeyValueProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onCopy\"> & {\n  label: React.ReactNode;\n  children?: React.ReactNode;\n  /** Text to put on the clipboard. Pass it to show a copy button beside the value. */\n  copyValue?: string;\n  /** Monospace, for IDs, hashes, keys and hostnames. */\n  mono?: boolean;\n  /** \"middle\" keeps the end of a long string ID visible: dpl_8fK2…Ta9c. Needs a string child. */\n  truncate?: \"end\" | \"middle\" | \"none\";\n  /** Turns the value into a link; external URLs get an arrow and open in a new tab. */\n  href?: string;\n  /** Makes the value editable in place. Resolve to save; throw to keep the field open with an error. */\n  onSave?: (next: string) => void | Promise<unknown>;\n  /** The raw text being edited. Defaults to children when it's a string. */\n  editValue?: string;\n  /** Returns an error message, or null when the draft is fine. */\n  validate?: (draft: string) => string | null;\n  /** Shown in place of an empty value. */\n  emptyLabel?: string;\n};\n\nexport function KeyValue({\n  label,\n  children,\n  copyValue,\n  mono = false,\n  truncate = \"end\",\n  href,\n  onSave,\n  editValue,\n  validate,\n  emptyLabel = \"Not set\",\n  className,\n  ...rest\n}: KeyValueProps) {\n  const { divided } = useContext(Ctx);\n  const [editing, setEditing] = useState(false);\n  // Each copy or save bumps a counter; the flash replays on every bump.\n  const [flash, setFlash] = useState({ n: 0, tone: \"copied\" as \"copied\" | \"saved\" });\n  const editRef = useRef<HTMLButtonElement>(null);\n  const empty = children == null || children === \"\" || children === false;\n  const text = typeof children === \"string\" ? children : undefined;\n\n  const valueNode = empty ? (\n    <span className=\"text-fg-4\" aria-label={emptyLabel}>\n      —\n    </span>\n  ) : truncate === \"middle\" && text && text.length > 14 ? (\n    // The head shrinks with an ellipsis; the last six characters never do.\n    <span className=\"flex min-w-0\" title={text}>\n      <span className=\"truncate\">{text.slice(0, -6)}</span>\n      <span className=\"shrink-0\">{text.slice(-6)}</span>\n    </span>\n  ) : (\n    <span className={cn(\"min-w-0\", truncate === \"none\" ? \"break-words [overflow-wrap:anywhere]\" : \"truncate\")} title={truncate === \"end\" && text ? text : undefined}>\n      {children}\n    </span>\n  );\n\n  const external = href ? /^https?:\\/\\//.test(href) : false;\n\n  return (\n    <div\n      data-editing={editing || undefined}\n      className={cn(\n        \"group/kv relative grid min-h-10 grid-cols-[var(--kv-label)_minmax(0,1fr)] items-center gap-x-4 py-1.5\",\n        \"@max-sm:grid-cols-1 @max-sm:gap-y-0.5 @max-sm:py-2\",\n        divided && \"border-b border-line last:border-b-0\",\n        className,\n      )}\n      {...rest}\n    >\n      <dt className=\"self-start truncate pt-[5px] text-[12.5px] text-fg-3 @max-sm:pt-0\">{label}</dt>\n      <dd className=\"flex min-h-7 min-w-0 items-center gap-1\">\n        {editing && onSave ? (\n          <InlineEditor\n            initial={editValue ?? text ?? \"\"}\n            label={typeof label === \"string\" ? label : \"Value\"}\n            mono={mono}\n            validate={validate}\n            onSave={onSave}\n            onDone={(didSave) => {\n              setEditing(false);\n              if (didSave) setFlash((f) => ({ n: f.n + 1, tone: \"saved\" }));\n              // Hand focus back to the pencil so the keyboard user is where they started.\n              requestAnimationFrame(() => editRef.current?.focus());\n            }}\n          />\n        ) : (\n          <>\n            <span className={cn(\"relative isolate flex min-w-0 items-center\", mono ? \"font-mono text-[12px] tracking-[0.01em] text-fg\" : \"text-fg\")}>\n              {href && !empty ? (\n                <a\n                  href={href}\n                  target={external ? \"_blank\" : undefined}\n                  rel={external ? \"noopener noreferrer\" : undefined}\n                  className=\"group/link flex min-w-0 items-center gap-0.5 rounded-sm underline decoration-fg-4 underline-offset-[3px] outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n                >\n                  {valueNode}\n                  {external && (\n                    <ArrowUpRight size={12} className=\"shrink-0 text-fg-3 transition-transform duration-150 ease-out group-hover/link:-translate-y-px group-hover/link:translate-x-px\" />\n                  )}\n                </a>\n              ) : (\n                valueNode\n              )}\n              <Flash token={flash.n} tone={flash.tone} />\n            </span>\n            {(copyValue || onSave) && (\n              <span className=\"flex shrink-0 items-center\">\n                {copyValue && !empty && (\n                  <CopyAction text={copyValue} label={typeof label === \"string\" ? label : \"value\"} onCopied={() => setFlash((f) => ({ n: f.n + 1, tone: \"copied\" }))} />\n                )}\n                {onSave && (\n                  <RowAction ref={editRef} aria-label={`Edit ${typeof label === \"string\" ? label.toLowerCase() : \"value\"}`} onClick={() => setEditing(true)}>\n                    <Pencil size={13} />\n                  </RowAction>\n                )}\n              </span>\n            )}\n          </>\n        )}\n      </dd>\n    </div>\n  );\n}\n\n// Row actions stay hidden until the row is hovered or focused, and are always there on touch.\nfunction RowAction({ className, ...rest }: React.ComponentProps<\"button\">) {\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"relative grid size-6 place-items-center rounded-md text-fg-3 outline-none\",\n        \"opacity-0 transition-[opacity,background-color,color,scale] duration-150 group-hover/kv:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100\",\n        \"hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n        \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nfunction CopyAction({ text, label, onCopied }: { text: string; label: string; onCopied: () => void }) {\n  const { state, copy } = useCopy({ timeout: 1600, onCopied });\n  const reduce = useReducedMotion();\n  const name = state === \"copied\" ? \"Copied\" : state === \"failed\" ? \"Couldn’t copy\" : `Copy ${label.toLowerCase()}`;\n  return (\n    <>\n      <RowAction\n        aria-label={name}\n        title={name}\n        onClick={() => copy(text)}\n        data-state={state}\n        className={cn(state !== \"idle\" && \"opacity-100\", state === \"copied\" && \"text-fg\", state === \"failed\" && \"text-danger hover:text-danger\")}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={state}\n            className=\"grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n          >\n            {state === \"copied\" ? <DrawnMark d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" /> : state === \"failed\" ? <DrawnMark d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" /> : <Copy size={13} />}\n          </motion.span>\n        </AnimatePresence>\n      </RowAction>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {state === \"copied\" ? `${label} copied` : state === \"failed\" ? \"Couldn’t copy. Select the text and copy it instead.\" : \"\"}\n      </span>\n    </>\n  );\n}\n\nfunction DrawnMark({ d }: { d: string }) {\n  const reduce = useReducedMotion();\n  return (\n    <svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path d={d} initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.28, ease: ease.out, delay: 0.04 }} />\n    </svg>\n  );\n}\n\n// The value washes once and fades, right where you're looking: like a text selection after\n// a copy, green after a save. It says what just happened to which value.\nfunction Flash({ token, tone }: { token: number; tone: \"copied\" | \"saved\" }) {\n  const reduce = useReducedMotion();\n  if (!token) return null;\n  return (\n    <motion.span\n      key={token}\n      aria-hidden\n      className={cn(\"pointer-events-none absolute -inset-x-1 -inset-y-0.5 -z-1 rounded-md\", tone === \"saved\" ? \"bg-success-soft\" : \"bg-fg/12\")}\n      initial={{ opacity: 1 }}\n      animate={{ opacity: 0 }}\n      transition={{ duration: reduce ? 0.3 : 0.9, ease: ease.outQuart, delay: 0.2 }}\n    />\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Inline editor\n * -----------------------------------------------------------------------------------------------*/\n\nfunction InlineEditor({\n  initial,\n  label,\n  mono,\n  validate,\n  onSave,\n  onDone,\n}: {\n  initial: string;\n  label: string;\n  mono: boolean;\n  validate?: (draft: string) => string | null;\n  onSave: (next: string) => void | Promise<unknown>;\n  onDone: (saved: boolean) => void;\n}) {\n  const [draft, setDraft] = useState(initial);\n  const [busy, setBusy] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const errorId = useId();\n  const reduce = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  // Focus and select once on open, so typing replaces the old value.\n  useEffect(() => {\n    inputRef.current?.focus();\n    inputRef.current?.select();\n  }, []);\n\n  const submit = async () => {\n    if (busy) return;\n    const next = draft.trim();\n    if (next === initial.trim()) return onDone(false);\n    const problem = validate?.(next) ?? null;\n    if (problem) return setError(problem);\n    setBusy(true);\n    setError(null);\n    try {\n      await onSave(next);\n      onDone(true);\n    } catch {\n      setBusy(false);\n      setError(\"Couldn’t save. Try again.\");\n    }\n  };\n\n  return (\n    <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n      <div\n        className={cn(\n          \"relative flex h-7 items-center rounded-md border bg-frame transition-[border-color,box-shadow] duration-150\",\n          error ? \"border-danger ring-2 ring-danger-soft\" : \"border-fg-4 ring-2 ring-fg/10\",\n        )}\n      >\n        <input\n          ref={inputRef}\n          value={draft}\n          readOnly={busy}\n          aria-label={label}\n          aria-invalid={!!error || undefined}\n          aria-describedby={error ? errorId : undefined}\n          aria-busy={busy || undefined}\n          onChange={(e) => (setDraft(e.target.value), error && setError(null))}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              e.preventDefault();\n              submit();\n            } else if (e.key === \"Escape\") {\n              e.preventDefault();\n              e.stopPropagation();\n              onDone(false);\n            }\n          }}\n          // Clicking away keeps the edit: saving is what people expect when they leave a field they changed.\n          onBlur={() => {\n            if (!error) submit();\n          }}\n          className={cn(\n            \"h-full min-w-0 flex-1 bg-transparent px-2 text-base text-fg outline-none sm:text-[13px]\",\n            mono && \"font-mono sm:text-[12px]\",\n            busy && \"text-fg-2\",\n          )}\n        />\n        <AnimatePresence initial={false}>\n          {busy && (\n            <motion.span\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.14, delay: 0.12 }}\n              className=\"mr-2 grid place-items-center text-fg-3\"\n            >\n              <Loader size={13} className=\"animate-spin-slow\" />\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n      <AnimatePresence initial={false}>\n        {error && (\n          <motion.p\n            id={errorId}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -2 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.18, ease: ease.out }}\n            className=\"text-[12px] text-danger\"\n          >\n            {error}\n          </motion.p>\n        )}\n      </AnimatePresence>\n      <span className=\"sr-only\">Enter to save, Escape to cancel.</span>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Status value\n * -----------------------------------------------------------------------------------------------*/\n\nexport type KeyValueStatusProps = React.ComponentProps<\"span\"> & {\n  tone?: \"neutral\" | \"success\" | \"warning\" | \"danger\" | \"info\";\n  /** A soft ring pulses on the dot while the state is still changing (building, syncing). */\n  live?: boolean;\n};\n\nconst dot = { neutral: \"bg-fg-3\", success: \"bg-success\", warning: \"bg-warning\", danger: \"bg-danger\", info: \"bg-info\" } as const;\n\nexport function KeyValueStatus({ tone = \"neutral\", live = false, className, children, ...rest }: KeyValueStatusProps) {\n  return (\n    <span className={cn(\"inline-flex items-center gap-2 text-fg\", className)} {...rest}>\n      <span aria-hidden className=\"relative grid size-2 place-items-center\">\n        {live && <span className={cn(\"absolute inset-0 rounded-full motion-safe:animate-ping-soft\", dot[tone])} />}\n        <span className={cn(\"relative size-1.5 rounded-full\", dot[tone])} />\n      </span>\n      {children}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/key-value-list"
}
