From 154ec47046cf71e9c51624e3f10c081f8c74b4cd Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 26 Feb 2026 00:19:58 +0100 Subject: [PATCH] Dashboard: reusable MultiSelect dropdown component for workstreams filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dashboard/src/components/multiselect.js | 257 ++++++++++++++++++++++++ dashboard/src/workstreams.md | 19 +- 2 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 dashboard/src/components/multiselect.js diff --git a/dashboard/src/components/multiselect.js b/dashboard/src/components/multiselect.js new file mode 100644 index 0000000..671c8dc --- /dev/null +++ b/dashboard/src/components/multiselect.js @@ -0,0 +1,257 @@ +/** + * MultiSelect — compact dropdown multi-select filter component. + * + * Observable-compatible: exposes `.value` (string[]) and dispatches bubbling + * `input` events on change, so it works with `view()`, `Inputs.form`, and + * `Generators.input`. + * + * Usage: + * const el = MultiSelect(["a", "b", "c"], {label: "Domain"}); + * const selected = view(el); // string[] — empty means "all / no filter" + */ + +const STYLE_ID = "ms-component-styles"; + +function ensureStyles() { + if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return; + const s = document.createElement("style"); + s.id = STYLE_ID; + s.textContent = ` +.ms-wrap { + position: relative; + display: inline-block; + font-family: var(--sans-serif, system-ui, sans-serif); + font-size: 0.85rem; +} +.ms-trigger { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.28rem 0.6rem; + border-radius: 6px; + border: 1px solid var(--theme-foreground-faint, #ccc); + background: var(--theme-background, #fff); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + white-space: nowrap; + transition: border-color 0.15s, background 0.15s; + user-select: none; + color: var(--theme-foreground, #111); +} +.ms-trigger:hover { + border-color: var(--theme-foreground-muted, #888); +} +.ms-trigger.ms-has-selection { + border-color: steelblue; + background: color-mix(in srgb, steelblue 8%, var(--theme-background, #fff)); +} +.ms-trigger-label { + color: var(--theme-foreground-muted, #666); +} +.ms-trigger-value { + font-weight: 500; + color: var(--theme-foreground, #111); + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; +} +.ms-trigger-value.ms-placeholder { + font-weight: 400; + color: var(--theme-foreground-muted, #888); +} +.ms-chevron { + font-size: 0.65rem; + color: var(--theme-foreground-muted, #888); + transition: transform 0.15s; + line-height: 1; +} +.ms-wrap.ms-open .ms-chevron { + transform: rotate(180deg); +} +.ms-dropdown { + display: none; + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 1000; + min-width: max(100%, 160px); + background: var(--theme-background, #fff); + border: 1px solid var(--theme-foreground-faint, #ddd); + border-radius: 8px; + box-shadow: 0 4px 18px rgba(0,0,0,0.12); + padding: 0.35rem 0; +} +.ms-wrap.ms-open .ms-dropdown { + display: block; +} +.ms-clear { + display: block; + width: 100%; + padding: 0.2rem 0.75rem 0.35rem; + font: inherit; + font-size: 0.75rem; + color: steelblue; + background: none; + border: none; + text-align: left; + cursor: pointer; + border-bottom: 1px solid var(--theme-foreground-faint, #eee); + margin-bottom: 0.2rem; +} +.ms-clear:hover { text-decoration: underline; } +.ms-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.28rem 0.75rem; + cursor: pointer; + border-radius: 0; +} +.ms-option:hover { + background: var(--theme-background-alt, #f5f5f5); +} +.ms-option input[type=checkbox] { + margin: 0; + cursor: pointer; + accent-color: steelblue; + flex-shrink: 0; +} +`; + document.head.append(s); +} + +/** + * @param {string[] | {value: string, label: string}[]} options + * @param {{ label?: string, value?: string[], placeholder?: string }} opts + * @returns {HTMLElement} + */ +export function MultiSelect(options, { label = "", value = [], placeholder = "All" } = {}) { + ensureStyles(); + + // Normalise options to {value, label} pairs + const opts = options.map(o => typeof o === "string" ? { value: o, label: o } : o); + let selected = new Set(value); + + // ── Build DOM ────────────────────────────────────────────────────────────── + + const wrap = document.createElement("div"); + wrap.className = "ms-wrap"; + + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "ms-trigger"; + + const labelSpan = document.createElement("span"); + labelSpan.className = "ms-trigger-label"; + if (label) labelSpan.textContent = label + ":"; + + const valueSpan = document.createElement("span"); + + const chevron = document.createElement("span"); + chevron.className = "ms-chevron"; + chevron.textContent = "▾"; + + if (label) trigger.append(labelSpan, "\u00a0"); // non-breaking space between label and value + trigger.append(valueSpan, chevron); + + // Dropdown + const dropdown = document.createElement("div"); + dropdown.className = "ms-dropdown"; + + const clearBtn = document.createElement("button"); + clearBtn.type = "button"; + clearBtn.className = "ms-clear"; + clearBtn.textContent = "Clear selection"; + dropdown.append(clearBtn); + + const checkboxes = opts.map(opt => { + const row = document.createElement("label"); + row.className = "ms-option"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.value = opt.value; + cb.checked = selected.has(opt.value); + row.append(cb, opt.label); + dropdown.append(row); + return cb; + }); + + wrap.append(trigger, dropdown); + + // ── State helpers ────────────────────────────────────────────────────────── + + function syncUI() { + const n = selected.size; + if (n === 0) { + valueSpan.textContent = placeholder; + valueSpan.className = "ms-trigger-value ms-placeholder"; + trigger.classList.remove("ms-has-selection"); + clearBtn.style.display = "none"; + } else { + const names = [...selected].map(v => opts.find(o => o.value === v)?.label ?? v); + valueSpan.textContent = n <= 2 ? names.join(", ") : `${n} of ${opts.length}`; + valueSpan.className = "ms-trigger-value"; + trigger.classList.add("ms-has-selection"); + clearBtn.style.display = "block"; + } + } + + function emit() { + syncUI(); + wrap.dispatchEvent(new Event("input", { bubbles: true })); + } + + // ── Open / close ─────────────────────────────────────────────────────────── + + function open() { + // Close any other open dropdowns on the page + document.querySelectorAll(".ms-wrap.ms-open").forEach(w => { + if (w !== wrap) w.classList.remove("ms-open"); + }); + wrap.classList.add("ms-open"); + } + + function close() { + wrap.classList.remove("ms-open"); + } + + // ── Events ───────────────────────────────────────────────────────────────── + + trigger.addEventListener("click", e => { + e.stopPropagation(); + wrap.classList.contains("ms-open") ? close() : open(); + }); + + checkboxes.forEach((cb, i) => { + cb.addEventListener("change", () => { + if (cb.checked) selected.add(opts[i].value); + else selected.delete(opts[i].value); + emit(); + }); + }); + + clearBtn.addEventListener("click", e => { + e.stopPropagation(); + selected.clear(); + checkboxes.forEach(cb => (cb.checked = false)); + emit(); + }); + + // Prevent dropdown clicks from bubbling to the document closer + dropdown.addEventListener("click", e => e.stopPropagation()); + + document.addEventListener("click", close); + document.addEventListener("keydown", e => { if (e.key === "Escape") close(); }); + + // ── Observable compatibility ─────────────────────────────────────────────── + + // Empty array = "no filter" (show all). Semantics: any checked item = restrict to those. + Object.defineProperty(wrap, "value", { + get: () => [...selected], + enumerable: true, + }); + + syncUI(); + return wrap; +} diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 7f17e45..5939c3b 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -56,21 +56,22 @@ display(html`
``` ```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`
-
${domain}
-
${status}
-
${owner}
+ ${domain}${status} +
${owner}
`, } )); @@ -146,9 +147,9 @@ if (wsWithDeps.length === 0) {