From f9addff5a6d16a19ec71733be7d4df442612cf6b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:05:38 +0100 Subject: [PATCH] feat: add MultiSelect composite component Co-Authored-By: Claude Sonnet 4.6 --- .../MultiSelect/MultiSelect.module.css | 148 ++++++++++++++++ .../composites/MultiSelect/MultiSelect.tsx | 162 ++++++++++++++++++ src/design-system/composites/index.ts | 4 + 3 files changed, 314 insertions(+) create mode 100644 src/design-system/composites/MultiSelect/MultiSelect.module.css create mode 100644 src/design-system/composites/MultiSelect/MultiSelect.tsx diff --git a/src/design-system/composites/MultiSelect/MultiSelect.module.css b/src/design-system/composites/MultiSelect/MultiSelect.module.css new file mode 100644 index 0000000..e0267ca --- /dev/null +++ b/src/design-system/composites/MultiSelect/MultiSelect.module.css @@ -0,0 +1,148 @@ +.wrap { + position: relative; + display: inline-block; +} + +.trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-raised); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + gap: 8px; + min-width: 0; +} + +.trigger:focus-visible { + border-color: var(--amber); + box-shadow: 0 0 0 3px var(--amber-bg); +} + +.trigger[aria-disabled="true"] { + opacity: 0.6; + cursor: not-allowed; +} + +.triggerText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.triggerPlaceholder { + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + color: var(--text-faint); + font-size: 11px; + flex-shrink: 0; +} + +.panel { + position: fixed; + z-index: 1000; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + animation: panelIn 0.12s ease-out; +} + +@keyframes panelIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.search { + padding: 8px 12px; + border: none; + border-bottom: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 12px; + outline: none; +} + +.search::placeholder { + color: var(--text-faint); +} + +.optionList { + max-height: 200px; + overflow-y: auto; + padding: 4px 0; +} + +.option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + font-size: 12px; + font-family: var(--font-body); + color: var(--text-primary); + transition: background 0.1s; +} + +.option:hover { + background: var(--bg-hover); +} + +.checkbox { + accent-color: var(--amber); + cursor: pointer; +} + +.optionLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty { + padding: 12px; + text-align: center; + color: var(--text-faint); + font-size: 12px; + font-family: var(--font-body); +} + +.footer { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); + display: flex; + justify-content: flex-end; +} + +.applyBtn { + padding: 4px 16px; + border: none; + border-radius: var(--radius-sm); + background: var(--amber); + color: var(--bg-base); + font-family: var(--font-body); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.applyBtn:hover { + background: var(--amber-hover); +} diff --git a/src/design-system/composites/MultiSelect/MultiSelect.tsx b/src/design-system/composites/MultiSelect/MultiSelect.tsx new file mode 100644 index 0000000..4437e98 --- /dev/null +++ b/src/design-system/composites/MultiSelect/MultiSelect.tsx @@ -0,0 +1,162 @@ +import { useState, useRef, useEffect } from 'react' +import { createPortal } from 'react-dom' +import styles from './MultiSelect.module.css' + +export interface MultiSelectOption { + value: string + label: string +} + +interface MultiSelectProps { + options: MultiSelectOption[] + value: string[] + onChange: (value: string[]) => void + placeholder?: string + searchable?: boolean + disabled?: boolean + className?: string +} + +export function MultiSelect({ + options, + value, + onChange, + placeholder = 'Select...', + searchable = true, + disabled = false, + className, +}: MultiSelectProps) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [pending, setPending] = useState(value) + const triggerRef = useRef(null) + const panelRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) + + // Sync pending with value when opening + useEffect(() => { + if (open) { + setPending(value) + setSearch('') + } + }, [open, value]) + + // Position the panel below the trigger + useEffect(() => { + if (open && triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() + setPos({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }) + } + }, [open]) + + // Close on outside click + useEffect(() => { + if (!open) return + function handleClick(e: MouseEvent) { + if ( + panelRef.current && !panelRef.current.contains(e.target as Node) && + triggerRef.current && !triggerRef.current.contains(e.target as Node) + ) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + // Close on Escape + useEffect(() => { + if (!open) return + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [open]) + + function toggleOption(optValue: string) { + setPending((prev) => + prev.includes(optValue) ? prev.filter((v) => v !== optValue) : [...prev, optValue] + ) + } + + function handleApply() { + onChange(pending) + setOpen(false) + } + + const filtered = options.filter((opt) => + opt.label.toLowerCase().includes(search.toLowerCase()) + ) + + const triggerLabel = value.length > 0 ? `${value.length} selected` : placeholder + + return ( +
+ + + {open && createPortal( +
+ {searchable && ( + setSearch(e.target.value)} + autoFocus + /> + )} +
+ {filtered.map((opt) => ( + + ))} + {filtered.length === 0 && ( +
No matches
+ )} +
+
+ +
+
, + document.body, + )} +
+ ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index fd65de4..8b236b6 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -6,6 +6,8 @@ export { BarChart } from './BarChart/BarChart' export { Breadcrumb } from './Breadcrumb/Breadcrumb' export { CommandPalette } from './CommandPalette/CommandPalette' export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types' +export { ConfirmDialog } from './ConfirmDialog/ConfirmDialog' +export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog' export { DataTable } from './DataTable/DataTable' export type { Column, DataTableProps } from './DataTable/types' export { DetailPanel } from './DetailPanel/DetailPanel' @@ -17,6 +19,8 @@ export { FilterBar } from './FilterBar/FilterBar' export { LineChart } from './LineChart/LineChart' export { MenuItem } from './MenuItem/MenuItem' export { Modal } from './Modal/Modal' +export { MultiSelect } from './MultiSelect/MultiSelect' +export type { MultiSelectOption } from './MultiSelect/MultiSelect' export { Popover } from './Popover/Popover' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'