diff --git a/CHANGELOG.md b/CHANGELOG.md index b84c249a1..e2c9df856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0e3f20ad4..495c843e4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index d93a5b053..7dbe1b570 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -172,9 +172,8 @@ try { // 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') - && [...document.querySelectorAll('.map-stack-chip')] - .filter((chip) => chip.getAttribute('aria-pressed') === 'true') - .every((chip) => chip.dataset.stackId === 'osm'); + && 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)); } @@ -230,16 +229,17 @@ try { 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 waitTray('true', 'photoreal'); + 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), ); @@ -256,14 +256,14 @@ try { ); await page.keyboard.press('Space'); - await waitTray('true', 'photoreal'); + 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), ); @@ -271,21 +271,68 @@ try { await page.keyboard.down('Enter'); await new Promise((resolve) => setTimeout(resolve, 320)); await page.keyboard.up('Enter'); - await waitTray('true', 'photoreal'); // let the hold's scheduled focus hand-off land + 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 waitTray('true', 'photoreal'); + 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; @@ -617,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(() => ({ diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index 4e4c56b64..7d19a3f0f 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -230,3 +230,10 @@ test('pointer leave preserves established keyboard focus and the open tray', () }); + +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); +}); diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index 4472bd447..7f425fd12 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -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, diff --git a/src/ui.js b/src/ui.js index 1598f40e9..29f3cfa57 100644 --- a/src/ui.js +++ b/src/ui.js @@ -10167,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;