test: the side panel's focus step is held in its commit; the MenuCheck contract counts chosen roles
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
// hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel
|
// hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel
|
||||||
// asks from three switches, and can flip them live (a tablet turning).
|
// asks from three switches, and can flip them live (a tablet turning).
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -106,7 +106,7 @@ describe("SidePanel · the tab under an open card", () => {
|
|||||||
expect(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }).tabIndex).toBe(0);
|
expect(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }).tabIndex).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hands a key's focus on the tab to the card it peeks open, not to the hidden tab", async () => {
|
it("hands a key's focus on the tab to the card it peeks open, not to the hidden tab", () => {
|
||||||
stubMedia({ coarse: false, narrow: false, wide: true });
|
stubMedia({ coarse: false, narrow: false, wide: true });
|
||||||
globalThis.ResizeObserver ??= class {
|
globalThis.ResizeObserver ??= class {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -115,10 +115,14 @@ describe("SidePanel · the tab under an open card", () => {
|
|||||||
} as unknown as typeof ResizeObserver;
|
} as unknown as typeof ResizeObserver;
|
||||||
mount(true);
|
mount(true);
|
||||||
const tab = screen.getByRole("button", { name: "sidebar.pinNamed:Filters" });
|
const tab = screen.getByRole("button", { name: "sidebar.pinNamed:Filters" });
|
||||||
|
// A page the browser is not painting runs no animation frame: the step must not wait for one.
|
||||||
|
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0);
|
||||||
act(() => tab.focus()); // jsdom calls every focus :focus-visible — a key's focus, here
|
act(() => tab.focus()); // jsdom calls every focus :focus-visible — a key's focus, here
|
||||||
await waitFor(() => expect(document.activeElement?.closest("aside")).not.toBeNull());
|
// in the commit that opens the card, not a frame later
|
||||||
|
expect(document.activeElement?.closest("aside")).not.toBeNull();
|
||||||
expect(document.activeElement?.getAttribute("aria-label")).toBe("sidebar.pinNamed:Filters");
|
expect(document.activeElement?.getAttribute("aria-label")).toBe("sidebar.pinNamed:Filters");
|
||||||
expect(document.activeElement).not.toBe(tab);
|
expect(document.activeElement).not.toBe(tab);
|
||||||
|
raf.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ const sources = () =>
|
|||||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
||||||
.map((f) => ({ f, src: readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8") }));
|
.map((f) => ({ f, src: readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8") }));
|
||||||
|
|
||||||
|
/** Checkable menu rows a file renders: a literal `role="menuitemradio"`, and a role CHOSEN by an
|
||||||
|
* expression that can come out checkable — FilterSelect's and ActionMenu's rows, every filter select
|
||||||
|
* and checkable action menu in the app, which a literal-only count skipped (round 3). */
|
||||||
|
function checkableRoles(src: string): number {
|
||||||
|
const literal = (src.match(/role="menuitem(?:radio|checkbox)"/g) ?? []).length;
|
||||||
|
const chosen = [...src.matchAll(/role=\{([^{}]*)\}/g)].filter(([, e]) =>
|
||||||
|
/"menuitem(?:radio|checkbox)"/.test(e!),
|
||||||
|
).length;
|
||||||
|
return literal + chosen;
|
||||||
|
}
|
||||||
|
|
||||||
// E28 review L2: `ui/MenuCheck` is the ONE drawn check box of a checkable row (S3e3b replaced seven
|
// E28 review L2: `ui/MenuCheck` is the ONE drawn check box of a checkable row (S3e3b replaced seven
|
||||||
// hand-drawn copies), yet FilterSelect and Combobox still painted their own — a 14px box beside the
|
// hand-drawn copies), yet FilterSelect and Combobox still painted their own — a 14px box beside the
|
||||||
// menus' 16px one. The filled-box paint is the tell: it may live in MenuCheck only.
|
// menus' 16px one. The filled-box paint is the tell: it may live in MenuCheck only.
|
||||||
@@ -25,7 +36,7 @@ describe("the drawn check box", () => {
|
|||||||
it("marks every checkable menu row, with no tick of its own beside it", () => {
|
it("marks every checkable menu row, with no tick of its own beside it", () => {
|
||||||
const offenders = sources()
|
const offenders = sources()
|
||||||
.filter(({ src }) => {
|
.filter(({ src }) => {
|
||||||
const checkable = (src.match(/role="menuitem(?:radio|checkbox)"/g) ?? []).length;
|
const checkable = checkableRoles(src);
|
||||||
const drawn = (src.match(/<MenuCheck\b/g) ?? []).length;
|
const drawn = (src.match(/<MenuCheck\b/g) ?? []).length;
|
||||||
return checkable > drawn || (checkable > 0 && /<Check\b/.test(src));
|
return checkable > drawn || (checkable > 0 && /<Check\b/.test(src));
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user