Files
siftlode/frontend/src/components/ui/dialogFocus.dom.test.tsx
T
peter b4aea7fa5b fix(ui): dialogs, menus and busy buttons keep the keyboard focus where it belongs
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.
2026-09-13 01:04:11 +02:00

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();
}
});
});