feat(ui): add MultiSelect, LineChart, SignalIndicator, PressHoldButton, EditableText, TimeRangeSelect
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>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user