mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
11 files changed
+497
-234
No files matched your search
@@ -26,6 +26,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
### Fixed
|
### 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,
|
- Reuse cached military aircraft during adsb.lol rate limits and server errors,
|
||||||
honor bounded retry delays, and preserve cached observation times and stale
|
honor bounded retry delays, and preserve cached observation times and stale
|
||||||
indicators. Show installation zoom guidance without a false LOAD FAILED.
|
indicators. Show installation zoom guidance without a false LOAD FAILED.
|
||||||
|
|||||||
@@ -182,3 +182,16 @@ keyless Photon fallback. The application passes this service to location
|
|||||||
controls, annotation resolution and voice/radio actions. Those consumers retain
|
controls, annotation resolution and voice/radio actions. Those consumers retain
|
||||||
framing, landmark recovery, footprint matching and playback decisions. Existing
|
framing, landmark recovery, footprint matching and playback decisions. Existing
|
||||||
reverse geocoding and nearby/text-search routes remain separate.
|
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.
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# God's Eye View Current State
|
# 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
|
## Military feed cooldown and loading guidance
|
||||||
|
|
||||||
The adsb.lol military proxy reuses its last response during upstream 429/5xx
|
The adsb.lol military proxy reuses its last response during upstream 429/5xx
|
||||||
|
|||||||
+2
-1
@@ -118,6 +118,7 @@
|
|||||||
"./server/standalone/key-setup": {
|
"./server/standalone/key-setup": {
|
||||||
"node": "./server/standalone/key-setup.js"
|
"node": "./server/standalone/key-setup.js"
|
||||||
},
|
},
|
||||||
"./search": "./src/search/index.js"
|
"./search": "./src/search/index.js",
|
||||||
|
"./ui/panels": "./src/ui/panelDisclosure.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,5 +119,6 @@
|
|||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
"src/search/google.js",
|
"src/search/google.js",
|
||||||
"src/keylessGeocoder.js",
|
"src/keylessGeocoder.js",
|
||||||
"src/standalone/placeSearch.js"
|
"src/standalone/placeSearch.js",
|
||||||
|
"src/ui/panelDisclosure.js"
|
||||||
]
|
]
|
||||||
@@ -263,5 +263,10 @@
|
|||||||
"src/keylessGeocoder.js"
|
"src/keylessGeocoder.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"panel-disclosure": {
|
||||||
|
"exports": ["./ui/panels"],
|
||||||
|
"modules": ["src/ui/panelDisclosure.js"],
|
||||||
|
"external": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -44,6 +45,10 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
|||||||
if (!listeners.has(type)) listeners.set(type, []);
|
if (!listeners.has(type)) listeners.set(type, []);
|
||||||
listeners.get(type).push(callback);
|
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; },
|
matches(selector) { return selector === ':hover' ? this.hovered : true; },
|
||||||
closest() { return this.isDisclosure ? this : null; },
|
closest() { return this.isDisclosure ? this : null; },
|
||||||
};
|
};
|
||||||
@@ -107,9 +112,12 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
|||||||
const window = {
|
const window = {
|
||||||
setTimeout(callback, delay) { timers.set(++nextTimer, { callback, at: now + delay }); return nextTimer; },
|
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)}});`)(
|
`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 saves = [];
|
||||||
const claims = [];
|
const claims = [];
|
||||||
@@ -385,3 +393,61 @@ test('disposed UI cannot complete a pending handoff', () => {
|
|||||||
assert.equal(h.document.activeElement, h.disclosure);
|
assert.equal(h.document.activeElement, h.disclosure);
|
||||||
assert.equal(h.calls(), 1); assert.equal(h.timers.size, 0);
|
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);
|
||||||
|
});
|
||||||
@@ -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"/,
|
/<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',
|
'the compact wing must expose a semantic keyboard disclosure',
|
||||||
);
|
);
|
||||||
assert.match(ui, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
|
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
|
||||||
assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panelEl\.querySelector\('\.map-stack-chip'\)/);
|
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(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -38,7 +39,7 @@ function panelFixture({ id = 'data-panel', nested = false, nestedCollapsed = fal
|
|||||||
disclosure.closest = () => panel;
|
disclosure.closest = () => panel;
|
||||||
}
|
}
|
||||||
const document = { getElementById: (candidate) => candidate === id ? panel : null };
|
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) => {
|
manager.setPanelCollapsed = (candidate, value, options) => {
|
||||||
assert.equal(candidate, id);
|
assert.equal(candidate, id);
|
||||||
assert.equal(value, true);
|
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', () => {
|
test('panel chrome wires Escape for every declared collapse target', () => {
|
||||||
const init = method('_initPanelChrome', '_collapsePanelOnEscape');
|
const init = method('_initPanelChrome', '_collapsePanelOnEscape');
|
||||||
assert.match(init, /for \(const targetId of targets\)[\s\S]*?addEventListener\('keydown'[\s\S]*?_collapsePanelOnEscape\(event, targetId\)/);
|
assert.match(init, /for \(const \[targetId, buttons\] of targets\)/);
|
||||||
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, /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', () => {
|
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',
|
'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();
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { retroShader } from './styles/retro.js';
|
import { retroShader } from './styles/retro.js';
|
||||||
import { animeShader } from './styles/anime.js';
|
import { animeShader } from './styles/anime.js';
|
||||||
@@ -4009,23 +4010,24 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_initPanelChrome() {
|
_initPanelChrome() {
|
||||||
const targets = new Set();
|
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||||
document.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((btn) => {
|
this._panelDisclosureControls = [];
|
||||||
const targetId = btn.dataset.collapseTarget;
|
const targets = new Map();
|
||||||
if (targetId) targets.add(targetId);
|
document.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((button) => {
|
||||||
btn.addEventListener('click', () => {
|
const targetId = button.dataset.collapseTarget;
|
||||||
const targetId = btn.dataset.collapseTarget;
|
if (!targetId) return;
|
||||||
if (!targetId) return;
|
if (!targets.has(targetId)) targets.set(targetId, []);
|
||||||
const nextCollapsed = !document.getElementById(targetId)?.classList.contains('collapsed');
|
targets.get(targetId).push(button);
|
||||||
this.setPanelCollapsed(targetId, nextCollapsed, { explicit: true });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
for (const [targetId, buttons] of targets) {
|
||||||
for (const targetId of targets) {
|
const panel = document.getElementById(targetId);
|
||||||
const panelEl = document.getElementById(targetId);
|
if (!panel) continue;
|
||||||
panelEl?.addEventListener('keydown', (event) => {
|
this._panelDisclosureControls.push(bindPanelDisclosure({
|
||||||
this._collapsePanelOnEscape(event, targetId);
|
panel,
|
||||||
});
|
buttons,
|
||||||
|
onChange: (collapsed, options) => this.setPanelCollapsed(targetId, collapsed, options),
|
||||||
|
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
|
||||||
|
}));
|
||||||
this._restorePanelCollapsedState(targetId, {
|
this._restorePanelCollapsedState(targetId, {
|
||||||
allowStored: !this._initialShareState,
|
allowStored: !this._initialShareState,
|
||||||
});
|
});
|
||||||
@@ -4052,33 +4054,16 @@ export class StyleManager {
|
|||||||
* @returns {boolean} Whether this panel handled the key.
|
* @returns {boolean} Whether this panel handled the key.
|
||||||
*/
|
*/
|
||||||
_collapsePanelOnEscape(event, panelId) {
|
_collapsePanelOnEscape(event, panelId) {
|
||||||
if (event.key !== 'Escape' || event.defaultPrevented) return false;
|
return collapsePanelOnEscape(event, {
|
||||||
const panelEl = document.getElementById(panelId);
|
panel: document.getElementById(panelId),
|
||||||
if (!panelEl || panelEl.classList.contains('collapsed') || !panelEl.contains(event.target)) {
|
onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options),
|
||||||
return false;
|
beforeCollapse: () => {
|
||||||
}
|
if (panelId !== 'location-bar' || !this._locationSearch) return;
|
||||||
const focusedPanel = event.target?.closest?.(
|
this._locationSearch.classList.remove('expanded');
|
||||||
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
|
this._locationSearch.value = '';
|
||||||
);
|
this._locationSearch.blur();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4220,191 +4205,28 @@ export class StyleManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_initAutoHoverPanel(panelId, { openDelayMs = 850, closeDelayMs = 1000 } = {}) {
|
_initAutoHoverPanel(panelId, { openDelayMs = 850, closeDelayMs = 1000 } = {}) {
|
||||||
const panelEl = document.getElementById(panelId);
|
const panel = document.getElementById(panelId);
|
||||||
if (!panelEl) return;
|
if (!panel) return;
|
||||||
const disclosure = panelEl.querySelector(`[data-dock-toggle-target="${panelId}"]`);
|
this._hoverPanelControls ??= new Map();
|
||||||
let openTimer = null;
|
this._hoverPanelControls.get(panelId)?.destroy();
|
||||||
let closeTimer = null;
|
const controller = createHoverDisclosure({
|
||||||
let lastWheelTime = 0;
|
panel,
|
||||||
let disclosureFocusTimer = null;
|
documentRef: document,
|
||||||
let focusRequest = 0;
|
disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
|
||||||
|
openDelayMs,
|
||||||
const cancelMapSourceFocus = () => {
|
closeDelayMs,
|
||||||
clearTimeout(disclosureFocusTimer);
|
isActive: () => !this._disposed,
|
||||||
disclosureFocusTimer = null;
|
onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options),
|
||||||
focusRequest += 1;
|
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') {
|
if (panelId === 'control-panel') {
|
||||||
this._cancelMapSourceFocus?.();
|
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;
|
this._globalStatusNotice = null;
|
||||||
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
|
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
|
||||||
this._disposed = 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._locationSearchController?.abort();
|
||||||
this._cancelMapSourceFocus?.();
|
this._cancelMapSourceFocus?.();
|
||||||
// Revoke persistence/hash authority before teardown can emit manager changes.
|
// Revoke persistence/hash authority before teardown can emit manager changes.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user