refactor(ui): extract panel disclosure lifecycle controls

This commit is contained in:
Sameh Khamis committed 2026-09-12 17:38:34 -07:00
1 parent 29653cb3d8
commit f35468040e
11 files changed
+497 -234

No files matched your search

+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.