Files
state-hub/dashboard/src/components/multiselect.js
tegwick 154ec47046 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>
2026-02-26 00:19:58 +01:00

258 lines
7.6 KiB
JavaScript

/**
* 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;
}