Merge pull request #385: extract panel disclosure controls

Share panel keyboard and hover lifecycle with explicit listener and timer cleanup.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-12 17:44:42 -07:00
commit d5b09efa74
11 files changed
+497 -234

No files matched your search

+3
View File
@@ -26,6 +26,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Fixed
- Extract panel disclosure and hover/focus controls into a reusable module;
cancel their listeners and pending work during replacement and teardown.
- Reuse cached military aircraft during adsb.lol rate limits and server errors,
honor bounded retry delays, and preserve cached observation times and stale
indicators. Show installation zoom guidance without a false LOAD FAILED.
+13
View File
@@ -182,3 +182,16 @@ keyless Photon fallback. The application passes this service to location
controls, annotation resolution and voice/radio actions. Those consumers retain
framing, landmark recovery, footprint matching and playback decisions. Existing
reverse geocoding and nearby/text-search routes remain separate.
## Panel controls
`gods-eye-view/ui/panels` owns collapse-button binding, nearest-panel Escape
handling, hover delays and delayed content-focus handoff. It accepts existing
DOM elements and callbacks; importing it creates no browser state. `destroy()`
removes owned listeners and cancels pending work without changing saved state
or moving focus. Call it before removing or replacing the controls.
`src/ui.js` retains panel layout, persistence, share restoration and application
reactions to state changes. Map Source selection and Location draft cleanup are
provided through callbacks. The component imports no globe, data, application
or server modules. Package checks and scoped formatting cover this entry.
+10
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State
## Panel disclosure lifecycle
Panel collapse buttons, nested Escape handling and dock hover/focus timing now
use `ui/panels`. The component receives existing DOM elements and callbacks for
state changes and content focus. Panel layout, saved state, share restoration,
Location draft cleanup and Map Source selection remain with their existing
callers. Listener and timer cleanup is synchronous when controls are replaced
or disposed, preventing old hover or focus work from changing a later view.
## Military feed cooldown and loading guidance
The adsb.lol military proxy reuses its last response during upstream 429/5xx
+2 -1
View File
@@ -118,6 +118,7 @@
"./server/standalone/key-setup": {
"node": "./server/standalone/key-setup.js"
},
"./search": "./src/search/index.js"
"./search": "./src/search/index.js",
"./ui/panels": "./src/ui/panelDisclosure.js"
}
}
+2 -1
View File
@@ -119,5 +119,6 @@
"src/search/placeSearch.js",
"src/search/google.js",
"src/keylessGeocoder.js",
"src/standalone/placeSearch.js"
"src/standalone/placeSearch.js",
"src/ui/panelDisclosure.js"
]
+5
View File
@@ -263,5 +263,10 @@
"src/keylessGeocoder.js"
],
"external": []
},
"panel-disclosure": {
"exports": ["./ui/panels"],
"modules": ["src/ui/panelDisclosure.js"],
"external": []
}
}
+68 -2
View File
@@ -1,3 +1,4 @@
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -44,6 +45,10 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(callback);
},
removeEventListener(type, callback) {
const values = listeners.get(type) || [];
listeners.set(type, values.filter((item) => item !== callback));
},
matches(selector) { return selector === ':hover' ? this.hovered : true; },
closest() { return this.isDisclosure ? this : null; },
};
@@ -107,9 +112,12 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
const window = {
setTimeout(callback, delay) { timers.set(++nextTimer, { callback, at: now + delay }); return nextTimer; },
};
const methods = new Function('document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
window.clearTimeout = (id) => timers.delete(id);
window.performance = { now: () => now };
document.defaultView = window;
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
`return ({${source.slice(initStart, initEnd)},\n${source.slice(escapeStart, escapeEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)(
document, window, (id) => timers.delete(id), { now: () => now }, () => {},
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {},
);
const saves = [];
const claims = [];
@@ -385,3 +393,61 @@ test('disposed UI cannot complete a pending handoff', () => {
assert.equal(h.document.activeElement, h.disclosure);
assert.equal(h.calls(), 1); assert.equal(h.timers.size, 0);
});
test('destroying hover controls cancels pending opens and removes their event routes', () => {
const h = harness();
h.panel.hovered = true;
h.emit(h.panel, 'pointerenter', { pointerType: 'mouse' });
assert.ok(h.timers.size > 0);
const control = h.manager._hoverPanelControls.get('control-panel');
control.destroy();
control.destroy();
assert.equal(h.timers.size, 0);
for (const node of [h.panel, h.disclosure]) {
assert.equal([...node.listeners.values()].flat().length, 0);
}
h.key();
h.drain();
assert.equal(h.panel.classes.has('collapsed'), true);
assert.equal(h.calls(), 0);
});
test('destroying an open tray cancels close and focus work without changing saved state', () => {
const h = harness({ hidden: true });
h.key();
h.emit(h.panel, 'pointerleave', { pointerType: 'mouse' });
const callbacks = [...h.timers.values()].map((timer) => timer.callback);
const saves = h.saves.length;
h.manager._hoverPanelControls.get('control-panel').destroy();
assert.equal(h.timers.size, 0);
h.show();
for (const callback of callbacks) callback();
assert.equal(h.panel.classes.has('collapsed'), false);
assert.equal(h.saves.length, saves);
assert.equal(h.calls(), 0, 'even an already queued focus attempt is revoked');
});
test('replacing hover controls removes the previous listeners before rebinding', () => {
const h = harness();
h.manager._initAutoHoverPanel('control-panel');
h.key();
assert.equal(h.panel.classes.has('collapsed'), false, 'one activation toggles only once');
h.tick();
assert.equal(h.calls(), 1);
});
test('teardown during an opening callback cannot enqueue a later focus handoff', () => {
const h = harness({ hidden: true });
const change = h.manager.setPanelCollapsed.bind(h.manager);
h.manager.setPanelCollapsed = (...args) => {
change(...args);
h.manager._hoverPanelControls.get('control-panel').destroy();
};
h.key();
assert.equal(h.timers.size, 0);
h.show();
h.drain();
assert.equal(h.calls(), 0);
});
+3 -2
View File
@@ -343,8 +343,9 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
/<button id="control-panel-toggle"[\s\S]*?data-dock-toggle-target="control-panel"[\s\S]*?aria-controls="control-panel-popover"/,
'the compact wing must expose a semantic keyboard disclosure',
);
assert.match(ui, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panelEl\.querySelector\('\.map-stack-chip'\)/);
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panel\.querySelector\('\.map-stack-chip'\)/);
assert.match(
ui,
+37 -3
View File
@@ -1,3 +1,4 @@
import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
@@ -38,7 +39,7 @@ function panelFixture({ id = 'data-panel', nested = false, nestedCollapsed = fal
disclosure.closest = () => panel;
}
const document = { getElementById: (candidate) => candidate === id ? panel : null };
const manager = new Function('document', `return {${method('_collapsePanelOnEscape', '_initCommandDockPins')}};`)(document);
const manager = new Function('document', 'collapsePanelOnEscape', `return {${method('_collapsePanelOnEscape', '_initCommandDockPins')}};`)(document, collapsePanelOnEscape);
manager.setPanelCollapsed = (candidate, value, options) => {
assert.equal(candidate, id);
assert.equal(value, true);
@@ -128,8 +129,9 @@ test('Location Escape clears a hidden draft search before restoring disclosure f
test('panel chrome wires Escape for every declared collapse target', () => {
const init = method('_initPanelChrome', '_collapsePanelOnEscape');
assert.match(init, /for \(const targetId of targets\)[\s\S]*?addEventListener\('keydown'[\s\S]*?_collapsePanelOnEscape\(event, targetId\)/);
assert.match(source, /_initAutoHoverPanel[\s\S]*?if \(event\.key !== 'Escape'\) return;[\s\S]*?_collapsePanelOnEscape\(event, panelId\)[\s\S]*?clearOpen\(\);[\s\S]*?clearClose\(\);/);
assert.match(init, /for \(const \[targetId, buttons\] of targets\)/);
assert.match(init, /bindPanelDisclosure\(\{[\s\S]*?onEscape: \(event\) => this\._collapsePanelOnEscape\(event, targetId\)/);
assert.match(source, /createHoverDisclosure\(\{[\s\S]*?onEscape: \(event\) => this\._collapsePanelOnEscape\(event, panelId\)/);
});
test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => {
@@ -167,3 +169,35 @@ test('Cockpit utility Escape leaves an expanded nested Parameters panel to the s
'compact Radio disclosure clears its own focus when Escape closes it',
);
});
test('panel bindings have a single owner and are inert after destruction', () => {
const panel = new EventTarget();
const button = new EventTarget();
let collapsed = true;
let changes = 0;
let keys = 0;
panel.classList = { contains: () => collapsed };
const bind = () => bindPanelDisclosure({
panel, buttons: [button, button],
onChange(value, options) { collapsed = value; changes += 1; assert.equal(options.explicit, true); },
onEscape() { keys += 1; },
});
const first = bind();
button.dispatchEvent(new Event('click'));
panel.dispatchEvent(new Event('keydown'));
assert.equal(collapsed, false);
assert.equal(changes, 1);
assert.equal(keys, 1);
first.destroy();
first.destroy();
button.dispatchEvent(new Event('click'));
panel.dispatchEvent(new Event('keydown'));
assert.equal(changes, 1);
assert.equal(keys, 1);
const second = bind();
button.dispatchEvent(new Event('click'));
assert.equal(changes, 2);
assert.equal(collapsed, true);
second.destroy();
});
+51 -225
View File
@@ -1,3 +1,4 @@
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
import * as Cesium from 'cesium';
import { retroShader } from './styles/retro.js';
import { animeShader } from './styles/anime.js';
@@ -4009,23 +4010,24 @@ export class StyleManager {
* @returns {void}
*/
_initPanelChrome() {
const targets = new Set();
document.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((btn) => {
const targetId = btn.dataset.collapseTarget;
if (targetId) targets.add(targetId);
btn.addEventListener('click', () => {
const targetId = btn.dataset.collapseTarget;
if (!targetId) return;
const nextCollapsed = !document.getElementById(targetId)?.classList.contains('collapsed');
this.setPanelCollapsed(targetId, nextCollapsed, { explicit: true });
});
for (const control of this._panelDisclosureControls || []) control.destroy();
this._panelDisclosureControls = [];
const targets = new Map();
document.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((button) => {
const targetId = button.dataset.collapseTarget;
if (!targetId) return;
if (!targets.has(targetId)) targets.set(targetId, []);
targets.get(targetId).push(button);
});
for (const targetId of targets) {
const panelEl = document.getElementById(targetId);
panelEl?.addEventListener('keydown', (event) => {
this._collapsePanelOnEscape(event, targetId);
});
for (const [targetId, buttons] of targets) {
const panel = document.getElementById(targetId);
if (!panel) continue;
this._panelDisclosureControls.push(bindPanelDisclosure({
panel,
buttons,
onChange: (collapsed, options) => this.setPanelCollapsed(targetId, collapsed, options),
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
}));
this._restorePanelCollapsedState(targetId, {
allowStored: !this._initialShareState,
});
@@ -4052,33 +4054,16 @@ export class StyleManager {
* @returns {boolean} Whether this panel handled the key.
*/
_collapsePanelOnEscape(event, panelId) {
if (event.key !== 'Escape' || event.defaultPrevented) return false;
const panelEl = document.getElementById(panelId);
if (!panelEl || panelEl.classList.contains('collapsed') || !panelEl.contains(event.target)) {
return false;
}
const focusedPanel = event.target?.closest?.(
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
);
if (focusedPanel && focusedPanel !== panelEl) return false;
event.preventDefault();
event.stopPropagation();
if (panelId === 'location-bar' && this._locationSearch) {
// The document-level Escape cleanup cannot run after this panel consumes
// the event. Mirror that cleanup here so reopening Location never reveals
// a hidden draft query or expanded search field.
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
this.setPanelCollapsed(panelId, true, { explicit: true });
const disclosure = panelEl.querySelector(`[data-dock-toggle-target="${panelId}"]`)
|| panelEl.querySelector(`[data-collapse-target="${panelId}"]`);
const escapedFromDisclosure = event.target === disclosure
|| disclosure?.contains?.(event.target);
if (escapedFromDisclosure) disclosure?.blur?.();
else disclosure?.focus?.({ preventScroll: true });
return true;
return collapsePanelOnEscape(event, {
panel: document.getElementById(panelId),
onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options),
beforeCollapse: () => {
if (panelId !== 'location-bar' || !this._locationSearch) return;
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
},
});
}
/**
@@ -4220,191 +4205,28 @@ export class StyleManager {
* @returns {void}
*/
_initAutoHoverPanel(panelId, { openDelayMs = 850, closeDelayMs = 1000 } = {}) {
const panelEl = document.getElementById(panelId);
if (!panelEl) return;
const disclosure = panelEl.querySelector(`[data-dock-toggle-target="${panelId}"]`);
let openTimer = null;
let closeTimer = null;
let lastWheelTime = 0;
let disclosureFocusTimer = null;
let focusRequest = 0;
const cancelMapSourceFocus = () => {
clearTimeout(disclosureFocusTimer);
disclosureFocusTimer = null;
focusRequest += 1;
};
const panel = document.getElementById(panelId);
if (!panel) return;
this._hoverPanelControls ??= new Map();
this._hoverPanelControls.get(panelId)?.destroy();
const controller = createHoverDisclosure({
panel,
documentRef: document,
disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
openDelayMs,
closeDelayMs,
isActive: () => !this._disposed,
onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options),
onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
focusTarget: panelId === 'control-panel' ? () => (
panel.querySelector('.map-stack-chip.active') || panel.querySelector('.map-stack-chip')
) : null,
});
this._hoverPanelControls.set(panelId, controller);
if (panelId === 'control-panel') {
this._cancelMapSourceFocus?.();
this._cancelMapSourceFocus = cancelMapSourceFocus;
this._cancelMapSourceFocus = controller.cancelPendingFocus;
}
const clearOpen = () => {
if (!openTimer) return;
clearTimeout(openTimer);
openTimer = null;
};
const clearClose = () => {
if (!closeTimer) return;
clearTimeout(closeTimer);
closeTimer = null;
};
const scheduleOpen = () => {
clearOpen();
openTimer = window.setTimeout(() => {
openTimer = null;
if (!panelEl.matches(':hover')) return;
if (performance.now() - lastWheelTime < 280) return;
if (!panelEl.classList.contains('collapsed')) return;
this.setPanelCollapsed(panelId, false);
}, openDelayMs);
};
// Focus inside the tray defers the unpinned auto-dismiss, but only for the
// KEYBOARD: the disclosure hands focus to a Map Source tile on Enter/Space,
// and closing the tray out from under that focus would strand the caret.
// Plain `document.activeElement` is the wrong test — Chromium focuses a
// <button> on mouse press, so once Map Source moved into this tray a tile
// CLICK left focus parked inside and the popover never dismissed on
// mouse-away (owner field report; Location, whose input is genuinely
// keyboard-focused when clicked, still dismissed). `:focus-visible` is the
// platform's own pointer-vs-keyboard focus signal, so a typed-into field
// still holds the tray open while a clicked tile does not. A browser
// without `:focus-visible` keeps the conservative hold.
const keyboardFocusInside = () => {
const active = document.activeElement;
if (!active || !panelEl.contains(active)) return false;
try { return active.matches(':focus-visible'); } catch { return true; }
};
const scheduleClose = () => {
clearClose();
closeTimer = window.setTimeout(() => {
closeTimer = null;
if (panelEl.matches(':hover') || keyboardFocusInside()) return;
if (panelEl.classList.contains('dock-pinned')) return;
if (panelEl.classList.contains('collapsed')) return;
this.setPanelCollapsed(panelId, true);
}, closeDelayMs);
};
panelEl.addEventListener('wheel', () => {
lastWheelTime = performance.now();
clearOpen();
}, { passive: true });
panelEl.addEventListener('click', (event) => {
if (event.target.closest('.panel-collapse-btn, .dock-tray-toggle')) return;
clearOpen();
clearClose();
if (panelEl.classList.contains('collapsed')) {
this.setPanelCollapsed(panelId, false, { explicit: true });
}
});
panelEl.addEventListener('pointerenter', (event) => {
const pointerType = event.pointerType || 'mouse';
if (pointerType !== 'mouse' && pointerType !== 'pen') return;
clearClose();
if (panelEl.classList.contains('collapsed')) {
scheduleOpen();
}
});
panelEl.addEventListener('pointerleave', (event) => {
const pointerType = event.pointerType || 'mouse';
if (pointerType !== 'mouse' && pointerType !== 'pen') return;
clearOpen();
scheduleClose();
});
panelEl.addEventListener('pointerdown', () => {
cancelMapSourceFocus();
clearOpen();
clearClose();
});
const focusMapSource = () => {
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 = () => {
cancelMapSourceFocus();
if (panelId !== 'control-panel') return;
const request = focusRequest;
let attempts = 0;
const attemptFocus = () => {
if (request !== focusRequest) return;
disclosureFocusTimer = null;
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');
this.setPanelCollapsed(panelId, !shouldOpen, { explicit: true });
if (shouldOpen && focusSource) scheduleMapSourceFocus();
};
disclosure?.addEventListener('click', (event) => {
event.stopPropagation();
// Keep native button activation semantics: Enter activates on keydown,
// Space on keyup, and pointer clicks report a non-zero detail. Scheduling
// focus from the synthesized click avoids a key latch that can outlive the
// disclosure after a long Enter hold moves focus into the tray.
toggleDisclosure({ focusSource: event.detail === 0 });
});
disclosure?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
// Preserve immediate Enter activation while leaving Space to the native
// button path, which emits its synthesized click only after key release.
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
toggleDisclosure({ focusSource: true });
});
panelEl.addEventListener('focusin', () => clearClose());
panelEl.addEventListener('focusout', (event) => {
cancelMapSourceFocus();
if (panelEl.contains(event.relatedTarget)) return;
scheduleClose();
});
panelEl.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (!event.defaultPrevented) this._collapsePanelOnEscape(event, panelId);
cancelMapSourceFocus();
clearOpen();
clearClose();
});
}
/**
@@ -10318,6 +10140,10 @@ export class StyleManager {
this._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._disposed = true;
for (const control of this._panelDisclosureControls || []) control.destroy();
this._panelDisclosureControls = [];
this._hoverPanelControls?.forEach((control) => control.destroy());
this._hoverPanelControls?.clear();
this._locationSearchController?.abort();
this._cancelMapSourceFocus?.();
// Revoke persistence/hash authority before teardown can emit manager changes.
+303
View File
@@ -0,0 +1,303 @@
/** Track listeners so a discarded controller leaves no active DOM callbacks. */
function listenerScope() {
const removers = [];
return {
listen(target, type, callback, options) {
if (!target) return;
target.addEventListener(type, callback, options);
removers.push(() => target.removeEventListener(type, callback, options));
},
destroy() {
for (const remove of removers.splice(0)) remove();
},
};
}
/** Bind collapse buttons and bubbling Escape to an existing panel. */
export function bindPanelDisclosure({
panel,
buttons = [],
onChange,
onEscape,
}) {
if (
!panel ||
typeof onChange !== 'function' ||
typeof onEscape !== 'function'
) {
throw new TypeError(
'Panel disclosure requires a panel, onChange and onEscape',
);
}
const scope = listenerScope();
for (const button of new Set(buttons)) {
scope.listen(button, 'click', () => {
onChange(!panel.classList.contains('collapsed'), { explicit: true });
});
}
scope.listen(panel, 'keydown', onEscape);
return { destroy: () => scope.destroy() };
}
/** Close the nearest expanded panel and return focus to its disclosure. */
export function collapsePanelOnEscape(
event,
{ panel, onChange, beforeCollapse },
) {
if (event.key !== 'Escape' || event.defaultPrevented) return false;
if (
!panel ||
panel.classList.contains('collapsed') ||
!panel.contains(event.target)
)
return false;
const focusedPanel = event.target?.closest?.(
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
);
if (focusedPanel && focusedPanel !== panel) return false;
event.preventDefault();
event.stopPropagation();
beforeCollapse?.();
onChange(true, { explicit: true });
const disclosure =
panel.querySelector(`[data-dock-toggle-target="${panel.id}"]`) ||
panel.querySelector(`[data-collapse-target="${panel.id}"]`);
const escapedFromDisclosure =
event.target === disclosure || disclosure?.contains?.(event.target);
if (escapedFromDisclosure) disclosure?.blur?.();
else disclosure?.focus?.({ preventScroll: true });
return true;
}
/**
* Own hover/focus timing for an existing dock tray. Application callbacks own
* collapsed state and optional content focus; destroying cancels every timer
* and listener without changing saved state or moving focus.
*/
export function createHoverDisclosure({
panel: panelEl,
disclosure = null,
onChange,
onEscape,
focusTarget = null,
isActive = () => true,
openDelayMs = 850,
closeDelayMs = 1000,
documentRef = panelEl?.ownerDocument,
}) {
if (
!panelEl ||
!documentRef ||
typeof onChange !== 'function' ||
typeof onEscape !== 'function'
) {
throw new TypeError(
'Hover disclosure requires a panel, document, onChange and onEscape',
);
}
const document = documentRef;
const window = document.defaultView;
const clearTimeout = window.clearTimeout.bind(window);
const performance = window.performance;
const scope = listenerScope();
const listen = scope.listen;
let destroyed = false;
let openTimer = null;
let closeTimer = null;
let lastWheelTime = 0;
let disclosureFocusTimer = null;
let focusRequest = 0;
const cancelPendingFocus = () => {
clearTimeout(disclosureFocusTimer);
disclosureFocusTimer = null;
focusRequest += 1;
};
const clearOpen = () => {
if (!openTimer) return;
clearTimeout(openTimer);
openTimer = null;
};
const clearClose = () => {
if (!closeTimer) return;
clearTimeout(closeTimer);
closeTimer = null;
};
const scheduleOpen = () => {
clearOpen();
openTimer = window.setTimeout(() => {
openTimer = null;
if (destroyed || !isActive()) return;
if (!panelEl.matches(':hover')) return;
if (performance.now() - lastWheelTime < 280) return;
if (!panelEl.classList.contains('collapsed')) return;
onChange(false);
}, openDelayMs);
};
// Focus inside the tray defers the unpinned auto-dismiss, but only for the
// KEYBOARD: the disclosure hands focus to a Map Source tile on Enter/Space,
// and closing the tray out from under that focus would strand the caret.
// Plain `document.activeElement` is the wrong test — Chromium focuses a
// <button> on mouse press, so once Map Source moved into this tray a tile
// CLICK left focus parked inside and the popover never dismissed on
// mouse-away (owner field report; Location, whose input is genuinely
// keyboard-focused when clicked, still dismissed). `:focus-visible` is the
// platform's own pointer-vs-keyboard focus signal, so a typed-into field
// still holds the tray open while a clicked tile does not. A browser
// without `:focus-visible` keeps the conservative hold.
const keyboardFocusInside = () => {
const active = document.activeElement;
if (!active || !panelEl.contains(active)) return false;
try {
return active.matches(':focus-visible');
} catch {
return true;
}
};
const scheduleClose = () => {
clearClose();
closeTimer = window.setTimeout(() => {
closeTimer = null;
if (destroyed || !isActive()) return;
if (panelEl.matches(':hover') || keyboardFocusInside()) return;
if (panelEl.classList.contains('dock-pinned')) return;
if (panelEl.classList.contains('collapsed')) return;
onChange(true);
}, closeDelayMs);
};
listen(
panelEl,
'wheel',
() => {
lastWheelTime = performance.now();
clearOpen();
},
{ passive: true },
);
listen(panelEl, 'click', (event) => {
if (event.target.closest('.panel-collapse-btn, .dock-tray-toggle')) return;
clearOpen();
clearClose();
if (panelEl.classList.contains('collapsed')) {
onChange(false, { explicit: true });
}
});
listen(panelEl, 'pointerenter', (event) => {
const pointerType = event.pointerType || 'mouse';
if (pointerType !== 'mouse' && pointerType !== 'pen') return;
clearClose();
if (panelEl.classList.contains('collapsed')) {
scheduleOpen();
}
});
listen(panelEl, 'pointerleave', (event) => {
const pointerType = event.pointerType || 'mouse';
if (pointerType !== 'mouse' && pointerType !== 'pen') return;
clearOpen();
scheduleClose();
});
listen(panelEl, 'pointerdown', () => {
cancelPendingFocus();
clearOpen();
clearClose();
});
const focusContent = () => {
const target = focusTarget?.();
if (destroyed || !isActive() || !target?.focus) return false;
target.focus({ preventScroll: true });
return document.activeElement === target;
};
// 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 scheduleContentFocus = () => {
cancelPendingFocus();
if (destroyed || !isActive() || !focusTarget) return;
const request = focusRequest;
let attempts = 0;
const attemptFocus = () => {
if (request !== focusRequest) return;
disclosureFocusTimer = null;
if (destroyed || !isActive() || 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 (focusContent()) 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 = ({ focusContent = false } = {}) => {
if (destroyed || !isActive()) return;
cancelPendingFocus();
clearOpen();
clearClose();
const shouldOpen = panelEl.classList.contains('collapsed');
onChange(!shouldOpen, { explicit: true });
if (shouldOpen && focusContent) scheduleContentFocus();
};
listen(disclosure, 'click', (event) => {
event.stopPropagation();
// Keep native button activation semantics: Enter activates on keydown,
// Space on keyup, and pointer clicks report a non-zero detail. Scheduling
// focus from the synthesized click avoids a key latch that can outlive the
// disclosure after a long Enter hold moves focus into the tray.
toggleDisclosure({ focusContent: event.detail === 0 });
});
listen(disclosure, 'keydown', (event) => {
if (event.key !== 'Enter') return;
// Preserve immediate Enter activation while leaving Space to the native
// button path, which emits its synthesized click only after key release.
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
toggleDisclosure({ focusContent: true });
});
listen(panelEl, 'focusin', () => clearClose());
listen(panelEl, 'focusout', (event) => {
cancelPendingFocus();
if (panelEl.contains(event.relatedTarget)) return;
scheduleClose();
});
listen(panelEl, 'keydown', (event) => {
if (event.key !== 'Escape') return;
if (!event.defaultPrevented) onEscape(event);
cancelPendingFocus();
clearOpen();
clearClose();
});
return {
cancelPendingFocus,
destroy() {
if (destroyed) return;
destroyed = true;
clearOpen();
clearClose();
cancelPendingFocus();
scope.destroy();
},
};
}