Files
gods-eye-view/src/ui/panelDisclosure.js
T
Sameh Khamis dd3a2c87f9 feat(panels): render collapse, dock and pin buttons from the store
PanelChrome no longer writes the panels' collapse buttons, the command
dock tray toggles' names or the dock pins' pressed state. It presents each
panel's open and pinned state to the new gev-panel-chrome store, and the
buttons, now views, call it there: PanelCollapseButton for the panel
headers, bound toggles and pins in the command dock. Clicks take effect
once the chrome is initialized; a new owner starts from its own states.

bindPanelDisclosure binds only a panel's Escape and refuses the retired
buttons and onChange arguments. UiButton joins the component kit.
2026-10-31 23:30:08 -07:00

306 lines
10 KiB
JavaScript

/** 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 bubbling Escape to an existing panel. Its collapse buttons are views
* that ask the panel's owner to toggle it (PanelCollapseButton).
*/
export function bindPanelDisclosure({ panel, onEscape, ...retired }) {
if (!panel || typeof onEscape !== 'function') {
throw new TypeError('Panel disclosure requires a panel and onEscape');
}
// Buttons are views now; a caller still passing them would lose its clicks.
if ('buttons' in retired || 'onChange' in retired) {
throw new TypeError(
'Panel disclosure binds Escape only; render the buttons as views',
);
}
const scope = listenerScope();
scope.listen(panel, 'keydown', onEscape);
return { destroy: () => scope.destroy() };
}
/**
* Close the nearest expanded panel and return focus to its disclosure, or to
* `returnFocusTo()` when that names a control (a rail item standing in for a
* panel that collapsing hid).
*/
export function collapsePanelOnEscape(
event,
{ panel, onChange, beforeCollapse, returnFocusTo },
) {
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 stand = returnFocusTo?.();
if (stand) {
stand.focus?.({ preventScroll: true });
return 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();
},
};
}