Files
siftlode/frontend/src/lib/useHoverFocusWithin.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

54 lines
1.8 KiB
TypeScript

// @vitest-environment jsdom
//
// The focus half of the side panels' peek-open model. What is pinned: a dialog opened from inside
// the panel (rendered at the app root, so outside it in both the DOM and the React tree) does not
// collapse the panel — collapsing unmounted the row the dialog hands the focus back to, stranding a
// keyboard user on <body> after Cancel (measured in Chrome, E11 S2).
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useHoverFocusWithin } from "./useHoverFocusWithin";
afterEach(cleanup);
function Panel() {
const peek = useHoverFocusWithin();
return (
<>
<div {...peek.focusHandlers} data-testid="panel">
<button>row</button>
{peek.active && <span>expanded</span>}
</div>
<div role="dialog" aria-modal="true">
<button>cancel</button>
</div>
<button>elsewhere</button>
</>
);
}
const moveFocus = (from: HTMLElement, to: HTMLElement) =>
act(() => {
fireEvent.blur(from, { relatedTarget: to });
to.focus();
});
describe("useHoverFocusWithin · focus", () => {
it("stays open while the focus is in a dialog opened over the page", () => {
render(<Panel />);
const row = screen.getByText("row");
act(() => row.focus());
expect(screen.queryByText("expanded")).not.toBeNull();
moveFocus(row, screen.getByText("cancel"));
expect(screen.queryByText("expanded")).not.toBeNull();
});
it("still closes when the focus leaves for the page itself", () => {
render(<Panel />);
const row = screen.getByText("row");
act(() => row.focus());
moveFocus(row, screen.getByText("elsewhere"));
expect(screen.queryByText("expanded")).toBeNull();
});
});