Files
bellsystems-cp/frontend/src/components/ui/MultiSelect.jsx
T
bonaminandClaude Sonnet 5 2f7c5e859d 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>
2026-09-21 18:22:02 +03:00

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}
</>
)
}