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
+123
View File
@@ -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>
)
}
+514
View File
@@ -0,0 +1,514 @@
// src/components/ui/LineChart.jsx
// Time-series line chart — hand-rolled SVG, no charting library. Single primitive
// reused for every metric-over-time graph in the console (RSSI, ping latency,
// free heap, etc). Never build a one-off inline chart in a page — use this.
//
// Props:
// data — array<{ x: number|string (ms epoch or index), y: number|null }>
// null y-values render as gaps (device offline / no sample), not zeros.
// color — CSS color token, e.g. 'var(--color-primary)' (default: 'var(--color-primary)')
// Ignored if colorStops is set.
// colorStops — array<{ value: number, color: string }>, sorted ascending by
// value — colors the line/fill as a smooth vertical gradient
// keyed to the Y VALUE (not x-position), so "danger red" always
// means the same y-value everywhere on the chart regardless of
// when it occurred. Requires >= 2 stops.
// height — number, px (default: 160)
// formatValue — (y) => string — used in tooltip + trailing value badge
// formatX — (x) => string — used in tooltip
// unit — string appended after formatValue in the trailing badge, e.g. 'dBm', 'ms', 'KB'
// xMin, xMax — number (ms epoch) — explicit axis bounds. When provided, the
// chart renders this FULL span even if data covers less of it
// (e.g. a 30-day range with 1 day of real data shows the data
// bunched at one end, not silently zoomed to fit). Omit to
// auto-fit to the data's own x-range (previous behaviour).
// gapThresholdMs — if the x-gap between two consecutive points exceeds this,
// the segment break is drawn as a dotted "possibly offline"
// connector instead of a silent gap. Omit to just break silently.
// offlineWindows — array<{ start: number, end: number }> (ms epoch) — explicit
// "known offline" windows to render as dotted baseline
// regardless of this chart's own data gaps. Use this instead
// of (or alongside) gapThresholdMs when offline periods are
// computed once from a shared/authoritative signal (e.g. the
// device's heartbeat cadence) and applied identically across
// several charts whose own series may be too sparse for
// per-series gap detection to find the same windows —
// otherwise a chart fed from a low-frequency series (ping,
// diagnostics) can under- or over-detect gaps relative to one
// fed from a high-frequency series (heartbeats) even though
// both describe the same device over the same real outage.
// Always rendered in the 'baseline' style (see gapStyle)
// since these are known facts, not inferred from this
// chart's own two neighboring points.
// gapStyle — 'connector' | 'baseline' (default: 'connector')
// 'connector' draws the dotted line directly between the last point
// before the gap and the first point after it (diagonal if their
// y-values differ). 'baseline' instead draws it as a flat dotted
// line along the chart's bottom edge, spanning the same x-range —
// reads as "no data" rather than implying a value in between.
// emptyLabel — string shown when data has no non-null points
// className — extra classes on the root element
import { useMemo, useState, useRef, useId } from 'react'
const PAD = { top: 12, right: 12, bottom: 22, left: 8 }
// Above this many raw points, downsample to ~2 points (min+max) per pixel
// column so spikes survive being zoomed out over a wide time range instead
// of getting smoothed away by naive decimation.
const DOWNSAMPLE_THRESHOLD = 400
// Centripetal Catmull-Rom → cubic Bezier. Using each segment's actual
// Euclidean distance (not a fixed 1/6 fraction) keeps tangents proportional
// to real point spacing — with the naive uniform version, a run of tightly-
// packed points followed by a big gap produces an overshooting tangent that
// visibly loops the curve backward before continuing forward.
function clampToRange(v, a, b) {
return Math.min(Math.max(v, Math.min(a, b)), Math.max(a, b))
}
function smoothPath(seg) {
if (seg.length < 2) return `M${seg[0].px},${seg[0].py}`
if (seg.length === 2) return `M${seg[0].px},${seg[0].py}L${seg[1].px},${seg[1].py}`
const dist = (a, b) => Math.max(1e-3, Math.hypot(b.px - a.px, b.py - a.py))
const n = seg.length
let d = `M${seg[0].px},${seg[0].py}`
for (let i = 0; i < n - 1; i++) {
const p0 = seg[i - 1] || seg[i]
const p1 = seg[i]
const p2 = seg[i + 1]
const p3 = seg[i + 2] || p2
const d1 = dist(p0, p1)
const d2 = dist(p1, p2)
const d3 = dist(p2, p3)
// Tangent at p1, scaled by the ratio of the adjacent segment length to
// the sum of both neighboring segments — this is what makes it
// "centripetal": short segments pull the tangent in, long ones don't
// over-extend it.
const t1 = d1 / (d1 + d2)
let c1x = p1.px + (p2.px - p0.px) * (t1 / 3)
const c1y = p1.py + (p2.py - p0.py) * (t1 / 3)
const t2 = d2 / (d2 + d3)
let c2x = p2.px - (p3.px - p1.px) * ((1 - t2) / 3)
const c2y = p2.py - (p3.py - p1.py) * ((1 - t2) / 3)
// X is always time in these charts, so it must be monotonic — a cubic
// segment whose control point x strays outside its own [p1.x, p2.x] span
// renders as the line visibly reversing direction (looping backward)
// before continuing forward. This happens whenever a neighboring point
// pulls the tangent estimate too far: most commonly right at a segment's
// open boundary (next to a data gap, where there's no further point to
// temper the tangent), and also wherever the min/max-per-bucket
// downsampler emits two points with nearly identical x — a near-vertical
// spike that the same tangent math overshoots on either side of. Clamping
// x keeps every curve horizontally monotonic without altering its y
// shape, which is what removes the loop while leaving normal smoothing
// (evenly spaced points, no reordering) completely unaffected.
c1x = clampToRange(c1x, p1.px, p2.px)
c2x = clampToRange(c2x, p1.px, p2.px)
d += `C${c1x},${c1y} ${c2x},${c2y} ${p2.px},${p2.py}`
}
return d
}
function buildSegments(points) {
// Splits into contiguous segments at null gaps — each becomes its own path.
const segments = []
let current = []
for (const p of points) {
if (p.y == null) {
if (current.length) segments.push(current)
current = []
} else {
current.push(p)
}
}
if (current.length) segments.push(current)
return segments
}
// Min/max-per-pixel-bucket downsampling. Guarantees any spike/dip within a
// bucket survives (both the min AND max point are kept), unlike averaging
// which can smooth a brief extreme out of existence when zoomed out over a
// wide range. Null (gap) points pass through untouched — they carry their
// own meaning and must never be merged into a bucket.
function downsample(points, targetBuckets) {
if (points.length <= targetBuckets * 2) return points
const validXs = points.filter(p => p.y != null).map(p => p.x)
if (validXs.length === 0) return points
const xMin = Math.min(...validXs)
const xMax = Math.max(...validXs)
const span = Math.max(1, xMax - xMin)
const buckets = new Map()
for (const p of points) {
if (p.y == null) continue
const bucketIdx = Math.min(targetBuckets - 1, Math.floor(((p.x - xMin) / span) * targetBuckets))
const existing = buckets.get(bucketIdx)
if (!existing) {
buckets.set(bucketIdx, { min: p, max: p })
} else {
if (p.y < existing.min.y) existing.min = p
if (p.y > existing.max.y) existing.max = p
}
}
// Each bucket contributes its min and max point (the same point twice if
// only one sample fell in that bucket) — order doesn't matter here since
// gap markers are merged back in and everything is sorted by x afterward.
const flattened = []
for (const { min, max } of buckets.values()) {
flattened.push(min)
if (max !== min) flattened.push(max)
}
const gaps = points.filter(p => p.y == null)
return [...flattened, ...gaps].sort((a, b) => a.x - b.x)
}
export default function LineChart({
data = [],
color = 'var(--color-primary)',
colorStops = null,
height = 160,
formatValue = v => String(v),
formatX = x => String(x),
unit = '',
xMin: xMinProp = null,
xMax: xMaxProp = null,
gapThresholdMs = null,
offlineWindows = null,
gapStyle = 'connector',
emptyLabel = 'No data yet.',
className = '',
}) {
const containerRef = useRef(null)
const [width, setWidth] = useState(600)
const [hoverIdx, setHoverIdx] = useState(null)
const gradientId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
// Measure container width responsively — ResizeObserver is the only
// chart-sizing mechanism in the app.
const measureRef = el => {
containerRef.current = el
if (!el) return
const ro = new ResizeObserver(entries => {
const w = entries[0]?.contentRect?.width
if (w) setWidth(w)
})
ro.observe(el)
el._ro = ro
}
const validPoints = data.filter(d => d.y != null)
const hasData = validPoints.length > 0
const { points, yMin, yMax, xMin, xMax } = useMemo(() => {
if (!hasData) return { points: [], yMin: 0, yMax: 1, xMin: 0, xMax: 1 }
const xs = data.map(d => Number(d.x))
const ys = validPoints.map(d => Number(d.y))
// Explicit range props win — the chart must render the FULL requested
// span (e.g. "last 30 days") even when real data only covers a sliver of
// it, rather than silently auto-zooming to whatever data happens to exist.
let xMin = xMinProp != null ? Number(xMinProp) : Math.min(...xs)
let xMax = xMaxProp != null ? Number(xMaxProp) : Math.max(...xs)
let yMin = Math.min(...ys)
let yMax = Math.max(...ys)
// colorStops are value-anchored on an absolute scale (e.g. alert
// thresholds), not the data's own range — if the y-axis only auto-fit to
// the data, stops outside that range would clamp to the same 0%/100%
// gradient offset and collapse into a hard edge instead of a smooth
// blend. Widening yMin/yMax to cover every stop keeps the full gradient
// visible even when the data currently sits well inside a safe band.
if (colorStops && colorStops.length) {
const stopValues = colorStops.map(s => s.value)
yMin = Math.min(yMin, ...stopValues)
yMax = Math.max(yMax, ...stopValues)
}
if (xMin === xMax) { xMin -= 1; xMax += 1 }
if (yMin === yMax) { yMin -= 1; yMax += 1 }
// Headroom so the line/dots never touch the plot edge.
const yPad = (yMax - yMin) * 0.12
yMin -= yPad
yMax += yPad
const innerW = Math.max(1, width - PAD.left - PAD.right)
const innerH = Math.max(1, height - PAD.top - PAD.bottom)
let mapped = data.map(d => {
const x = Number(d.x)
const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
const py = d.y == null ? null : PAD.top + innerH - ((Number(d.y) - yMin) / (yMax - yMin)) * innerH
return { x, y: d.y, px, py: py ?? 0 }
})
// Drop points that fall outside the explicit range entirely (can happen
// if a caller passes data slightly wider than xMin/xMax).
mapped = mapped.filter(p => p.x >= xMin - 1 && p.x <= xMax + 1)
// Cut every real segment at explicit offline-window boundaries —
// offlineWindows is computed externally (e.g. from a denser series like
// heartbeats) and can disagree with THIS series' own point spacing. Two
// distinct cases both need handling, not just one:
// 1. A real sample happens to fall inside the window (mask it to null).
// 2. No sample at all falls inside a narrow window in a sparse series
// (ping every 60s, diagnostics every 5min) — the point just before
// and just after the window are still adjacent in the array with
// nothing between them, so without inserting explicit boundary gap
// markers, smoothPath() draws one continuous curve straight across
// the "offline" stretch, right on top of the dotted connector.
// Inserting a null-y marker at both edges of the window forces
// buildSegments() to break there even when case 2 applies.
if (offlineWindows && offlineWindows.length) {
mapped = mapped.map(p => {
if (p.y == null) return p
const inWindow = offlineWindows.some(w => p.x >= w.start && p.x <= w.end)
return inWindow ? { ...p, y: null } : p
})
const boundaryMarkers = []
for (const w of offlineWindows) {
const spansWindow = mapped.some(p => p.x < w.start) && mapped.some(p => p.x > w.end)
if (!spansWindow) continue
for (const x of [w.start, w.end]) {
if (x < xMin || x > xMax) continue
const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
boundaryMarkers.push({ x, y: null, px, py: 0 })
}
}
if (boundaryMarkers.length) {
mapped = [...mapped, ...boundaryMarkers].sort((a, b) => a.x - b.x)
}
}
const targetBuckets = Math.max(50, Math.ceil(width / 3))
if (mapped.length > DOWNSAMPLE_THRESHOLD) {
mapped = downsample(mapped, targetBuckets)
}
return { points: mapped, yMin, yMax, xMin, xMax }
}, [data, width, height, hasData, validPoints, xMinProp, xMaxProp, colorStops, offlineWindows])
if (!hasData) {
return (
<div
className={['linechart linechart--empty', className].filter(Boolean).join(' ')}
style={{
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-muted)',
fontSize: 'var(--font-size-sm)',
fontStyle: 'italic',
}}
>
{emptyLabel}
</div>
)
}
const segments = buildSegments(points)
const innerW = Math.max(1, width - PAD.left - PAD.right)
const innerH = Math.max(1, height - PAD.top - PAD.bottom)
const baseline = PAD.top + innerH
const hovered = hoverIdx != null ? points[hoverIdx] : null
const latest = [...points].reverse().find(p => p.y != null)
// colorStops → an SVG linearGradient keyed to Y VALUE. gradientUnits
// userSpaceOnUse + y1/y2 in pixel-space (not objectBoundingBox) is what
// makes "red" always mean the same underlying value across every chart,
// rather than always meaning "top of whatever this chart's own range is."
const strokeColor = colorStops ? `url(#${gradientId})` : color
const sortedStops = colorStops ? [...colorStops].sort((a, b) => a.value - b.value) : null
// Dotted "possibly offline" connectors — drawn between the last point of one
// segment and the first point of the next, only when the real x-gap between
// them exceeds gapThresholdMs. A shorter gap (e.g. one missed 30s heartbeat)
// is just a silent break; a longer one is worth flagging visually.
// gapStyle 'baseline' flattens the connector to the chart's bottom edge
// instead of a diagonal between the two real y-values — a straight line
// at the actual readings would visually imply an interpolated value that
// was never measured, which is misleading for a gap that means "offline."
const offlineConnectors = []
if (gapThresholdMs) {
for (let i = 0; i < segments.length - 1; i++) {
const end = segments[i][segments[i].length - 1]
const start = segments[i + 1][0]
if (start.x - end.x > gapThresholdMs) {
if (gapStyle === 'baseline') {
offlineConnectors.push({ x1: end.px, y1: baseline, x2: start.px, y2: baseline })
} else {
offlineConnectors.push({ x1: end.px, y1: end.py, x2: start.px, y2: start.py })
}
}
}
}
// Explicit offline windows (computed once, shared across charts — see the
// prop doc above) mapped straight into this chart's own pixel space using
// the same xMin/xMax/innerW as the data points, and always drawn along the
// baseline — these are known facts about the device/console, not something
// inferred from this particular series' own two neighboring points, so they
// render even in a stretch where this series happens to have no points at
// all on either side of the window.
if (offlineWindows && offlineWindows.length) {
const toPx = x => PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
for (const w of offlineWindows) {
const clampedStart = Math.max(w.start, xMin)
const clampedEnd = Math.min(w.end, xMax)
if (clampedEnd <= clampedStart) continue
offlineConnectors.push({ x1: toPx(clampedStart), y1: baseline, x2: toPx(clampedEnd), y2: baseline })
}
}
const handleMove = e => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const relX = e.clientX - rect.left
// Nearest point by px distance.
let nearest = 0
let nearestDist = Infinity
points.forEach((p, i) => {
const d = Math.abs(p.px - relX)
if (d < nearestDist) { nearestDist = d; nearest = i }
})
setHoverIdx(nearest)
}
// Gridlines: 3 horizontal bands (min/mid/max) — recessive, label-free except axis text.
const gridY = [yMin + (yMax - yMin) * 0.0, yMin + (yMax - yMin) * 0.5, yMin + (yMax - yMin) * 1.0]
const legendColor = sortedStops
? (latest ? sortedStops.reduce((closest, s) => Math.abs(s.value - latest.y) < Math.abs(closest.value - latest.y) ? s : closest, sortedStops[0]).color : sortedStops[0].color)
: color
return (
<div
ref={measureRef}
className={['linechart', className].filter(Boolean).join(' ')}
style={{ position: 'relative', width: '100%', height }}
onMouseMove={handleMove}
onMouseLeave={() => setHoverIdx(null)}
>
{/* Trailing value badge — top-right, current/most-recent reading */}
{latest && (
<div style={{
position: 'absolute', top: 0, right: 0,
fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)',
color: legendColor, fontFamily: 'var(--font-family-mono)', zIndex: 1,
}}>
{formatValue(latest.y)}{unit}
</div>
)}
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block', overflow: 'visible' }}>
{sortedStops && (
<defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1={0} y1={PAD.top + innerH} x2={0} y2={PAD.top}>
{sortedStops.map((s, i) => {
const py = PAD.top + innerH - ((s.value - yMin) / (yMax - yMin)) * innerH
const offset = Math.max(0, Math.min(1, (py - PAD.top) / innerH))
// Gradient offsets run top(1)→bottom(0) in our y1/y2 direction
// (y1 at bottom = offset 0), so invert: a stop's pixel-y needs
// to map to (1 - offset-from-top).
return <stop key={i} offset={`${(1 - offset) * 100}%`} stopColor={s.color} />
})}
</linearGradient>
</defs>
)}
{/* Gridlines */}
{gridY.map((gy, i) => {
const py = PAD.top + innerH - ((gy - yMin) / (yMax - yMin)) * innerH
return (
<line key={i} x1={PAD.left} x2={PAD.left + innerW} y1={py} y2={py}
stroke="var(--color-border)" strokeWidth={1} />
)
})}
{/* Area fill under each segment — same color/gradient as the line,
low opacity, closed down to the plot baseline. Each fill path is
its own closed shape per segment, so a gap never produces a
spurious rectangle spanning the whole chart. */}
{segments.map((seg, i) => {
if (seg.length < 2) return null
const curve = smoothPath(seg)
const fillPath = `${curve}L${seg[seg.length - 1].px},${baseline}L${seg[0].px},${baseline}Z`
return <path key={`fill-${i}`} d={fillPath} fill={strokeColor} opacity={0.1} stroke="none" />
})}
{/* Dotted connectors across long gaps — "device likely offline" cue,
drawn under the solid segments so segment ends stay crisp on top. */}
{offlineConnectors.map((c, i) => (
<line key={`gap-${i}`} x1={c.x1} y1={c.y1} x2={c.x2} y2={c.y2}
stroke="var(--color-text-muted)" strokeWidth={1.5} strokeDasharray="3,4" opacity={0.6} />
))}
{/* Line segments (smoothed, gaps at null y) */}
{segments.map((seg, i) => (
<path key={i} d={smoothPath(seg)} fill="none" stroke={strokeColor} strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" />
))}
{/* Rounded data-end on the most recent point */}
{latest && (
<circle cx={latest.px} cy={latest.py} r={4} fill={legendColor} stroke="var(--color-bg-surface)" strokeWidth={2} />
)}
{/* Hover crosshair + marker */}
{hovered && (
<g>
<line x1={hovered.px} x2={hovered.px} y1={PAD.top} y2={PAD.top + innerH}
stroke="var(--color-border-strong)" strokeWidth={1} strokeDasharray="3,3" />
{hovered.y != null && (
<circle cx={hovered.px} cy={hovered.py} r={5} fill={strokeColor} stroke="var(--color-bg-surface)" strokeWidth={2} />
)}
</g>
)}
{/* X-axis endpoints — always the resolved range bounds, not just
whatever the first/last real sample happens to be, so the axis
reflects the selected time range even when data is sparse. */}
<text x={PAD.left} y={height - 6} fontSize="10" fill="var(--color-text-muted)" fontFamily="var(--font-family-base)">
{formatX(xMin)}
</text>
<text x={PAD.left + innerW} y={height - 6} fontSize="10" fill="var(--color-text-muted)"
fontFamily="var(--font-family-base)" textAnchor="end">
{formatX(xMax)}
</text>
</svg>
{/* Tooltip */}
{hovered && (
<div style={{
position: 'absolute',
left: Math.min(Math.max(hovered.px, 60), width - 60),
top: 0,
transform: 'translateX(-50%)',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border-strong)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-1) var(--space-2)',
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-primary)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-md)',
zIndex: 2,
}}>
<div style={{ color: 'var(--color-text-muted)' }}>{formatX(hovered.x)}</div>
<div style={{ fontWeight: 'var(--font-weight-semibold)', color: hovered.y != null ? legendColor : 'var(--color-text-muted)' }}>
{hovered.y != null ? `${formatValue(hovered.y)}${unit}` : 'No data'}
</div>
</div>
)}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
// src/components/ui/MultiSelect.jsx
// Multi-select dropdown — same visual language as Select.jsx (floating portal
// menu, .select-* trigger styling) but with checkbox rows so multiple options
// can be chosen at once. Trigger shows "All", a single label, or "N selected".
//
// Props:
// options — Array<{ value: string, label: ReactNode }>
// value — Array<string> — currently selected values (empty = "all")
// onChange — (nextValues: string[]) => void
// placeholder — shown when value is empty (default: 'All')
// disabled — boolean
// className — extra classes on the trigger button
// id — forwarded for label association
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition'
export default function MultiSelect({
options = [],
value = [],
onChange,
placeholder = 'All',
disabled = false,
className = '',
id,
...props
}) {
const [open, setOpen] = useState(false)
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0, maxHeight: 280 })
const triggerRef = useRef(null)
const menuRef = useRef(null)
useEffect(() => {
if (!open) return
function close(e) {
if (triggerRef.current?.contains(e.target)) return
if (menuRef.current?.contains(e.target)) return
setOpen(false)
}
function closeOnScroll(e) {
if (menuRef.current?.contains(e.target)) return
if (triggerRef.current?.contains(e.target)) return
setOpen(false)
}
document.addEventListener('mousedown', close)
document.addEventListener('scroll', closeOnScroll, true)
return () => {
document.removeEventListener('mousedown', close)
document.removeEventListener('scroll', closeOnScroll, true)
}
}, [open])
function openMenu() {
if (disabled) return
if (triggerRef.current) {
setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect()))
}
setOpen(true)
}
function handleToggle() {
if (open) setOpen(false)
else openMenu()
}
function toggleValue(v) {
const next = value.includes(v) ? value.filter((x) => x !== v) : [...value, v]
onChange?.(next)
}
const label = value.length === 0
? placeholder
: value.length === 1
? (options.find((o) => o.value === value[0])?.label ?? value[0])
: `${value.length} selected`
const menu = open && createPortal(
<div
ref={menuRef}
className="select-menu multi-select-menu"
style={{ top: menuPos.top, bottom: menuPos.bottom, left: menuPos.left, width: menuPos.width, maxHeight: menuPos.maxHeight }}
role="listbox"
aria-multiselectable="true"
aria-label="Options"
>
{value.length > 0 && (
<div
className="select-option multi-select-clear"
onMouseDown={(e) => { e.preventDefault(); onChange?.([]) }}
>
<span>Clear selection</span>
</div>
)}
{options.map((opt) => {
const checked = value.includes(opt.value)
return (
<div
key={opt.value}
role="option"
aria-selected={checked}
className={['select-option', checked ? 'select-option-selected' : ''].filter(Boolean).join(' ')}
onMouseDown={(e) => { e.preventDefault(); toggleValue(opt.value) }}
>
<input
type="checkbox"
checked={checked}
readOnly
tabIndex={-1}
style={{ accentColor: 'var(--color-primary)', flexShrink: 0, pointerEvents: 'none' }}
/>
<span>{opt.label}</span>
</div>
)
})}
</div>,
document.body
)
return (
<>
<button
ref={triggerRef}
type="button"
id={id}
disabled={disabled}
onClick={handleToggle}
className={[
'input',
'select-trigger',
open ? 'select-trigger-open' : '',
className,
].filter(Boolean).join(' ')}
aria-haspopup="listbox"
aria-expanded={open}
{...props}
>
<span className={value.length ? 'select-value' : 'select-placeholder'}>
{label}
</span>
<span className="select-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
{menu}
</>
)
}
@@ -0,0 +1,124 @@
// src/components/ui/PressHoldButton.jsx
// A button that requires holding it down for a fixed duration to confirm,
// rather than a single click — for destructive actions where a misclick
// should not be able to trigger the action (a slip releases instantly and
// does nothing). Renders a fill sweeping left-to-right as visual progress.
//
// Props:
// holdMs — number — how long the button must be held, in ms (default: 3000)
// onConfirm — () => void — called once the hold completes
// variant — 'danger' | 'primary' (default: 'danger')
// size — 'sm' | 'md' | 'lg' (default: 'md')
// disabled — boolean
// children — ReactNode — label shown when not held
// holdingLabel — ReactNode — label shown while holding (falls back to children)
// className — extra classes
import { useRef, useState, useCallback } from 'react'
export default function PressHoldButton({
holdMs = 3000,
onConfirm,
variant = 'danger',
size = 'md',
disabled = false,
children,
holdingLabel,
className = '',
}) {
const [progress, setProgress] = useState(0) // 0–100
const [holding, setHolding] = useState(false)
const rafRef = useRef(null)
const startRef = useRef(0)
const stop = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = null
setHolding(false)
setProgress(0)
}, [])
// Named function expression — `tickFrame` inside the body refers to the
// function itself via its own name, not the outer `tick` const binding,
// so there's no self-reference-before-declaration issue on the recursive
// requestAnimationFrame call.
const tick = useCallback(function tickFrame() {
const elapsed = Date.now() - startRef.current
const pct = Math.min(100, (elapsed / holdMs) * 100)
setProgress(pct)
if (pct >= 100) {
setHolding(false)
setProgress(0)
onConfirm?.()
return
}
rafRef.current = requestAnimationFrame(tickFrame)
}, [holdMs, onConfirm])
const start = useCallback(() => {
if (disabled) return
startRef.current = Date.now()
setHolding(true)
rafRef.current = requestAnimationFrame(tick)
}, [disabled, tick])
const color = variant === 'danger' ? 'var(--color-danger)' : 'var(--color-primary)'
const bg = variant === 'danger' ? 'var(--color-danger-bg)' : 'var(--color-primary-subtle)'
const sizeStyles = {
sm: { height: 32, paddingInline: 'var(--space-3)', fontSize: 'var(--font-size-xs)' },
md: { height: 38, paddingInline: 'var(--space-4)', fontSize: 'var(--font-size-sm)' },
lg: { height: 44, paddingInline: 'var(--space-5)', fontSize: 'var(--font-size-base)' },
}[size] || {}
return (
<button
type="button"
disabled={disabled}
onMouseDown={start}
onMouseUp={stop}
onMouseLeave={stop}
onTouchStart={start}
onTouchEnd={stop}
onTouchCancel={stop}
className={className}
aria-pressed={holding}
style={{
position: 'relative',
overflow: 'hidden',
border: `1px solid ${color}`,
borderRadius: 'var(--radius-md)',
background: bg,
color,
fontWeight: 'var(--font-weight-semibold)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
touchAction: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-2)',
transition: 'opacity 0.15s',
...sizeStyles,
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
width: `${progress}%`,
background: color,
opacity: 0.28,
transition: progress === 0 ? 'width 0.15s ease-out' : 'none',
pointerEvents: 'none',
}}
/>
<span style={{ position: 'relative' }}>
{holding ? (holdingLabel ?? children) : children}
</span>
</button>
)
}
@@ -0,0 +1,90 @@
// src/components/ui/SignalIndicator.jsx
// Wi-Fi signal-strength glyph — concentric arcs + dot, filled by RSSI (dBm).
// Never use a raw icon/emoji for signal strength — always use SignalIndicator.
//
// Props:
// rssi — number | null | undefined — signal strength in dBm (e.g. -62). null/undefined = unknown.
// size — 'sm' | 'md' (default: 'md')
// className — extra classes
//
// RSSI → level (4 levels: dot + 3 arcs, lit from the center outward):
// > -50 dBm → Excellent (4/4 lit), var(--color-success)
// >= -60 dBm → Good (3/4 lit), var(--color-info)
// >= -70 dBm → Fair (2/4 lit), var(--color-warning)
// < -70 dBm → Weak (1/4 lit), var(--color-danger)
// null/undefined → 0/4 lit, var(--color-text-muted) — "unknown", not "bad"
function rssiToLevel(rssi) {
if (rssi > -50) return 4
if (rssi >= -60) return 3
if (rssi >= -70) return 2
return 1
}
function rssiToColor(rssi) {
if (rssi == null) return 'var(--color-text-muted)'
if (rssi > -50) return 'var(--color-success)'
if (rssi >= -60) return 'var(--color-info)'
if (rssi >= -70) return 'var(--color-warning)'
return 'var(--color-danger)'
}
const SIZES = {
sm: { box: 16, strokeWidth: 1.6 },
md: { box: 20, strokeWidth: 1.8 },
}
const DIM_COLOR = 'var(--color-bg-island)'
export default function SignalIndicator({ rssi, size = 'md', className = '' }) {
const known = rssi != null && Number.isFinite(rssi)
const level = known ? rssiToLevel(rssi) : 0
const color = rssiToColor(known ? rssi : null)
const { box, strokeWidth } = SIZES[size] || SIZES.md
const label = known
? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)`
: 'Signal strength unknown'
return (
<svg
width={box}
height={box}
viewBox="0 0 24 24"
className={['signal-indicator', className].filter(Boolean).join(' ')}
role="img"
aria-label={label}
style={{ flexShrink: 0 }}
>
<title>{label}</title>
{/* Center dot — level 1 */}
<circle cx="12" cy="18.5" r="1.6" fill={level >= 1 ? color : DIM_COLOR} style={{ transition: 'fill 0.2s' }} />
{/* Inner arc — level 2 */}
<path
d="M8.2 14.8a5.4 5.4 0 0 1 7.6 0"
fill="none"
stroke={level >= 2 ? color : DIM_COLOR}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }}
/>
{/* Middle arc — level 3 */}
<path
d="M5 11.6a9.8 9.8 0 0 1 14 0"
fill="none"
stroke={level >= 3 ? color : DIM_COLOR}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }}
/>
{/* Outer arc — level 4 */}
<path
d="M1.8 8.4a14.2 14.2 0 0 1 20.4 0"
fill="none"
stroke={level >= 4 ? color : DIM_COLOR}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }}
/>
</svg>
)
}
@@ -0,0 +1,83 @@
// src/components/ui/TimeRangeSelect.jsx
// Time-range picker for history/telemetry pages — a preset dropdown (10m
// through all-time) plus a "Custom…" option opening a small range modal.
// Drives every time-scoped section on a page from one control: pass the
// resolved { since, until } down to whatever fetches/filters data.
//
// Props:
// value — { key: string, since: Date|null, until: Date|null } — since=null means "all-time"
// onChange — (value) => void
// className — extra classes on the trigger
import { useState } from 'react'
import Select from '@/components/ui/Select'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import DateTimePicker from '@/components/ui/DateTimePicker'
import { PRESETS, resolveRange } from '@/lib/timeRange'
function CustomRangeModal({ open, onClose, initialSince, initialUntil, onApply }) {
const [since, setSince] = useState(initialSince ? initialSince.toISOString() : '')
const [until, setUntil] = useState(initialUntil ? initialUntil.toISOString() : '')
const apply = () => {
onApply({
since: since ? new Date(since) : null,
until: until ? new Date(until) : null,
})
onClose()
}
return (
<Modal open={open} onClose={onClose} title="Custom Time Range" size="sm">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<DateTimePicker label="From" name="range-since" value={since} onChange={setSince} />
<DateTimePicker label="To" name="range-until" value={until} onChange={setUntil} placeholder="Leave empty for now" />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-2)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!since} onClick={apply}>Apply</Button>
</div>
</div>
</Modal>
)
}
export default function TimeRangeSelect({ value, onChange, className = '' }) {
const [showCustom, setShowCustom] = useState(false)
const handleSelectChange = e => {
const key = e.target.value
if (key === 'custom') {
setShowCustom(true)
return
}
onChange(resolveRange(key))
}
const selectValue = value?.key === 'custom' ? 'custom' : (value?.key || '7d')
return (
<>
<Select
id="time-range-select"
name="time-range"
value={selectValue}
onChange={handleSelectChange}
className={className}
>
{PRESETS.map(p => (
<option key={p.key} value={p.key}>{p.label}</option>
))}
<option value="custom">Custom range…</option>
</Select>
<CustomRangeModal
open={showCustom}
onClose={() => setShowCustom(false)}
initialSince={value?.since}
initialUntil={value?.until}
onApply={({ since, until }) => onChange({ key: 'custom', since, until })}
/>
</>
)
}
+26
View File
@@ -0,0 +1,26 @@
// frontend/src/lib/timeRange.js
// Shared time-range presets + resolution logic for TimeRangeSelect and any
// page/component that needs to compute a { since, until } window from a key.
export const PRESETS = [
{ key: '10m', label: 'Last 10 minutes', ms: 10 * 60 * 1000 },
{ key: '1h', label: 'Last hour', ms: 60 * 60 * 1000 },
{ key: '3h', label: 'Last 3 hours', ms: 3 * 60 * 60 * 1000 },
{ key: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 },
{ key: '3d', label: 'Last 3 days', ms: 3 * 24 * 60 * 60 * 1000 },
{ key: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 },
{ key: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
{ key: '3mo', label: 'Last 3 months', ms: 90 * 24 * 60 * 60 * 1000 },
{ key: '6mo', label: 'Last 6 months', ms: 182 * 24 * 60 * 60 * 1000 },
{ key: 'all', label: 'All time', ms: null },
]
/** Resolve a preset key (or 'custom') into { key, since: Date|null, until: Date|null }. */
export function resolveRange(key, customSince, customUntil) {
if (key === 'custom') {
return { key, since: customSince ?? null, until: customUntil ?? null }
}
const preset = PRESETS.find(p => p.key === key) || PRESETS[5] // default: 7d
if (preset.ms == null) return { key: preset.key, since: null, until: null }
return { key: preset.key, since: new Date(Date.now() - preset.ms), until: null }
}
+281
View File
@@ -6,6 +6,12 @@ import { useState } from 'react'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import RowActions from '@/components/ui/RowActions' import RowActions from '@/components/ui/RowActions'
import StatusBadge from '@/components/ui/StatusBadge' 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 FormField from '@/components/ui/FormField'
import Modal from '@/components/ui/Modal' import Modal from '@/components/ui/Modal'
import DataTable from '@/components/ui/DataTable' import DataTable from '@/components/ui/DataTable'
@@ -18,6 +24,7 @@ import Tabs from '@/components/ui/Tabs'
import { ToastProvider, useToast } from '@/components/ui/Toast' import { ToastProvider, useToast } from '@/components/ui/Toast'
import SearchBar from '@/components/ui/SearchBar' import SearchBar from '@/components/ui/SearchBar'
import Select from '@/components/ui/Select' import Select from '@/components/ui/Select'
import MultiSelect from '@/components/ui/MultiSelect'
import HeaderSearch from '@/components/ui/HeaderSearch' import HeaderSearch from '@/components/ui/HeaderSearch'
import Breadcrumbs from '@/components/ui/Breadcrumbs' import Breadcrumbs from '@/components/ui/Breadcrumbs'
import ConfirmDialog from '@/components/ui/ConfirmDialog' import ConfirmDialog from '@/components/ui/ConfirmDialog'
@@ -42,6 +49,35 @@ function urlMapToList(map) {
}).sort((a, b) => a.name.localeCompare(b.name)) }).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 = [ const ASSET_ICON_GROUPS = [
{ title: 'Global Icons', items: urlMapToList(_globalIconUrls) }, { title: 'Global Icons', items: urlMapToList(_globalIconUrls) },
{ title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) }, { title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) },
@@ -404,7 +440,11 @@ export default function StyleGuide() {
const [modalSize, setModalSize] = useState('md') const [modalSize, setModalSize] = useState('md')
const [paginationPage, setPaginationPage] = useState(3) const [paginationPage, setPaginationPage] = useState(3)
const [paginationSize, setPaginationSize] = useState(20) const [paginationSize, setPaginationSize] = useState(20)
const [demoRange, setDemoRange] = useState(() => resolveRange('7d'))
const [formValues, setFormValues] = useState({ text: '', email: '', select: '', textarea: '' }) 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 // DataTable interactive demo state
const [tableSortKey, setTableSortKey] = useState('') const [tableSortKey, setTableSortKey] = useState('')
@@ -499,7 +539,13 @@ export default function StyleGuide() {
['#spacing', 'Spacing'], ['#spacing', 'Spacing'],
['#buttons', 'Button'], ['#buttons', 'Button'],
['#badges', 'StatusBadge'], ['#badges', 'StatusBadge'],
['#signal-indicator', 'SignalIndicator'],
['#editable-text', 'EditableText'],
['#line-chart', 'LineChart'],
['#press-hold-button', 'PressHoldButton'],
['#time-range-select', 'TimeRangeSelect'],
['#formfields', 'FormField'], ['#formfields', 'FormField'],
['#multiselect', 'MultiSelect'],
['#spinner', 'Spinner'], ['#spinner', 'Spinner'],
['#datatable', 'DataTable'], ['#datatable', 'DataTable'],
['#rowactions', 'RowActions'], ['#rowactions', 'RowActions'],
@@ -1008,6 +1054,202 @@ export default function StyleGuide() {
</Section> </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 5b. PILL BUTTON
================================================================ */} ================================================================ */}
@@ -1281,6 +1523,45 @@ export default function StyleGuide() {
</Section> </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 7. SPINNER
================================================================ */} ================================================================ */}
+29
View File
@@ -503,6 +503,24 @@ tr:focus-within .btn-table-actions {
color: var(--color-primary); color: var(--color-primary);
} }
/* ==========================================================================
MULTI-SELECT (.multi-select-menu)
Reuses .select-trigger / .select-menu / .select-option — adds a checkbox
per row and a "Clear selection" row when something is selected.
========================================================================== */
.multi-select-clear {
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
border-radius: 0;
margin-bottom: var(--space-1);
padding-bottom: var(--space-2);
}
.multi-select-clear:hover {
color: var(--color-danger);
}
/* Keep old .select-wrapper for any legacy uses */ /* Keep old .select-wrapper for any legacy uses */
.select-wrapper { .select-wrapper {
position: relative; position: relative;
@@ -3220,3 +3238,14 @@ tr:focus-within .btn-table-actions {
filter: brightness(1.06); filter: brightness(1.06);
box-shadow: var(--shadow-primary-glow); box-shadow: var(--shadow-primary-glow);
} }
/* EditableText — pencil icon hidden until the whole component is hovered
or the pencil itself has keyboard focus (so it's reachable via Tab). */
.editable-text:hover .editable-text__pencil,
.editable-text__pencil:focus-visible {
opacity: 1 !important;
}
.editable-text__pencil:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}