Files
bellsystems-cp/frontend/src/components/ui/EditableText.jsx
T
bonaminandClaude Sonnet 5 2f7c5e859d 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>
2026-09-21 18:22:02 +03:00

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>
)
}