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:
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user