mirror of
https://github.com/m3y54m/Embedded-Engineering-Roadmap.git
synced 2026-10-12 09:18:16 +03:00
feat(explorer): compact map toolbar with Start here and Filter menus
This commit is contained in:
+130
-39
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user