diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs new file mode 100644 index 000000000..4e4c56b64 --- /dev/null +++ b/src/mapSourceFocus.test.mjs @@ -0,0 +1,232 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +// Exercise the installed event routes and central close method, without WebGL. +const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +const initStart = source.indexOf(' _initAutoHoverPanel('); +const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart); +const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {'); +const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart); +const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {'); +const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart); +assert.ok(initStart >= 0 && initEnd > initStart + && closeStart >= 0 && closeEnd > closeStart); +assert.ok(syncStart >= 0 && syncEnd > syncStart); + +function harness({ hidden = false, selected = true, noChips = false } = {}) { + let now = 0; + let nextTimer = 0; + let focusCalls = 0; + const timers = new Map(); + const document = { activeElement: null }; + const makeNode = (id, attributes = {}) => { + const listeners = new Map(); + const classes = new Set(); + return { + id, listeners, classes, hovered: false, + setAttribute(name, value) { attributes[name] = String(value); }, + getAttribute(name) { return attributes[name] ?? null; }, + classList: { + contains: (name) => classes.has(name), + remove: (name) => classes.delete(name), + toggle(name, value) { if (value) classes.add(name); else classes.delete(name); }, + }, + addEventListener(type, callback) { + if (!listeners.has(type)) listeners.set(type, []); + listeners.get(type).push(callback); + }, + matches(selector) { return selector === ':hover' ? this.hovered : true; }, + closest() { return this.isDisclosure ? this : null; }, + }; + }; + const panel = makeNode('control-panel'); + const location = makeNode('location-bar'); + panel.classes.add('collapsed'); + location.classes.add('collapsed'); + const disclosure = makeNode('control-panel-toggle'); + disclosure.isDisclosure = true; + const first = makeNode('photoreal'); + const active = makeNode('osm'); + const elsewhere = makeNode('elsewhere'); + const nodes = [panel, location, disclosure, first, active, elsewhere].filter(Boolean); + document.getElementById = (id) => nodes.find((node) => node.id === id); + panel.contains = (node) => [panel, disclosure, first, active].includes(node); + location.contains = (node) => Boolean(node) && [location].includes(node); + panel.querySelector = (selector) => { + if (selector.startsWith('[data-dock-toggle-target')) return disclosure; + if (selector.split(',').some((part) => part.trim() === '.panel-title')) return { textContent: 'VISUAL STYLES' }; + if (noChips) return null; + if (selector === '.map-stack-chip.active') return selected ? active : null; + return selector === '.map-stack-chip' ? first : null; + }; + location.querySelector = (selector) => { + if (selector.split(',').some((part) => part.trim() === '.location-toolbar-label')) return { textContent: 'LOCATION' }; + return null; + }; + panel.querySelectorAll = location.querySelectorAll = () => []; + const emit = (node, type, values = {}) => { + assert.ok(node, 'the event target exists in the actual panel markup'); + const event = { + target: node, defaultPrevented: false, propagationStopped: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.propagationStopped = true; }, + ...values, + }; + for (const callback of node.listeners.get(type) || []) callback(event); + return event; + }; + const focus = (node) => { + const prior = document.activeElement; + if (prior === node) return; + document.activeElement = node; + for (const owner of [panel, location]) { + if (owner.contains(prior)) emit(owner, 'focusout', { target: prior, relatedTarget: node }); + if (owner.contains(node)) emit(owner, 'focusin', { target: node, relatedTarget: prior }); + } + }; + disclosure.focus = () => focus(disclosure); + for (const chip of [first, active]) { + chip.focus = () => { focusCalls += 1; if (!hidden) focus(chip); }; + } + const window = { + setTimeout(callback, delay) { timers.set(++nextTimer, { callback, at: now + delay }); return nextTimer; }, + }; + const methods = new Function('document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', + `return ({${source.slice(initStart, initEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)( + document, window, (id) => timers.delete(id), { now: () => now }, () => {}, + ); + const saves = []; + const claims = []; + let shareSyncs = 0; + const manager = { + ...methods, + _savePanelCollapsedState(...args) { saves.push(args); }, + _scheduleLeftPanelLayout() {}, _scheduleRightPanelLayout() {}, + shareLinkManager: { + claimRestoreLane(...args) { claims.push(args); }, + onPanelStateChange() { shareSyncs += 1; }, + }, + }; + manager._initAutoHoverPanel('control-panel'); + manager._initAutoHoverPanel('location-bar'); + disclosure.focus(); + const tick = () => { + const next = [...timers.entries()].sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) return; + timers.delete(next[0]); now = next[1].at; next[1].callback(); + }; + return { + manager, panel, location, disclosure, + first, active, elsewhere, document, timers, emit, focus, tick, saves, claims, + calls: () => focusCalls, + shareSyncs: () => shareSyncs, + show() { hidden = false; }, + select(value) { selected = value; }, + key(key = 'Enter', repeat = false) { return emit(disclosure, 'keydown', { key, repeat }); }, + escape() { emit(panel, 'keydown', { key: 'Escape' }); }, + click(detail = 1) { emit(disclosure, 'click', { detail }); }, + drain() { for (let i = 0; i < 40 && timers.size; i += 1) tick(); }, + }; +} + +test('Enter focuses selected OSM rather than the first chip', () => { + const h = harness(); h.key(); h.tick(); + assert.equal(h.document.activeElement, h.active); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.timers.size, 0); +}); +test('repeated keydown does not close an opening tray or replace its timer', () => { + const h = harness(); h.key(); const timer = [...h.timers.keys()][0]; h.key('Enter', true); + assert.equal([...h.timers.keys()][0], timer); + assert.equal(h.panel.classList.contains('collapsed'), false); +}); +test('missing selection falls back to the first chip', () => { + const h = harness({ selected: false }); h.key(); h.tick(); + assert.equal(h.document.activeElement, h.first); +}); +test('delayed visibility retries until focus lands', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); h.tick(); + assert.equal(h.document.activeElement, h.disclosure); + assert.equal(h.timers.size, 1); h.show(); h.tick(); + assert.equal(h.document.activeElement, h.active); + assert.equal(h.calls(), 3); assert.equal(h.timers.size, 0); +}); +test('selection is read again when a delayed attempt can focus', () => { + const h = harness({ hidden: true, selected: false }); h.key(); h.tick(); + h.select(true); h.show(); h.tick(); + assert.equal(h.document.activeElement, h.active); +}); +test('permanently hidden and missing targets have bounded work', () => { + for (const options of [{ hidden: true }, { noChips: true }]) { + const h = harness(options); h.key(); h.drain(); + assert.equal(h.timers.size, 0); + assert.equal(h.calls(), options.noChips ? 0 : 25); + assert.equal(h.document.activeElement, h.disclosure); + } +}); +test('focus departure cancels immediately even if focus returns before the timer', () => { + const h = harness(); h.key(); h.focus(h.elsewhere); h.focus(h.disclosure); + assert.equal(h.timers.size, 0); h.drain(); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); +}); +test('deliberate focus movement inside the tray cancels pending retries', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); h.focus(h.first); + assert.equal(h.timers.size, 0); h.show(); h.drain(); + assert.equal(h.document.activeElement, h.first); assert.equal(h.calls(), 1); +}); +test('Escape cancels immediately, closes the tray and restores disclosure focus', () => { + const h = harness({ hidden: true }); h.key(); h.tick(); h.escape(); + assert.equal(h.timers.size, 0); assert.equal(h.document.activeElement, h.disclosure); + assert.equal(h.panel.classList.contains('collapsed'), true); h.show(); h.drain(); + assert.equal(h.calls(), 1); +}); +test('the central close seam cancels before both transition and already-closed return', () => { + for (const alreadyClosed of [false, true]) { + const h = harness(); h.key(); + if (alreadyClosed) h.panel.classes.add('collapsed'); + h.manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); + assert.equal(h.timers.size, 0); h.drain(); assert.equal(h.calls(), 0); + } +}); +test('opening the unpinned Location sibling cancels the Map Source request', () => { + const h = harness(); h.key(); h.manager.setPanelCollapsed('location-bar', false); + assert.equal(h.panel.classList.contains('collapsed'), true); + assert.equal(h.timers.size, 0); h.drain(); assert.equal(h.calls(), 0); +}); +test('a pinned Map Source tray retains its own request when the sibling opens', () => { + const h = harness(); h.panel.classes.add('dock-pinned'); h.key(); + h.manager.setPanelCollapsed('location-bar', false); h.tick(); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.document.activeElement, h.active); +}); +test('pointer activation revokes a pending keyboard request without moving focus', () => { + const h = harness(); h.key(); h.emit(h.panel, 'pointerdown'); + assert.equal(h.timers.size, 0); h.drain(); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); +}); +test('close then pointer or programmatic reopen cannot inherit a keyboard request', () => { + for (const reopen of [(h) => h.click(1), (h) => h.manager.setPanelCollapsed('control-panel', false)]) { + const h = harness(); h.key(); const stale = [...h.timers.values()][0].callback; + h.escape(); reopen(h); stale(); h.drain(); + assert.equal(h.panel.classList.contains('collapsed'), false); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); + } +}); +test('a stale callback cannot focus or clear the timer owned by a new keyboard opening', () => { + const h = harness(); h.key(); const stale = [...h.timers.values()][0].callback; + h.escape(); h.key(); const timer = [...h.timers.keys()][0]; stale(); + assert.equal(h.calls(), 0); assert.equal([...h.timers.keys()][0], timer); + h.tick(); assert.equal(h.document.activeElement, h.active); +}); +test('plain pointer opening does not request focus', () => { + const h = harness(); h.click(1); h.drain(); + assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 0); +}); +test('pointer leave preserves established keyboard focus and the open tray', () => { + const h = harness(); h.key(); h.tick(); h.emit(h.panel, 'pointerleave', { pointerType: 'mouse' }); h.drain(); + assert.equal(h.document.activeElement, h.active); + assert.equal(h.panel.classList.contains('collapsed'), false); +}); + + diff --git a/src/ui.js b/src/ui.js index 9b8d22ed7..1598f40e9 100644 --- a/src/ui.js +++ b/src/ui.js @@ -4107,6 +4107,17 @@ export class StyleManager { let closeTimer = null; let lastWheelTime = 0; let disclosureFocusTimer = null; + let focusRequest = 0; + + const cancelMapSourceFocus = () => { + clearTimeout(disclosureFocusTimer); + disclosureFocusTimer = null; + focusRequest += 1; + }; + if (panelId === 'control-panel') { + this._cancelMapSourceFocus?.(); + this._cancelMapSourceFocus = cancelMapSourceFocus; + } const clearOpen = () => { if (!openTimer) return; @@ -4190,13 +4201,15 @@ export class StyleManager { }); panelEl.addEventListener('pointerdown', () => { + cancelMapSourceFocus(); clearOpen(); clearClose(); }); const focusMapSource = () => { if (panelId !== 'control-panel') return false; - const chip = panelEl.querySelector('.map-stack-chip.active, .map-stack-chip'); + const chip = panelEl.querySelector('.map-stack-chip.active') + || panelEl.querySelector('.map-stack-chip'); if (!chip?.focus) return false; chip.focus({ preventScroll: true }); // .focus() on a still-hidden element is a SILENT no-op, so the caller @@ -4213,12 +4226,19 @@ export class StyleManager { // cadence until focus actually lands, bounded so a permanently hidden tray // cannot spin. const scheduleMapSourceFocus = () => { - clearTimeout(disclosureFocusTimer); + cancelMapSourceFocus(); + if (panelId !== 'control-panel') return; + const request = focusRequest; let attempts = 0; const attemptFocus = () => { + if (request !== focusRequest) return; disclosureFocusTimer = null; - if (panelEl.classList.contains('collapsed')) return; + if (this._disposed || panelEl.classList.contains('collapsed')) return; + // A Tab or click elsewhere owns focus now. A delayed transition must + // not pull the keyboard back into a tray the user has already left. + if (document.activeElement !== disclosure) return; if (focusMapSource()) return; + if (request !== focusRequest) return; if (++attempts > 24) return; // ~720ms past the first try, then give up disclosureFocusTimer = window.setTimeout(attemptFocus, 30); }; @@ -4226,6 +4246,7 @@ export class StyleManager { }; const toggleDisclosure = ({ focusSource = false } = {}) => { + cancelMapSourceFocus(); clearOpen(); clearClose(); const shouldOpen = panelEl.classList.contains('collapsed'); @@ -4254,11 +4275,14 @@ export class StyleManager { panelEl.addEventListener('focusin', () => clearClose()); panelEl.addEventListener('focusout', (event) => { + cancelMapSourceFocus(); if (panelEl.contains(event.relatedTarget)) return; scheduleClose(); }); panelEl.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || panelEl.classList.contains('collapsed')) return; + if (event.key !== 'Escape') return; + cancelMapSourceFocus(); + if (panelEl.classList.contains('collapsed')) return; event.preventDefault(); clearOpen(); clearClose(); @@ -7695,6 +7719,7 @@ export class StyleManager { persist = true, syncShare = true, } = {}) { + if (panelId === 'control-panel' && collapsed) this._cancelMapSourceFocus?.(); const panelEl = document.getElementById(panelId); if (!panelEl) return; if (explicit && !restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId);