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.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render
|
||||
// the row as a NEW button; without a refocus the keyboard user was dropped on <body> and lost their
|
||||
// place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is
|
||||
// not the rename path is stubbed.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
});
|
||||
vi.mock("./PlaylistsProvider", () => ({
|
||||
usePlaylists: () => ({ search: "", selectedId: 1 }),
|
||||
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
|
||||
vi.mock("./comfortPin", () => ({
|
||||
useComfortPin: () => ({ item: () => [] }),
|
||||
pinPlaylist: (id: number) => id,
|
||||
}));
|
||||
const rename = vi.fn(async () => {});
|
||||
vi.mock("./usePlaylistActions", () => ({
|
||||
usePlaylistActions: () => ({ rename, remove: vi.fn() }),
|
||||
}));
|
||||
// The SidePanel/PanelGroups chrome is not what is under test: render the rail's bodies directly.
|
||||
vi.mock("./FilterRail", () => ({
|
||||
default: ({ items }: { items: { id: string; body: ReactNode }[] }) => (
|
||||
<>
|
||||
{items.map((i) => (
|
||||
<div key={i.id}>{i.body}</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import PlaylistsRail from "./PlaylistsRail";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
rename.mockClear();
|
||||
});
|
||||
|
||||
const alpha = {
|
||||
id: 1,
|
||||
name: "alpha",
|
||||
kind: "custom",
|
||||
source: "local",
|
||||
item_count: 3,
|
||||
total_duration_seconds: 0,
|
||||
cover_thumbnail: null,
|
||||
dirty: false,
|
||||
} as unknown as Playlist;
|
||||
|
||||
async function openRename() {
|
||||
vi.spyOn(api, "playlists").mockResolvedValue([alpha]);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistsRail
|
||||
layout={{} as never}
|
||||
setLayout={() => {}}
|
||||
collapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const row = (await screen.findByText("alpha")).closest("button")!;
|
||||
act(() => row.focus());
|
||||
fireEvent.keyDown(row, { key: "F10", shiftKey: true });
|
||||
fireEvent.click(await screen.findByText("playlists.rename"));
|
||||
// The menu runs its action a task after it closes (see ui/ActionMenu).
|
||||
const field = await screen.findByRole("textbox", { name: "playlists.rename" });
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(field));
|
||||
return field as HTMLInputElement;
|
||||
}
|
||||
|
||||
const rowButton = () => screen.getByText("alpha").closest("button");
|
||||
|
||||
describe("PlaylistsRail · rename from the keyboard", () => {
|
||||
it("Escape cancels and puts the focus back on the row", async () => {
|
||||
const field = await openRename();
|
||||
fireEvent.change(field, { target: { value: "alpha2" } });
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
|
||||
expect(rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Enter commits and puts the focus back on the row", async () => {
|
||||
const field = await openRename();
|
||||
fireEvent.change(field, { target: { value: "beta" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(rename).toHaveBeenCalledWith(1, "beta");
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user