Dashboard: reusable MultiSelect dropdown component for workstreams filters

Adds src/components/multiselect.js — a compact dropdown multi-select that is
Observable-compatible (exposes .value, dispatches bubbling input events) so it
works with view(), Inputs.form, and Generators.input without modification.

Component behaviour:
- Closed state: pill button showing "Label: All" (muted) or active selection
  (1-2 items shown by name, 3+ shown as "N of M"); blue border when active
- Open state: dropdown with per-item checkboxes + "Clear selection" link
  (only visible when something is selected); closes on outside click / Escape
- Styles injected once into document.head (STYLE_ID guard prevents duplicates)
- Uses CSS custom properties for light/dark mode compatibility

Workstreams page update:
- Domain and Status filters now use MultiSelect instead of Inputs.checkbox
- Filter bar layout reduced to a tight inline row (0.5rem gap)
- Owner text filter restyled to match trigger button height
- No changes to filter logic or downstream cells (filters.domain / .status
  are still string[] with empty = show all)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 00:19:58 +01:00
parent de936acd6d
commit 154ec47046
2 changed files with 267 additions and 9 deletions

View File

@@ -56,21 +56,22 @@ display(html`<div class="live-bar">
```
```js
import {MultiSelect} from "./components/multiselect.js";
// Static options — no dependency on `data`, so selections survive polls
const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
const STATUSES = ["active", "blocked", "completed", "archived"];
const filters = view(Inputs.form(
{
domain: Inputs.checkbox(DOMAINS, {label: "Domain"}),
status: Inputs.checkbox(STATUSES, {label: "Status"}),
owner: Inputs.text({label: "Owner contains", placeholder: "filter by owner…"}),
domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}),
status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}),
owner: Inputs.text({placeholder: "Owner…", style: "width:120px"}),
},
{
template: ({domain, status, owner}) => html`<div class="filter-bar">
<div class="filter-group">${domain}</div>
<div class="filter-group">${status}</div>
<div class="filter-group filter-group-text">${owner}</div>
${domain}${status}
<div class="filter-owner">${owner}</div>
</div>`,
}
));
@@ -146,9 +147,9 @@ if (wsWithDeps.length === 0) {
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.dim { color: gray; font-style: italic; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start; padding: 0.75rem 1rem; background: var(--theme-background-alt); border-radius: 8px; margin-bottom: 1rem; }
.filter-group { min-width: 140px; }
.filter-group-text { align-self: flex-end; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.filter-owner { display: flex; align-items: center; }
.filter-owner input { height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; }
.dep-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.dep-card { border: 1px solid #e0e0e0; border-radius: 6px; padding: 0.75rem 1rem; background: var(--theme-background-alt, #fafafa); }
.dep-title { font-weight: 600; margin-bottom: 0.25rem; }