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.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
isTypingTarget,
|
||||
matches,
|
||||
} from "./keymap";
|
||||
import { markEscapeHandled } from "./layerStack";
|
||||
|
||||
/** A keydown as it arrives from a real focus target (jsdom only fills `e.target` on dispatch). */
|
||||
function press(init: KeyboardEventInit, on?: HTMLElement): KeyboardEvent {
|
||||
@@ -143,6 +144,16 @@ describe("dispatch", () => {
|
||||
input.remove();
|
||||
});
|
||||
|
||||
// E28: an Escape a menu above already took is spent — a player or page acting on it too is the
|
||||
// double close the layer stack exists to prevent.
|
||||
it("ignores an Escape a layer already took", () => {
|
||||
const calls: string[] = [];
|
||||
const e = press({ key: "Escape" });
|
||||
markEscapeHandled(e);
|
||||
expect(dispatch(e, "plexPlayer", plexHandlers(calls))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
// A `handledLocally` row is documentation + a shared chord, matched on the focused element by its
|
||||
// own component (Shift+F10 belongs to whichever row has focus). `dispatch` must not claim it, and
|
||||
// the mapped handler type must not demand one — this test is the runtime half of that.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { anyBlockingLayer } from "./layerStack";
|
||||
import { anyBlockingLayer, escapeHandled } from "./layerStack";
|
||||
import { createStore } from "./store";
|
||||
import type { Page } from "./urlState";
|
||||
|
||||
@@ -322,6 +322,9 @@ export function dispatch<S extends KeyScope>(
|
||||
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
|
||||
): boolean {
|
||||
if (sequenceEvents.has(e)) return false;
|
||||
// An Escape a layer already took (a menu that just closed) is spent: a page or a player reacting to
|
||||
// it too is the double close the layer stack exists to prevent.
|
||||
if (e.key === "Escape" && escapeHandled(e)) return false;
|
||||
const live = liveSequence(e);
|
||||
if (live && live.scope !== scope) return false;
|
||||
const quiet = isPageLevelScope(scope) && anyBlockingLayer();
|
||||
|
||||
@@ -85,6 +85,16 @@ 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()`
|
||||
@@ -110,12 +120,9 @@ export function useLayer(
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
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?.();
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user