Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions public/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 28 additions & 2 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
177 changes: 177 additions & 0 deletions test-issue-1329-map-controls-accordion-e2e.js
Original file line number Diff line number Diff line change
@@ -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); });