generated from coulomb/repo-seed
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>
258 lines
7.6 KiB
JavaScript
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;
|
|
}
|