cm_bot_v2/web/components/editable-cell.tsx
yiekheng 3fe33772ce fix(web): editable cell wraps long values instead of overflowing
Long URLs in the link column would overflow on mobile because
truncate + inline-flex without min-w-0 expanded the cell beyond the
card width. Switch to flex+items-start, min-w-0 on the value span,
break-all so unbreakable strings wrap. Edit hint stays pinned right
with shrink-0.
2026-05-02 21:08:19 +08:00

143 lines
4.1 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 flex w-full min-w-0 items-start gap-2 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="min-w-0 flex-1 break-all">
{value || <em className="not-italic text-zinc-400"></em>}
</span>
<span
aria-hidden="true"
className="hidden shrink-0 pt-0.5 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>
);
}