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,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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user