{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-edit",
  "type": "registry:ui",
  "title": "Inline edit",
  "description": "Rename in place: the text becomes a field without moving.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/inline-edit/inline-edit.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type {\n  ChangeEvent,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n  MouseEvent as ReactMouseEvent,\n  ReactNode,\n} from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition, Variants } from \"motion/react\";\nimport { Check, CircleAlert, Pencil, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/**\n * Click-to-edit text for names and short fields that are read far more often than they change, such as a project name or its description.\n * The text turns into a field in place with the same metrics, so nothing around it moves, and the box grows with what you type. Enter saves\n * optimistically and a check draws once the save lands; Escape rolls the text back. A failed save restores the last saved value and says why.\n * Use a regular form when several fields must be saved together.\n */\nexport interface InlineEditProps {\n  /** The saved value. A new value from outside replaces the text while it is not being edited. */\n  value: string;\n  /** Persists the new value. Return a promise to show the saving state, and reject it to roll back. */\n  onSave: (next: string) => void | Promise<unknown>;\n  /** Accessible name, for example “Project name”. */\n  label: string;\n  /** Returns a message when the draft cannot be saved. */\n  validate?: (next: string) => string | null | undefined;\n  /** Shown when the value is empty. */\n  placeholder?: string;\n  /** Wraps onto several lines and grows in height. Enter still saves; Shift+Enter adds a line break. */\n  multiline?: boolean;\n  /** `title` for names and headings, `body` for descriptions. */\n  variant?: \"title\" | \"body\";\n  /** The element that holds the text, so a title can stay a heading. */\n  as?: \"span\" | \"p\" | \"h1\" | \"h2\" | \"h3\";\n  className?: string;\n}\n\ntype Phase = \"idle\" | \"saving\" | \"saved\" | \"failed\";\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst blur = (px: number) => `blur(${px}px)`;\nconst noop = () => () => {};\n/** Typing retargets many times a second, so the frame follows on a quicker spring without overshoot and keeps up with a fast typist. */\nconst typingSpring = { ...motionTokens.spring.snappy, visualDuration: motionTokens.duration.fast, bounce: 0 };\n\n/** Reduced motion only counts after hydration, so the server and the first client render always agree. */\nfunction useReduced() {\n  const hydrated = useSyncExternalStore(\n    noop,\n    () => true,\n    () => false,\n  );\n  const reduced = useReducedMotion() ?? false;\n  return hydrated && reduced;\n}\n\n/** Text rises in from a soft blur. A rollback runs the other way, dropping the old words down and the saved ones in from above. */\nconst textMotion: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.3}em`, filter: blur(motionTokens.blur.soft) }),\n  rest: {\n    opacity: 1,\n    y: \"0em\",\n    filter: blur(0),\n    transition: { duration: motionTokens.duration.standard, ease: enter },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * -0.3}em`,\n    filter: blur(motionTokens.blur.subtle),\n    transition: { duration: motionTokens.duration.fast, ease: standard },\n  }),\n};\nconst textFade: Variants = {\n  enter: { opacity: 0 },\n  rest: { opacity: 1, transition: { duration: motionTokens.duration.fast } },\n  exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } },\n};\nconst rest: TargetAndTransition = { opacity: 1, scale: 1, filter: blur(0) };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: blur(motionTokens.blur.subtle) };\nconst iconOut: TargetAndTransition = {\n  ...iconIn,\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst fadeIn: TargetAndTransition = { opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */\nconst iconEnter = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: enter },\n  filter: { duration: motionTokens.duration.fast, ease: enter },\n};\nconst actionButton =\n  \"grid size-7 flex-none cursor-pointer place-items-center rounded-button border border-transparent p-0 [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),scale_var(--duration-instant)_var(--ease-standard)] active:scale-96 motion-reduce:transition-none\";\n\n/** The tick draws itself from its short stroke, the way a hand would write it. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return (\n    <svg\n      width={16}\n      height={16}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.25}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <motion.path\n        d=\"M4 12.5l5 5L20 6.5\"\n        initial={reduced ? false : { pathLength: 0, opacity: 0 }}\n        animate={{ pathLength: 1, opacity: 1 }}\n        transition={{\n          pathLength: { duration: 0.32, ease: enter, delay: 0.06 },\n          opacity: { duration: 0.05, delay: 0.06 },\n        }}\n      />\n    </svg>\n  );\n}\n\n/** A message row that opens and closes its height on a spring while the words rise in, so an error never pushes the page in one frame. */\nfunction Reveal({\n  id,\n  message,\n  reduced,\n}: {\n  id: string;\n  message: { key: string; tone: \"error\" | \"failed\"; node: ReactNode } | null;\n  reduced: boolean;\n}) {\n  const inner = useRef<HTMLSpanElement>(null);\n  const height = useMotionValue(0);\n  useEffect(() => {\n    const node = inner.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n      if (!measured || reduced) {\n        measured = true;\n        height.jump(next);\n        return;\n      }\n      animate(height, next, motionTokens.spring.smooth);\n    });\n    observer.observe(node, { box: \"border-box\" });\n    return () => {\n      observer.disconnect();\n      height.stop();\n    };\n  }, [height, reduced]);\n  return (\n    <motion.span className=\"block overflow-hidden\" style={{ height }}>\n      <span ref={inner} id={id} className=\"relative block\" aria-live=\"polite\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={1}>\n          {message && (\n            <motion.span\n              key={message.key}\n              className={cn(\n                \"flex items-start gap-1.5 pt-1.5 pr-(--pad-x) pb-1 pl-[calc(var(--pad-x)+1px)] text-xs leading-body font-normal\",\n                message.tone === \"error\" ? \"text-red\" : \"text-ink-2\",\n              )}\n              data-tone={message.tone}\n              custom={1}\n              variants={reduced ? textFade : textMotion}\n              initial=\"enter\"\n              animate=\"rest\"\n              exit=\"exit\"\n            >\n              {message.node}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\ntype CaretDocument = Document & {\n  caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;\n  caretRangeFromPoint?: (x: number, y: number) => Range | null;\n};\n\nexport function InlineEdit({\n  value,\n  onSave,\n  label,\n  validate,\n  placeholder = \"\",\n  multiline = false,\n  variant = \"title\",\n  as: Tag = \"span\",\n  className,\n}: InlineEditProps) {\n  const reduced = useReduced();\n  const ids = useId();\n  const displayHintId = `${ids}-display`,\n    editHintId = `${ids}-edit`,\n    messageId = `${ids}-message`;\n  const [committed, setCommitted] = useState(value);\n  const [shown, setShown] = useState(value);\n  const [seen, setSeen] = useState(value);\n  const [layer, setLayer] = useState({ key: 0, direction: 1 });\n  const [editing, setEditing] = useState(false);\n  const [draft, setDraft] = useState(value);\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [error, setError] = useState<string | null>(null);\n  const [failed, setFailed] = useState<string | null>(null);\n  const [flash, setFlash] = useState<\"on\" | \"off\" | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const root = useRef<HTMLDivElement>(null);\n  const display = useRef<HTMLButtonElement>(null);\n  const control = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);\n  const selection = useRef<number | \"all\" | null>(null);\n  const focusDisplay = useRef(false);\n  const saveRun = useRef(0);\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const frame = useRef<HTMLSpanElement>(null);\n  const box = useRef<HTMLSpanElement>(null);\n  // Single line: the layout box and the input take a new width at once, so the input never scrolls, while the visible frame springs after it.\n  // Multiline: the box's height springs, so content below glides, while the textarea already has the room its text needs.\n  // Both start at their natural size (\"100%\", \"auto\"), so the server markup is already right before any script runs.\n  const frameWidth = useMotionValue<number | string>(\"100%\");\n  const boxHeight = useMotionValue<number | string>(\"auto\");\n  const size = useRef(0);\n  const wasEditing = useRef(false);\n\n  // A new value from outside replaces the text, unless the person is typing or a save is still in flight.\n  if (value !== seen) {\n    setSeen(value);\n    if (value !== committed) {\n      setCommitted(value);\n      if (!editing && phase !== \"saving\" && value !== shown) {\n        setShown(value);\n        setLayer((current) => ({ key: current.key + 1, direction: 1 }));\n      }\n    }\n  }\n\n  const layerText = editing ? draft : shown;\n  const saving = phase === \"saving\";\n  const later = (fn: () => void, ms: number) => {\n    timers.current.push(setTimeout(fn, ms));\n  };\n  useEffect(() => () => timers.current.forEach(clearTimeout), []);\n\n  const naturalSize = () => {\n    const node = display.current;\n    return node ? parseFloat(getComputedStyle(node)[multiline ? \"height\" : \"width\"]) : 0;\n  };\n  /** Written straight to the DOM before paint, because a motion value only reaches the style on its next frame, and one frame at the wrong size shows. */\n  const snap = (next: number) => {\n    const field = control.current;\n    if (multiline && field) {\n      field.style.height = `${next}px`;\n      field.scrollTop = 0;\n    }\n    const surface = multiline ? box.current : frame.current;\n    if (surface) surface.style[multiline ? \"height\" : \"width\"] = `${next}px`;\n    (multiline ? boxHeight : frameWidth).jump(next);\n  };\n\n  // Changes the person caused (typing, switching modes, a rollback) spring from the size on screen. Measured in a layout effect, so no frame shows the new size early.\n  const onContentChange = useEffectEvent(() => {\n    const next = naturalSize();\n    const previous = size.current;\n    const typing = editing && wasEditing.current;\n    size.current = next;\n    wasEditing.current = editing;\n    if (!next) return;\n    const field = control.current;\n    if (multiline && field) {\n      field.style.height = `${next}px`;\n      field.scrollTop = 0;\n    }\n    const target = multiline ? boxHeight : frameWidth;\n    if (!previous || reduced || typeof target.get() !== \"number\") {\n      snap(next);\n      return;\n    }\n    if (Math.abs(next - previous) < 0.1) return;\n    animate(target, next, multiline ? motionTokens.spring.smooth : typing ? typingSpring : motionTokens.spring.morph);\n  });\n  useLayoutEffect(() => {\n    onContentChange();\n  }, [layerText, editing, multiline, reduced]);\n\n  // A late web font or a resized container follows at once instead of animating.\n  const onResize = useEffectEvent(() => {\n    const next = naturalSize();\n    if (!next || Math.abs(next - size.current) < 0.1) return;\n    size.current = next;\n    snap(next);\n  });\n  useEffect(() => {\n    const node = display.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    // Border box, so the room added while editing (padding, not content) is noticed too.\n    const observer = new ResizeObserver(() => onResize());\n    observer.observe(node, { box: \"border-box\" });\n    return () => observer.disconnect();\n  }, []);\n\n  // Focus follows the mode: into the field with the caret where the text was clicked, and back to the text after Enter or Escape.\n  useLayoutEffect(() => {\n    const field = control.current;\n    if (editing && field && selection.current !== null) {\n      const at = selection.current;\n      selection.current = null;\n      field.focus({ preventScroll: true });\n      if (at === \"all\") field.select();\n      else field.setSelectionRange(at, at);\n    }\n    if (!editing && focusDisplay.current) {\n      focusDisplay.current = false;\n      display.current?.focus({ preventScroll: true });\n    }\n  });\n\n  function startEdit(at: number | \"all\", text = shown) {\n    if (editing || saving) return;\n    setDraft(text);\n    setEditing(true);\n    setError(null);\n    setFailed(null);\n    setFlash(null);\n    if (phase !== \"idle\") setPhase(\"idle\");\n    selection.current = at;\n  }\n\n  /** The caret lands on the character that was clicked, the way it would in a text field. */\n  function caretAt(x: number, y: number) {\n    const node = display.current?.querySelector(`[data-layer=\"${layer.key}\"]`)?.firstChild;\n    if (!node || !shown) return shown.length;\n    const doc = document as CaretDocument;\n    const position = doc.caretPositionFromPoint?.(x, y);\n    if (position) return position.offsetNode === node ? Math.min(position.offset, shown.length) : shown.length;\n    const range = doc.caretRangeFromPoint?.(x, y);\n    return range && range.startContainer === node ? Math.min(range.startOffset, shown.length) : shown.length;\n  }\n\n  function onDisplayClick(event: ReactMouseEvent<HTMLButtonElement>) {\n    if (saving) return;\n    // A keyboard activation selects everything, ready to retype; a click puts the caret where it landed.\n    startEdit(event.detail === 0 ? \"all\" : caretAt(event.clientX, event.clientY));\n  }\n\n  const clean = (text: string) => (multiline ? text.trim() : text.replace(/\\s+/g, \" \").trim());\n\n  function submit(source: \"key\" | \"button\" | \"blur\") {\n    const next = clean(draft);\n    const problem = validate?.(next) || null;\n    if (problem) {\n      setError(problem);\n      if (source !== \"blur\") control.current?.focus();\n      return;\n    }\n    setEditing(false);\n    setError(null);\n    if (source !== \"blur\") focusDisplay.current = true;\n    // Trimmed spaces would shift the text in one frame, so a cleaned value arrives with the text motion instead.\n    if (next !== draft) setLayer((current) => ({ key: current.key + 1, direction: 1 }));\n    if (next === shown) return;\n    const previous = committed;\n    const run = ++saveRun.current;\n    setShown(next);\n    setPhase(\"saving\");\n    setAnnouncement(`Saving ${label.toLowerCase()}`);\n    Promise.resolve()\n      .then(() => onSave(next))\n      .then(\n        () => {\n          if (run !== saveRun.current) return;\n          setCommitted(next);\n          setPhase(\"saved\");\n          setAnnouncement(`${label} saved`);\n          later(() => setPhase((current) => (current === \"saved\" ? \"idle\" : current)), 1800);\n        },\n        () => {\n          if (run !== saveRun.current) return;\n          setShown(previous);\n          setLayer((current) => ({ key: current.key + 1, direction: -1 }));\n          setPhase(\"failed\");\n          setFailed(next);\n          setFlash(\"on\");\n          setAnnouncement(\"\");\n          later(() => setFlash((current) => (current === \"on\" ? \"off\" : current)), 1400);\n          later(() => setFlash((current) => (current === \"off\" ? null : current)), 2000);\n        },\n      );\n  }\n\n  function cancel() {\n    setEditing(false);\n    setError(null);\n    focusDisplay.current = true;\n    if (draft !== shown) setLayer((current) => ({ key: current.key + 1, direction: -1 }));\n  }\n\n  function retry() {\n    if (!failed) return;\n    startEdit(failed.length, failed);\n  }\n\n  function onChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {\n    const next = event.target.value;\n    setDraft(next);\n    if (error) setError(validate?.(clean(next)) || null);\n  }\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      cancel();\n      return;\n    }\n    if (event.key === \"Enter\" && !event.nativeEvent.isComposing && !(multiline && event.shiftKey)) {\n      event.preventDefault();\n      submit(\"key\");\n    }\n  }\n\n  // Leaving the component saves, the way a rename does; switching windows does not.\n  function onBlur(event: ReactFocusEvent<HTMLDivElement>) {\n    if (!editing) return;\n    const next = event.relatedTarget as Node | null;\n    if (next && root.current?.contains(next)) return;\n    if (!next && !document.hasFocus()) return;\n    submit(\"blur\");\n  }\n\n  const noun = label.toLowerCase();\n  const message = error\n    ? {\n        key: `error:${error}`,\n        tone: \"error\" as const,\n        node: (\n          <>\n            <CircleAlert className=\"mt-0.5 flex-none text-red\" size={14} strokeWidth={2} aria-hidden=\"true\" />\n            <span>{error}</span>\n          </>\n        ),\n      }\n    : failed && !editing\n      ? {\n          key: `failed:${failed}`,\n          tone: \"failed\" as const,\n          node: (\n            <>\n              <CircleAlert className=\"mt-0.5 flex-none text-red\" size={14} strokeWidth={2} aria-hidden=\"true\" />\n              <span>\n                {`Couldn’t save “${failed}”, so the last saved ${noun} is back.`}{\" \"}\n                <button\n                  type=\"button\"\n                  className=\"ml-0.5 cursor-pointer rounded-[4px] p-0 font-medium text-ink underline decoration-line-strong underline-offset-3 hover:decoration-current\"\n                  onClick={retry}\n                >\n                  Try again\n                </button>\n              </span>\n            </>\n          ),\n        }\n      : null;\n  const slot = editing ? \"edit\" : phase;\n  const describedBy = (hint: string) => [hint, message ? messageId : null].filter(Boolean).join(\" \");\n  const fieldProps = {\n    className: cn(\n      \"absolute top-0 left-0 z-2 m-0 box-border size-full resize-none overflow-hidden rounded-control border border-transparent bg-transparent px-(--pad-x) py-(--pad-y) text-left leading-(--leading) [word-spacing:inherit] normal-case indent-0 caret-ink outline-none [-webkit-tap-highlight-color:transparent] placeholder:text-ink-3 placeholder:opacity-100 focus-visible:outline-none\",\n      multiline && \"whitespace-pre-wrap [overflow-wrap:break-word]\",\n    ),\n    value: draft,\n    onChange,\n    onKeyDown,\n    placeholder,\n    \"aria-label\": label,\n    \"aria-invalid\": error ? true : undefined,\n    \"aria-describedby\": describedBy(editHintId),\n    autoComplete: \"off\",\n    spellCheck: variant === \"body\",\n  };\n\n  // The box sits a border and padding outside the text, so the text lines up with the content around it and the hover surface reaches past it.\n  // Whole-pixel line heights: an input centres its text by rounding, so a fractional line would sit the field a pixel below the display.\n  return (\n    <div\n      ref={root}\n      className={cn(\n        \"relative grid min-w-0 ms-[calc(-1*(var(--pad-x)+1px))] font-body tracking-body [--pad-x:8px]\",\n        variant === \"title\"\n          ? \"[--leading:round(var(--text-xl)*1.3,2px)] [--pad-y:3px]\"\n          : \"[--leading:round(var(--text-sm)*1.5,1px)] [--pad-y:5px]\",\n        className,\n      )}\n      data-variant={variant}\n      data-multiline={multiline || undefined}\n      data-editing={editing || undefined}\n      data-phase={phase}\n      data-invalid={error ? \"\" : undefined}\n      data-flash={flash ?? undefined}\n      onBlur={onBlur}\n    >\n      {/* Room for the actions is reserved in every state, so the text never rewraps when editing starts. Inter at every size here: a heading host keeps its semantics but not the display face. */}\n      <Tag\n        className={cn(\n          \"m-0 block min-w-0 font-body leading-(--leading) tracking-body\",\n          multiline ? \"pe-[38px]\" : \"pe-[66px]\",\n          variant === \"title\" ? \"text-xl font-medium text-ink\" : \"text-sm font-normal text-ink-2\",\n        )}\n      >\n        <motion.span\n          ref={box}\n          className={cn(\"group/box relative max-w-full align-top\", multiline ? \"block\" : \"inline-block\")}\n          style={multiline ? { height: boxHeight } : undefined}\n        >\n          {/* The text stays in flow while editing, hidden and mirroring the draft, so it sizes the box and can roll back with motion on Escape. Display and field share every metric: border, padding, font, and line height. */}\n          <button\n            ref={display}\n            type=\"button\"\n            data-display=\"\"\n            className={cn(\n              \"peer relative z-1 m-0 box-border block max-w-full cursor-text rounded-control border border-transparent bg-transparent px-(--pad-x) py-(--pad-y) text-left leading-(--leading) [word-spacing:inherit] normal-case indent-0 [-webkit-tap-highlight-color:transparent] aria-disabled:cursor-progress\",\n              multiline && \"w-full\",\n              editing && \"invisible\",\n              // While editing, a single line keeps a little extra room, so the next character never has to wait for the box.\n              editing && !multiline && \"min-w-[5em] pe-[calc(var(--pad-x)+16px)]\",\n            )}\n            onClick={onDisplayClick}\n            tabIndex={editing ? -1 : undefined}\n            aria-label={`${label}: ${shown || placeholder}`}\n            aria-describedby={describedBy(displayHintId)}\n            aria-disabled={saving || undefined}\n          >\n            {/* An optimistic save shows the new text at once, a little quieter until the save lands; then it settles to full strength. */}\n            <span\n              className={cn(\n                \"relative block transition-opacity duration-standard ease-standard motion-reduce:transition-none\",\n                !layerText && \"text-ink-3\",\n                saving && \"opacity-50 duration-fast\",\n              )}\n              data-empty={layerText ? undefined : \"\"}\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={layer.direction}>\n                <motion.span\n                  key={layer.key}\n                  data-layer={layer.key}\n                  className={cn(\n                    \"block\",\n                    multiline\n                      ? \"overflow-visible whitespace-pre-wrap [overflow-wrap:break-word]\"\n                      : \"overflow-hidden text-ellipsis whitespace-pre\",\n                  )}\n                  custom={layer.direction}\n                  variants={reduced ? textFade : textMotion}\n                  initial=\"enter\"\n                  animate=\"rest\"\n                  exit=\"exit\"\n                >\n                  {/* A zero-width space keeps the line box, so an empty field never collapses. */}\n                  {(layerText || placeholder || \"\\u200b\") + (multiline && editing ? \"\\u200b\" : \"\")}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </button>\n          {editing &&\n            (multiline ? (\n              <textarea\n                ref={(node) => {\n                  control.current = node;\n                }}\n                {...fieldProps}\n                rows={1}\n                enterKeyHint=\"done\"\n              />\n            ) : (\n              <input\n                ref={(node) => {\n                  control.current = node;\n                }}\n                {...fieldProps}\n                type=\"text\"\n                enterKeyHint=\"done\"\n              />\n            ))}\n          {/* The frame is the only surface: a hover tint at rest, a bordered field while editing, a soft danger wash when a save fails. The wash leaves slowly, without any movement, once the message has been read. */}\n          <motion.span\n            ref={frame}\n            className={cn(\n              \"pointer-events-none absolute top-0 bottom-0 left-0 z-0 box-border rounded-control border border-transparent bg-transparent [transition:background-color_var(--duration-fast)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-standard)_var(--ease-standard)]\",\n              multiline && \"right-0\",\n              !saving && \"peer-hover:bg-hover\",\n              editing &&\n                (error\n                  ? \"border-red bg-surface shadow-[0_0_0_3px_color-mix(in_oklab,var(--red)_24%,transparent)]\"\n                  : \"border-ink bg-surface shadow-[0_0_0_3px_var(--focus-ring)]\"),\n              flash === \"on\" &&\n                \"border-[color-mix(in_oklab,var(--red)_30%,transparent)] bg-[color-mix(in_oklab,var(--red)_9%,transparent)]\",\n              flash === \"off\" &&\n                \"[transition:background-color_var(--duration-considered)_var(--ease-standard),border-color_var(--duration-considered)_var(--ease-standard),box-shadow_var(--duration-considered)_var(--ease-standard)]\",\n            )}\n            style={multiline ? undefined : { width: frameWidth }}\n          >\n            {/* The slot rides on the frame's edge, so it follows the width morph instead of jumping to the new width. */}\n            <span\n              className={cn(\n                \"pointer-events-auto absolute grid min-h-7\",\n                multiline\n                  ? \"top-0 left-[calc(100%+8px)] mt-[calc(var(--pad-y)+var(--leading)/2-13px)]\"\n                  : \"top-1/2 left-[calc(100%+6px)] -mt-3.5\",\n              )}\n            >\n              <AnimatePresence initial={false}>\n                <motion.span\n                  key={slot}\n                  className=\"col-start-1 row-start-1 grid min-h-7 items-center justify-items-start\"\n                  initial={reduced ? fadeIn : iconIn}\n                  animate={rest}\n                  exit={reduced ? fadeOut : iconOut}\n                  transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}\n                >\n                  {slot === \"edit\" ? (\n                    <span className={cn(\"flex gap-1\", multiline && \"flex-col\")}>\n                      <button\n                        type=\"button\"\n                        className={cn(\n                          actionButton,\n                          \"bg-ink text-page hover:bg-[color-mix(in_oklab,var(--ink)_86%,var(--page))]\",\n                        )}\n                        aria-label={`Save ${noun}`}\n                        onPointerDown={(event) => event.preventDefault()}\n                        onClick={() => submit(\"button\")}\n                      >\n                        <Check size={15} strokeWidth={2} aria-hidden=\"true\" />\n                      </button>\n                      <button\n                        type=\"button\"\n                        className={cn(\n                          actionButton,\n                          \"border-line bg-surface text-ink-2 hover:border-line-strong hover:bg-hover hover:text-ink\",\n                        )}\n                        aria-label=\"Cancel editing\"\n                        onPointerDown={(event) => event.preventDefault()}\n                        onClick={cancel}\n                      >\n                        <X size={15} strokeWidth={2} aria-hidden=\"true\" />\n                      </button>\n                    </span>\n                  ) : slot === \"saving\" ? (\n                    <span\n                      className=\"ml-0.5 block size-3.5 animate-inline-edit-spin rounded-full border-[1.5px] border-ink-3 border-r-transparent motion-reduce:animate-none motion-reduce:border-t-transparent motion-reduce:border-r-ink-3\"\n                      aria-hidden=\"true\"\n                    />\n                  ) : slot === \"saved\" ? (\n                    <span className=\"ml-px grid place-items-center text-green\" aria-hidden=\"true\">\n                      <DrawnCheck reduced={reduced} />\n                    </span>\n                  ) : slot === \"failed\" ? (\n                    <CircleAlert className=\"ml-px text-red\" size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                  ) : (\n                    <Pencil\n                      className=\"ml-0.5 cursor-pointer text-ink-3 pointer-fine:opacity-0 pointer-fine:transition-opacity pointer-fine:duration-fast pointer-fine:ease-standard motion-reduce:transition-none pointer-fine:group-has-[[data-display]:hover]/box:opacity-100 pointer-fine:group-has-[[data-display]:focus-visible]/box:opacity-100 pointer-fine:hover:opacity-100\"\n                      size={14}\n                      strokeWidth={1.75}\n                      aria-hidden=\"true\"\n                      onClick={() => startEdit(shown.length)}\n                    />\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </motion.span>\n        </motion.span>\n      </Tag>\n      <Reveal id={messageId} message={message} reduced={reduced} />\n      <span id={displayHintId} className=\"sr-only\">\n        Activate to edit.\n      </span>\n      <span id={editHintId} className=\"sr-only\">\n        {multiline ? \"Enter saves, Shift+Enter adds a line break, Escape cancels.\" : \"Enter saves, Escape cancels.\"}\n      </span>\n      <output className=\"sr-only\">{announcement}</output>\n    </div>\n  );\n}\n\nexport default InlineEdit;\n",
      "type": "registry:component",
      "target": "@components/iris/inline-edit/inline-edit.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/inline-edit",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "InlineEdit",
    "tags": [
      "inline edit",
      "rename",
      "click to edit"
    ],
    "docs": "https://irisui.dev/components/inline-edit"
  }
}
