feat(explorer): compact map toolbar with Start here and Filter menus

This commit is contained in:
Meysam Parvizi
2026-10-11 16:59:15 +02:00
parent 4564daca70
commit 456bcb954b
2 changed files with 210 additions and 75 deletions
+130 -39
View File
@@ -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 {
+80 -36
View File
@@ -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,
);