{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-edit",
  "type": "registry:ui",
  "title": "Inline Edit",
  "description": "Text that becomes an input in place, saves optimistically, then draws a tick.",
  "categories": [
    "inputs"
  ],
  "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/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/inline-edit.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, Loader, Pencil } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Mode = \"idle\" | \"editing\" | \"saving\" | \"saved\";\ntype SaveResult = void | string | undefined | null;\n\nexport type InlineEditProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** The saved text, when controlled. Only changes after a save succeeds. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Names the field for assistive tech, e.g. \"Project name\". */\n  label: string;\n  /** Persist the new text. Return or resolve a string, or throw, to stay in edit mode with that message. */\n  onSave?: (next: string) => SaveResult | Promise<SaveResult>;\n  /** Runs before saving. Return a message to keep editing. */\n  validate?: (next: string) => string | undefined | null;\n  /** Shown in the quiet style when the value is empty. */\n  placeholder?: string;\n  /** Trims whitespace before validating and saving. */\n  trim?: boolean;\n  maxLength?: number;\n  disabled?: boolean;\n};\n\nconst wait = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\nexport function InlineEdit({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  label,\n  onSave,\n  validate,\n  placeholder = \"Empty\",\n  trim = true,\n  maxLength,\n  disabled,\n  className,\n  ...rest\n}: InlineEditProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [mode, setMode] = useState<Mode>(\"idle\");\n  const [draft, setDraft] = useState(value);\n  const [pending, setPending] = useState<string | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [slow, setSlow] = useState(false);\n  const reduce = useReducedMotion();\n  const input = useRef<HTMLInputElement>(null);\n  const display = useRef<HTMLButtonElement>(null);\n  // Where focus should land after leaving edit mode: back on the text for Enter and Escape, nowhere for blur.\n  const refocus = useRef(false);\n  // True only while the input is live, so a blur that follows Enter, Escape or unmounting can't save twice.\n  const live = useRef(false);\n  const focusOnEdit = useRef(true);\n  const savedTimer = useRef<number>(undefined);\n  const errorId = useId();\n  const hintId = useId();\n\n  useEffect(() => () => window.clearTimeout(savedTimer.current), []);\n\n  useEffect(() => {\n    if (mode === \"editing\") {\n      const el = input.current;\n      if (el && focusOnEdit.current && document.activeElement !== el) {\n        el.focus();\n        el.select();\n        // Selecting scrolls a long value to its end; keep the start where the text just was.\n        el.scrollLeft = 0;\n      }\n      focusOnEdit.current = true;\n    } else if (refocus.current) {\n      refocus.current = false;\n      display.current?.focus();\n    }\n  }, [mode]);\n\n  const startEditing = () => {\n    if (disabled || mode === \"saving\") return;\n    window.clearTimeout(savedTimer.current);\n    live.current = true;\n    setDraft(value);\n    setError(null);\n    setMode(\"editing\");\n  };\n\n  const cancel = () => {\n    live.current = false;\n    refocus.current = true;\n    setError(null);\n    setMode(\"idle\");\n  };\n\n  const commit = async (raw: string, fromKeyboard: boolean) => {\n    if (!live.current) return;\n    const next = trim ? raw.trim() : raw;\n    if (next === value) {\n      live.current = false;\n      refocus.current = fromKeyboard;\n      setError(null);\n      setMode(\"idle\");\n      return;\n    }\n    const invalid = validate?.(next);\n    if (invalid) {\n      // Stays open with the message. Focus is left where the person put it; Enter already has it here.\n      setError(invalid);\n      return;\n    }\n\n    // Optimistic: the new text shows at once, with a spinner only if the save is slow.\n    live.current = false;\n    refocus.current = fromKeyboard;\n    setPending(next);\n    setError(null);\n    setMode(\"saving\");\n    const started = performance.now();\n    const slowTimer = window.setTimeout(() => setSlow(true), 150);\n    let failure: string | null = null;\n    try {\n      const result = await onSave?.(next);\n      if (typeof result === \"string\" && result) failure = result;\n    } catch (e) {\n      failure = e instanceof Error && e.message ? e.message : \"Couldn't save. Try again.\";\n    }\n    window.clearTimeout(slowTimer);\n    // A spinner that appeared stays at least 300ms, so it never reads as a flicker.\n    const elapsed = performance.now() - started;\n    if (elapsed > 150) await wait(Math.max(0, 450 - elapsed));\n    setSlow(false);\n    setPending(null);\n\n    if (failure) {\n      refocus.current = false;\n      // Reopen with the message; take focus back only if they were still here (Enter), never after they moved on.\n      focusOnEdit.current = fromKeyboard;\n      live.current = true;\n      setDraft(next);\n      setError(failure);\n      setMode(\"editing\");\n      return;\n    }\n    setValue(next);\n    setMode(\"saved\");\n    savedTimer.current = window.setTimeout(() => setMode((m) => (m === \"saved\" ? \"idle\" : m)), 1600);\n  };\n\n  const shown = pending ?? value;\n  const editing = mode === \"editing\";\n\n  // Both states share this box and inherit the root's type (set it with className): same font, padding,\n  // border width and line box, so the text never moves when it turns into an input.\n  const box = \"w-full min-w-0 rounded-md border px-2 py-[5px] text-left [font:inherit] [letter-spacing:inherit] outline-none pointer-coarse:[font-size:max(16px,1em)]\";\n\n  return (\n    <div\n      data-slot=\"inline-edit\"\n      data-state={mode}\n      data-invalid={error ? \"\" : undefined}\n      data-disabled={disabled || undefined}\n      className={cn(\"group/inline relative -mx-2 flex min-w-0 flex-col\", className)}\n      {...rest}\n    >\n      {editing ? (\n        <input\n          ref={input}\n          value={draft}\n          maxLength={maxLength}\n          aria-label={label}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={error ? errorId : undefined}\n          enterKeyHint=\"done\"\n          onChange={(e) => {\n            setDraft(e.target.value);\n            if (error) setError(null);\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" && !e.nativeEvent.isComposing) {\n              e.preventDefault();\n              commit(e.currentTarget.value, true);\n            } else if (e.key === \"Escape\") {\n              // Handled here so a surrounding dialog doesn't close along with the edit.\n              e.preventDefault();\n              e.stopPropagation();\n              cancel();\n            }\n          }}\n          onBlur={(e) => commit(e.currentTarget.value, false)}\n          className={cn(\n            box,\n            \"block bg-raised text-fg shadow-[var(--shadow)] ring-3 transition-[border-color,box-shadow] duration-150 ease-out\",\n            error ? \"border-danger/70 ring-danger/15\" : \"border-line-2 ring-fg/8\",\n          )}\n        />\n      ) : (\n        <button\n          ref={display}\n          type=\"button\"\n          disabled={disabled}\n          aria-label={`${label}: ${shown || placeholder}`}\n          aria-describedby={hintId}\n          aria-busy={mode === \"saving\" || undefined}\n          onClick={startEditing}\n          onKeyDown={(e) => {\n            if (e.key === \"F2\") {\n              e.preventDefault();\n              startEditing();\n            }\n          }}\n          className={cn(\n            box,\n            \"group/display relative flex items-center gap-1.5 border-transparent pr-8\",\n            \"transition-[background-color,color] duration-150 ease-out hover:bg-hover\",\n            \"focus-visible:bg-hover focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n            \"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent\",\n            mode === \"saving\" && \"cursor-progress\",\n          )}\n        >\n          <span className={cn(\"min-w-0 truncate\", shown ? \"text-fg\" : \"text-fg-4\")}>{shown || placeholder}</span>\n\n          {/* Spinner and tick share one slot right after the text, so the result lands where the eye already is. */}\n          <span aria-hidden className=\"relative grid size-3.5 shrink-0 place-items-center text-fg-3\">\n            <AnimatePresence initial={false}>\n              {mode === \"saving\" && slow && (\n                <motion.span\n                  key=\"spin\"\n                  className=\"absolute inset-0 grid place-items-center\"\n                  initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, scale: reduce ? 1 : 0.6, transition: { duration: 0.1 } }}\n                  transition={reduce ? { duration: 0.12 } : spring.pop}\n                >\n                  <Loader size={14} className=\"animate-spin\" />\n                </motion.span>\n              )}\n              {mode === \"saved\" && (\n                <motion.span\n                  key=\"tick\"\n                  className=\"absolute inset-0 grid place-items-center text-success\"\n                  initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.2 } }}\n                  transition={reduce ? { duration: 0.12 } : spring.pop}\n                >\n                  <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                    <motion.path\n                      d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                      initial={reduce ? false : { pathLength: 0 }}\n                      animate={{ pathLength: 1 }}\n                      transition={{ duration: 0.32, ease: ease.out, delay: 0.04 }}\n                    />\n                  </svg>\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n\n          {/* The pencil only surfaces on hover or keyboard focus; on touch it stays faintly visible. */}\n          <span\n            aria-hidden\n            className={cn(\n              \"absolute right-2 top-1/2 -translate-y-1/2 text-fg-4 opacity-0 transition-[opacity,translate] duration-150 ease-out\",\n              \"-translate-x-0.5 group-hover/display:translate-x-0 group-hover/display:opacity-100 group-focus-visible/display:translate-x-0 group-focus-visible/display:opacity-100\",\n              \"pointer-coarse:translate-x-0 pointer-coarse:opacity-60\",\n              (mode === \"saving\" || disabled) && \"hidden\",\n            )}\n          >\n            <Pencil size={14} />\n          </span>\n        </button>\n      )}\n\n      <div className=\"grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out-quart data-[open]:grid-rows-[1fr] motion-reduce:transition-none\" data-open={error ? \"\" : undefined}>\n        <div className=\"min-h-0 overflow-hidden\">\n          <AnimatePresence initial={false}>\n            {error && (\n              <motion.p\n                key=\"error\"\n                id={errorId}\n                className=\"flex items-start gap-1.5 px-2 pt-1.5 font-sans text-[12px] font-normal leading-4 tracking-normal text-danger\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.2, ease: ease.out }}\n              >\n                <Alert size={14} className=\"mt-px size-3.5 shrink-0\" />\n                <span className=\"min-w-0\">{error}</span>\n              </motion.p>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <span id={hintId} className=\"sr-only\">\n        Press Enter to edit\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {mode === \"saved\" ? `${label} saved` : error ?? \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/inline-edit"
}
