feat(inbox): EditableField with blur-save, enter-commit, esc-cancel

Task 26 of the slice plan. Click-to-edit primitive used for
title, summary, and intent. Blur commits via injected onSave;
Enter blurs (single-line); Escape cancels and reverts to
the prop value. Failed save shows a 1px red outline for 800ms
and restores the prior value (no error message — caller decides
visual feedback). singleLine prop swaps <input> for <textarea>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-25 12:17:53 +09:00
parent 6e0b7a55da
commit 09b3c62da7

View File

@@ -1,9 +1,66 @@
import React, { CSSProperties } from 'react';
export function EditableField(props: {
import React, { useEffect, useRef, useState, CSSProperties } from 'react';
interface Props {
value: string;
onSave: (next: string) => Promise<void>;
style?: CSSProperties;
singleLine?: boolean;
}): React.ReactElement {
return <div style={props.style}>{props.value}</div>;
}
export function EditableField({
value, onSave, style, singleLine = true
}: Props): React.ReactElement {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState(false);
const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
useEffect(() => { if (!editing) setDraft(value); }, [value, editing]);
useEffect(() => { if (editing) ref.current?.focus(); }, [editing]);
async function commit() {
if (draft === value) { setEditing(false); return; }
try { await onSave(draft); setEditing(false); }
catch { setError(true); setDraft(value); setTimeout(() => setError(false), 800); }
}
if (!editing) {
return (
<span
onClick={() => setEditing(true)}
style={{ ...style, cursor: 'text', outline: error ? '1px solid #c93030' : 'none' }}
>
{value || <em style={{ color: '#bbb' }}>( )</em>}
</span>
);
}
if (singleLine) {
return (
<input
ref={ref as React.RefObject<HTMLInputElement>}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') { setDraft(value); setEditing(false); }
}}
style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 2 }}
/>
);
}
return (
<textarea
ref={ref as React.RefObject<HTMLTextAreaElement>}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Escape') { setDraft(value); setEditing(false); }
}}
style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 4, width: '100%', minHeight: 60 }}
/>
);
}