refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The wizard is a modal overlay in every way that matters to a user, but it renders its own shell
|
||||
// instead of going through `ui/Modal` — and for a keyboard or screen-reader user it had nothing of
|
||||
// the dialog contract: no role, no label, no initial focus, no trap, no restore. Tab past its last
|
||||
// control walked into the app behind the scrim, where the focus ring is invisible.
|
||||
//
|
||||
// `focusablesIn` filters on `el.offsetParent !== null` and jsdom reports null for everything, so the
|
||||
// trap is untestable as written without the same stub `ui/Modal.dom.test.tsx` uses.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.fn() }));
|
||||
|
||||
import OnboardingWizard from "./OnboardingWizard";
|
||||
import { api, type Me } from "../../lib/api";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
|
||||
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
|
||||
|
||||
let origDesc: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "myStatus").mockResolvedValue({ channels_total: 0 } as never);
|
||||
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.isConnected ? document.body : null;
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>).offsetParent;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mount = () =>
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<OnboardingWizard me={me} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("the onboarding wizard as a dialog", () => {
|
||||
it("announces itself as a labelled modal dialog", () => {
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
expect(panel.getAttribute("aria-modal")).toBe("true");
|
||||
// The label is the step's own heading — the wizard has no header row to take a title from.
|
||||
const heading = document.getElementById(panel.getAttribute("aria-labelledby")!);
|
||||
expect(heading?.textContent).toBe("onboarding.read.title");
|
||||
});
|
||||
|
||||
it("opens focused inside itself and keeps Tab from leaving", () => {
|
||||
const outside = document.createElement("button");
|
||||
outside.textContent = "behind the scrim";
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
|
||||
const { unmount } = mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
expect(panel.contains(document.activeElement)).toBe(true);
|
||||
|
||||
// From the last control, Tab wraps back to the first instead of reaching the page behind.
|
||||
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
|
||||
stops[stops.length - 1]!.focus();
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(stops[0]);
|
||||
|
||||
// …and closing hands the focus back to whatever opened it.
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it("holds a Shift+Tab that starts on the panel itself", () => {
|
||||
// `Node.contains(self)` is true, so focus sitting ON the tabIndex=-1 panel — where a click on
|
||||
// its dead space puts it — matched no branch of `trapTab` and the browser's native Shift+Tab
|
||||
// ran, into the app behind the scrim. That is what the third argument exists for, and this
|
||||
// caller was written without it on the very commit that extracted the helper.
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
panel.focus();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
|
||||
fireEvent.keyDown(window, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(stops[stops.length - 1]);
|
||||
});
|
||||
|
||||
it("still hands the focus back after it visited the wizard's own toast", () => {
|
||||
// Ownership has to be measured over the same scope the Tab trap uses. The wizard's toast host
|
||||
// sits beside the panel and INSIDE the dialog, so tabbing to a toast's ✕ is legal — but read
|
||||
// against the panel alone it said "the user left", and since removing the toast fires no
|
||||
// focusin to re-arm, closing afterwards stranded the focus on <body>. Unlike
|
||||
// `ui/Modal`, whose root carries the aria-modal attribute, nothing else can rescue this one.
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
const { unmount } = mount();
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
expect(document.activeElement).toBe(dismissToast);
|
||||
act(() => clearAll()); // the toast goes away under the focus — no focusin follows
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it("keeps its own toast's dismiss button inside the Tab cycle", () => {
|
||||
// The wizard renders its own `<Toaster overlay>` beside the panel, and the connection notice it
|
||||
// can raise never auto-dismisses — so a trap scoped to the panel alone left that ✕ permanently
|
||||
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
expect(panel.contains(dismissToast)).toBe(false);
|
||||
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
|
||||
stops[stops.length - 1]!.focus();
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
// No longer the edge: Tab carries on (natively) towards the toast instead of wrapping.
|
||||
expect(document.activeElement).toBe(stops[stops.length - 1]);
|
||||
dismissToast.focus();
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(stops[0]);
|
||||
} finally {
|
||||
act(() => clearAll());
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user