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