diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 41f44730..f57239fa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -283,6 +283,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/map.js b/public/map.js index 01b752d1..ad0423dc 100644 --- a/public/map.js +++ b/public/map.js @@ -262,6 +262,48 @@ toggleBtn.setAttribute('aria-expanded', String(!controlsCollapsed)); }); + // #1329: Map controls accordion. Make each section's legend a button- + // style toggle with aria-expanded. On mobile (≤640px) only one section + // is open at a time so the panel never needs internal scrolling. On + // desktop the .mc-collapsed class has no visual effect (CSS only hides + // section bodies inside the mobile media query) so all controls stay + // visible — but single-open behaviour is still tracked for state + // consistency. See test-issue-1329-map-controls-accordion-e2e.js. + (function initMapControlsAccordion() { + const isMobile = window.innerWidth <= 640; + const sections = Array.from(controlsPanel.querySelectorAll('fieldset.mc-section')); + sections.forEach((fs, idx) => { + const legend = fs.querySelector('legend.mc-label'); + if (!legend) return; + // Initial state: on mobile only the first section is open; on + // desktop all sections are open. + const open = !isMobile || idx === 0; + legend.setAttribute('role', 'button'); + legend.setAttribute('tabindex', '0'); + legend.setAttribute('aria-expanded', String(open)); + fs.classList.toggle('mc-collapsed', !open); + const setOpen = (target, openNow) => { + target.setAttribute('aria-expanded', String(openNow)); + const parent = target.closest('fieldset.mc-section'); + if (parent) parent.classList.toggle('mc-collapsed', !openNow); + }; + const onActivate = (e) => { + e.preventDefault(); + const currentlyOpen = legend.getAttribute('aria-expanded') === 'true'; + // Single-open: close every other section first. + sections.forEach(other => { + const otherLegend = other.querySelector('legend.mc-label'); + if (otherLegend && otherLegend !== legend) setOpen(otherLegend, false); + }); + setOpen(legend, !currentlyOpen); + }; + legend.addEventListener('click', onActivate); + legend.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') onActivate(e); + }); + }); + })(); + // Bind controls var clustersEl = document.getElementById('mcClusters'); if (clustersEl) { diff --git a/public/style.css b/public/style.css index 4a59dc22..f609500d 100644 --- a/public/style.css +++ b/public/style.css @@ -1833,8 +1833,34 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .search-box { width: 95vw; } .search-overlay { padding-top: 60px; } - /* Map controls */ - .map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: 200px; font-size: 12px; padding: 10px 12px; } + /* Map controls — #1329: drop fixed 200px cap, use accordion sections + instead so visible content fits without internal scrolling. Panel can + grow to fill available height; max-height bound by viewport so it + never escapes the screen. */ + .map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: calc(100vh - 80px); font-size: 12px; padding: 10px 12px; } + /* On mobile, hide collapsed section bodies (everything inside the + fieldset except the legend). The legend remains tappable to expand. */ + .map-controls fieldset.mc-section.mc-collapsed > *:not(legend) { display: none; } + .map-controls fieldset.mc-section > legend.mc-label { + cursor: pointer; + user-select: none; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + } + /* ▸ / ▾ indicator via ::after so we don't touch markup */ + .map-controls fieldset.mc-section > legend.mc-label::after { + content: '▾'; + font-size: 10px; + color: var(--text-muted); + margin-left: 8px; + transition: transform 0.15s; + } + .map-controls fieldset.mc-section.mc-collapsed > legend.mc-label::after { + content: '▸'; + } #leaflet-map { z-index: 0; } #map-wrap { z-index: 0; } diff --git a/test-issue-1329-map-controls-accordion-e2e.js b/test-issue-1329-map-controls-accordion-e2e.js new file mode 100644 index 00000000..b5aa91a2 --- /dev/null +++ b/test-issue-1329-map-controls-accordion-e2e.js @@ -0,0 +1,177 @@ +/** + * E2E (#1329): Map controls panel on mobile must NOT be capped at 200px + * with internal scroll. Use accordion sections — one expanded at a time — + * so the visible content always fits without scrolling. + * + * Mobile (375x812): + * - Open Map controls. + * - Panel must have accordion sections (legend acts as toggle, with + * aria-expanded attribute). + * - Default state: at most one section expanded. + * - Panel contents must NOT require internal scroll + * (scrollHeight <= clientHeight + 1). + * - Clicking a different section's legend collapses the previously-open + * section (single-open behavior). + * + * Desktop (1280x800): + * - Existing layout unchanged: all sections visible by default, + * panel position:absolute, modest width. + * + * Run: BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' \u2713 ' + name); } + catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function run() { + const launchOpts = { args: ['--no-sandbox'] }; + if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH; + const browser = await chromium.launch(launchOpts); + + // === Mobile: 375x812 === + const ctx = await browser.newContext({ viewport: { width: 375, height: 812 } }); + const page = await ctx.newPage(); + + await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 }); + await page.waitForSelector('#leaflet-map', { timeout: 10000 }); + await page.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 }); + await page.waitForTimeout(500); + + // Ensure controls panel is expanded (default is collapsed on mobile). + await page.evaluate(() => { + const panel = document.getElementById('mapControls'); + const btn = document.getElementById('mapControlsToggle'); + if (panel && panel.classList.contains('collapsed')) btn && btn.click(); + }); + await page.waitForTimeout(300); + + await step('mobile: at least one accordion section present with aria-expanded', async () => { + const data = await page.evaluate(() => { + const panel = document.getElementById('mapControls'); + // Accordion section markers: legend (or button) carrying aria-expanded + // inside a .mc-section.mc-accordion (or equivalent) descendant. + const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]'); + const sections = panel.querySelectorAll('.mc-section'); + return { + toggles: toggles.length, + sections: sections.length, + expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length, + }; + }); + assert(data.toggles >= 1, + 'expected ≥1 accordion toggle (aria-expanded), got ' + data.toggles + + ' (sections=' + data.sections + ')'); + }); + + await step('mobile: at most one section expanded by default', async () => { + const data = await page.evaluate(() => { + const panel = document.getElementById('mapControls'); + const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]'); + return { + expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length, + total: toggles.length, + }; + }); + assert(data.expandedCount <= 1, + 'expected ≤1 section expanded by default, got ' + data.expandedCount + '/' + data.total); + }); + + await step('mobile: panel content does NOT require internal scroll', async () => { + const data = await page.evaluate(() => { + const panel = document.getElementById('mapControls'); + return { + scrollH: panel.scrollHeight, + clientH: panel.clientHeight, + overflowY: getComputedStyle(panel).overflowY, + }; + }); + // The accordion sections should keep content within viewport — when only + // one section is expanded, panel must not need to scroll internally. + assert(data.scrollH <= data.clientH + 1, + 'panel must not require internal scroll (scrollH=' + data.scrollH + + ' clientH=' + data.clientH + ')'); + }); + + await step('mobile: clicking a 2nd toggle collapses the first (single-open)', async () => { + const result = await page.evaluate(() => { + const panel = document.getElementById('mapControls'); + const toggles = Array.from(panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]')); + if (toggles.length < 2) return { skip: true, n: toggles.length }; + // Find one currently closed and one open; if all closed, open first then click second. + let openIdx = toggles.findIndex(t => t.getAttribute('aria-expanded') === 'true'); + if (openIdx === -1) { + toggles[0].click(); + openIdx = 0; + } + const otherIdx = openIdx === 0 ? 1 : 0; + toggles[otherIdx].click(); + return { + skip: false, + firstNow: toggles[openIdx].getAttribute('aria-expanded'), + otherNow: toggles[otherIdx].getAttribute('aria-expanded'), + }; + }); + if (result.skip) { + throw new Error('need at least 2 accordion toggles to test single-open (got ' + result.n + ')'); + } + assert(result.otherNow === 'true', + 'second toggle should be open after click, got ' + result.otherNow); + assert(result.firstNow === 'false', + 'first toggle should auto-close (single-open), got ' + result.firstNow); + }); + + await ctx.close(); + + // === Desktop: 1280x800 === + const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const p2 = await ctx2.newPage(); + await p2.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 }); + await p2.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 }); + await p2.waitForTimeout(300); + + await step('desktop (1280px): panel position:absolute, all section contents visible', async () => { + const data = await p2.evaluate(() => { + const panel = document.getElementById('mapControls'); + const cs = getComputedStyle(panel); + const rect = panel.getBoundingClientRect(); + // Check that section content (e.g., labels) is visible on desktop. + const allInputs = panel.querySelectorAll('input[type=checkbox], select, button'); + let visible = 0; + allInputs.forEach(el => { + const r = el.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) visible++; + }); + return { + position: cs.position, + width: Math.round(rect.width), + vw: window.innerWidth, + visibleControls: visible, + totalControls: allInputs.length, + }; + }); + assert(data.position === 'absolute', + 'desktop panel must be position:absolute, got ' + data.position); + assert(data.width < data.vw * 0.5, + 'desktop panel must be <50% viewport width, got ' + data.width + '/' + data.vw); + // All (or nearly all) controls should be visible on desktop — accordion + // collapse must NOT apply at desktop sizes. + assert(data.visibleControls >= data.totalControls - 2, + 'desktop must show all controls (got ' + data.visibleControls + '/' + data.totalControls + ')'); + }); + + await browser.close(); + + console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' + + (failed ? ', ' + failed + ' failed' : '')); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch(err => { console.error('Fatal:', err); process.exit(1); });