From 456bcb954b6a30490f40f8da1d029d6cf18fbbae Mon Sep 17 00:00:00 2001 From: Meysam Parvizi Date: Sun, 11 Oct 2026 16:59:15 +0200 Subject: [PATCH] feat(explorer): compact map toolbar with Start here and Filter menus --- explorer/map.css | 169 ++++++++++++++++++++++++++++++++++---------- explorer/mapview.js | 116 ++++++++++++++++++++---------- 2 files changed, 210 insertions(+), 75 deletions(-) diff --git a/explorer/map.css b/explorer/map.css index af0e58d..4dbc231 100644 --- a/explorer/map.css +++ b/explorer/map.css @@ -9,38 +9,23 @@ .map-top { position: relative; - z-index: 2; - display: grid; - gap: 8px; - padding: 12px 16px 10px; + z-index: 3; + padding: 8px 16px; border-bottom: 1px solid var(--border); background: var(--surface); backdrop-filter: blur(12px); } -.start-strip, .map-toolbar { display: flex; - flex-wrap: wrap; align-items: center; - gap: 6px; + gap: 8px; } -.start-strip > *, .map-toolbar > * { flex: none; } -.map-toolbar-label { - min-width: 74px; - color: var(--muted); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.start-chip, .map-chip { display: inline-flex; align-items: center; @@ -57,7 +42,6 @@ transition: background 0.15s, border-color 0.15s; } -.start-chip:hover, .map-chip:hover { border-color: var(--accent); background: var(--chip-hover); @@ -80,11 +64,116 @@ background: transparent; } -.map-toolbar-sep { - width: 1px; - height: 22px; - margin: 0 4px; - background: var(--border-strong); +.map-menu { + position: relative; +} + +.menu-button { + list-style: none; + user-select: none; +} + +.menu-button::-webkit-details-marker { + display: none; +} + +.menu-button::after { + content: ""; + width: 6px; + height: 6px; + margin: -3px 2px 0 2px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + transition: transform 0.15s; +} + +.map-menu[open] > .menu-button::after { + margin-top: 3px; + transform: rotate(-135deg); +} + +.map-menu[open] > .menu-button, +.menu-button.active { + border-color: var(--accent); +} + +.menu-count { + min-width: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--accent); + color: var(--bg); + font-size: 11px; + line-height: 18px; + text-align: center; +} + +.map-menu-panel { + position: absolute; + top: calc(100% + 8px); + left: 0; + display: grid; + gap: 8px; + width: max-content; + max-width: min(440px, calc(100vw - 24px)); + max-height: min(70vh, 520px); + overflow-y: auto; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-solid); + box-shadow: var(--shadow); +} + +.menu-list { + display: grid; + gap: 2px; + min-width: 260px; +} + +.menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text); + font-size: 14px; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.menu-item:hover, +.menu-item:focus-visible { + background: var(--chip-hover); +} + +.menu-icon { + width: 1.4em; + text-align: center; +} + +.menu-heading { + margin: 0; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.menu-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.map-menu-panel .map-chip.clear { + justify-self: start; } .map-summary { @@ -412,29 +501,31 @@ @media (max-width: 960px) { .map-top { - padding: 8px 10px; + padding: 6px 10px; } - /* One swipeable row per strip; the fade at the right edge shows there is more. */ - .start-strip, .map-toolbar { - flex-wrap: nowrap; - overflow-x: auto; - scrollbar-width: none; - padding-right: 28px; - mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); + gap: 6px; } - .start-strip::-webkit-scrollbar, - .map-toolbar::-webkit-scrollbar { + .map-summary { display: none; } - .start-chip, - .map-chip, - .map-toolbar-label, - .map-summary { - flex: none; + /* Panels span the bar instead of hanging off their button, so they stay on screen. */ + .map-menu { + position: static; + } + + .map-menu-panel { + left: 10px; + right: 10px; + width: auto; + max-width: none; + } + + .menu-list { + min-width: 0; } .zoom-controls { diff --git a/explorer/mapview.js b/explorer/mapview.js index cd29555..4b8bd52 100644 --- a/explorer/mapview.js +++ b/explorer/mapview.js @@ -40,38 +40,89 @@ export function createMapView({ root, plan, data, build, onSelect }) { const legendParts = []; // ---------- toolbar ---------- - const chip = (label, cls, pressed, onclick, title) => h('button', { - type: 'button', class: `map-chip ${cls}`, 'aria-pressed': String(pressed), onclick, title, + // One slim row above the map: a menu of getting-started topics, a filter menu and the connections toggle. + const chip = (label, cls, onclick, title) => h('button', { + type: 'button', class: `map-chip ${cls}`, 'aria-pressed': 'false', onclick, title, }, label); - const toolbar = h('div', { class: 'map-toolbar', role: 'toolbar', 'aria-label': 'Highlight topics' }); + const menus = []; + function menu(cls, label, ...panel) { + const button = h('summary', { class: `map-chip menu-button ${cls}` }, label); + const el = h('details', { class: 'map-menu' }, button, h('div', { class: `map-menu-panel ${cls}` }, panel)); + el.addEventListener('toggle', () => { + if (el.open) for (const other of menus) if (other !== el) other.open = false; + }); + el.addEventListener('keydown', (e) => { + if (e.key !== 'Escape' || !el.open) return; + e.stopPropagation(); + el.open = false; + button.focus(); + }); + menus.push(el); + return { el, button }; + } + document.addEventListener('pointerdown', (e) => { + for (const m of menus) if (m.open && !m.contains(e.target)) m.open = false; + }); + + // README topics that the diagram does not cover. + const extra = data.root.children.filter((t) => !t.areas.length); + const start = menu('start', 'Start here', + h('nav', { class: 'menu-list', 'aria-label': 'Getting started topics' }, extra.map((t) => h('button', { + type: 'button', + class: 'menu-item', + onclick: () => { + start.el.open = false; + onSelect(t); + }, + }, h('span', { class: 'menu-icon', 'aria-hidden': 'true' }, t.icon || '•'), t.title)))); + + const isCross = () => state.areas.size === 2 && state.areas.has('SOFTWARE') && state.areas.has('HARDWARE'); + const isArea = (area) => state.areas.has(area) && !isCross(); + const areaChips = Object.entries(AREAS).map(([area, info]) => [area, chip(h('span', {}, h('i', { class: `dot ${info.cls}` }), info.label), + '', () => { + state.areas = isArea(area) ? new Set() : new Set([area]); + refresh(); + }, `Topics in ${info.label}`)]); + const crossChip = chip(h('span', {}, h('i', { class: 'dot both' }), 'Software ∩ Hardware'), 'cross', () => { + state.areas = isCross() ? new Set() : new Set(['SOFTWARE', 'HARDWARE']); + refresh(); + }, 'Topics that belong to both Software and Hardware'); + const importanceChips = Object.entries(IMPORTANCE).map(([level, label]) => [level, + chip(h('span', {}, h('i', { class: `imp-swatch ${level}` }), label), '', () => toggle(state.importance, level))]); + const clearButton = h('button', { + type: 'button', + class: 'map-chip clear', + onclick: () => { + state.areas.clear(); + state.importance.clear(); + refresh(); + }, + }, 'Clear filters'); + const filterCount = h('span', { class: 'menu-count' }); + const filter = menu('filter', h('span', {}, 'Filter', filterCount), + h('p', { class: 'menu-heading' }, 'Area'), + h('div', { class: 'menu-chips' }, areaChips.map(([, el]) => el), crossChip), + h('p', { class: 'menu-heading' }, 'Importance'), + h('div', { class: 'menu-chips' }, importanceChips.map(([, el]) => el)), + clearButton); + const linksChip = chip('All connections', 'links', () => { + state.showAll = !state.showAll; + refresh(); + }, 'Show the lines between all related topics'); const summary = h('span', { class: 'map-summary', role: 'status' }); + const toolbar = h('div', { class: 'map-toolbar', role: 'toolbar', 'aria-label': 'Map controls' }, + start.el, filter.el, linksChip, summary); function renderToolbar() { - const cross = state.areas.size === 2 && state.areas.has('SOFTWARE') && state.areas.has('HARDWARE'); - toolbar.replaceChildren(...[ - h('span', { class: 'map-toolbar-label' }, 'Highlight'), - Object.entries(AREAS).map(([area, info]) => chip(h('span', {}, h('i', { class: `dot ${info.cls}` }), info.label), - '', state.areas.has(area) && !cross, () => { - state.areas = state.areas.has(area) && !cross ? new Set() : new Set([area]); - refresh(); - }, `Topics in ${info.label}`)), - chip(h('span', {}, h('i', { class: 'dot both' }), 'Software ∩ Hardware'), 'cross', cross, () => { - state.areas = cross ? new Set() : new Set(['SOFTWARE', 'HARDWARE']); - refresh(); - }, 'Topics that belong to both Software and Hardware'), - h('span', { class: 'map-toolbar-sep', 'aria-hidden': 'true' }), - Object.entries(IMPORTANCE).map(([level, label]) => chip(h('span', {}, h('i', { class: `imp-swatch ${level}` }), label), - '', state.importance.has(level), () => toggle(state.importance, level))), - h('span', { class: 'map-toolbar-sep', 'aria-hidden': 'true' }), - chip('Show all connections', 'links', state.showAll, () => { state.showAll = !state.showAll; refresh(); }, - 'Lines between topics that share resources or mention each other'), - state.areas.size || state.importance.size ? chip('Clear', 'clear', false, () => { - state.areas.clear(); - state.importance.clear(); - refresh(); - }) : null, - summary, - ].flat().filter(Boolean)); + for (const [area, el] of areaChips) el.setAttribute('aria-pressed', String(isArea(area))); + crossChip.setAttribute('aria-pressed', String(isCross())); + for (const [level, el] of importanceChips) el.setAttribute('aria-pressed', String(state.importance.has(level))); + linksChip.setAttribute('aria-pressed', String(state.showAll)); + const active = (state.areas.size ? 1 : 0) + state.importance.size; + filterCount.textContent = active ? String(active) : ''; + filterCount.hidden = !active; + filter.button.classList.toggle('active', active > 0); + clearButton.hidden = !active; } function toggle(set, value) { @@ -80,13 +131,6 @@ export function createMapView({ root, plan, data, build, onSelect }) { refresh(); } - // README topics that the diagram does not cover. - const extra = data.root.children.filter((t) => !t.areas.length); - const strip = h('nav', { class: 'start-strip', 'aria-label': 'Getting started topics' }, - h('span', { class: 'map-toolbar-label' }, 'Start here'), - extra.map((t) => h('button', { type: 'button', class: 'start-chip', onclick: () => onSelect(t) }, - t.icon ? h('span', { 'aria-hidden': 'true' }, t.icon) : null, t.title))); - // ---------- svg ---------- const chart = svg('svg', { class: 'diagram', @@ -569,7 +613,7 @@ export function createMapView({ root, plan, data, build, onSelect }) { fitButton); root.replaceChildren( - h('div', { class: 'map-top' }, strip, toolbar), + h('div', { class: 'map-top' }, toolbar), h('div', { class: 'map-canvas' }, chart, zoomControls), tip, );