{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-edit",
  "type": "registry:ui",
  "title": "Inline Edit",
  "description": "Click-to-edit text that becomes a field without moving a pixel, with async save, revert-on-error and validation.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/inline-edit.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport type InlineEditSize = \"sm\" | \"md\" | \"lg\";\n\n// Text and field share these classes, so entering edit mode never changes the line box.\nconst TYPE: Record<InlineEditSize, string> = {\n  sm: \"text-[14px] leading-6\",\n  md: \"text-[18px] font-medium leading-7 tracking-[-0.01em]\",\n  lg: \"text-[28px] font-semibold leading-9 tracking-[-0.025em]\",\n};\n\nexport interface InlineEditProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Controlled text. */\n  value?: string;\n  /** Initial text when uncontrolled. */\n  defaultValue?: string;\n  /** Field name used in the accessible label: \"Edit {label}: {value}\". */\n  label?: string;\n  /**\n   * Persists the new text. Return a promise for async saves: the new text shows as pending, and a\n   * rejection reverts to the previous text and shows the error's message.\n   */\n  onSave?: (next: string) => void | Promise<void>;\n  /** Called when editing is cancelled (Escape). */\n  onCancel?: () => void;\n  /** Return an error message to block saving, or nothing when valid. */\n  validate?: (next: string) => string | null | undefined;\n  /** Edit in a textarea: Enter adds a line, Ctrl/⌘+Enter saves. */\n  multiline?: boolean;\n  /** Shown (muted) when the value is empty. */\n  placeholder?: string;\n  size?: InlineEditSize;\n  /** Always show the pencil affordance (otherwise it appears on hover and focus, and always on touch). */\n  showEditButton?: boolean;\n  /** Save when focus leaves the field (otherwise blur cancels). */\n  saveOnBlur?: boolean;\n  /** Select the existing text when editing starts. */\n  selectOnEdit?: boolean;\n  maxLength?: number;\n  /** Focus ring and field outline colour (any CSS colour). */\n  accent?: string;\n  disabled?: boolean;\n}\n\ntype Status = { kind: \"idle\" } | { kind: \"pending\"; next: string } | { kind: \"error\"; message: string };\n\nconst errorMessage = (e: unknown) =>\n  e instanceof Error && e.message ? e.message : typeof e === \"string\" && e ? e : \"Something went wrong.\";\n\n/**\n * Click-to-edit text. The text is a button (\"Edit title: …\"); activating it swaps in an input\n * that is sized by an invisible copy of its own text, so the line box never moves. Enter (or\n * blur) saves, Escape cancels, async saves show a pending state and revert with a message on\n * failure, and focus returns to the text after a keyboard save or cancel.\n */\nexport function InlineEdit({\n  value,\n  defaultValue = \"\",\n  label = \"text\",\n  onSave,\n  onCancel,\n  validate,\n  multiline = false,\n  placeholder = \"Untitled\",\n  size = \"md\",\n  showEditButton = false,\n  saveOnBlur = true,\n  selectOnEdit = true,\n  maxLength,\n  accent = \"#FF7448\",\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: InlineEditProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const errorId = `ie-${uid}-error`;\n  const hintId = `ie-${uid}-hint`;\n\n  const [inner, setInner] = React.useState(defaultValue);\n  const committed = value ?? inner;\n  const [editing, setEditing] = React.useState(false);\n  const [draft, setDraft] = React.useState(committed);\n  const [invalid, setInvalid] = React.useState<string | null>(null);\n  const [status, setStatus] = React.useState<Status>({ kind: \"idle\" });\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const fieldRef = React.useRef<HTMLInputElement & HTMLTextAreaElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const refocusTrigger = React.useRef(false);\n  const saveToken = React.useRef(0);\n\n  const pending = status.kind === \"pending\";\n  const shown = pending ? status.next : committed;\n\n  // Focus moves after the swap has rendered: into the field, or back to the text.\n  React.useEffect(() => {\n    if (editing) {\n      const field = fieldRef.current;\n      field?.focus({ preventScroll: true });\n      if (field && selectOnEdit) field.select();\n      else field?.setSelectionRange(field.value.length, field.value.length);\n    } else if (refocusTrigger.current) {\n      refocusTrigger.current = false;\n      triggerRef.current?.focus({ preventScroll: true });\n    }\n  }, [editing, selectOnEdit]);\n\n  // Ignore late results from a save that the component has outlived.\n  React.useEffect(() => () => void (saveToken.current += 1), []);\n\n  const shake = () => {\n    if (reduceMotion) return;\n    rootRef.current?.animate(\n      [{ transform: \"translateX(0)\" }, { transform: \"translateX(-5px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"translateX(0)\" }],\n      { duration: 320, easing: \"ease-out\" },\n    );\n  };\n\n  const startEditing = () => {\n    if (disabled || pending) return;\n    setDraft(committed);\n    setInvalid(null);\n    if (status.kind === \"error\") setStatus({ kind: \"idle\" });\n    setEditing(true);\n  };\n\n  const cancel = (refocus: boolean) => {\n    refocusTrigger.current = refocus;\n    setEditing(false);\n    setInvalid(null);\n    setDraft(committed);\n    onCancel?.();\n  };\n\n  const save = (refocus: boolean) => {\n    const next = multiline ? draft.replace(/\\s+$/, \"\") : draft.trim();\n    if (next === committed) {\n      refocusTrigger.current = refocus;\n      setEditing(false);\n      setInvalid(null);\n      return;\n    }\n    const problem = validate?.(next);\n    if (problem) {\n      setInvalid(problem);\n      shake();\n      // Stay in edit mode with the message; focus is never pulled back on blur.\n      return;\n    }\n    refocusTrigger.current = refocus;\n    setInvalid(null);\n    setEditing(false);\n\n    let result: void | Promise<void>;\n    try {\n      result = onSave?.(next);\n    } catch (e) {\n      setStatus({ kind: \"error\", message: errorMessage(e) });\n      shake();\n      return;\n    }\n    if (!result || typeof (result as Promise<void>).then !== \"function\") {\n      if (value === undefined) setInner(next);\n      setStatus({ kind: \"idle\" });\n      return;\n    }\n    const token = ++saveToken.current;\n    setStatus({ kind: \"pending\", next });\n    (result as Promise<void>).then(\n      () => {\n        if (token !== saveToken.current) return;\n        if (value === undefined) setInner(next);\n        setStatus({ kind: \"idle\" });\n      },\n      (e: unknown) => {\n        if (token !== saveToken.current) return;\n        // The previous text stays committed, so the view reverts on its own.\n        setStatus({ kind: \"error\", message: errorMessage(e) });\n        shake();\n      },\n    );\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n    if (event.nativeEvent.isComposing) return;\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      cancel(true);\n    } else if (event.key === \"Enter\" && (!multiline || event.metaKey || event.ctrlKey)) {\n      event.preventDefault();\n      save(true);\n    }\n  };\n\n  const type = TYPE[size];\n  const box = `rounded-lg border px-2 py-0.5 -mx-2 ${type}`;\n  const pencilVisible = showEditButton\n    ? \"opacity-100\"\n    : \"opacity-0 group-hover/ie:opacity-100 group-focus-visible/ie:opacity-100 [@media(hover:none)]:opacity-100\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative min-w-0 ${multiline ? \"w-full\" : \"max-w-full\"} ${className}`}\n      style={{ [\"--ie-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {editing ? (\n        <div\n          className={`grid ${box} border-[color-mix(in_srgb,var(--ie-accent)_70%,transparent)] bg-white shadow-[0_0_0_3px_color-mix(in_srgb,var(--ie-accent)_18%,transparent)] dark:bg-zinc-950 ${\n            invalid ? \"!border-rose-500/80 shadow-[0_0_0_3px_rgb(244_63_94/0.15)]\" : \"\"\n          } ${multiline ? \"w-[calc(100%+1rem)]\" : \"w-fit max-w-[calc(100%+1rem)]\"}`}\n        >\n          {/* Invisible copy of the draft sizes the field exactly like the text it replaces. */}\n          <span\n            aria-hidden=\"true\"\n            className={`invisible col-start-1 row-start-1 ${multiline ? \"whitespace-pre-wrap break-words\" : \"overflow-hidden whitespace-pre\"}`}\n          >\n            {(draft || placeholder) + (multiline ? \"​\" : \"\")}\n          </span>\n          {multiline ? (\n            <textarea\n              ref={fieldRef}\n              value={draft}\n              rows={1}\n              maxLength={maxLength}\n              aria-label={label}\n              aria-invalid={invalid ? true : undefined}\n              aria-describedby={invalid ? `${errorId} ${hintId}` : hintId}\n              placeholder={placeholder}\n              onChange={(e) => {\n                setDraft(e.target.value);\n                if (invalid) setInvalid(null);\n              }}\n              onKeyDown={handleKeyDown}\n              onBlur={() => (saveOnBlur ? save(false) : cancel(false))}\n              className=\"col-start-1 row-start-1 m-0 h-full w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-zinc-950 caret-[var(--ie-accent)] outline-hidden placeholder:text-zinc-400 dark:text-zinc-50 dark:placeholder:text-zinc-500\"\n            />\n          ) : (\n            <input\n              ref={fieldRef}\n              type=\"text\"\n              // size=1 removes the input's intrinsic width so the sizer alone decides it.\n              size={1}\n              value={draft}\n              maxLength={maxLength}\n              aria-label={label}\n              aria-invalid={invalid ? true : undefined}\n              aria-describedby={invalid ? `${errorId} ${hintId}` : hintId}\n              placeholder={placeholder}\n              autoComplete=\"off\"\n              onChange={(e) => {\n                setDraft(e.target.value);\n                if (invalid) setInvalid(null);\n              }}\n              onKeyDown={handleKeyDown}\n              onBlur={() => (saveOnBlur ? save(false) : cancel(false))}\n              className=\"col-start-1 row-start-1 m-0 w-full min-w-[3ch] border-0 bg-transparent p-0 text-zinc-950 caret-[var(--ie-accent)] outline-hidden placeholder:text-zinc-400 dark:text-zinc-50 dark:placeholder:text-zinc-500\"\n            />\n          )}\n        </div>\n      ) : (\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          onClick={startEditing}\n          disabled={disabled}\n          aria-disabled={pending || undefined}\n          aria-busy={pending || undefined}\n          aria-label={`Edit ${label}: ${shown || \"empty\"}`}\n          aria-describedby={status.kind === \"error\" ? errorId : undefined}\n          className={`group/ie flex items-start gap-2 border-transparent text-left outline-hidden transition-[background-color,border-color] duration-150 hover:border-zinc-900/10 hover:bg-zinc-900/[0.03] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--ie-accent)] disabled:cursor-not-allowed disabled:opacity-60 dark:hover:border-white/10 dark:hover:bg-white/[0.04] motion-reduce:transition-none ${box} ${\n            multiline ? \"w-[calc(100%+1rem)]\" : \"w-fit max-w-[calc(100%+1rem)]\"\n          } ${pending ? \"cursor-progress\" : \"cursor-text\"}`}\n        >\n          <span\n            className={`min-w-0 transition-opacity duration-200 ${multiline ? \"flex-1 whitespace-pre-wrap break-words\" : \"overflow-hidden text-ellipsis whitespace-pre\"} ${\n              shown ? \"text-zinc-950 dark:text-zinc-50\" : \"text-zinc-400 dark:text-zinc-500\"\n            } ${pending ? \"opacity-55\" : \"\"}`}\n          >\n            {shown || placeholder}\n          </span>\n          {/* Pencil affordance (or a spinner while saving), centred on the first line. */}\n          <span aria-hidden=\"true\" className=\"flex h-[1lh] shrink-0 items-center\">\n            {pending ? (\n              <svg viewBox=\"0 0 16 16\" className=\"size-3.5 animate-spin text-[var(--ie-accent)] motion-reduce:animate-none\">\n                <circle cx=\"8\" cy=\"8\" r=\"6\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.8\" />\n                <path d=\"M14 8a6 6 0 0 0-6-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n              </svg>\n            ) : (\n              <svg\n                viewBox=\"0 0 16 16\"\n                className={`size-3.5 text-zinc-400 transition-opacity duration-150 group-hover/ie:text-[var(--ie-accent)] dark:text-zinc-500 motion-reduce:transition-none ${pencilVisible}`}\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.4\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              >\n                <path d=\"M10.5 2.5l3 3L6 13H3v-3l7.5-7.5zM9 4l3 3\" />\n              </svg>\n            )}\n          </span>\n        </button>\n      )}\n\n      <span id={hintId} className=\"sr-only\">\n        {multiline ? \"Press Control or Command plus Enter to save, Escape to cancel.\" : \"Press Enter to save, Escape to cancel.\"}\n      </span>\n\n      <div aria-live=\"polite\" aria-atomic=\"true\">\n        {(invalid || status.kind === \"error\") && (\n          <p id={errorId} className=\"mt-1 flex items-center gap-1.5 text-[12px] font-medium leading-snug text-rose-600 dark:text-rose-400\">\n            <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3 shrink-0\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\">\n              <circle cx=\"6\" cy=\"6\" r=\"5\" />\n              <path d=\"M6 3.5v3M6 8.4v.1\" />\n            </svg>\n            {invalid ?? (status.kind === \"error\" ? `${status.message} Reverted to the previous ${label}.` : \"\")}\n          </p>\n        )}\n        {pending && <span className=\"sr-only\">Saving…</span>}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/inline-edit"
}
