feat(t5): ein Tabs-Baustein für Listen, Einstellungen und Statistik (T5a A8)
- ui/tabs.tsx nur noch Variante segment: Leiste auf --muted, aktiver Tab mit Kartenfläche, fetter Schrift und 3-px-Balken in --primary-ink; 44 px bei Touch, kompakt mit Maus; inaktive Schrift ≥ 6,27:1. - ActiveHistoryTabs als Vorlage auf Tabs für 10 Listen (Pfeiltasten schalten direkt, Daten sind geladen), mit toolbar und heading. - Einstellungen (Automatisierung, Benutzer & Rollen) und Statistik mit manueller Aktivierung: Pfeil bewegt den Fokus, Enter/Leertaste wählt; ?tab= über router.replace. TabSwitcher entfernt. - Menüeinträge (Dropdown, Select) mit innerem Fokusring ≥ 3:1. - Aufgaben: der Prioritätsfilter lädt die Liste jetzt neu (queryKey). - Wächter: keine eigenen tablist/tab/tabpanel außerhalb ui/tabs.tsx. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
759d85dc67
commit
fce9d10192
26 files changed
+1137
-606
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
|
||||
|
||||
const has = (element: HTMLElement, token: string) => element.className.split(/\s+/).includes(token);
|
||||
|
||||
function Harness({ initial = "a" }: { initial?: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<Tabs value={value} onValueChange={(next) => setValue(String(next))}>
|
||||
<TabsList aria-label="Bereiche">
|
||||
<TabsTrigger value="a">Völker</TabsTrigger>
|
||||
<TabsTrigger value="b">Königinnen</TabsTrigger>
|
||||
<TabsTrigger value="c">Ernte</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">Panel Völker</TabsContent>
|
||||
<TabsContent value="b">Panel Königinnen</TabsContent>
|
||||
<TabsContent value="c">Panel Ernte</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Tabs (spec 10.4)", () => {
|
||||
it("arrow keys move the focus, Enter or Space switch the tab, and the panel is labelled by it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
const first = screen.getByRole("tab", { name: "Völker" });
|
||||
await user.click(first);
|
||||
await user.keyboard("{ArrowRight}");
|
||||
|
||||
// Manual activation (APG): the panels fetch on mount, so an arrow only moves the focus.
|
||||
const second = screen.getByRole("tab", { name: "Königinnen" });
|
||||
expect(second).toHaveFocus();
|
||||
expect(second).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Panel Völker");
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(second).toHaveAttribute("aria-selected", "true");
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
expect(panel).toHaveTextContent("Panel Königinnen");
|
||||
expect(panel).toHaveAccessibleName("Königinnen");
|
||||
expect(second).toHaveAttribute("aria-controls", panel.id);
|
||||
|
||||
// Focus wraps around at the end (one tab stop, arrows inside the list); Space selects too.
|
||||
await user.keyboard("{ArrowRight}{ArrowRight}");
|
||||
expect(first).toHaveFocus();
|
||||
await user.keyboard(" ");
|
||||
expect(first).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Panel Völker");
|
||||
});
|
||||
|
||||
it("the active tab has the surface, bold text and the 3 px ink bar", () => {
|
||||
render(<Harness initial="b" />);
|
||||
const active = screen.getByRole("tab", { name: "Königinnen" });
|
||||
expect(active).toHaveAttribute("data-active");
|
||||
for (const token of [
|
||||
"data-active:bg-card",
|
||||
"data-active:text-foreground",
|
||||
"data-active:font-semibold",
|
||||
"border-b-3",
|
||||
"data-active:border-b-primary-ink",
|
||||
]) {
|
||||
expect(has(active, token), token).toBe(true);
|
||||
}
|
||||
// Inactive tabs: --muted-foreground on --muted (≥ 4.5:1 in all four sets, contrast.spec.ts).
|
||||
const inactive = screen.getByRole("tab", { name: "Völker" });
|
||||
expect(has(inactive, "text-muted-foreground")).toBe(true);
|
||||
expect(inactive.className).not.toMatch(/text-foreground\/\d+/);
|
||||
expect(has(screen.getByRole("tablist"), "bg-muted")).toBe(true);
|
||||
});
|
||||
|
||||
it("tabs are 44 px for a thumb, compact only with a fine pointer, 16 px text, 4 px apart", () => {
|
||||
render(<Harness />);
|
||||
const list = screen.getByRole("tablist");
|
||||
expect(has(list, "p-1")).toBe(true);
|
||||
expect(has(list, "gap-1")).toBe(true);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(has(tab, "h-11")).toBe(true);
|
||||
expect(has(tab, "pointer-fine:h-9")).toBe(true);
|
||||
expect(has(tab, "text-base")).toBe(true);
|
||||
expect(tab.className).not.toMatch(/(^|\s)sm:(?:min-)?h-/);
|
||||
}
|
||||
});
|
||||
|
||||
it("the list wraps on a narrow screen instead of overflowing", () => {
|
||||
render(<Harness />);
|
||||
const list = screen.getByRole("tablist");
|
||||
expect(has(list, "flex-wrap")).toBe(true);
|
||||
expect(has(list, "max-w-full")).toBe(true);
|
||||
expect(list.className).not.toMatch(/(^|\s)h-10(\s|$)/);
|
||||
});
|
||||
|
||||
it("has one variant, segment, and no own focus ring or movement", () => {
|
||||
render(<Harness />);
|
||||
expect(screen.getByRole("tablist")).toHaveAttribute("data-variant", "segment");
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(tab.className).not.toMatch(/outline-none|ring-ring|focus-visible:ring|translate|transition-all/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user