import { type JSX, Show, createSignal, createEffect, untrack } from 'solid-js'; import { cn } from '../utils/cn'; import { Input } from './input'; export interface EditableLabelProps { /** The label text. */ value: string; /** Controlled edit state. When it flips true the field opens; double-click and * the host's `edit()` open it too. */ editing?: boolean; /** Placeholder shown while editing (and as muted text when the value is empty). */ placeholder?: string; /** Disable entering edit mode. */ disabled?: boolean; /** Fires on commit (Enter / blur) with the new value — ONLY when it changed. */ onRename?: (value: string) => void; /** Fires on Esc (cancel); the text is restored. */ onCancel?: () => void; class?: string; } /** * `EditableLabel`: inline rename. Shows `value` as text; double-click (or the * `editing` prop, or a host `edit()`) swaps in an autofocused `Input` with the * text pre-selected. Enter or blur commits (fires `onRename` only when the value * changed); Esc cancels (fires `onCancel`, restores the text). Both exit editing. * * Parts: `text` (the read view), `input` (the field while editing). */ export function EditableLabel(props: EditableLabelProps): JSX.Element { // `editing` is seeded from the controlled prop and follows it; double-click / // commit / cancel toggle it locally without touching the prop (no feedback loop). const [editing, setEditing] = createSignal(!!props.editing); createEffect(() => setEditing(!!props.editing)); // The displayed text. Seeded from `value`, kept in sync with it, and updated // optimistically on a successful rename so the read view shows the new label. const [text, setText] = createSignal(props.value); createEffect(() => setText(props.value)); let root: HTMLElement | undefined; // Set before an Enter/Esc unmounts the field so its teardown blur does not // double-commit (mirrors the AMUX inline-rename guard). let suppressBlur = false; const focusSelect = () => { const input = root?.querySelector('input'); if (input) { input.focus(); input.select(); } }; createEffect(() => { if (editing()) queueMicrotask(focusSelect); }); const enterEdit = () => { if (!props.disabled) setEditing(true); }; const commit = (next: string) => { if (!untrack(editing)) return; // idempotent: a teardown blur after Enter is a no-op setEditing(false); if (next !== untrack(text)) { setText(next); props.onRename?.(next); } }; const cancel = () => { if (!untrack(editing)) return; setEditing(false); props.onCancel?.(); }; const onFieldKeyDown = (e: KeyboardEvent) => { const el = e.currentTarget as HTMLInputElement; if (e.key === 'Enter') { e.preventDefault(); suppressBlur = true; commit(el.value); } else if (e.key === 'Escape') { e.preventDefault(); suppressBlur = true; cancel(); } }; const onFieldBlur = (v: string) => { if (suppressBlur) { suppressBlur = false; return; } commit(v); }; return ( { if (!props.disabled && (e.key === 'Enter' || e.key === 'F2')) { e.preventDefault(); enterEdit(); } }} > {text() || props.placeholder || ''} } > ); }