Files
siftlode/frontend/src/lib/layerStack.ts
T
peter b4a6569d0d fix(design): the saved-view chip on one line, one Escape per layer, the /watch action row (E28 S3d)
- SelectTrigger lays its toolbar value out as a row; the saved-view chip passes its glyph and
  modified dot through the icon/trailing slots (an <svg> is display:block and broke the chip in two).
- One Escape in a dialog's open list closed the list AND the dialog: the list's close flushes
  synchronously and pops its layer before the dialog's window listener runs. takeEscape() is the one
  close-on-Escape test (topmost AND not yet taken); Modal, the Welcome lightbox and useLayer use it,
  and keymap.dispatch ignores a taken Escape.
- /watch: the Download sits on the toolbar rung, level with the Repeat chip.
2026-09-25 01:40:45 +02:00

143 lines
7.2 KiB
TypeScript

import { useEffect, useRef } from "react";
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
// its own `stopPropagation`: Modal's private `modalStack`, the popovers' document listeners, and the
// two players' `modalCount()` checks. Now EVERY layer keeps its own key handler but coordinates
// through this one stack — a layer acts on Escape only when it is topmost, and reads `anyAbove()` to
// know whether to defer. Because the decision is a stack lookup, not event-propagation order, the fix
// is robust to which listener happens to run first (the old stopPropagation was not).
//
// Notably this fixes the documented PlexPlayer bug: its menus register here, so the player sees a menu
// as a layer ABOVE it and defers, letting the menu's own Escape close it first — the player's
// auto-skip-cancel Escape then lands on the next press, instead of being swallowed by a blanket
// stopPropagation. (Popover-vs-popover ordering is still floating-ui's <FloatingTree>; this stack adds
// the cross-TYPE ordering the tree can't see.)
let stack: number[] = [];
let nextId = 1;
// Layers that take Escape but do not own the keyboard: an open tooltip or hover card. They sit on the
// stack for Escape ordering like any other layer, but `anyBlockingLayer` looks past them — otherwise
// a tooltip that happens to be open under a resting mouse pointer would silence the page's `j`/`k`.
const passive = new Set<number>();
// Layers that DO own the keyboard but leave the Tab trap to the dialog below: a small inline prompt
// inside one (the recorder's step field). It must silence the page's keys — an autofocused field
// does — yet it is not a dialog of its own, so claiming the trap left the dialog around it with
// none, and Tab from anything outside the prompt walked into the page behind the scrim. The two
// questions are separate; a layer answers each for itself (deep-review round 3).
const defers = new Set<number>();
export function pushLayer(opts?: { passive?: boolean; trapsTab?: boolean }): number {
const id = nextId++;
stack.push(id);
if (opts?.passive) passive.add(id);
if (opts?.trapsTab === false) defers.add(id);
return id;
}
export function removeLayer(id: number): void {
stack = stack.filter((x) => x !== id);
passive.delete(id);
defers.delete(id);
}
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
* page-level keys (`s`, the `g` chords, list navigation) stay quiet then: they act on the page
* underneath, which the user cannot see or reach while such a layer is up. */
export function anyBlockingLayer(): boolean {
return stack.some((id) => !passive.has(id));
}
export function isTopmost(id: number): boolean {
return stack.length > 0 && stack[stack.length - 1] === id;
}
/** True when at least one layer sits above `id` — the "should I defer?" predicate for a layer with a
* richer key handler than a plain close (the players). */
export function anyLayerAbove(id: number): boolean {
const i = stack.indexOf(id);
return i !== -1 && i < stack.length - 1;
}
/** True when a layer that TRAPS THE TAB sits above `id` — the predicate for a dialog's Tab trap. A
* tooltip open under a resting pointer (passive) and an inline prompt inside the dialog itself
* (`trapsTab: false`) both take Escape first but neither takes the Tab: deferring the trap to one
* of those left Shift+Tab walking out of the dialog into the page behind its scrim. */
export function anyBlockingLayerAbove(id: number): boolean {
const i = stack.indexOf(id);
return i !== -1 && stack.slice(i + 1).some((x) => !passive.has(x) && !defers.has(x));
}
// UX-8 — a layer that ACTS on an Escape stamps the event here; a lower layer with its own key handler
// (the players, listening on `window`) reads the stamp instead of re-reading the stack. This closes
// the "one Escape closes BOTH the popover and the player" bug: the popover's Escape listener is on
// `document` and fires before the player's on `window` (bubble order), and closing the popover flushes
// its layer off the stack synchronously — so by the time the player's `anyLayerAbove()` runs, the stack
// already reads "nothing above" and the player ALSO closes. Tagging the one shared KeyboardEvent object
// (every listener in a single dispatch is handed the same instance) is immune to that mid-dispatch
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
// WeakSet so a spent event is GC'd without a manual sweep.
const handledEscapes = new WeakSet<KeyboardEvent>();
export function markEscapeHandled(e: KeyboardEvent): void {
handledEscapes.add(e);
}
export function escapeHandled(e: KeyboardEvent): boolean {
return handledEscapes.has(e);
}
/** THE close-on-Escape test for layer `id`: an Escape no layer above has taken yet, while `id` is on
* top — and it claims the event for `id`. Both halves are needed: a list inside a dialog closes with a
* synchronous flush that pops its layer before the dialog's `window` listener runs, so topmost alone
* read "yes" and one Escape closed the list AND the dialog (E28, the share dialog's user picker). */
export function takeEscape(e: KeyboardEvent, id: number): boolean {
if (e.key !== "Escape" || escapeHandled(e) || !isTopmost(id)) return false;
markEscapeHandled(e);
return true;
}
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
* to decide whether to defer (the players). */
export function useLayer(
active: boolean,
onEscape?: () => void,
opts?: { passive?: boolean; trapsTab?: boolean },
): { isTopmost: () => boolean; anyAbove: () => boolean; blockingAbove: () => boolean } {
const idRef = useRef<number | null>(null);
const escRef = useRef(onEscape);
escRef.current = onEscape;
const optsRef = useRef(opts);
optsRef.current = opts;
useEffect(() => {
if (!active) return;
const id = pushLayer({
passive: !!optsRef.current?.passive,
trapsTab: optsRef.current?.trapsTab,
});
idRef.current = id;
let onKey: ((e: KeyboardEvent) => void) | undefined;
if (escRef.current) {
onKey = (e: KeyboardEvent) => {
// takeEscape stamps BEFORE closing, so a `window` listener below (fires after this `document`
// one) sees the Escape as consumed even though closing us pops this layer off the stack.
if (takeEscape(e, id)) escRef.current?.();
};
document.addEventListener("keydown", onKey);
}
return () => {
if (onKey) document.removeEventListener("keydown", onKey);
removeLayer(id);
idRef.current = null;
};
// Re-register only when open/closed toggles; onEscape is read fresh via escRef.
}, [active]);
return {
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
anyAbove: () => idRef.current != null && anyLayerAbove(idRef.current),
blockingAbove: () => idRef.current != null && anyBlockingLayerAbove(idRef.current),
};
}