// src/components/ui/EditableText.jsx // Inline hover-to-edit text — shows a pencil icon on hover, click swaps to a // text input in place. Enter/blur saves, Escape cancels. Use for a single // display value that's editable in-place without a modal (e.g. a page hero // title) — for anything with more than one field, use a Modal instead. // // Props: // value — string — current display value // placeholder — string — shown (italic, muted) when value is empty // onSave — (newValue) => void | Promise — called on confirm; not called if unchanged // editable — bool — hide the pencil / disable editing entirely (default: true) // textStyle — style object applied to the display span AND the input, so // swapping between them doesn't shift surrounding layout // className — extra class on the root element // inputAriaLabel — string — accessible label for the input while editing import { useState, useRef, useEffect } from 'react' import Icon from '@/components/ui/Icon' export default function EditableText({ value, placeholder = 'Untitled', onSave, editable = true, textStyle = {}, className = '', inputAriaLabel = 'Edit value', }) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(value || '') const [saving, setSaving] = useState(false) const inputRef = useRef(null) useEffect(() => { if (editing) { setDraft(value || '') // Wait a tick so the input exists before focusing. requestAnimationFrame(() => inputRef.current?.select()) } }, [editing, value]) const commit = async () => { const trimmed = draft.trim() setEditing(false) if (trimmed === (value || '')) return setSaving(true) try { await onSave?.(trimmed) } finally { setSaving(false) } } const cancel = () => { setDraft(value || '') setEditing(false) } if (editing) { return ( setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit() } if (e.key === 'Escape') { e.preventDefault(); cancel() } }} aria-label={inputAriaLabel} disabled={saving} className={className} style={{ ...textStyle, background: 'var(--color-bg-elevated)', border: '1px solid var(--color-border-strong)', borderRadius: 'var(--radius-sm)', padding: '1px 6px', outline: 'none', minWidth: '120px', }} /> ) } return ( {value || placeholder} {editable && ( )} ) }