// 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 && (
)}
)
}