From f5f2e977a1eea9825e15e07d64a434a11f995832 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:31:20 +0100 Subject: [PATCH] web: collapse federation map ledgend (#604) * web: collapse federation map ledgend * web: cover missing unit test vectors --- .../js/app/__tests__/federation-page.test.js | 186 +++++++++++++++++- web/public/assets/js/app/federation-page.js | 115 +++++++++++ web/views/federation.erb | 2 +- 3 files changed, 299 insertions(+), 4 deletions(-) diff --git a/web/public/assets/js/app/__tests__/federation-page.test.js b/web/public/assets/js/app/__tests__/federation-page.test.js index eaae281..8210b2a 100644 --- a/web/public/assets/js/app/__tests__/federation-page.test.js +++ b/web/public/assets/js/app/__tests__/federation-page.test.js @@ -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(); } diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js index 967d30c..a3f2130 100644 --- a/web/public/assets/js/app/federation-page.js +++ b/web/public/assets/js/app/federation-page.js @@ -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) { diff --git a/web/views/federation.erb b/web/views/federation.erb index 13dc7bf..67ecebb 100644 --- a/web/views/federation.erb +++ b/web/views/federation.erb @@ -16,7 +16,7 @@
- <%= erb :"shared/_map_panel", locals: { full_screen: true } %> + <%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
<%= erb :"shared/_instances_table" %>