Component
Inline edit
Text that turns into an input in place, with validation and save on commit.
- #arc
- #component
- #input
- #edit
- #text
- #validation
- #form
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/inline-edit.jsonFiles
components/arc/inline-edit/inline-edit.module.csscomponents/arc/inline-edit/inline-edit.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
Registry items it pulls in
Source
View as Markdowninline-edit.module.cssinline-edit.module.cssinline-edit.module.csscss
/* 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. */
.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); }
/* Whole-pixel line heights: an input centres its text by rounding, so a fractional line would sit the field a pixel below the display. */
.root[data-variant="title"] { --size: var(--text-xl); --leading: round(var(--size) * 1.3, 2px); --weight: 500; --ink: var(--foreground); }
.root[data-variant="body"] { --pad-y: 5px; --size: var(--text-sm); --leading: round(var(--size) * 1.5, 1px); --weight: 400; --ink: var(--text-secondary); }
/* 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. */
.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); }
.root[data-multiline] .line { padding-inline-end: 38px; }
.box { position: relative; display: inline-block; max-width: 100%; vertical-align: top; }
.root[data-multiline] .box { display: block; }
/* Display and field share every metric: border, padding, font, and line height. Only the frame behind them changes. */
.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; }
.display { position: relative; z-index: 1; display: block; max-width: 100%; cursor: text; }
.root[data-multiline] .display { width: 100%; }
.root[data-editing] .display { visibility: hidden; }
/* While editing, a single line keeps a little extra room, so the next character never has to wait for the box. */
.root[data-editing]:not([data-multiline]) .display { min-width: 5em; padding-inline-end: calc(var(--pad-x) + 16px); }
.display[aria-disabled="true"] { cursor: progress; }
.layer { position: relative; display: block; transition: opacity var(--duration-standard) var(--ease-standard); }
.layer[data-empty] { color: var(--text-muted); }
/* An optimistic save shows the new text at once, a little quieter until the save lands; then it settles to full strength. */
.root[data-phase="saving"] .layer { opacity: .5; transition-duration: var(--duration-fast); }
.text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: pre; }
.root[data-multiline] .text { overflow: visible; white-space: pre-wrap; overflow-wrap: break-word; }
.control { position: absolute; top: 0; left: 0; z-index: 2; width: 100%; height: 100%; outline: none; resize: none; overflow: hidden; caret-color: var(--foreground); }
/* The frame carries the focus ring for the field, so the field itself never draws a second one. */
.control:focus-visible { outline: none; }
.control::placeholder { color: var(--text-muted); opacity: 1; }
textarea.control { white-space: pre-wrap; overflow-wrap: break-word; }
/* The frame is the only surface: a hover tint at rest, a filled field while editing, a soft danger wash when a save fails. */
.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); }
.root[data-multiline] .frame { right: 0; }
@media (hover: hover) and (pointer: fine) {
.display:not([aria-disabled="true"]):hover ~ .frame { background: var(--surface-muted); }
.pencil { opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }
.display:hover ~ .frame .pencil, .display:focus-visible ~ .frame .pencil, .pencil:hover { opacity: 1; }
}
/* Editing fills the frame with the accent and an error fills it with danger, exactly like Input; the text switches to the accent foreground and no ring is drawn. */
.root[data-editing] .frame { border-color: var(--accent); background: var(--accent); }
.root[data-editing][data-invalid] .frame { border-color: var(--danger); background: var(--danger); }
.root[data-editing] .control { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.root[data-editing] .control::placeholder { color: var(--accent-foreground-muted); }
/* A rejected save that has already left the field shows only a soft danger ring on the resting text. */
.root[data-invalid]:not([data-editing]) .frame { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in oklab, var(--danger) 24%, transparent); }
.root[data-flash="on"] .frame { border-color: color-mix(in oklab, var(--danger) 30%, transparent); background: color-mix(in oklab, var(--danger) 9%, transparent); }
/* The wash leaves slowly, without any movement, once the message has been read. */
.root[data-flash="off"] .frame { transition-duration: var(--duration-considered); }
/* The slot rides on the frame's edge, so it follows the width morph instead of jumping to the new width. */
.slot { position: absolute; top: 50%; left: calc(100% + 6px); display: grid; min-height: 28px; margin-top: -14px; pointer-events: auto; }
.root[data-multiline] .slot { top: 0; left: calc(100% + 8px); margin-top: calc(var(--pad-y) + var(--leading) / 2 - 13px); }
.slotItem { display: grid; grid-area: 1 / 1; min-height: 28px; place-items: center start; }
.actions { display: flex; gap: 4px; }
.root[data-multiline] .actions { flex-direction: column; }
.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); }
.save { background: var(--foreground); color: var(--background); }
.cancel { border-color: var(--border); background: var(--surface); color: var(--text-secondary); }
.save:active, .cancel:active { transform: scale(.96); }
@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); } }
.pencil { margin-left: 2px; color: var(--text-muted); cursor: pointer; }
.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; }
.saved { display: grid; margin-left: 1px; place-items: center; color: var(--success); }
.failedIcon { margin-left: 1px; color: var(--danger); }
/* The message row opens from a true zero; its spacing lives inside the clipped slot. */
.reveal { display: block; overflow: hidden; }
.revealInner { position: relative; display: block; }
.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); }
.message[data-tone="error"] { color: var(--danger); }
.messageIcon { flex: none; margin-top: 2px; color: var(--danger); }
.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; }
@media (hover: hover) and (pointer: fine) { .retry:hover { text-decoration-color: currentColor; } }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-right-color: var(--text-muted); border-top-color: transparent; } .layer, .save, .cancel, .pencil { transition: none; } }
inline-edit.tsxinline-edit.tsxinline-edit.tsxtsx
"use client";
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { ChangeEvent, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { TargetAndTransition, Variants } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { PencilEdit02Icon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./inline-edit.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
/**
* 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.
* 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
* 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.
* Use a regular form when several fields must be saved together.
*/
export interface InlineEditProps {
/** The saved value. A new value from outside replaces the text while it is not being edited. */
value: string;
/** Persists the new value. Return a promise to show the saving state, and reject it to roll back. */
onSave: (next: string) => void | Promise<unknown>;
/** Accessible name, 예: “프로젝트 이름”. */
label: string;
/** Returns a message when the draft cannot be saved. */
validate?: (next: string) => string | null | undefined;
/** Shown when the value is empty. */
placeholder?: string;
/** Wraps onto several lines and grows in height. Enter still saves; Shift+Enter adds a line break. */
multiline?: boolean;
/** `title` for names and headings, `body` for descriptions. */
variant?: "title" | "body";
/** The element that holds the text, so a title can stay a heading. */
as?: "span" | "p" | "h1" | "h2" | "h3";
className?: string;
}
type Phase = "idle" | "saving" | "saved" | "failed";
const enter = [...motionTokens.ease.enter] as [number, number, number, number];
const standard = [...motionTokens.ease.standard] as [number, number, number, number];
const blur = (px: number) => `blur(${px}px)`;
const noop = () => () => {};
/** Typing retargets many times a second, so the frame follows on a quicker spring without overshoot and keeps up with a fast typist. */
const typingSpring = { ...motionTokens.spring.snappy, visualDuration: motionTokens.duration.fast, bounce: 0 };
/** Reduced motion only counts after hydration, so the server and the first client render always agree. */
function useReduced() {
const hydrated = useSyncExternalStore(noop, () => true, () => false);
const reduced = useReducedMotion() ?? false;
return hydrated && reduced;
}
/** 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. */
const textMotion: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${direction * .3}em`, filter: blur(motionTokens.blur.soft) }),
rest: { opacity: 1, y: "0em", filter: blur(0), transition: { duration: motionTokens.duration.standard, ease: enter } },
exit: (direction: number) => ({ opacity: 0, y: `${direction * -.3}em`, filter: blur(motionTokens.blur.subtle), transition: { duration: motionTokens.duration.fast, ease: standard } }),
};
const textFade: Variants = { enter: { opacity: 0 }, rest: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
const rest: TargetAndTransition = { opacity: 1, scale: 1, filter: blur(0) };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) };
const iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };
const fadeIn: TargetAndTransition = { opacity: 0 };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };
/** The tick draws itself from its short stroke, the way a hand would write it. */
function DrawnCheck({ reduced }: { reduced: boolean }) {
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">
<motion.path d="M4 12.5l5 5L20 6.5" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.moderate, ease: enter, delay: motionTokens.delay.micro }, opacity: { duration: motionTokens.duration.micro, delay: motionTokens.delay.micro } }} />
</svg>;
}
/** 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. */
function Reveal({ id, message, reduced }: { id: string; message: { key: string; tone: "error" | "failed"; node: ReactNode } | null; reduced: boolean }) {
const inner = useRef<HTMLSpanElement>(null);
const height = useMotionValue(0);
useEffect(() => {
const node = inner.current;
if (!node || typeof ResizeObserver === "undefined") return;
let measured = false;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
if (!measured || reduced) { measured = true; height.jump(next); return; }
animate(height, next, motionTokens.spring.smooth);
});
observer.observe(node, { box: "border-box" });
return () => { observer.disconnect(); height.stop(); };
}, [height, reduced]);
return <motion.span className={styles.reveal} style={{ height }}>
<span ref={inner} id={id} className={styles.revealInner} aria-live="polite">
<AnimatePresence mode="popLayout" initial={false} custom={1}>
{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>}
</AnimatePresence>
</span>
</motion.span>;
}
type CaretDocument = Document & {
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
caretRangeFromPoint?: (x: number, y: number) => Range | null;
};
export function InlineEdit({ value, onSave, label, validate, placeholder = "", multiline = false, variant = "title", as: Tag = "span", className }: InlineEditProps) {
const reduced = useReduced();
const ids = useId();
const displayHintId = `${ids}-display`, editHintId = `${ids}-edit`, messageId = `${ids}-message`;
const [committed, setCommitted] = useState(value);
const [shown, setShown] = useState(value);
const [seen, setSeen] = useState(value);
const [layer, setLayer] = useState({ key: 0, direction: 1 });
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [phase, setPhase] = useState<Phase>("idle");
const [error, setError] = useState<string | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const [flash, setFlash] = useState<"on" | "off" | null>(null);
const [announcement, setAnnouncement] = useState("");
const root = useRef<HTMLDivElement>(null);
const display = useRef<HTMLButtonElement>(null);
const control = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const selection = useRef<number | "all" | null>(null);
const focusDisplay = useRef(false);
const saveRun = useRef(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const frame = useRef<HTMLSpanElement>(null);
const box = useRef<HTMLSpanElement>(null);
// 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.
// Multiline: the box's height springs, so content below glides, while the textarea already has the room its text needs.
// Both start at their natural size ("100%", "auto"), so the server markup is already right before any script runs.
const frameWidth = useMotionValue<number | string>("100%");
const boxHeight = useMotionValue<number | string>("auto");
const size = useRef(0);
const wasEditing = useRef(false);
// A new value from outside replaces the text, unless the person is typing or a save is still in flight.
if (value !== seen) {
setSeen(value);
if (value !== committed) {
setCommitted(value);
if (!editing && phase !== "saving" && value !== shown) { setShown(value); setLayer(current => ({ key: current.key + 1, direction: 1 })); }
}
}
const layerText = editing ? draft : shown;
const saving = phase === "saving";
const later = (fn: () => void, ms: number) => { timers.current.push(setTimeout(fn, ms)); };
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const naturalSize = () => { const node = display.current; return node ? parseFloat(getComputedStyle(node)[multiline ? "height" : "width"]) : 0; };
/** 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. */
const snap = (next: number) => {
const field = control.current;
if (multiline && field) { field.style.height = `${next}px`; field.scrollTop = 0; }
const surface = multiline ? box.current : frame.current;
if (surface) surface.style[multiline ? "height" : "width"] = `${next}px`;
(multiline ? boxHeight : frameWidth).jump(next);
};
// 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.
const onContentChange = useEffectEvent(() => {
const next = naturalSize();
const previous = size.current;
const typing = editing && wasEditing.current;
size.current = next;
wasEditing.current = editing;
if (!next) return;
const field = control.current;
if (multiline && field) { field.style.height = `${next}px`; field.scrollTop = 0; }
const target = multiline ? boxHeight : frameWidth;
if (!previous || reduced || typeof target.get() !== "number") { snap(next); return; }
if (Math.abs(next - previous) < .1) return;
animate(target, next, multiline ? motionTokens.spring.smooth : typing ? typingSpring : motionTokens.spring.morph);
});
useLayoutEffect(() => { onContentChange(); }, [layerText, editing, multiline, reduced]);
// A late web font or a resized container follows at once instead of animating.
const onResize = useEffectEvent(() => {
const next = naturalSize();
if (!next || Math.abs(next - size.current) < .1) return;
size.current = next;
snap(next);
});
useEffect(() => {
const node = display.current;
if (!node || typeof ResizeObserver === "undefined") return;
// Border box, so the room added while editing (padding, not content) is noticed too.
const observer = new ResizeObserver(() => onResize());
observer.observe(node, { box: "border-box" });
return () => observer.disconnect();
}, []);
// Focus follows the mode: into the field with the caret where the text was clicked, and back to the text after Enter or Escape.
useLayoutEffect(() => {
const field = control.current;
if (editing && field && selection.current !== null) {
const at = selection.current;
selection.current = null;
field.focus({ preventScroll: true });
if (at === "all") field.select(); else field.setSelectionRange(at, at);
}
if (!editing && focusDisplay.current) { focusDisplay.current = false; display.current?.focus({ preventScroll: true }); }
});
function startEdit(at: number | "all", text = shown) {
if (editing || saving) return;
setDraft(text);
setEditing(true);
setError(null);
setFailed(null);
setFlash(null);
if (phase !== "idle") setPhase("idle");
selection.current = at;
}
/** The caret lands on the character that was clicked, the way it would in a text field. */
function caretAt(x: number, y: number) {
const node = display.current?.querySelector(`[data-layer="${layer.key}"]`)?.firstChild;
if (!node || !shown) return shown.length;
const doc = document as CaretDocument;
const position = doc.caretPositionFromPoint?.(x, y);
if (position) return position.offsetNode === node ? Math.min(position.offset, shown.length) : shown.length;
const range = doc.caretRangeFromPoint?.(x, y);
return range && range.startContainer === node ? Math.min(range.startOffset, shown.length) : shown.length;
}
function onDisplayClick(event: ReactMouseEvent<HTMLButtonElement>) {
if (saving) return;
if (!editing) playUiSound("open");
// A keyboard activation selects everything, ready to retype; a click puts the caret where it landed.
startEdit(event.detail === 0 ? "all" : caretAt(event.clientX, event.clientY));
}
const clean = (text: string) => multiline ? text.trim() : text.replace(/\s+/g, " ").trim();
function submit(source: "key" | "button" | "blur") {
const next = clean(draft);
const problem = validate?.(next) || null;
if (problem) { playUiSound("error"); setError(problem); if (source !== "blur") control.current?.focus(); return; }
setEditing(false);
setError(null);
if (source !== "blur") focusDisplay.current = true;
// Trimmed spaces would shift the text in one frame, so a cleaned value arrives with the text motion instead.
if (next !== draft) setLayer(current => ({ key: current.key + 1, direction: 1 }));
if (next === shown) { if (source === "button") playUiSound("select"); return; }
playUiSound("select");
const previous = committed;
const run = ++saveRun.current;
setShown(next);
setPhase("saving");
setAnnouncement(`${label} 저장하는 중`);
Promise.resolve().then(() => onSave(next)).then(() => {
if (run !== saveRun.current) return;
setCommitted(next);
setPhase("saved");
setAnnouncement(`${label} 저장했어요`);
later(() => setPhase(current => current === "saved" ? "idle" : current), 1800);
}, () => {
if (run !== saveRun.current) return;
playUiSound("error");
setShown(previous);
setLayer(current => ({ key: current.key + 1, direction: -1 }));
setPhase("failed");
setFailed(next);
setFlash("on");
setAnnouncement("");
later(() => setFlash(current => current === "on" ? "off" : current), 1400);
later(() => setFlash(current => current === "off" ? null : current), 2000);
});
}
function cancel() {
playUiSound("close");
setEditing(false);
setError(null);
focusDisplay.current = true;
if (draft !== shown) setLayer(current => ({ key: current.key + 1, direction: -1 }));
}
function retry() {
if (!failed) return;
playUiSound("click");
startEdit(failed.length, failed);
}
function onChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const next = event.target.value;
setDraft(next);
if (error) setError(validate?.(clean(next)) || null);
}
function onKeyDown(event: ReactKeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); cancel(); return; }
if (event.key === "Enter" && !(multiline && event.shiftKey)) { event.preventDefault(); submit("key"); }
}
// Leaving the component saves, the way a rename does; switching windows does not.
function onBlur(event: ReactFocusEvent<HTMLDivElement>) {
if (!editing) return;
const next = event.relatedTarget as Node | null;
if (next && root.current?.contains(next)) return;
if (!next && !document.hasFocus()) return;
submit("blur");
}
const noun = label.toLowerCase();
const message = error
? { key: `error:${error}`, tone: "error" as const, node: <><UiIcon iconRole="alert" className={styles.messageIcon} size={14} strokeWidth={2} aria-hidden="true" /><span>{error}</span></> }
: failed && !editing
? { key: `failed:${failed}`, tone: "failed" as const, node: <><UiIcon iconRole="alert" className={styles.messageIcon} size={14} strokeWidth={2} aria-hidden="true" /><span>{`저장하지 못했어요. 마지막으로 저장한 값으로 되돌렸어요.`} <button type="button" className={styles.retry} onClick={retry}>다시 해 볼까요?</button></span></> }
: null;
const slot = editing ? "edit" : phase;
const describedBy = (hint: string) => [hint, message ? messageId : null].filter(Boolean).join(" ");
const fieldProps = {
className: styles.control, value: draft, onChange, onKeyDown, placeholder, "aria-label": label, "aria-invalid": error ? true : undefined,
"aria-describedby": describedBy(editHintId), autoComplete: "off", spellCheck: variant === "body",
};
return <div ref={root} className={[styles.root, className].filter(Boolean).join(" ")} data-variant={variant} data-multiline={multiline || undefined} data-editing={editing || undefined}
data-phase={phase} data-invalid={error ? "" : undefined} data-flash={flash ?? undefined} onBlur={onBlur}>
<Tag className={styles.line}>
<motion.span ref={box} className={styles.box} style={multiline ? { height: boxHeight } : undefined}>
{/* 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. */}
<button ref={display} type="button" className={styles.display} onClick={onDisplayClick} tabIndex={editing ? -1 : undefined}
aria-label={`${label}: ${shown || placeholder}`} aria-describedby={describedBy(displayHintId)} aria-disabled={saving || undefined}>
<span className={styles.layer} data-empty={layerText ? undefined : ""}>
<AnimatePresence mode="popLayout" initial={false} custom={layer.direction}>
<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">
{/* A zero-width space keeps the line box, so an empty field never collapses. */}
{(layerText || placeholder || "\u200b") + (multiline && editing ? "\u200b" : "")}
</motion.span>
</AnimatePresence>
</span>
</button>
{editing && (multiline
? <textarea ref={node => { control.current = node; }} {...fieldProps} rows={1} enterKeyHint="done" />
: <input ref={node => { control.current = node; }} {...fieldProps} type="text" enterKeyHint="done" />)}
<motion.span ref={frame} className={styles.frame} style={multiline ? undefined : { width: frameWidth }}>
<span className={styles.slot}>
<AnimatePresence initial={false}>
<motion.span key={slot} className={styles.slotItem} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}>
{slot === "edit" ? <span className={styles.actions}>
<button type="button" className={styles.save} aria-label={`${noun} 저장하기`} onPointerDown={event => event.preventDefault()} onClick={() => submit("button")}><UiIcon iconRole="check" size={15} strokeWidth={2} aria-hidden="true" /></button>
<button type="button" className={styles.cancel} aria-label="수정 취소하기" onPointerDown={event => event.preventDefault()} onClick={cancel}><UiIcon iconRole="close" size={15} strokeWidth={2} aria-hidden="true" /></button>
</span>
: slot === "saving" ? <span className={styles.spinner} aria-hidden="true" />
: slot === "saved" ? <span className={styles.saved} aria-hidden="true"><DrawnCheck reduced={reduced} /></span>
: slot === "failed" ? <UiIcon iconRole="alert" className={styles.failedIcon} size={16} strokeWidth={1.75} aria-hidden="true" />
: <HugeiconsIcon icon={PencilEdit02Icon} className={styles.pencil} size={14} strokeWidth={1.75} aria-hidden="true" onClick={() => { if (!editing && !saving) playUiSound("open"); startEdit(shown.length); }} />}
</motion.span>
</AnimatePresence>
</span>
</motion.span>
</motion.span>
</Tag>
<Reveal id={messageId} message={message} reduced={reduced} />
<span id={displayHintId} className={styles.srOnly}>눌러서 수정해요.</span>
<span id={editHintId} className={styles.srOnly}>{multiline ? "Enter로 저장하고, Shift+Enter로 줄을 바꾸고, Esc로 취소해요." : "Enter로 저장하고, Esc로 취소해요."}</span>
<span className={styles.srOnly} role="status">{announcement}</span>
</div>;
}
export default InlineEdit;