import { resolve } from "node:path";
import { fireEvent, render } from "@testing-library/preact";
import { describe, expect, it, vi } from "vitest";
import { TabBar } from "./TabBar.js";
// The POSITIONING is this primitive's real contract -- fixed to the viewport,
// in 14.8's easy band, clearing the safe-area inset -- and jsdom cannot lay
// out, so none of it is evaluable here. Those claims live in
// scripts/layout-check.mjs in real Chromium. See docs/testing-tiers.md: an
// assertion placed in a tier that cannot evaluate it does not fail, it passes.
//
// What this file covers is the contract a consumer programs against, and one
// claim that matters more than the rest: the current destination's VISUAL
// state and its ANNOUNCED state are the same attribute, so they cannot drift.
const ITEMS = [
{ id: "discover", label: "Discover", href: "/discover" },
{ id: "calendar", label: "Calendar", href: "/calendar" },
{ id: "people", label: "People", href: "/people" },
];
describe("TabBar", () => {
it("is a navigation landmark with an accessible name", () => {
// Unnamed, it is one of several nav landmarks a screen reader user has to
// tell apart by guessing.
const { container } = render();
const nav = container.querySelector("nav");
expect(nav?.classList.contains("strand-tabbar")).toBe(true);
expect(nav?.getAttribute("aria-label")).toBe("Primary");
});
it("takes a specific landmark name when a page has more than one nav", () => {
const { container } = render();
expect(container.querySelector("nav")?.getAttribute("aria-label")).toBe("Sections");
});
it("renders one item per destination", () => {
const { container } = render();
expect(container.querySelectorAll(".strand-tabbar__item")).toHaveLength(3);
expect(container.textContent).toContain("Discover");
expect(container.textContent).toContain("People");
});
// The load-bearing one. aria-current is the styling hook AND the announced
// state, so there is no way to paint the current item without also telling
// a screen reader which it is.
it("marks the current destination with aria-current, which is also what styles it", () => {
const { container } = render();
const marked = container.querySelectorAll('[aria-current="page"]');
expect(marked).toHaveLength(1);
expect(marked[0].textContent).toContain("Calendar");
});
it("marks nothing when the current id matches no destination", () => {
// A stale route must leave the bar unmarked rather than guessing, because
// a wrong "you are here" is worse than none.
const { container } = render();
expect(container.querySelectorAll('[aria-current="page"]')).toHaveLength(0);
});
// A destination with a URL stays a link so middle-click, open-in-new-tab and
// the browser's status preview all keep working. Rendering every item as a
// button would silently take those away.
it("renders a destination with a href as a link", () => {
const { container } = render();
const items = container.querySelectorAll(".strand-tabbar__item");
expect([...items].every((el) => el.tagName === "A")).toBe(true);
expect(items[0].getAttribute("href")).toBe("/discover");
});
it("falls back to a button only for a destination with no href", () => {
const { container } = render(
,
);
const items = container.querySelectorAll(".strand-tabbar__item");
expect(items[0].tagName).toBe("BUTTON");
expect(items[0].getAttribute("type")).toBe("button");
expect(items[1].tagName).toBe("A");
});
it("reports the destination that was activated", () => {
const onNavigate = vi.fn();
const { container } = render();
fireEvent.click(container.querySelectorAll(".strand-tabbar__item")[2]);
expect(onNavigate).toHaveBeenCalledWith("people");
});
// Found by reading past a green summary: jsdom logged "Not implemented:
// navigation" on every click test. That noise was a real defect. Without
// preventDefault a consumer wiring onNavigate to a router gets BOTH the
// route change and a full page load on top of it, discarding the app.
it("does not let a plain click also hard-navigate when a handler owns it", () => {
const onNavigate = vi.fn();
const { container } = render();
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
container.querySelectorAll(".strand-tabbar__item")[0].dispatchEvent(event);
expect(onNavigate).toHaveBeenCalledWith("discover");
expect(event.defaultPrevented).toBe(true);
});
// The other half, and the reason these are links at all. Swallowing a
// modified click would remove open-in-new-tab with no way to get it back.
it("leaves a modified click to the browser and does not change the current view", () => {
const onNavigate = vi.fn();
const { container } = render();
for (const mod of ["metaKey", "ctrlKey", "shiftKey", "altKey"]) {
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
[mod]: true,
});
container.querySelectorAll(".strand-tabbar__item")[0].dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
}
expect(onNavigate).not.toHaveBeenCalled();
});
it("still reports a button-style destination, which has nothing to prevent", () => {
const onNavigate = vi.fn();
const { container } = render(
,
);
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
container.querySelector(".strand-tabbar__item")?.dispatchEvent(event);
expect(onNavigate).toHaveBeenCalledWith("solo");
expect(event.defaultPrevented).toBe(false);
});
it("hides a decorative icon from the accessibility tree", () => {
// The label already names the destination; an exposed glyph is a second
// announcement of the same thing.
const { container } = render(
}]} />,
);
expect(
container.querySelector(".strand-tabbar__icon")?.getAttribute("aria-hidden"),
).toBe("true");
});
it("renders without an icon at all", () => {
// Icons are optional, and a missing one must not leave an empty styled box
// taking up the row.
const { container } = render();
expect(container.querySelector(".strand-tabbar__icon")).toBeNull();
});
it("renders nothing but the landmark when there are no destinations", () => {
// Guards against a crash on an empty array during a routing transition.
const { container } = render();
expect(container.querySelector("nav")).not.toBeNull();
expect(container.querySelectorAll(".strand-tabbar__item")).toHaveLength(0);
});
it("passes arbitrary attributes through to the landmark", () => {
const { container } = render();
expect(container.querySelector("nav")?.getAttribute("data-testid")).toBe(
"shell-nav",
);
});
});
import { snapshotFixtures } from "../../test/snapshot.js";
import { snapshotStylesheet } from "../../test/stylesheet.js";
import { fixtures } from "./TabBar.fixtures.js";
snapshotFixtures(TabBar, fixtures);
snapshotStylesheet(resolve(__dirname, "./TabBar.css"));