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"));