fix(a11y): touch/keyboard reveal tail + single-Escape layer coordination (UX-7/UX-8)
UX-8: one Escape must close only the topmost layer, never both an open popover AND the player beneath it. The popover's Escape listener is on `document` and fires before the player's on `window`; closing the popover flushes its layer off the shared stack synchronously, so the player's `anyLayerAbove()` lookup read "nothing above" and closed too. Add a consumed-event flag to layerStack (`markEscapeHandled`/`escapeHandled`): the acting topmost layer stamps the shared KeyboardEvent, and both players check the stamp before the (now-mutated) stack. Immune to listener order and mid-dispatch stack mutation. Cover with layerStack.dom.test.ts. UX-7: the E4 `.reveal` sweep already covered the card action rows; two residual hover-only traps remained — invisible-but-tappable/Tab-focusable on touch. Add a group-agnostic `.reveal-hover` recipe (display:none under `(hover: none)`) paired with each site's own hover/focus-within reveal: - VideoCard play/Continue/Restart overlay: also reveal on group-focus-within (keyboard), and drop it on touch — the card tap already plays/resumes, so no permanent scrim over every mobile thumbnail. - Carousel prev/next arrows: gone on touch, where the strip swipes.
This commit is contained in:
@@ -38,6 +38,23 @@ export function anyLayerAbove(id: number): boolean {
|
||||
return i !== -1 && i < stack.length - 1;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** 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()`
|
||||
@@ -57,7 +74,12 @@ export function useLayer(
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) escRef.current?.();
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
// Stamp BEFORE closing so a player's `window` listener (fires after this `document` one)
|
||||
// sees the Escape as consumed even though closing us pops this layer off the stack.
|
||||
markEscapeHandled(e);
|
||||
escRef.current?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user