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:
@@ -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
|
||||
================================================================ */}
|
||||
|
||||
Reference in New Issue
Block a user