generated from coulomb/repo-seed
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:
257
dashboard/src/components/multiselect.js
Normal file
257
dashboard/src/components/multiselect.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user