Measured in Chrome before each change: - A dialog opened from a menu lost its focus: the menu row ran its action and closed in one batch, and floating-ui's focus return (a microtask) moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after it opened, where Enter activated the row. MenuItemList now closes first and runs the action a task later; the dialog also captures the row, not a dying menu item, as its restore target. - A side panel opened as a flyout collapsed the moment a dialog opened from inside it, unmounting the row the dialog hands the focus back to. useHoverFocusWithin holds while the focus is inside an aria-modal dialog. - ui/Button's loading state set native disabled; Chrome moved the focus of the pressed button to <body> and never gave it back. Loading is now aria-disabled with a click guard that also cancels implicit submission. - Escape/Enter in the playlist rail's rename field dropped the focus on <body>. The row refocuses from its ref callback when it re-attaches (a post-commit focus sticks; the earlier attempt ran before the button existed). - PlayerModal was the one aria-modal shell without the dialog contract: it is now named by its title, traps Tab, restores the focus to its opener and shares one counted body scroll lock with every dialog.
67 lines
2.4 KiB
TypeScript
67 lines
2.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The parts of the shared dialog contract that are not already pinned through ui/Modal: the ONE
|
|
// counted page scroll lock, and `focusScope` (the player opens focused on its card, not a button).
|
|
import { useRef } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, render } from "@testing-library/react";
|
|
import { useDialogChrome } from "./dialogFocus";
|
|
|
|
afterEach(cleanup);
|
|
|
|
function Dialog({ focusScope = false }: { focusScope?: boolean }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useDialogChrome(ref, undefined, { focusScope });
|
|
return (
|
|
<div ref={ref} tabIndex={-1} data-testid="scope">
|
|
<button>first</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("useDialogChrome · the page scroll lock", () => {
|
|
// Each dialog used to save and restore `overflow` itself, so the one mounted SECOND but unmounted
|
|
// LAST put back the "hidden" it had saved — a page left locked with no dialog open.
|
|
it("unlocks only when the last dialog closes, in whatever order they close", () => {
|
|
document.body.style.overflow = "";
|
|
const player = render(<Dialog />);
|
|
const modal = render(<Dialog />);
|
|
expect(document.body.style.overflow).toBe("hidden");
|
|
|
|
player.unmount(); // the one underneath goes first
|
|
expect(document.body.style.overflow).toBe("hidden"); // the other is still open
|
|
|
|
modal.unmount();
|
|
expect(document.body.style.overflow).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("useDialogChrome · initial focus", () => {
|
|
it("lands on the first control by default", () => {
|
|
// jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable.
|
|
const spy = vi
|
|
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
|
.mockReturnValue(document.body);
|
|
try {
|
|
const { getByText } = render(<Dialog />);
|
|
expect(document.activeElement).toBe(getByText("first"));
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("lands on the scope itself with focusScope", () => {
|
|
// Same layout stub as above: without it jsdom sees no focusable child, so the scope would get
|
|
// the focus with or without the option and this would pin nothing.
|
|
const spy = vi
|
|
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
|
.mockReturnValue(document.body);
|
|
try {
|
|
const { getByTestId } = render(<Dialog focusScope />);
|
|
expect(document.activeElement).toBe(getByTestId("scope"));
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
});
|