143 lines
4.0 KiB
TypeScript
143 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, useTransition } from "react";
|
|
|
|
type EditableCellProps = {
|
|
value: string;
|
|
onSave: (next: string) => Promise<{ ok: boolean; error?: string }>;
|
|
label?: string;
|
|
isCurrentlyEditing?: boolean;
|
|
onEditStart?: () => void;
|
|
onEditEnd?: () => void;
|
|
};
|
|
|
|
export default function EditableCell({
|
|
value,
|
|
onSave,
|
|
label,
|
|
isCurrentlyEditing,
|
|
onEditStart,
|
|
onEditEnd,
|
|
}: EditableCellProps) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(value);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Keep draft in sync if the underlying value changes from outside
|
|
// (auto-refresh, server revalidation) while we are NOT actively editing.
|
|
useEffect(() => {
|
|
if (!editing) setDraft(value);
|
|
}, [value, editing]);
|
|
|
|
// Auto-clear an error after 3 seconds.
|
|
useEffect(() => {
|
|
if (!error) return;
|
|
const id = setTimeout(() => setError(null), 3000);
|
|
return () => clearTimeout(id);
|
|
}, [error]);
|
|
|
|
function begin() {
|
|
setDraft(value);
|
|
setEditing(true);
|
|
onEditStart?.();
|
|
requestAnimationFrame(() => {
|
|
inputRef.current?.focus();
|
|
inputRef.current?.select();
|
|
});
|
|
}
|
|
|
|
function cancel() {
|
|
setEditing(false);
|
|
setDraft(value);
|
|
setError(null);
|
|
onEditEnd?.();
|
|
}
|
|
|
|
function commit() {
|
|
const next = draft;
|
|
if (next === value) {
|
|
cancel();
|
|
return;
|
|
}
|
|
startTransition(async () => {
|
|
const result = await onSave(next);
|
|
if (result.ok) {
|
|
setEditing(false);
|
|
setError(null);
|
|
onEditEnd?.();
|
|
} else {
|
|
setError(result.error ?? "Save failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!editing) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={begin}
|
|
aria-label={label ? `Edit ${label}` : undefined}
|
|
className="group relative -mx-1 -my-0.5 inline-flex w-full items-center rounded-sm px-1 py-0.5 text-left font-mono text-sm text-zinc-900 hover:bg-yellow-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400"
|
|
>
|
|
<span className="truncate">
|
|
{value || <em className="not-italic text-zinc-400">—</em>}
|
|
</span>
|
|
<span
|
|
aria-hidden="true"
|
|
className="ml-2 hidden text-[10px] font-semibold uppercase tracking-widest text-zinc-400 group-hover:inline"
|
|
>
|
|
edit
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative -mx-1 -my-0.5 flex flex-col">
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
ref={inputRef}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
commit();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
cancel();
|
|
}
|
|
}}
|
|
disabled={isPending}
|
|
className="w-full min-w-0 rounded-sm border-2 border-yellow-400 bg-white px-1 py-0.5 font-mono text-sm text-zinc-900 outline-none disabled:opacity-60"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={commit}
|
|
disabled={isPending}
|
|
aria-label="Save"
|
|
className="shrink-0 border border-zinc-900 bg-zinc-900 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-white hover:bg-zinc-700 disabled:opacity-60"
|
|
>
|
|
{isPending ? "…" : "save"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={cancel}
|
|
disabled={isPending}
|
|
aria-label="Cancel"
|
|
className="shrink-0 border border-zinc-300 bg-white px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-zinc-600 hover:border-zinc-500 hover:text-zinc-900 disabled:opacity-60"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{error && (
|
|
<p className="mt-1 font-mono text-[11px] text-red-700" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|