Merge pull request #171: preserve Map Source keyboard focus

Preserve the selected map source through delayed keyboard opening and cancel interrupted focus requests.

Retain Tom-Neverwinter's original focus repair and Manjunath's separately authored selection/cancellation refinements, with maintainer integration and verification commits.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-10 22:16:41 -07:00
commit a458d27067
6 files changed
+414 -22

No files matched your search

+4
View File
@@ -15,6 +15,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Fixed
- Map Source keyboard opening retries focus until the selected tile is visible.
Leaving the disclosure, pointer interaction, or closing the tray cancels the
pending handoff so delayed work cannot pull focus back.
- Scope, Bloom, Sharpen, location search and generated style sliders expose
explicit accessible names. The first-run checkbox retains its native label.
- FIRMS records a source as successful only after appending its rows, avoiding
+7
View File
@@ -1,5 +1,12 @@
# God's Eye View Current State
## Map Source keyboard focus
Keyboard opening focuses the selected map-source tile, falling back to the first
only when no source is selected. A bounded retry handles delayed tray visibility.
Moving focus away, pointer interaction, closing the tray, or disposing the UI
cancels pending work; a later reopening cannot inherit an old focus request.
## September 8, 2026
Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering.
+112 -14
View File
@@ -29,7 +29,15 @@ fs.mkdirSync(shotsDir, { recursive: true });
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
executablePath,
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
// Metal is a macOS-only ANGLE backend: passing it on Windows or Linux makes
// WebGL initialization fail outright, so Cesium never constructs and this
// harness dies at the boot wait before a single assertion runs.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
});
const page = await browser.newPage();
const failures = [];
@@ -158,9 +166,17 @@ try {
await new Promise((resolve) => setTimeout(resolve, 25));
}
// The controller commits `activeId` before its fallback promise callback
// emits the terminal error state that re-syncs the chips. Give that
// callback one turn so the DOM assertion observes the completed contract.
await new Promise((resolve) => setTimeout(resolve, 50));
// emits the terminal error state that re-syncs the chips, and Cesium drops
// the Esri credit on a later render frame again. A fixed delay races both:
// 50ms was enough most runs and not enough on a slow one, which is the same
// flake as the tray timers above (#54). Poll the observable truth instead.
const domDeadline = performance.now() + 3000;
const settled = () => !document.body.innerText.includes('Powered by Esri')
&& JSON.stringify([...document.querySelectorAll('.map-stack-chip[aria-pressed="true"]')]
.map((chip) => chip.dataset.stackId)) === JSON.stringify(['osm']);
while (!settled() && performance.now() < domDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const afterTwo = {
activeId: controller.getActiveId(),
lastError: controller.getState().lastError,
@@ -190,21 +206,45 @@ try {
JSON.stringify(esriTileFailureFallback),
);
// The tray's state changes are timer-driven: opening schedules the focus
// hand-off to a Map Source tile 240ms later (scheduleMapSourceFocus in
// ui.js). A fixed sleep races that timer rather than observing it settle —
// 300ms left only 60ms of margin, which is what made these checks flake on
// a clean checkout (#54). Wait on controller truth instead, the way the
// Bing-switch check further down already does.
// The wait only settles state; the check() below it is still the assertion,
// so a swallowed timeout surfaces as that check failing with real values
// rather than as an opaque puppeteer error.
const waitTray = (wantExpanded, wantFocus) => page.waitForFunction(
(expanded, focus) => {
const toggle = document.getElementById('control-panel-toggle');
if (toggle?.getAttribute('aria-expanded') !== expanded) return false;
if (!focus) return true;
const active = document.activeElement;
return focus === 'toggle'
? active?.id === 'control-panel-toggle'
: active?.dataset?.stackId === focus;
},
{ timeout: 2000 },
wantExpanded, wantFocus,
).catch(() => {});
const keyboardSource = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId());
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 300));
await waitTray('true', keyboardSource);
const keyboardOpen = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
}));
check(
'Enter opens the tray and hands focus to a Map Source tile',
keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === 'photoreal',
'Enter opens the tray and hands focus to the selected Map Source tile',
keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === keyboardSource,
JSON.stringify(keyboardOpen),
);
await page.keyboard.press('Escape');
await new Promise((resolve) => setTimeout(resolve, 40));
await waitTray('false', 'toggle');
const keyboardClose = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeId: document.activeElement?.id || null,
@@ -216,14 +256,14 @@ try {
);
await page.keyboard.press('Space');
await new Promise((resolve) => setTimeout(resolve, 300));
await waitTray('true', keyboardSource);
const spaceOpen = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
}));
check(
'Space opens the tray through the same keyboard path',
spaceOpen.expanded === 'true' && spaceOpen.activeStack === 'photoreal',
spaceOpen.expanded === 'true' && spaceOpen.activeStack === keyboardSource,
JSON.stringify(spaceOpen),
);
@@ -231,19 +271,68 @@ try {
await page.keyboard.down('Enter');
await new Promise((resolve) => setTimeout(resolve, 320));
await page.keyboard.up('Enter');
await waitTray('true', keyboardSource); // let the hold's scheduled focus hand-off land
await page.keyboard.press('Escape');
await waitTray('false', 'toggle');
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 300));
await waitTray('true', keyboardSource);
const longHoldRecovery = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
}));
check(
'long Enter hold cannot strand the disclosure keyboard path',
longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === 'photoreal',
longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === keyboardSource,
JSON.stringify(longHoldRecovery),
);
// Force a delayed visible state while using the real controller and keyboard routes.
const hideTray = () => page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('control-panel', true);
window.__qaTrayStyles = [...document.querySelectorAll('.map-stack-chip')]
.map((chip) => [chip, chip.style.cssText]);
for (const [chip] of window.__qaTrayStyles) chip.style.setProperty('visibility', 'hidden', 'important');
document.getElementById('control-panel-toggle').focus();
});
const showTray = () => page.evaluate(() => {
for (const [chip, cssText] of window.__qaTrayStyles) chip.style.cssText = cssText;
delete window.__qaTrayStyles;
});
await hideTray();
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 350));
const delayedBefore = await page.evaluate(() => document.activeElement?.id);
await showTray();
await waitTray('true', keyboardSource);
const delayedAfter = await page.evaluate(() => document.activeElement?.dataset?.stackId);
check('a delayed visible tray receives selected-source focus after the first attempt',
delayedBefore === 'control-panel-toggle' && delayedAfter === keyboardSource,
JSON.stringify({ delayedBefore, delayedAfter, keyboardSource }));
await hideTray();
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 350));
await page.keyboard.press('Tab');
const departure = await page.evaluate(() => {
window.__qaDepartedFocus = document.activeElement;
return document.activeElement !== document.getElementById('control-panel-toggle');
});
await showTray();
await new Promise((resolve) => setTimeout(resolve, 1000));
const focusRetained = await page.evaluate(() => {
const same = document.activeElement === window.__qaDepartedFocus;
delete window.__qaDepartedFocus;
return same;
});
check('Tab away during the opening transition revokes delayed focus', departure && focusRetained,
JSON.stringify({ departure, focusRetained }));
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', true));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
if (forceKeyless) {
await page.evaluate(async () => {
const styleManager = window.__godsEyeView.styleManager;
@@ -575,8 +664,17 @@ try {
await new Promise((resolve) => setTimeout(resolve, 200));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter'); // opens and hands focus to the active tile
await new Promise((resolve) => setTimeout(resolve, 400));
await page.keyboard.press('Tab'); // tab ONTO a tile, keyboard modality
const selectedForHold = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId());
await waitTray('true', selectedForHold);
// Keyless starts on OSM, the last tile. Tab forward there correctly leaves
// the tray, so navigate to a neighbouring tile in the available direction.
const activeIsLastTile = await page.evaluate(() => {
const chips = [...document.querySelectorAll('#map-stack-chips .map-stack-chip')];
return document.activeElement === chips.at(-1);
});
if (activeIsLastTile) await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
if (activeIsLastTile) await page.keyboard.up('Shift');
await page.keyboard.press('Enter'); // activate it from the keyboard
await new Promise((resolve) => setTimeout(resolve, 200));
const keyboardAfterActivate = await page.evaluate(() => ({
+239
View File
@@ -0,0 +1,239 @@
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);
});
test('disposed UI cannot complete a pending handoff', () => {
const h = harness({ hidden: true }); h.key(); h.tick();
h.manager._disposed = true; h.show(); h.drain();
assert.equal(h.document.activeElement, h.disclosure);
assert.equal(h.calls(), 1); assert.equal(h.timers.size, 0);
});
+1 -1
View File
@@ -344,7 +344,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
'the compact wing must expose a semantic keyboard disclosure',
);
assert.match(ui, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
assert.match(ui, /map-stack-chip\.active, \.map-stack-chip/);
assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panelEl\.querySelector\('\.map-stack-chip'\)/);
assert.match(
ui,
+51 -7
View File
@@ -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,24 +4201,52 @@ export class StyleManager {
});
panelEl.addEventListener('pointerdown', () => {
cancelMapSourceFocus();
clearOpen();
clearClose();
});
const focusMapSource = () => {
if (panelId !== 'control-panel') return;
panelEl.querySelector('.map-stack-chip.active, .map-stack-chip')?.focus?.({ preventScroll: true });
if (panelId !== 'control-panel') return false;
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
// has to check whether focus actually landed rather than assume it did.
return document.activeElement === chip;
};
// The tray opens behind a 180ms `visibility` transition (.dock-popover-content
// in style.css), and a chip inside it cannot take focus until that lands.
// A single fixed delay therefore races the transition: when the machine is
// slow enough that the fade has not finished by the time the timer fires,
// focus() silently does nothing and the keyboard user is stranded on the
// disclosure with an open tray they cannot reach (#54). Retry on a short
// cadence until focus actually lands, bounded so a permanently hidden tray
// cannot spin.
const scheduleMapSourceFocus = () => {
clearTimeout(disclosureFocusTimer);
disclosureFocusTimer = window.setTimeout(() => {
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')) focusMapSource();
}, 240);
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);
};
disclosureFocusTimer = window.setTimeout(attemptFocus, 240);
};
const toggleDisclosure = ({ focusSource = false } = {}) => {
cancelMapSourceFocus();
clearOpen();
clearClose();
const shouldOpen = panelEl.classList.contains('collapsed');
@@ -4236,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();
@@ -7677,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);
@@ -10124,6 +10167,7 @@ export class StyleManager {
this._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._disposed = true;
this._cancelMapSourceFocus?.();
// Revoke persistence/hash authority before teardown can emit manager changes.
this._layerStateCoordinator?.destroy();
this._layerStateCoordinator = null;