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:
2026-09-21 18:22:02 +03:00
co-authored by Claude Sonnet 5
parent 883c0556dd
commit 2f7c5e859d
9 changed files with 1420 additions and 0 deletions
+281
View File
@@ -6,6 +6,12 @@ import { useState } from 'react'
import Button from '@/components/ui/Button'
import RowActions from '@/components/ui/RowActions'
import StatusBadge from '@/components/ui/StatusBadge'
import SignalIndicator from '@/components/ui/SignalIndicator'
import EditableText from '@/components/ui/EditableText'
import LineChart from '@/components/ui/LineChart'
import PressHoldButton from '@/components/ui/PressHoldButton'
import TimeRangeSelect from '@/components/ui/TimeRangeSelect'
import { resolveRange } from '@/lib/timeRange'
import FormField from '@/components/ui/FormField'
import Modal from '@/components/ui/Modal'
import DataTable from '@/components/ui/DataTable'
@@ -18,6 +24,7 @@ import Tabs from '@/components/ui/Tabs'
import { ToastProvider, useToast } from '@/components/ui/Toast'
import SearchBar from '@/components/ui/SearchBar'
import Select from '@/components/ui/Select'
import MultiSelect from '@/components/ui/MultiSelect'
import HeaderSearch from '@/components/ui/HeaderSearch'
import Breadcrumbs from '@/components/ui/Breadcrumbs'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
@@ -42,6 +49,35 @@ function urlMapToList(map) {
}).sort((a, b) => a.name.localeCompare(b.name))
}
// LineChart demo series — computed once at module init (not per-render) so
// the chart doesn't violate the "no impure calls during render" rule.
const _rssiBase = Date.now()
const RSSI_DEMO_DATA = Array.from({ length: 40 }, (_, i) => ({
x: _rssiBase - (40 - i) * 30000,
y: -55 + Math.sin(i / 4) * 12 + (Math.sin(i * 7.3) * 2),
}))
const _pingBase = Date.now()
const PING_DEMO_DATA = Array.from({ length: 30 }, (_, i) => ({
x: _pingBase - (30 - i) * 60000,
y: (i > 10 && i < 16) ? null : 40 + Math.sin(i * 3.1) * 30 + 30,
}))
// Irregular spacing: a tight cluster of points, then a big time jump, then
// another cluster — regression case for the Catmull-Rom overshoot bug (a
// naive uniform-spacing spline loops the curve backward at the jump).
const _irregularBase = Date.now()
const IRREGULAR_DEMO_DATA = [
...Array.from({ length: 8 }, (_, i) => ({ x: _irregularBase - 3600000 - i * 15000, y: 45 + Math.sin(i) * 8 })),
...Array.from({ length: 8 }, (_, i) => ({ x: _irregularBase - i * 15000, y: 50 + Math.cos(i) * 10 })),
].sort((a, b) => a.x - b.x)
// Sparse data in a wide requested range: only 6 points span 90 minutes, but
// xMin/xMax below requests a full 24h axis — the chart should show the data
// bunched near one edge with a mostly-empty axis, not auto-zoom to fit it.
const _sparseNow = Date.now()
const SPARSE_DEMO_DATA = Array.from({ length: 6 }, (_, i) => ({
x: _sparseNow - (6 - i) * 900000,
y: 20 + i * 3,
}))
const ASSET_ICON_GROUPS = [
{ title: 'Global Icons', items: urlMapToList(_globalIconUrls) },
{ title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) },
@@ -404,7 +440,11 @@ export default function StyleGuide() {
const [modalSize, setModalSize] = useState('md')
const [paginationPage, setPaginationPage] = useState(3)
const [paginationSize, setPaginationSize] = useState(20)
const [demoRange, setDemoRange] = useState(() => resolveRange('7d'))
const [formValues, setFormValues] = useState({ text: '', email: '', select: '', textarea: '' })
const [multiSelectDemo, setMultiSelectDemo] = useState(['vesper_plus', 'chronos'])
const [editableDemoValue, setEditableDemoValue] = useState('Bell Tower — North Wing')
const [editableDemoEmpty, setEditableDemoEmpty] = useState('')
// DataTable interactive demo state
const [tableSortKey, setTableSortKey] = useState('')
@@ -499,7 +539,13 @@ export default function StyleGuide() {
['#spacing', 'Spacing'],
['#buttons', 'Button'],
['#badges', 'StatusBadge'],
['#signal-indicator', 'SignalIndicator'],
['#editable-text', 'EditableText'],
['#line-chart', 'LineChart'],
['#press-hold-button', 'PressHoldButton'],
['#time-range-select', 'TimeRangeSelect'],
['#formfields', 'FormField'],
['#multiselect', 'MultiSelect'],
['#spinner', 'Spinner'],
['#datatable', 'DataTable'],
['#rowactions', 'RowActions'],
@@ -1008,6 +1054,202 @@ export default function StyleGuide() {
</Section>
{/* ================================================================
5a. SIGNAL INDICATOR
================================================================ */}
<Section title="SignalIndicator" id="signal-indicator">
<Subsection title="RSSI thresholds — size md (default)">
<DemoRow wrap>
{[-55, -65, -75, -85, -95, null].map((rssi) => (
<div key={String(rssi)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--space-1)' }}>
<SignalIndicator rssi={rssi} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{rssi == null ? 'unknown' : `${rssi} dBm`}
</span>
</div>
))}
</DemoRow>
</Subsection>
<Subsection title='size="sm" — inline with device name'>
<DemoRow>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--color-text-primary)' }}>
<SignalIndicator rssi={-58} size="sm" />
Bell Tower — North Wing
</span>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5a-1b. EDITABLE TEXT
================================================================ */}
<Section title="EditableText" id="editable-text">
<Subsection title="Hover to reveal pencil, click to edit — Enter/blur saves, Escape cancels">
<DemoRow>
<EditableText
value={editableDemoValue}
onSave={setEditableDemoValue}
inputAriaLabel="Edit demo name"
textStyle={{ fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--color-text-primary)' }}
/>
</DemoRow>
</Subsection>
<Subsection title="Empty value — falls back to placeholder">
<DemoRow>
<EditableText
value={editableDemoEmpty}
onSave={setEditableDemoEmpty}
placeholder="Unnamed Device"
inputAriaLabel="Edit demo name (empty)"
textStyle={{ fontSize: 'var(--font-size-lg)', fontWeight: 600 }}
/>
</DemoRow>
</Subsection>
<Subsection title="editable={false} — pencil hidden, read-only">
<DemoRow>
<EditableText
value={editableDemoValue}
editable={false}
textStyle={{ fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--color-text-primary)' }}
/>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5a-2. LINE CHART
================================================================ */}
<Section title="LineChart" id="line-chart">
<Subsection title="Gradient color stops — keyed to Y value, not position (RSSI signal quality)">
<Card padding>
<LineChart
data={RSSI_DEMO_DATA}
unit=" dBm"
formatValue={v => Math.round(v)}
formatX={x => new Date(x).toLocaleTimeString()}
colorStops={[
{ value: -90, color: 'var(--color-danger)' },
{ value: -70, color: 'var(--color-warning)' },
{ value: -50, color: 'var(--color-success)' },
]}
/>
</Card>
</Subsection>
<Subsection title="Series with gaps — null y renders as a break, not zero">
<Card padding>
<LineChart
data={PING_DEMO_DATA}
color="var(--color-primary)"
unit=" ms"
formatValue={v => Math.round(v)}
formatX={x => new Date(x).toLocaleTimeString()}
/>
</Card>
</Subsection>
<Subsection title="Irregular spacing — smooth curve must not loop backward at a time jump">
<Card padding>
<LineChart
data={IRREGULAR_DEMO_DATA}
color="var(--color-success)"
unit="°C"
formatValue={v => v.toFixed(1)}
formatX={x => new Date(x).toLocaleTimeString()}
/>
</Card>
</Subsection>
<Subsection title="Sparse data in a wide range — axis reflects the full requested range, not just the data">
<Card padding>
<LineChart
data={SPARSE_DEMO_DATA}
color="var(--color-warning)"
unit=" pts"
formatValue={v => Math.round(v)}
formatX={x => new Date(x).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
xMin={_sparseNow - 24 * 3600000}
xMax={_sparseNow}
/>
</Card>
</Subsection>
<Subsection title="Empty state">
<Card padding>
<LineChart data={[]} emptyLabel="No ping samples recorded yet." />
</Card>
</Subsection>
</Section>
{/* ================================================================
5a-3. PRESS HOLD BUTTON
================================================================ */}
<Section title="PressHoldButton" id="press-hold-button">
<Subsection title="Danger variant — 3s hold (default)">
<DemoRow>
<PressHoldButton variant="danger" onConfirm={() => alert('Confirmed!')}>
Hold to Reset Stats
</PressHoldButton>
</DemoRow>
</Subsection>
<Subsection title="Primary variant — custom hold duration + holding label">
<DemoRow>
<PressHoldButton
variant="primary"
holdMs={1500}
holdingLabel="Keep holding…"
onConfirm={() => alert('Confirmed!')}
>
Hold to Confirm (1.5s)
</PressHoldButton>
</DemoRow>
</Subsection>
<Subsection title="Sizes">
<DemoRow>
<PressHoldButton size="sm" onConfirm={() => {}}>Small</PressHoldButton>
<PressHoldButton size="md" onConfirm={() => {}}>Medium</PressHoldButton>
<PressHoldButton size="lg" onConfirm={() => {}}>Large</PressHoldButton>
</DemoRow>
</Subsection>
<Subsection title="Disabled">
<DemoRow>
<PressHoldButton disabled onConfirm={() => {}}>Cannot hold</PressHoldButton>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5a-4. TIME RANGE SELECT
================================================================ */}
<Section title="TimeRangeSelect" id="time-range-select">
<Subsection title="Presets + custom range modal">
<DemoRow>
<div style={{ width: 220 }}>
<TimeRangeSelect value={demoRange} onChange={setDemoRange} />
</div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
key: {demoRange.key} · since: {demoRange.since ? demoRange.since.toLocaleString() : 'null (all-time)'}
</span>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5b. PILL BUTTON
================================================================ */}
@@ -1281,6 +1523,45 @@ export default function StyleGuide() {
</Section>
{/* ================================================================
6b. MULTI-SELECT
================================================================ */}
<Section title="MultiSelect" id="multiselect">
<Subsection title="Checkbox dropdown — trigger shows placeholder / single label / 'N selected'">
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-3)' }}>
Same floating-menu language as <code>Select</code>, with checkbox rows so multiple values can be picked. Used for filters like "Board Type".
</p>
<div style={{ maxWidth: 280 }}>
<MultiSelect
value={multiSelectDemo}
onChange={setMultiSelectDemo}
placeholder="All Board Types"
options={[
{ value: 'vesper', label: 'Vesper' },
{ value: 'vesper_plus', label: 'Vesper Plus' },
{ value: 'vesper_pro', label: 'Vesper Pro' },
{ value: 'chronos', label: 'Chronos' },
{ value: 'chronos_pro', label: 'Chronos Pro' },
{ value: 'agnus_mini', label: 'Agnus Mini' },
{ value: 'agnus', label: 'Agnus' },
]}
/>
</div>
</Subsection>
<Subsection title="Disabled">
<div style={{ maxWidth: 280 }}>
<MultiSelect
value={[]}
onChange={() => {}}
disabled
placeholder="All Board Types"
options={[{ value: 'vesper', label: 'Vesper' }]}
/>
</div>
</Subsection>
</Section>
{/* ================================================================
7. SPINNER
================================================================ */}