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