Six new design-system components, all documented with live examples in the StyleGuide as required by CLAUDE.md before any page can use them: - MultiSelect: checkbox dropdown for filters/tags, built on Select's trigger/menu styling - LineChart: telemetry/time-series charting for the upcoming Health tab - SignalIndicator: signal-strength glyph for device RSSI display - PressHoldButton: press-and-hold confirmation for destructive actions - EditableText: inline click-to-edit text with a hover/focus-revealed pencil affordance - TimeRangeSelect: preset + custom time-range picker, backed by the new lib/timeRange.js helper Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
124 lines
3.8 KiB
React
124 lines
3.8 KiB
React
// 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 (
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={draft}
|
|
onChange={(e) => 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 (
|
|
<span
|
|
className={['editable-text', className].filter(Boolean).join(' ')}
|
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}
|
|
>
|
|
<span style={{ ...textStyle, ...(value ? {} : { color: 'var(--color-text-muted)', fontStyle: 'italic' }) }}>
|
|
{value || placeholder}
|
|
</span>
|
|
{editable && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
aria-label={inputAriaLabel}
|
|
title={inputAriaLabel}
|
|
className="editable-text__pencil"
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 24,
|
|
height: 24,
|
|
padding: 0,
|
|
border: 'none',
|
|
borderRadius: 'var(--radius-sm)',
|
|
background: 'transparent',
|
|
color: 'var(--color-text-muted)',
|
|
cursor: 'pointer',
|
|
opacity: 0,
|
|
transition: 'opacity 0.15s, color 0.15s, background 0.15s',
|
|
}}
|
|
>
|
|
<Icon name="edit" size={14} />
|
|
</button>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|