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>
151 lines
4.6 KiB
React
151 lines
4.6 KiB
React
// 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}
|
|
</>
|
|
)
|
|
}
|