{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"editable-text","type":"registry:ui","title":"Editable Text","description":"Text you click to change in place: a name, a title, a label.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/editable-text.tsx","type":"registry:file","target":"components/dashral/editable-text.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2, PencilIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\n\ntype EditableTextProps = {\n  /** The text, controlled. */\n  value?: string\n  defaultValue?: string\n  /**\n   * Called with the new text when an edit is saved (Enter, or leaving the field), not on every keystroke. Return a\n   * promise to save it somewhere: a spinner shows, and if it rejects, the text goes back and the error shows.\n   */\n  onValueChange?: (value: string) => void | Promise<unknown>\n  /** What the text is, for screen readers: \"Project name\" makes the button \"Edit Project name\". Required. */\n  label: string\n  /** Shown, muted, when the text is empty. */\n  placeholder?: string\n  /** Empty isn't allowed: an empty edit is refused with a message. */\n  required?: boolean\n  /** Checks an edit before it's saved: return a message to refuse it, nothing to accept it. */\n  validate?: (value: string) => string | undefined\n  maxLength?: number\n  disabled?: boolean\n  /**\n   * The text's classes, shared by the text and the field that replaces it (a size, a weight), so switching between\n   * them moves nothing: \"text-2xl font-semibold\" for a title.\n   */\n  className?: string\n  classNames?: { error?: string }\n}\n\n/**\n * Text you click to change in place, in one tag: `<EditableText label=\"Project name\" value={name} onValueChange={rename} />`.\n * It looks like text with a pencil on hover; pressed, it becomes a field of the same size and font, with the text\n * selected. Enter or leaving saves, Escape puts it back. An async save shows a spinner and undoes itself on failure.\n */\nfunction EditableText({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  label,\n  placeholder,\n  required = false,\n  validate,\n  maxLength,\n  disabled = false,\n  className,\n  classNames,\n}: EditableTextProps) {\n  const { t } = useLocale()\n  const errorId = React.useId()\n  const [internal, setInternal] = React.useState(defaultValue)\n  const text = value ?? internal\n  const [editing, setEditing] = React.useState(false)\n  const [draft, setDraft] = React.useState(text)\n  const [saving, setSaving] = React.useState(false)\n  // The edit while it's being saved: shown at once (with the spinner), so it never looks undone while the server answers.\n  const [pending, setPending] = React.useState<string | null>(null)\n  const shown = pending ?? text\n  const [error, setError] = React.useState<string | null>(null)\n  const field = React.useRef<HTMLInputElement>(null)\n  const button = React.useRef<HTMLButtonElement>(null)\n  // Where the focus goes after an edit: back to the text after Enter or Escape, but not after a click elsewhere.\n  const returnFocus = React.useRef(false)\n\n  React.useEffect(() => {\n    if (editing) field.current?.select()\n  }, [editing])\n  React.useEffect(() => {\n    if (!editing && returnFocus.current) {\n      returnFocus.current = false\n      button.current?.focus()\n    }\n  }, [editing])\n\n  function start() {\n    if (disabled || saving) return\n    setDraft(text)\n    setError(null)\n    setEditing(true)\n  }\n  function cancel() {\n    returnFocus.current = true\n    setError(null)\n    setEditing(false)\n  }\n  async function save(refocus: boolean) {\n    const next = draft.trim()\n    const problem = required && !next ? t(\"cantBeEmpty\") : validate?.(next)\n    if (problem) {\n      setError(problem)\n      field.current?.focus()\n      return\n    }\n    returnFocus.current = refocus\n    setEditing(false)\n    if (next === text) return\n    const previous = text\n    if (value === undefined) setInternal(next)\n    const result = onValueChange?.(next)\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      setSaving(true)\n      setPending(next)\n      try {\n        await result\n      } catch (reason) {\n        // Back to what it was, with why.\n        if (value === undefined) setInternal(previous)\n        setError(reason instanceof Error && reason.message ? reason.message : t(\"saveFailed\"))\n      } finally {\n        setPending(null)\n        setSaving(false)\n      }\n    }\n  }\n\n  // Text and field share one box: the same padding, border width and line height, so nothing shifts between them.\n  // The box reaches 0.375rem past each side (-mx-1.5), so its widest is the wrapper's width plus those 0.75rem:\n  // max-w-full alone would cut the text short by exactly that much.\n  const box = \"-mx-1.5 max-w-[calc(100%+0.75rem)] rounded-md border border-transparent px-1.5 py-0.5 leading-[inherit]\"\n\n  return (\n    <span data-slot=\"editable-text\" className=\"inline-flex max-w-full min-w-0 flex-col items-start gap-1 align-top\">\n      {editing ? (\n        <input\n          ref={field}\n          value={draft}\n          aria-label={label}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={error ? errorId : undefined}\n          maxLength={maxLength}\n          size={Math.max(4, draft.length + 1)}\n          onChange={(event) => {\n            setDraft(event.target.value)\n            if (error) setError(null)\n          }}\n          onKeyDown={(event) => {\n            if (event.key === \"Enter\") {\n              event.preventDefault()\n              void save(true)\n            } else if (event.key === \"Escape\") {\n              event.preventDefault()\n              event.stopPropagation()\n              cancel()\n            }\n          }}\n          onBlur={() => {\n            // Leaving with a refused edit keeps it open, with its message, rather than losing it.\n            if (!error) void save(false)\n          }}\n          className={cn(\n            box,\n            \"min-w-0 border-ring bg-background font-[inherit] text-[length:inherit] tracking-[inherit] text-inherit shadow-xs ring-2 ring-ring/15 outline-none\",\n            \"animate-in duration-(--duration-fast) ease-standard fade-in-0 motion-reduce:animate-none\",\n            error && \"border-destructive ring-destructive/15\",\n            className\n          )}\n        />\n      ) : (\n        <button\n          ref={button}\n          type=\"button\"\n          disabled={disabled}\n          aria-label={`${t(\"edit\")} ${label}`}\n          aria-busy={saving || undefined}\n          onClick={start}\n          className={cn(\n            box,\n            \"group/editable inline-flex min-w-0 items-center gap-2 text-start outline-none transition-colors duration-(--duration-fast)\",\n            \"hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none\",\n            className\n          )}\n        >\n          <span className={cn(\"min-w-0 truncate\", !shown && \"text-muted-foreground\")}>{shown || placeholder}</span>\n          {saving ? (\n            <Loader2 aria-hidden className=\"size-[0.8em] min-h-3.5 min-w-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-[spin_1.5s_linear_infinite]\" />\n          ) : (\n            <PencilIcon\n              aria-hidden\n              className=\"size-[0.8em] min-h-3.5 min-w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-(--duration-fast) group-hover/editable:opacity-100 group-focus-visible/editable:opacity-100 pointer-coarse:opacity-100\"\n            />\n          )}\n        </button>\n      )}\n      {error && (\n        <span\n          id={errorId}\n          role=\"alert\"\n          className={cn(\n            \"animate-in text-xs text-destructive-text duration-(--duration-standard) ease-enter fade-in-0 slide-in-from-top-1 motion-reduce:animate-none\",\n            classNames?.error\n          )}\n        >\n          {error}\n        </span>\n      )}\n    </span>\n  )\n}\n\nexport { EditableText, type EditableTextProps }\n"}],"cssVars":{"theme":{"color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c"}}}