{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/inline-edit/inline-edit.tsx",
      "type": "registry:component",
      "target": "@components/xzero/inline-edit/inline-edit.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ChangeEvent, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } 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 styles from \"./inline-edit.module.css\";\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(noop, () => true, () => false);\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 * .3}em`, filter: blur(motionTokens.blur.soft) }),\n  rest: { opacity: 1, y: \"0em\", filter: blur(0), transition: { duration: motionTokens.duration.standard, ease: enter } },\n  exit: (direction: number) => ({ opacity: 0, y: `${direction * -.3}em`, filter: blur(motionTokens.blur.subtle), transition: { duration: motionTokens.duration.fast, ease: standard } }),\n};\nconst textFade: Variants = { enter: { opacity: 0 }, rest: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };\nconst rest: TargetAndTransition = { opacity: 1, scale: 1, filter: blur(0) };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };\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 = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };\n\n/** The tick draws itself from its short stroke, the way a hand would write it. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return <svg width={16} height={16} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.25} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <motion.path d=\"M4 12.5l5 5L20 6.5\" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: .32, ease: enter, delay: .06 }, opacity: { duration: .05, delay: .06 } }} />\n  </svg>;\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({ id, message, reduced }: { id: string; message: { key: string; tone: \"error\" | \"failed\"; node: ReactNode } | null; reduced: boolean }) {\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) { measured = true; height.jump(next); return; }\n      animate(height, next, motionTokens.spring.smooth);\n    });\n    observer.observe(node, { box: \"border-box\" });\n    return () => { observer.disconnect(); height.stop(); };\n  }, [height, reduced]);\n  return <motion.span className={styles.reveal} style={{ height }}>\n    <span ref={inner} id={id} className={styles.revealInner} aria-live=\"polite\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={1}>\n        {message && <motion.span key={message.key} className={styles.message} data-tone={message.tone} custom={1} variants={reduced ? textFade : textMotion} initial=\"enter\" animate=\"rest\" exit=\"exit\">{message.node}</motion.span>}\n      </AnimatePresence>\n    </span>\n  </motion.span>;\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({ value, onSave, label, validate, placeholder = \"\", multiline = false, variant = \"title\", as: Tag = \"span\", className }: InlineEditProps) {\n  const reduced = useReduced();\n  const ids = useId();\n  const displayHintId = `${ids}-display`, editHintId = `${ids}-edit`, 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) { setShown(value); setLayer(current => ({ key: current.key + 1, direction: 1 })); }\n    }\n  }\n\n  const layerText = editing ? draft : shown;\n  const saving = phase === \"saving\";\n  const later = (fn: () => void, ms: number) => { timers.current.push(setTimeout(fn, ms)); };\n  useEffect(() => () => timers.current.forEach(clearTimeout), []);\n\n  const naturalSize = () => { const node = display.current; return node ? parseFloat(getComputedStyle(node)[multiline ? \"height\" : \"width\"]) : 0; };\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) { field.style.height = `${next}px`; field.scrollTop = 0; }\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) { field.style.height = `${next}px`; field.scrollTop = 0; }\n    const target = multiline ? boxHeight : frameWidth;\n    if (!previous || reduced || typeof target.get() !== \"number\") { snap(next); return; }\n    if (Math.abs(next - previous) < .1) return;\n    animate(target, next, multiline ? motionTokens.spring.smooth : typing ? typingSpring : motionTokens.spring.morph);\n  });\n  useLayoutEffect(() => { onContentChange(); }, [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) < .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(); else field.setSelectionRange(at, at);\n    }\n    if (!editing && focusDisplay.current) { focusDisplay.current = false; display.current?.focus({ preventScroll: true }); }\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) { setError(problem); if (source !== \"blur\") control.current?.focus(); return; }\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().then(() => onSave(next)).then(() => {\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      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  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\") { event.preventDefault(); event.stopPropagation(); cancel(); return; }\n    if (event.key === \"Enter\" && !event.nativeEvent.isComposing && !(multiline && event.shiftKey)) { event.preventDefault(); submit(\"key\"); }\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    ? { key: `error:${error}`, tone: \"error\" as const, node: <><CircleAlert className={styles.messageIcon} size={14} strokeWidth={2} aria-hidden=\"true\" /><span>{error}</span></> }\n    : failed && !editing\n      ? { key: `failed:${failed}`, tone: \"failed\" as const, node: <><CircleAlert className={styles.messageIcon} size={14} strokeWidth={2} aria-hidden=\"true\" /><span>{`Couldn’t save “${failed}”, so the last saved ${noun} is back.`} <button type=\"button\" className={styles.retry} onClick={retry}>Try again</button></span></> }\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: styles.control, value: draft, onChange, onKeyDown, placeholder, \"aria-label\": label, \"aria-invalid\": error ? true : undefined,\n    \"aria-describedby\": describedBy(editHintId), autoComplete: \"off\", spellCheck: variant === \"body\",\n  };\n\n  return <div ref={root} className={[styles.root, className].filter(Boolean).join(\" \")} data-variant={variant} data-multiline={multiline || undefined} data-editing={editing || undefined}\n    data-phase={phase} data-invalid={error ? \"\" : undefined} data-flash={flash ?? undefined} onBlur={onBlur}>\n    <Tag className={styles.line}>\n      <motion.span ref={box} className={styles.box} style={multiline ? { height: boxHeight } : undefined}>\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. */}\n        <button ref={display} type=\"button\" className={styles.display} onClick={onDisplayClick} tabIndex={editing ? -1 : undefined}\n          aria-label={`${label}: ${shown || placeholder}`} aria-describedby={describedBy(displayHintId)} aria-disabled={saving || undefined}>\n          <span className={styles.layer} data-empty={layerText ? undefined : \"\"}>\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={layer.direction}>\n              <motion.span key={layer.key} data-layer={layer.key} className={styles.text} custom={layer.direction} variants={reduced ? textFade : textMotion} initial=\"enter\" animate=\"rest\" exit=\"exit\">\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 && (multiline\n          ? <textarea ref={node => { control.current = node; }} {...fieldProps} rows={1} enterKeyHint=\"done\" />\n          : <input ref={node => { control.current = node; }} {...fieldProps} type=\"text\" enterKeyHint=\"done\" />)}\n        <motion.span ref={frame} className={styles.frame} style={multiline ? undefined : { width: frameWidth }}>\n          <span className={styles.slot}>\n            <AnimatePresence initial={false}>\n              <motion.span key={slot} className={styles.slotItem} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}>\n                {slot === \"edit\" ? <span className={styles.actions}>\n                  <button type=\"button\" className={styles.save} aria-label={`Save ${noun}`} onPointerDown={event => event.preventDefault()} onClick={() => submit(\"button\")}><Check size={15} strokeWidth={2} aria-hidden=\"true\" /></button>\n                  <button type=\"button\" className={styles.cancel} aria-label=\"Cancel editing\" onPointerDown={event => event.preventDefault()} onClick={cancel}><X size={15} strokeWidth={2} aria-hidden=\"true\" /></button>\n                </span>\n                  : slot === \"saving\" ? <span className={styles.spinner} aria-hidden=\"true\" />\n                  : slot === \"saved\" ? <span className={styles.saved} aria-hidden=\"true\"><DrawnCheck reduced={reduced} /></span>\n                  : slot === \"failed\" ? <CircleAlert className={styles.failedIcon} size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                  : <Pencil className={styles.pencil} size={14} strokeWidth={1.75} aria-hidden=\"true\" onClick={() => startEdit(shown.length)} />}\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={styles.srOnly}>Activate to edit.</span>\n    <span id={editHintId} className={styles.srOnly}>{multiline ? \"Enter saves, Shift+Enter adds a line break, Escape cancels.\" : \"Enter saves, Escape cancels.\"}</span>\n    <span className={styles.srOnly} role=\"status\">{announcement}</span>\n  </div>;\n}\n\nexport default InlineEdit;\n"
    },
    {
      "path": "registry/components/inline-edit/inline-edit.module.css",
      "type": "registry:component",
      "target": "@components/xzero/inline-edit/inline-edit.module.css",
      "content": "/* 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.root { --pad-x: 8px; --pad-y: 3px; position: relative; display: grid; min-width: 0; margin-inline-start: calc(-1 * (var(--pad-x) + 1px)); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\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.root[data-variant=\"title\"] { --size: var(--text-xl); --leading: round(var(--size) * 1.3, 2px); --weight: 500; --ink: var(--foreground); }\n.root[data-variant=\"body\"] { --pad-y: 5px; --size: var(--text-sm); --leading: round(var(--size) * 1.5, 1px); --weight: 400; --ink: var(--text-secondary); }\n/* Room for the actions is reserved in every state, so the text never rewraps when editing starts. */\n/* Inter at every size here: a heading host keeps its semantics but not the display face. */\n.line { display: block; min-width: 0; margin: 0; padding-inline-end: 66px; color: var(--ink); font-family: var(--font-body); font-size: var(--size); font-weight: var(--weight); line-height: var(--leading); letter-spacing: var(--tracking-body); }\n.root[data-multiline] .line { padding-inline-end: 38px; }\n.box { position: relative; display: inline-block; max-width: 100%; vertical-align: top; }\n.root[data-multiline] .box { display: block; }\n\n/* Display and field share every metric: border, padding, font, and line height. Only the frame behind them changes. */\n.display, .control { box-sizing: border-box; margin: 0; padding: var(--pad-y) var(--pad-x); border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: inherit; font: inherit; font-feature-settings: inherit; letter-spacing: inherit; word-spacing: inherit; line-height: var(--leading); text-align: left; text-indent: 0; text-transform: none; -webkit-tap-highlight-color: transparent; }\n.display { position: relative; z-index: 1; display: block; max-width: 100%; cursor: text; }\n.root[data-multiline] .display { width: 100%; }\n.root[data-editing] .display { visibility: hidden; }\n/* While editing, a single line keeps a little extra room, so the next character never has to wait for the box. */\n.root[data-editing]:not([data-multiline]) .display { min-width: 5em; padding-inline-end: calc(var(--pad-x) + 16px); }\n.display[aria-disabled=\"true\"] { cursor: progress; }\n.layer { position: relative; display: block; transition: opacity var(--duration-standard) var(--ease-standard); }\n.layer[data-empty] { color: var(--text-muted); }\n/* An optimistic save shows the new text at once, a little quieter until the save lands; then it settles to full strength. */\n.root[data-phase=\"saving\"] .layer { opacity: .5; transition-duration: var(--duration-fast); }\n.text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: pre; }\n.root[data-multiline] .text { overflow: visible; white-space: pre-wrap; overflow-wrap: break-word; }\n.control { position: absolute; top: 0; left: 0; z-index: 2; width: 100%; height: 100%; outline: none; resize: none; overflow: hidden; caret-color: var(--foreground); }\n/* The frame carries the focus ring for the field, so the field itself never draws a second one. */\n.control:focus-visible { outline: none; }\n.control::placeholder { color: var(--text-muted); opacity: 1; }\ntextarea.control { white-space: pre-wrap; overflow-wrap: break-word; }\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. */\n.frame { position: absolute; top: 0; bottom: 0; left: 0; z-index: 0; box-sizing: border-box; border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; pointer-events: none; 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.root[data-multiline] .frame { right: 0; }\n@media (hover: hover) and (pointer: fine) {\n  .display:not([aria-disabled=\"true\"]):hover ~ .frame { background: var(--surface-muted); }\n  .pencil { opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }\n  .display:hover ~ .frame .pencil, .display:focus-visible ~ .frame .pencil, .pencil:hover { opacity: 1; }\n}\n.root[data-editing] .frame { border-color: var(--foreground); background: var(--surface); box-shadow: 0 0 0 3px var(--focus-ring); }\n.root[data-invalid] .frame { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in oklab, var(--danger) 24%, transparent); }\n.root[data-flash=\"on\"] .frame { border-color: color-mix(in oklab, var(--danger) 30%, transparent); background: color-mix(in oklab, var(--danger) 9%, transparent); }\n/* The wash leaves slowly, without any movement, once the message has been read. */\n.root[data-flash=\"off\"] .frame { transition-duration: var(--duration-considered); }\n\n/* The slot rides on the frame's edge, so it follows the width morph instead of jumping to the new width. */\n.slot { position: absolute; top: 50%; left: calc(100% + 6px); display: grid; min-height: 28px; margin-top: -14px; pointer-events: auto; }\n.root[data-multiline] .slot { top: 0; left: calc(100% + 8px); margin-top: calc(var(--pad-y) + var(--leading) / 2 - 13px); }\n.slotItem { display: grid; grid-area: 1 / 1; min-height: 28px; place-items: center start; }\n.actions { display: flex; gap: 4px; }\n.root[data-multiline] .actions { flex-direction: column; }\n.save, .cancel { display: grid; width: 28px; height: 28px; flex: none; place-items: center; padding: 0; border: 1px solid transparent; border-radius: var(--radius-pill); cursor: pointer; -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), transform var(--duration-instant) var(--ease-standard); }\n.save { background: var(--foreground); color: var(--background); }\n.cancel { border-color: var(--border); background: var(--surface); color: var(--text-secondary); }\n.save:active, .cancel:active { transform: scale(.96); }\n@media (hover: hover) and (pointer: fine) { .save:hover { background: color-mix(in oklab, var(--foreground) 86%, var(--background)); } .cancel:hover { border-color: var(--border-strong); background: var(--surface-muted); color: var(--foreground); } }\n.pencil { margin-left: 2px; color: var(--text-muted); cursor: pointer; }\n.spinner { display: block; width: 14px; height: 14px; margin-left: 2px; border: 1.5px solid var(--text-muted); border-right-color: transparent; border-radius: var(--radius-pill); animation: spin .7s linear infinite; }\n.saved { display: grid; margin-left: 1px; place-items: center; color: var(--success); }\n.failedIcon { margin-left: 1px; color: var(--danger); }\n\n/* The message row opens from a true zero; its spacing lives inside the clipped slot. */\n.reveal { display: block; overflow: hidden; }\n.revealInner { position: relative; display: block; }\n.message { display: flex; align-items: flex-start; gap: 6px; padding: 6px var(--pad-x) 4px calc(var(--pad-x) + 1px); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 400; line-height: var(--leading-body); }\n.message[data-tone=\"error\"] { color: var(--danger); }\n.messageIcon { flex: none; margin-top: 2px; color: var(--danger); }\n.retry { margin: 0 0 0 2px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--foreground); font: inherit; font-weight: 500; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; cursor: pointer; }\n@media (hover: hover) and (pointer: fine) { .retry:hover { text-decoration-color: currentColor; } }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@keyframes spin { to { transform: rotate(360deg); } }\n@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-right-color: var(--text-muted); border-top-color: transparent; } .layer, .save, .cancel, .pencil { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/inline-edit",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inline edit",
      "rename",
      "click to edit"
    ],
    "docs": "https://ui.xzero.co.uk/components/inline-edit",
    "markdown": "https://ui.xzero.co.uk/components/inline-edit/markdown"
  }
}