fix(diagnostics): deep-review round 4 — a claim only its owner may write, and a player that keeps the Tab
Round 4 was targeted at the surfaces rounds 2 and 3 changed. 29 findings raised, 26 survived deduplication, 12 survived three adversarial readings each. `set_delivery` renewed the claim but never checked it was still ours, while its sibling `release_send` always did. `expire_on_commit` is off, so the token it re-stamped came from the caller's own memory, not the row: a send that outlived its 600 s lease came back, overwrote the take-over send's committed links with a full-column write, and put its own dead token back. It now takes the token, reads the row's claim with a targeted scalar (not a refresh, which would discard the `issue_url` assigned one line earlier) and raises `SendClaimLost` rather than writing. An issue created by a send that had already lost its claim is logged with its number — that record is the only one that will exist. The tracker's settings are read above the pre-call checkpoint instead of inside `_client`. Reading them there was a SELECT one line after the commit that was meant to end the transaction, and held it — idle, snapshot pinned — across the repository check and the create. The comment and the module docstring said otherwise; measuring it in pg_stat_activity is what settled it. Two more comments were saying things the code had stopped doing: the `**state` spread does not carry the claim (`set_delivery` strips and re-stamps it), and react-query does not debounce the preview — the editor being unmounted is what paces it. The composer stopped stranding the focus on `<body>`: a send that creates the issue and then fails on an attachment unmounts the button that was just pressed, and only the success path handed the focus on. Export no longer saves into a refusal it does not need — the zip is the way out of a stuck send, and its pre-save was the one step that could not survive one. The delivery banner announces through a role instead of an `aria-live` created with its own message. `historyState` returns a union, so `chipTone` matching on the same keys is checked by tsc rather than by hope. PlexPlayer was the only full-screen overlay in the tree with no focus containment at all — Tab walked out of an opaque player into a page nobody can see. It now carries the shared trap. Closing that hole exposed the one underneath it: `useDismiss` registered as a layer that traps the Tab while installing no trap, so the player stood its own down for its own menus. Measured in a real browser: 30 Tabs with the settings panel open, 9 escapes into the nav rail, and 0 after. Escape ordering is unchanged — the panel first, then the player. Three tests that did not hold their line: the advisory lock's call site (deleting it left the suite green), the trail assertion whose `all()` was vacuous over an empty filter, and a `layerStack` assertion the stand-in dialog already forced. All eleven executable fixes mutation-checked red for the right reason; the two comment corrections have nothing to pin.
This commit is contained in:
@@ -326,10 +326,20 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
);
|
||||
}
|
||||
|
||||
/** The five words a past session can read as. A union, not `string`, because `chipTone` below
|
||||
* matches on these same keys: untyped, a rename in one of them left the other matching nothing
|
||||
* and every chip untoned, with the gate green (deep-review round 4). */
|
||||
type HistoryState =
|
||||
| "recorder.history.sending"
|
||||
| "recorder.history.draft"
|
||||
| "recorder.history.unwritten"
|
||||
| "recorder.history.partial"
|
||||
| "recorder.history.sent";
|
||||
|
||||
/** What a past session reads as. An issue that still owes the tracker its attachments, or an edit
|
||||
* that shows its screenshots, is NOT "Sent": this list is where a user goes back to check, and a
|
||||
* green chip there is how a half-delivered report gets forgotten. */
|
||||
function historyState(s: DebugSession): string {
|
||||
function historyState(s: DebugSession): HistoryState {
|
||||
// A send under way outranks every other word here: this is the list a user comes back to in
|
||||
// order to check on one, and both "draft" (no issue yet) and "half sent" (the steps it has
|
||||
// already committed) would describe a send that STOPPED.
|
||||
@@ -340,11 +350,20 @@ function historyState(s: DebugSession): string {
|
||||
: "recorder.history.sent";
|
||||
}
|
||||
|
||||
/** The colour that reinforces that word. Exhaustive over `HistoryState`, so a key renamed in
|
||||
* `historyState` is a compile error here rather than a chip that quietly loses its tone. */
|
||||
const chipTone = (s: DebugSession): string => {
|
||||
const state = historyState(s);
|
||||
if (state === "recorder.history.sent") return "text-success";
|
||||
if (state === "recorder.history.sending") return "text-accent";
|
||||
return state === "recorder.history.partial" ? "text-warn" : "";
|
||||
switch (historyState(s)) {
|
||||
case "recorder.history.sent":
|
||||
return "text-success";
|
||||
case "recorder.history.sending":
|
||||
return "text-accent";
|
||||
case "recorder.history.partial":
|
||||
return "text-warn";
|
||||
case "recorder.history.draft":
|
||||
case "recorder.history.unwritten":
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
|
||||
|
||||
@@ -35,6 +35,8 @@ const api = vi.hoisted(() => ({
|
||||
// What happened, in order: a save is "save" when asked and "saved" when it answered.
|
||||
order: [] as string[],
|
||||
holdSend: null as Promise<void> | null,
|
||||
// The server's half-failure: the issue is committed, then an upload dies and the call 422s.
|
||||
failSendAfterCreate: false,
|
||||
}));
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
@@ -76,8 +78,13 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
api.session = {
|
||||
...api.session,
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: [],
|
||||
pending_files: api.failSendAfterCreate ? ["server.log"] : [],
|
||||
// What the server does on the way through: a send that got there shows the images too,
|
||||
// so the banner (and the Retry in it) goes. Leaving this true made the retry's own
|
||||
// banner outlive its success, which is not a state the server can produce.
|
||||
images_pending: false,
|
||||
};
|
||||
if (api.failSendAfterCreate) throw new Error("issues_unreachable");
|
||||
return { ok: true as const, session: api.session };
|
||||
},
|
||||
previewDebugIssue: (_id: string, text: string) => {
|
||||
@@ -138,6 +145,7 @@ beforeEach(() => {
|
||||
api.failShots = false;
|
||||
api.order = [];
|
||||
api.holdSend = null;
|
||||
api.failSendAfterCreate = false;
|
||||
api.sent = 0;
|
||||
api.previewed = [];
|
||||
tracker = null;
|
||||
@@ -326,6 +334,69 @@ describe("the issue composer", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("announces the half-sent banner instead of only drawing it", async () => {
|
||||
// A screen-reader user hears the failure toast — "couldn't send the report" — and nothing else.
|
||||
// WHICH attachments are missing, and that a Retry now stands beside them, live only in this
|
||||
// banner, which is inserted already carrying its first message. A live region created together
|
||||
// with its content is unreliably announced (the rule ui/Toaster.tsx states and follows), so the
|
||||
// announcement has to come from a ROLE, which announces on insertion.
|
||||
api.session = baseSession({
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["server.log"],
|
||||
});
|
||||
mount();
|
||||
const said = await screen.findByText(/recorder\.composer\.partial/);
|
||||
const announced = said.closest('[role="status"]');
|
||||
expect(announced).not.toBeNull();
|
||||
// ...and the Retry is inside what gets read out, not left behind silently beside it.
|
||||
expect(announced!.querySelector("button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hands the focus on when a send half-fails, not only when it works", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// The issue is created and an upload then dies, so the call FAILS with the link already
|
||||
// committed. The refetch brings the link back, `sent` flips, and "Create issue" — the control
|
||||
// holding the focus — unmounts under it. Without a hand-off the focus lands on <body> in the
|
||||
// middle of an open dialog: a screen reader loses its place and the Retry that just appeared
|
||||
// is several Tabs away, past the dialog's own ✕.
|
||||
api.failSendAfterCreate = true;
|
||||
mount();
|
||||
const send = (await ready("recorder.composer.send")) as HTMLButtonElement;
|
||||
send.focus();
|
||||
expect(document.activeElement).toBe(send);
|
||||
fireEvent.click(send);
|
||||
await waitFor(() => expect(api.sent).toBe(1));
|
||||
const retry = await screen.findByRole("button", { name: "recorder.composer.retry" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(retry));
|
||||
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
|
||||
});
|
||||
|
||||
it("still builds the zip while a send holds the draft, instead of saving into a refusal", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
// The zip is the only way out of a send that is stuck — a claim left by a dead request stands
|
||||
// for ten minutes. Its pre-save is pointless here (the draft is frozen, and the server would
|
||||
// 409 `issue_sending` and take the export down with it), and unnecessary: `GET /export` has no
|
||||
// send guard and builds from the saved draft, which is exactly what the send is uploading.
|
||||
api.session = baseSession({ sending: true });
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
|
||||
click.mockImplementation(() => void api.order.push("click"));
|
||||
const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL };
|
||||
URL.createObjectURL = () => "blob:zip";
|
||||
URL.revokeObjectURL = () => {};
|
||||
try {
|
||||
mount();
|
||||
expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull();
|
||||
fireEvent.click(await ready("recorder.composer.export"));
|
||||
await waitFor(() => expect(click).toHaveBeenCalled());
|
||||
expect(api.order).toEqual(["export", "click"]);
|
||||
expect(api.saved).toHaveLength(0);
|
||||
} finally {
|
||||
click.mockRestore();
|
||||
URL.createObjectURL = real.make;
|
||||
URL.revokeObjectURL = real.revoke;
|
||||
}
|
||||
});
|
||||
|
||||
it("asks to show the images, not to send what is already there", async () => {
|
||||
tracker = { repo: "o/r" };
|
||||
api.session = baseSession({
|
||||
|
||||
@@ -121,6 +121,7 @@ function ComposerBody({
|
||||
);
|
||||
const [reviewing, setReviewing] = useState<Source | null>(null);
|
||||
const issueLink = useRef<HTMLAnchorElement>(null);
|
||||
const retryButton = useRef<HTMLButtonElement>(null);
|
||||
// The log whose review just closed: its Review button takes the focus back.
|
||||
const reviewed = useRef<Source | null>(null);
|
||||
const sent = session.issue_url !== null;
|
||||
@@ -205,14 +206,17 @@ function ComposerBody({
|
||||
onSuccess: (r) => {
|
||||
qc.setQueryData(qk.debugSession(session.id), r.session);
|
||||
notify({ level: "success", message: t("recorder.composer.sentOk") });
|
||||
// The Retry that was just pressed lives in a banner that this answer unmounts. Hand the focus
|
||||
// to the link that takes its place, the way every other control-that-goes here does.
|
||||
window.setTimeout(() => issueLink.current?.focus(), 0);
|
||||
},
|
||||
// A failure half-way still created the issue: show what the server now has.
|
||||
onSettled: () => {
|
||||
onSettled: async () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
|
||||
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
|
||||
await qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
|
||||
// Whichever way it went, the button that was just pressed is gone: a success unmounts the
|
||||
// Retry's banner, and a half-failure unmounts Create issue (the refetch above brings back the
|
||||
// issue_url it created before it died). Either leaves the focus on <body> inside an open
|
||||
// dialog, so hand it on — to whatever now stands where the pressed control was. AFTER the
|
||||
// refetch, not in onSuccess/onError: before it lands, neither target is rendered yet.
|
||||
window.setTimeout(() => (retryButton.current ?? issueLink.current)?.focus(), 0);
|
||||
},
|
||||
});
|
||||
const locked = sent || send.isPending || sending;
|
||||
@@ -258,10 +262,14 @@ function ComposerBody({
|
||||
}, [locked]);
|
||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
||||
|
||||
// The export is built from the SAVED draft: save what is on screen first.
|
||||
// The export is built from the SAVED draft: save what is on screen first — except when the
|
||||
// draft is already frozen (`locked`), where the save is both pointless and refused. While a send
|
||||
// holds the claim the server 409s `issue_sending`, and that refusal would take the zip with it:
|
||||
// `GET /export` has no send guard and would have served exactly what the send is uploading, so
|
||||
// the one tracker-free way out of a stuck send used to fail at the last step (round 4).
|
||||
const exportZip = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sent) await save.mutateAsync();
|
||||
if (!locked) await save.mutateAsync();
|
||||
return api.debugExport(session.id);
|
||||
},
|
||||
meta: { failTitle: "recorder.composer.exportFailed" },
|
||||
@@ -297,7 +305,11 @@ function ComposerBody({
|
||||
className={`flex flex-wrap items-center justify-between gap-2 rounded-xl border px-3 py-2 text-sm ${
|
||||
sending ? "border-accent/40 bg-accent/10" : "border-warn/40 bg-warn/10"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
// `role="status"` rather than `aria-live`: this box is mounted TOGETHER with its first
|
||||
// message (a half-failed send inserts it already populated), and a live region created
|
||||
// with its content is unreliably announced — the rule ui/Toaster.tsx states and follows.
|
||||
// A role announces on insertion, and its implied atomic re-read is right for one line.
|
||||
role="status"
|
||||
>
|
||||
<span>
|
||||
{sending
|
||||
@@ -309,6 +321,7 @@ function ComposerBody({
|
||||
{/* Nothing to retry while it is still going — the button would only be refused. */}
|
||||
{!sending && (
|
||||
<Button
|
||||
ref={retryButton}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={send.isPending}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player paints an opaque, full-viewport layer over the whole app — and until deep-review
|
||||
// round 4 it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
|
||||
// from its menus, or from the debug recorder's pill (which docks in THIS player's toast host) walked
|
||||
// straight out into the page behind it, where the focus ring is invisible and the next Enter fires
|
||||
// something the user cannot see. `ui/Modal`, `PlayerModal`, `OnboardingWizard` and `Welcome` all trap;
|
||||
// the round-3 comment that handed the step prompt's trap to "the dialog below" assumed this one did
|
||||
// too. Its own file because `layerStack` is module state and these tests push into it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
for (const [name, value] of Object.entries({
|
||||
play: () => Promise.resolve(),
|
||||
pause: () => {},
|
||||
load: () => {},
|
||||
}))
|
||||
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
const behind: HTMLButtonElement[] = [];
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
while (behind.length) behind.pop()!.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "1",
|
||||
title: "Title 1",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
async function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/media/1",
|
||||
start_s: 0,
|
||||
} as Awaited<ReturnType<typeof api.plexSession>>);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="1" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
|
||||
);
|
||||
}
|
||||
|
||||
/** A control on the page the player is painted over — the thing focus used to escape onto. */
|
||||
function pageBehind() {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = "on the page behind";
|
||||
document.body.insertBefore(b, document.body.firstChild);
|
||||
behind.push(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
const player = () => document.querySelector<HTMLElement>(".fixed.z-overlay")!;
|
||||
|
||||
/** jsdom has no layout, so the trap's visibility filter (`offsetParent`) would see nothing tabbable
|
||||
* anywhere. Give every element one for the duration of a check — the house pattern (PlayerModal,
|
||||
* OnboardingWizard, ui/dialogFocus all do this). */
|
||||
function withLayout<T>(fn: () => T): T {
|
||||
const spy = vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("PlexPlayer · Tab stays inside the player", () => {
|
||||
it("pulls the focus back in when it is on the page behind", async () => {
|
||||
await mount();
|
||||
const outside = pageBehind();
|
||||
outside.focus();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
withLayout(() => press("Tab"));
|
||||
// Not left on a control under an opaque overlay: the trap takes it into the player.
|
||||
expect(document.activeElement).not.toBe(outside);
|
||||
expect(player().contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps from the last of its own controls back to the first", async () => {
|
||||
await mount();
|
||||
const items = [
|
||||
...player().querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
];
|
||||
expect(items.length).toBeGreaterThan(1);
|
||||
const first = items[0]!;
|
||||
const last = items[items.length - 1]!;
|
||||
last.focus();
|
||||
withLayout(() => press("Tab"));
|
||||
expect(document.activeElement).toBe(first);
|
||||
// ...and backwards, which is the direction that used to walk straight out behind the player.
|
||||
first.focus();
|
||||
withLayout(() => press("Tab", true));
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it("keeps trapping while one of its OWN menus is open", async () => {
|
||||
await mount();
|
||||
// The settings and track menus are `useDismiss` layers. That hook owns Escape and the
|
||||
// outside-press and installs no Tab trap at all — but it used to register as a layer that
|
||||
// DOES trap, so the player stood its own trap down for it and Tab walked out past the last
|
||||
// control into the nav rail. Measured in a real browser: 30 Tabs, 9 escapes. The menus render
|
||||
// inside the player, so the player's trap is the one that must stay on.
|
||||
const gear = [...player().querySelectorAll<HTMLElement>("button")].find(
|
||||
(b) => b.getAttribute("aria-label") === "plex.player.settings",
|
||||
);
|
||||
expect(gear).toBeDefined();
|
||||
act(() => gear!.click());
|
||||
const items = [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
|
||||
// The menu really opened — the trap now has to span its controls too.
|
||||
expect(items.length).toBeGreaterThan(12);
|
||||
const last = items[items.length - 1]!;
|
||||
last.focus();
|
||||
withLayout(() => press("Tab"));
|
||||
expect(player().contains(document.activeElement)).toBe(true);
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
});
|
||||
|
||||
it("leaves the Tab to a dialog opened above it, which traps for itself", async () => {
|
||||
await mount();
|
||||
const outside = pageBehind();
|
||||
outside.focus();
|
||||
const above = pushLayer(); // a Modal over the player — the download dialog, say
|
||||
try {
|
||||
withLayout(() => press("Tab"));
|
||||
// The player does not reach past it: the dialog above owns the keyboard and its own trap.
|
||||
expect(document.activeElement).toBe(outside);
|
||||
} finally {
|
||||
removeLayer(above);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,14 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import { trapTab } from "./ui/dialogFocus";
|
||||
import {
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
anyLayerAbove,
|
||||
anyBlockingLayerAbove,
|
||||
escapeHandled,
|
||||
} from "../lib/layerStack";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -896,6 +903,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||||
if (!isTypingTarget(e)) wake();
|
||||
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
|
||||
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
|
||||
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
|
||||
// PlayerModal, OnboardingWizard, Welcome); this one never did, and deep-review round 4 caught
|
||||
// it when the recorder's step prompt — which docks in this player's toast host — handed its
|
||||
// own trap to "the dialog below" and found none. Only while nothing that owns the keyboard is
|
||||
// layered above: a Modal opened over the player traps for itself. `wake()` above has already
|
||||
// revealed the control band, so the trap cannot leave focus on a faded-out button.
|
||||
if (e.key === "Tab") {
|
||||
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||||
if (wrapRef.current) trapTab(e, wrapRef.current);
|
||||
return;
|
||||
}
|
||||
dispatch(e, "plexPlayer", {
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||||
|
||||
@@ -90,8 +90,11 @@ describe("owning the keyboard and owning the Tab trap", () => {
|
||||
const prompt = pushLayer({ trapsTab: false });
|
||||
opened.push(prompt);
|
||||
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(false); // the dialog keeps trapping Tab...
|
||||
// ...and the page's own keys are quiet by the PROMPT's doing, not the stand-in dialog's: the
|
||||
// dialog is non-passive too, so asserting this with it still on the stack asserted nothing.
|
||||
removeLayer(dialog);
|
||||
expect(anyBlockingLayer()).toBe(true); // `j`, `g h` and the rest stay off the page behind
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(false); // ...and the dialog keeps trapping Tab
|
||||
});
|
||||
|
||||
it("a real dialog above does take the trap from the one below", () => {
|
||||
|
||||
@@ -100,7 +100,10 @@ export const qk = {
|
||||
debugSession: (id: string) => ["debug-sessions", id] as const,
|
||||
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
|
||||
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
|
||||
// The tracker's rendering of a body: the text is the key (typing pauses, then it renders again).
|
||||
// The tracker's rendering of a body: the text is the key. Nothing debounces it — react-query has
|
||||
// no such thing — so one fetch per distinct text. What holds the count down is that the composer
|
||||
// unmounts the editor while the preview is up, plus the cache for an unchanged draft; a preview
|
||||
// shown BESIDE the fields would need a real debounce first (see routes/debug_sessions.py).
|
||||
debugSessionPreview: (id: string, text: string) =>
|
||||
["debug-sessions", id, "preview", text] as const,
|
||||
adminDownloads: () => ["admin-downloads"] as const,
|
||||
|
||||
@@ -15,8 +15,14 @@ export function useDismiss(
|
||||
onClose: () => void,
|
||||
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
|
||||
): void {
|
||||
// Registration + topmost-only Escape.
|
||||
useLayer(active, onClose);
|
||||
// Registration + topmost-only Escape. `trapsTab: false`, because this hook installs NO Tab trap:
|
||||
// it owns Escape and the outside-press, nothing more. Claiming the trap made a dialog underneath
|
||||
// stand its own down (`anyBlockingLayerAbove`) for a layer that traps nothing, so Tab walked out
|
||||
// of the dialog while one of its own menus was open — measured in a real browser on the Plex
|
||||
// player, whose settings and track menus are both dismissables (deep-review round 4). Every
|
||||
// consumer renders its panel INSIDE the surface below it, so that surface's trap already spans
|
||||
// the panel; a portaled one would need its own trap and must not use this default.
|
||||
useLayer(active, onClose, { trapsTab: false });
|
||||
|
||||
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
|
||||
// that lands outside every registered element.
|
||||
|
||||
Reference in New Issue
Block a user