cm_bot_v2/web/components/editable-cell.tsx
yiekheng eb297e977e fix(web): don't apply break-all when EditableCell has a custom renderView
The break-all on the value span was added so long URLs (link column)
wrap inside the narrow mobile cards. But it was also forcing
character-by-character breaks inside the StatusBadge (e.g., 'available'
splitting into 'ava\nila\nble' on narrow screens). Skip break-all when
renderView is provided — those callers render their own atomic widgets
(badges) that should never break.
2026-05-03 10:19:02 +08:00

150 lines
4.2 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;
/**
* Override how the value is rendered in view mode. Use this to show
* something other than plain text (e.g., a status pill) — clicking
* the rendered element still starts edit mode.
*/
renderView?: (value: string) => React.ReactNode;
};
export default function EditableCell({
value,
onSave,
label,
isCurrentlyEditing,
onEditStart,
onEditEnd,
renderView,
}: 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);
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
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() {
if (draft === value) {
cancel();
return;
}
startTransition(async () => {
const result = await onSave(draft);
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 flex w-full min-w-0 items-center gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
>
<span
className={`min-w-0 flex-1 ${renderView ? "" : "break-all"}`}
>
{renderView
? renderView(value)
: value || <em className="not-italic text-zinc-400"></em>}
</span>
<span
aria-hidden="true"
className="hidden shrink-0 self-center text-[10px] font-medium uppercase tracking-wider text-zinc-400 group-hover:inline"
>
edit
</span>
</button>
);
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<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-md border-0 bg-zinc-100 px-2 py-1 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
/>
<button
type="button"
onClick={commit}
disabled={isPending}
aria-label="Save"
className="shrink-0 rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
>
{isPending ? "…" : "Save"}
</button>
<button
type="button"
onClick={cancel}
disabled={isPending}
aria-label="Cancel"
className="shrink-0 rounded-md px-2.5 py-1 text-[11px] font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
>
Cancel
</button>
</div>
{error && (
<p className="font-mono text-[11px] text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}