"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
export type InlineEditCommitOn = "blur" | "explicit";
export type InlineEditUnderline = "dotted" | "solid" | "none";
export interface InlineEditProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
label?: string;
placeholder?: string;
commitOn?: InlineEditCommitOn;
stiffness?: number;
damping?: number;
underline?: InlineEditUnderline;
padX?: number;
padY?: number;
radius?: number;
selectOnEdit?: boolean;
allowEmpty?: boolean;
showPencil?: boolean;
multiline?: boolean;
accentColor?: string;
reducedMotion?: boolean;
className?: string;
}
export default function InlineEdit({
value,
defaultValue = "",
onValueChange,
label = "Value",
placeholder = "Empty",
commitOn = "blur",
stiffness = 460,
damping = 32,
underline = "dotted",
padX = 8,
padY = 5,
radius = 6,
selectOnEdit = true,
allowEmpty = false,
showPencil = true,
multiline = false,
accentColor = "#a855f7",
reducedMotion = false,
className,
}: InlineEditProps) {
const controlled = value !== undefined;
const [self, setSelf] = useState(defaultValue);
const committed = controlled ? value : self;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(committed);
const rootRef = useRef<HTMLSpanElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);
const handBack = useRef(false);
useEffect(() => {
if (!editing) return;
const field = fieldRef.current;
if (!field) return;
field.focus({ preventScroll: true });
if (selectOnEdit) field.select();
}, [editing, selectOnEdit]);
useEffect(() => {
if (editing || !handBack.current) return;
handBack.current = false;
buttonRef.current?.focus({ preventScroll: true });
}, [editing]);
const begin = () => {
setDraft(committed);
setEditing(true);
};
const finish = (next: string) => {
if (!controlled) setSelf(next);
onValueChange?.(next);
setEditing(false);
};
const commit = (refocus = false) => {
handBack.current = refocus;
const next = draft.trim();
finish(!next && !allowEmpty ? committed : draft);
};
const cancel = (refocus = false) => {
handBack.current = refocus;
setDraft(committed);
setEditing(false);
};
const box = {
paddingLeft: padX,
paddingRight: padX,
paddingTop: padY,
paddingBottom: padY,
borderRadius: radius,
};
const type = cn(
"font-sans text-[15px] leading-normal",
multiline ? "whitespace-pre-wrap" : "whitespace-pre",
);
const spring = reducedMotion
? { duration: 0 }
: { type: "spring" as const, stiffness, damping };
return (
<span
ref={rootRef}
className={cn("group/edit relative inline-block", className)}
>
<motion.span
className="block border border-solid"
style={box}
initial={false}
animate={{
backgroundColor: editing ? `${accentColor}14` : `${accentColor}00`,
borderColor: editing ? accentColor : `${accentColor}00`,
boxShadow: editing
? `0 0 0 3px ${accentColor}26`
: `0 0 0 0px ${accentColor}00`,
}}
transition={spring}
>
{editing ? (
<span className="grid *:col-1 *:row-1">
<span aria-hidden className={cn(type, "invisible wrap-break-word")}>
{draft.endsWith("\n") ? `${draft} ` : draft || ""}
</span>
{multiline ? (
<textarea
ref={fieldRef}
rows={1}
cols={1}
value={draft}
placeholder={placeholder}
aria-label={label}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
cancel(true);
}
if (event.key === "Enter" && (event.metaKey || event.ctrlKey))
commit(true);
}}
onBlur={(event) => {
if (rootRef.current?.contains(event.relatedTarget)) return;
if (commitOn === "blur") commit();
else cancel();
}}
className={cn(
type,
"h-0 min-h-full w-0 min-w-full resize-none overflow-hidden bg-transparent text-ink outline-none placeholder:text-ink-mute/70",
)}
/>
) : (
<input
ref={fieldRef}
type="text"
size={1}
value={draft}
placeholder={placeholder}
aria-label={label}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
cancel(true);
}
if (event.key === "Enter") {
event.preventDefault();
commit(true);
}
}}
onBlur={(event) => {
if (rootRef.current?.contains(event.relatedTarget)) return;
if (commitOn === "blur") commit();
else cancel();
}}
className={cn(
type,
"w-0 min-w-full bg-transparent text-ink outline-none placeholder:text-ink-mute/70",
)}
/>
)}
</span>
) : (
<button
ref={buttonRef}
type="button"
onClick={begin}
aria-label={`${label}: ${committed || placeholder}`}
className={cn(
type,
"block w-full cursor-text text-left text-ink outline-none",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
!multiline && "overflow-hidden text-ellipsis",
!committed && "text-ink-mute",
)}
style={{
textDecorationLine: underline === "none" ? "none" : "underline",
textDecorationStyle:
underline === "dotted" ? "dotted" : ("solid" as const),
textDecorationColor: `${accentColor}66`,
textUnderlineOffset: 4,
}}
>
{committed || placeholder}
</button>
)}
</motion.span>
{showPencil && !editing ? (
<span
aria-hidden
className="pointer-events-none absolute top-1/2 -right-6 -translate-y-1/2 text-ink-mute opacity-0 transition-opacity duration-150 group-focus-within/edit:opacity-100 group-hover/edit:opacity-100"
>
<svg
viewBox="0 0 24 24"
className="size-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</span>
) : null}
{editing && commitOn === "explicit" ? (
<span className="absolute top-1/2 -right-13 flex -translate-y-1/2 gap-1">
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => commit(true)}
aria-label="Save"
className="rounded-sm border border-hairline p-1 text-ink-dim transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<svg
viewBox="0 0 24 24"
className="size-3"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12.5 9.5 18 20 6.5" />
</svg>
</button>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => cancel(true)}
aria-label="Cancel"
className="rounded-sm border border-hairline p-1 text-ink-dim transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<svg
viewBox="0 0 24 24"
className="size-3"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 5 19 19M19 5 5 19" />
</svg>
</button>
</span>
) : null}
</span>
);
}