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