web: collapse federation map ledgend (#604)

* web: collapse federation map ledgend

* web: cover missing unit test vectors
This commit is contained in:
l5y
2026-01-06 17:31:20 +01:00
committed by GitHub
parent e9a0dc0d59
commit f5f2e977a1
3 changed files with 299 additions and 4 deletions
@@ -27,6 +27,9 @@ test('federation map centers on configured coordinates and follows theme filters
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const mapPanel = createElement('div', 'mapPanel');
mapPanel.dataset.legendCollapsed = 'true';
registerElement('mapPanel', mapPanel);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
@@ -408,15 +411,192 @@ test('federation table sorting, contact rendering, and legend creation', async (
assert.deepEqual(mapSetViewCalls[0], [[0, 0], 3]);
assert.equal(mapFitBoundsCalls[0][0].length, 3);
assert.equal(legendContainers.length, 1);
const legend = legendContainers[0];
assert.ok(legend.className.includes('legend'));
assert.equal(legendContainers.length, 2);
const legend = legendContainers.find(container => container.className.includes('legend--instances'));
assert.ok(legend);
assert.ok(legend.className.includes('legend-hidden'));
const legendHeader = legend.children.find(child => child.className === 'legend-header');
const legendTitle = legendHeader && Array.isArray(legendHeader.children)
? legendHeader.children.find(child => child.className === 'legend-title')
: null;
assert.ok(legendTitle);
assert.equal(legendTitle.textContent, 'Active nodes');
const legendToggle = legendContainers.find(container => container.className.includes('legend-toggle'));
assert.ok(legendToggle);
} finally {
cleanup();
}
});
test('federation legend toggle respects media query changes', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { document, createElement, registerElement, cleanup } = env;
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const mapPanel = createElement('div', 'mapPanel');
mapPanel.setAttribute('data-legend-collapsed', 'false');
registerElement('mapPanel', mapPanel);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
tableEl.appendChild(tbodyEl);
const configPayload = {
mapCenter: { lat: 0, lon: 0 },
mapZoom: 3,
tileFilters: { light: 'none', dark: 'invert(1)' }
};
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify(configPayload));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
let mediaQueryHandler = null;
window.matchMedia = () => ({
matches: false,
addListener(handler) {
mediaQueryHandler = handler;
}
});
const legendContainers = [];
const legendButtons = [];
const DomUtil = {
create(tag, className, parent) {
const classSet = new Set(className ? className.split(/\s+/).filter(Boolean) : []);
const el = {
tagName: tag,
className,
classList: {
toggle(name, force) {
const shouldAdd = typeof force === 'boolean' ? force : !classSet.has(name);
if (shouldAdd) {
classSet.add(name);
} else {
classSet.delete(name);
}
el.className = Array.from(classSet).join(' ');
}
},
children: [],
style: {},
textContent: '',
attributes: new Map(),
setAttribute(name, value) {
this.attributes.set(name, String(value));
},
appendChild(child) {
this.children.push(child);
return child;
},
addEventListener(event, handler) {
if (event === 'click') {
this._clickHandler = handler;
}
},
querySelector() {
return null;
}
};
if (parent && parent.appendChild) parent.appendChild(el);
if (className && className.includes('legend-toggle-button')) {
legendButtons.push(el);
}
return el;
}
};
const controlStub = () => {
const ctrl = {
onAdd: null,
container: null,
addTo(map) {
this.container = this.onAdd ? this.onAdd(map) : null;
legendContainers.push(this.container);
return this;
},
getContainer() {
return this.container;
}
};
return ctrl;
};
const markersLayer = {
addLayer() {
return null;
},
addTo() {
return this;
}
};
const leafletStub = {
map() {
return {
setView() {},
on() {},
fitBounds() {}
};
},
tileLayer() {
return {
addTo() {
return this;
},
getContainer() {
return null;
},
on() {}
};
},
layerGroup() {
return markersLayer;
},
circleMarker() {
return {
bindPopup() {
return this;
}
};
},
control: controlStub,
DomUtil,
DomEvent: {
disableClickPropagation() {},
disableScrollPropagation() {}
}
};
const fetchImpl = async () => ({
ok: true,
json: async () => []
});
try {
await initializeFederationPage({ config: configPayload, fetchImpl, leaflet: leafletStub });
const legend = legendContainers.find(container => container.className.includes('legend--instances'));
assert.ok(legend);
assert.ok(!legend.className.includes('legend-hidden'));
assert.equal(legendButtons.length, 1);
legendButtons[0]._clickHandler?.({ preventDefault() {}, stopPropagation() {} });
assert.ok(legend.className.includes('legend-hidden'));
if (mediaQueryHandler) {
mediaQueryHandler({ matches: false });
assert.ok(!legend.className.includes('legend-hidden'));
}
} finally {
cleanup();
}
+115
View File
@@ -15,6 +15,7 @@
*/
import { readAppConfig } from './config.js';
import { resolveLegendVisibility } from './map-legend-visibility.js';
import { mergeConfig } from './settings.js';
import { roleColors } from './role-helpers.js';
@@ -204,6 +205,31 @@ function hasNumberValue(value) {
return toFiniteNumber(value) != null;
}
/**
* Toggle the legend hidden class on a container element.
*
* @param {HTMLElement|{ classList?: { toggle?: Function }, className?: string }} container Legend container.
* @param {boolean} hidden Whether the legend should be hidden.
* @returns {void}
*/
function toggleLegendHiddenClass(container, hidden) {
if (!container) return;
if (container.classList && typeof container.classList.toggle === 'function') {
container.classList.toggle('legend-hidden', hidden);
return;
}
if (typeof container.className === 'string') {
const classes = container.className.split(/\s+/).filter(Boolean);
const hasHidden = classes.includes('legend-hidden');
if (hidden && !hasHidden) {
classes.push('legend-hidden');
} else if (!hidden && hasHidden) {
classes.splice(classes.indexOf('legend-hidden'), 1);
}
container.className = classes.join(' ');
}
}
const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
/**
@@ -223,6 +249,7 @@ export async function initializeFederationPage(options = {}) {
const fetchImpl = options.fetchImpl || fetch;
const leaflet = options.leaflet || (typeof window !== 'undefined' ? window.L : null);
const mapContainer = document.getElementById('map');
const mapPanel = document.getElementById('mapPanel');
const tableEl = document.getElementById('instances');
const tableBody = document.querySelector('#instances tbody');
const statusEl = document.getElementById('status');
@@ -239,6 +266,13 @@ export async function initializeFederationPage(options = {}) {
let map = null;
let markersLayer = null;
let tileLayer = null;
let legendContainer = null;
let legendToggleButton = null;
let legendVisible = true;
const legendCollapsedValue = mapPanel ? mapPanel.getAttribute('data-legend-collapsed') : null;
const legendDefaultCollapsed = legendCollapsedValue == null
? true
: legendCollapsedValue.trim() !== 'false';
const tableSorters = {
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
@@ -357,6 +391,37 @@ export async function initializeFederationPage(options = {}) {
syncSortIndicators();
};
/**
* Update the pressed state of the legend visibility toggle button.
*
* @returns {void}
*/
const updateLegendToggleState = () => {
if (!legendToggleButton) return;
const baseLabel = legendVisible ? 'Hide map legend' : 'Show map legend';
const baseText = legendVisible ? 'Hide legend' : 'Show legend';
legendToggleButton.setAttribute('aria-pressed', legendVisible ? 'true' : 'false');
legendToggleButton.setAttribute('aria-label', baseLabel);
legendToggleButton.textContent = baseText;
};
/**
* Show or hide the map legend component.
*
* @param {boolean} visible Whether the legend should be displayed.
* @returns {void}
*/
const setLegendVisibility = visible => {
legendVisible = Boolean(visible);
if (legendContainer) {
toggleLegendHiddenClass(legendContainer, !legendVisible);
if (typeof legendContainer.setAttribute === 'function') {
legendContainer.setAttribute('aria-hidden', legendVisible ? 'false' : 'true');
}
}
updateLegendToggleState();
};
/**
* Wire up click and keyboard handlers for sortable headers.
*
@@ -483,6 +548,15 @@ export async function initializeFederationPage(options = {}) {
const canRenderLegend =
typeof leaflet.control === 'function' && leaflet.DomUtil && typeof leaflet.DomUtil.create === 'function';
if (canRenderLegend) {
const legendMediaQuery = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(max-width: 1024px)')
: null;
const initialLegendVisible = resolveLegendVisibility({
defaultCollapsed: legendDefaultCollapsed,
mediaQueryMatches: legendMediaQuery ? legendMediaQuery.matches : false
});
legendVisible = initialLegendVisible;
const legendStops = NODE_COUNT_COLOR_STOPS.map((stop, index) => {
const lower = index === 0 ? 0 : NODE_COUNT_COLOR_STOPS[index - 1].limit;
const upper = stop.limit - 1;
@@ -495,7 +569,11 @@ export async function initializeFederationPage(options = {}) {
const legend = leaflet.control({ position: 'bottomright' });
legend.onAdd = function onAdd() {
const container = leaflet.DomUtil.create('div', 'legend legend--instances');
container.id = 'federationLegend';
container.setAttribute('aria-label', 'Active nodes legend');
container.setAttribute('role', 'region');
container.setAttribute('aria-hidden', initialLegendVisible ? 'false' : 'true');
toggleLegendHiddenClass(container, !initialLegendVisible);
const header = leaflet.DomUtil.create('div', 'legend-header', container);
const title = leaflet.DomUtil.create('span', 'legend-title', header);
title.textContent = 'Active nodes';
@@ -508,9 +586,46 @@ export async function initializeFederationPage(options = {}) {
const label = leaflet.DomUtil.create('span', 'legend-label', item);
label.textContent = stop.label;
});
legendContainer = container;
return container;
};
legend.addTo(map);
const legendToggleControl = leaflet.control({ position: 'bottomright' });
legendToggleControl.onAdd = function onAdd() {
const container = leaflet.DomUtil.create('div', 'leaflet-control legend-toggle');
const button = leaflet.DomUtil.create('button', 'legend-toggle-button', container);
button.type = 'button';
button.setAttribute('aria-controls', 'federationLegend');
button.addEventListener?.('click', event => {
event.preventDefault();
event.stopPropagation();
setLegendVisibility(!legendVisible);
});
legendToggleButton = button;
updateLegendToggleState();
if (leaflet.DomEvent && typeof leaflet.DomEvent.disableClickPropagation === 'function') {
leaflet.DomEvent.disableClickPropagation(container);
}
if (leaflet.DomEvent && typeof leaflet.DomEvent.disableScrollPropagation === 'function') {
leaflet.DomEvent.disableScrollPropagation(container);
}
return container;
};
legendToggleControl.addTo(map);
setLegendVisibility(initialLegendVisible);
if (legendMediaQuery) {
const changeHandler = event => {
if (legendDefaultCollapsed) return;
setLegendVisibility(!event.matches);
};
if (typeof legendMediaQuery.addEventListener === 'function') {
legendMediaQuery.addEventListener('change', changeHandler);
} else if (typeof legendMediaQuery.addListener === 'function') {
legendMediaQuery.addListener(changeHandler);
}
}
}
for (const instance of instances) {
+1 -1
View File
@@ -16,7 +16,7 @@
<section class="federation-page federation-page--full-width">
<div class="federation-page__content">
<div class="federation-page__map-row">
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
</div>
<%= erb :"shared/_instances_table" %>
</div>