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,115 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "./active-history-tabs";
|
||||
|
||||
function Harness({ onValueChange }: { onValueChange?: (value: EntityLifecycleTab) => void }) {
|
||||
const [value, setValue] = useState<EntityLifecycleTab>("active");
|
||||
return (
|
||||
<ActiveHistoryTabs
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
setValue(next);
|
||||
onValueChange?.(next);
|
||||
}}
|
||||
activeCount={1}
|
||||
historyCount={0}
|
||||
toolbar={<button type="button">Filter</button>}
|
||||
>
|
||||
{{ active: <p>Liste aktiv</p>, history: <p>Liste Historie</p> }}
|
||||
</ActiveHistoryTabs>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ActiveHistoryTabs (spec 10.4)", () => {
|
||||
it("ActiveHistoryTabs shows the counts and renders the matching panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness onValueChange={onValueChange} />);
|
||||
|
||||
const active = screen.getByRole("tab", { name: "Aktiv (1)" });
|
||||
const history = screen.getByRole("tab", { name: "Historie (0)" });
|
||||
expect(active).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "Aktiv (1)" })).toHaveTextContent("Liste aktiv");
|
||||
expect(screen.queryByText("Liste Historie")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(history);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("history");
|
||||
expect(history).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "Historie (0)" })).toHaveTextContent("Liste Historie");
|
||||
expect(screen.queryByText("Liste aktiv")).not.toBeInTheDocument();
|
||||
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("active");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Liste aktiv");
|
||||
});
|
||||
|
||||
it("leaves the counts out while they are unknown", () => {
|
||||
render(
|
||||
<ActiveHistoryTabs value="active" onValueChange={() => {}}>
|
||||
{{ active: null, history: null }}
|
||||
</ActiveHistoryTabs>,
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Aktiv" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts the toolbar next to the tabs, outside the panels", () => {
|
||||
render(<Harness />);
|
||||
const filter = screen.getByRole("button", { name: "Filter" });
|
||||
expect(within(screen.getByRole("tabpanel")).queryByRole("button", { name: "Filter" })).toBeNull();
|
||||
expect(screen.getByRole("tablist").parentElement).toContainElement(filter);
|
||||
});
|
||||
|
||||
it("arrow keys switch at once here, because both panels filter data that is already loaded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await user.click(screen.getByRole("tab", { name: "Aktiv (1)" }));
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Historie (0)" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Liste Historie");
|
||||
});
|
||||
|
||||
it("toolbarAlign end pushes the toolbar to the far right of the row", () => {
|
||||
render(
|
||||
<ActiveHistoryTabs
|
||||
value="active"
|
||||
onValueChange={() => {}}
|
||||
toolbar={<button type="button">Neue Rolle</button>}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: null, history: null }}
|
||||
</ActiveHistoryTabs>,
|
||||
);
|
||||
const wrapper = screen.getByRole("button", { name: "Neue Rolle" }).parentElement as HTMLElement;
|
||||
expect(wrapper.className.split(/\s+/)).toContain("ml-auto");
|
||||
expect(screen.getByRole("tablist").parentElement).toContainElement(wrapper);
|
||||
});
|
||||
|
||||
it("a heading opens the row before the tabs, the tabs sit on the right", () => {
|
||||
render(
|
||||
<ActiveHistoryTabs
|
||||
value="active"
|
||||
onValueChange={() => {}}
|
||||
heading={<h2>Lagerbehälter</h2>}
|
||||
>
|
||||
{{ active: null, history: null }}
|
||||
</ActiveHistoryTabs>,
|
||||
);
|
||||
const heading = screen.getByRole("heading", { name: "Lagerbehälter" });
|
||||
const list = screen.getByRole("tablist");
|
||||
expect(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(list.parentElement).toContainElement(heading);
|
||||
expect(list.className.split(/\s+/)).toContain("ml-auto");
|
||||
});
|
||||
|
||||
it("the chips are 44 px high on touch and compact only with a fine pointer", () => {
|
||||
render(<Harness />);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
const classes = tab.className.split(/\s+/);
|
||||
expect(classes).toContain("h-11");
|
||||
expect(classes).toContain("pointer-fine:h-9");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export type EntityLifecycleTab = "active" | "history";
|
||||
|
||||
interface ActiveHistoryTabsProps {
|
||||
export interface ActiveHistoryTabsProps {
|
||||
value: EntityLifecycleTab;
|
||||
onChange: (value: EntityLifecycleTab) => void;
|
||||
onValueChange: (value: EntityLifecycleTab) => void;
|
||||
activeCount?: number;
|
||||
historyCount?: number;
|
||||
/** A section heading that opens the row; the tabs then sit on its right. */
|
||||
heading?: ReactNode;
|
||||
/** Controls next to the tabs (a filter, a create button), outside the panels. */
|
||||
toolbar?: ReactNode;
|
||||
/** `end` pushes the toolbar to the far right of the row (a create button). */
|
||||
toolbarAlign?: "start" | "end";
|
||||
/** The list of each panel; only the selected one is mounted. */
|
||||
children: { active: ReactNode; history: ReactNode };
|
||||
}
|
||||
|
||||
const withCount = (label: string, count: number | undefined) =>
|
||||
count === undefined ? label : `${label} (${count})`;
|
||||
|
||||
const isLifecycleTab = (value: unknown): value is EntityLifecycleTab =>
|
||||
value === "active" || value === "history";
|
||||
|
||||
/**
|
||||
* Template on Tabs (spec 10.4): „Aktiv“ and „Historie“, each panel holds its list. Both panels
|
||||
* filter data the page has already loaded, so the arrow keys switch at once (activateOnFocus).
|
||||
*/
|
||||
export function ActiveHistoryTabs({
|
||||
value,
|
||||
onChange,
|
||||
onValueChange,
|
||||
activeCount,
|
||||
historyCount,
|
||||
heading,
|
||||
toolbar,
|
||||
toolbarAlign = "start",
|
||||
children,
|
||||
}: ActiveHistoryTabsProps) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className="inline-flex w-fit rounded-xl border border-border/60 bg-muted/40 p-1"
|
||||
<Tabs
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
if (isLifecycleTab(next)) onValueChange(next);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === "active"}
|
||||
onClick={() => onChange("active")}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
value === "active"
|
||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{heading}
|
||||
<TabsList
|
||||
aria-label="Aktiv oder Historie"
|
||||
activateOnFocus
|
||||
className={cn(heading !== undefined && "ml-auto")}
|
||||
>
|
||||
<TabsTrigger value="active">{withCount("Aktiv", activeCount)}</TabsTrigger>
|
||||
<TabsTrigger value="history">{withCount("Historie", historyCount)}</TabsTrigger>
|
||||
</TabsList>
|
||||
{toolbarAlign === "end" && toolbar !== undefined ? (
|
||||
<div className="ml-auto">{toolbar}</div>
|
||||
) : (
|
||||
toolbar
|
||||
)}
|
||||
>
|
||||
Aktiv{activeCount !== undefined ? ` (${activeCount})` : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === "history"}
|
||||
onClick={() => onChange("history")}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
value === "history"
|
||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Historie{historyCount !== undefined ? ` (${historyCount})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="active">{children.active}</TabsContent>
|
||||
<TabsContent value="history">{children.history}</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TabSwitcher } from "./tab-switcher";
|
||||
|
||||
describe("TabSwitcher", () => {
|
||||
it("makes every tab at least 44 px high for a thumb", () => {
|
||||
render(
|
||||
<TabSwitcher
|
||||
tabs={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
]}
|
||||
value="a"
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(tab).toHaveClass("min-h-11", "pointer-fine:min-h-9");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TabSwitcherProps<T extends string> {
|
||||
tabs: { value: T; label: string }[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwitcherProps<T>) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className="inline-flex w-fit flex-wrap gap-1 rounded-xl border border-border/60 bg-muted/40 p-1"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === tab.value}
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={cn(
|
||||
"min-h-11 pointer-fine:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
|
||||
value === tab.value
|
||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -159,7 +159,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
||||
|
||||
/**
|
||||
* Menu and select items keep `outline-hidden` (the item itself is the moving highlight), so the
|
||||
* accent fill alone was the focus cue, and it stays below 3:1 on the popover. Each item now
|
||||
* draws a 2 px inset ring in --ring while focused (≥ 3:1 in all four sets, contrast.spec.ts).
|
||||
*/
|
||||
const FOCUS_CUE = ["focus:inset-ring-2", "focus:inset-ring-ring"];
|
||||
|
||||
function expectFocusCue(element: HTMLElement) {
|
||||
const classes = element.className.split(/\s+/);
|
||||
for (const token of FOCUS_CUE) expect(classes, token).toContain(token);
|
||||
}
|
||||
|
||||
describe("menu items show a focus cue of at least 3:1 (task A8)", () => {
|
||||
it("every dropdown item kind draws the inset ring while focused", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>Mehr</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Exportieren</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive">Löschen</DropdownMenuItem>
|
||||
<DropdownMenuCheckboxItem checked>Mit Details</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuRadioGroup value="a">
|
||||
<DropdownMenuRadioItem value="a">Liste</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Weitere</DropdownMenuSubTrigger>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expectFocusCue(screen.getByRole("menuitem", { name: "Exportieren" }));
|
||||
expectFocusCue(screen.getByRole("menuitem", { name: "Löschen" }));
|
||||
expectFocusCue(screen.getByRole("menuitemcheckbox", { name: "Mit Details" }));
|
||||
expectFocusCue(screen.getByRole("menuitemradio", { name: "Liste" }));
|
||||
expectFocusCue(screen.getByRole("menuitem", { name: "Weitere" }));
|
||||
});
|
||||
|
||||
it("a select option draws the inset ring while focused", () => {
|
||||
render(
|
||||
<Select open value="a">
|
||||
<SelectTrigger aria-label="Status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">Aktiv</SelectItem>
|
||||
<SelectItem value="b">Aufgelöst</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
);
|
||||
for (const option of screen.getAllByRole("option")) expectFocusCue(option);
|
||||
});
|
||||
});
|
||||
@@ -117,7 +117,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:inset-ring-2 focus:inset-ring-ring focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
/**
|
||||
* Tabs (spec 10.4): one variant, `segment`. Base UI draws role tablist/tab/tabpanel,
|
||||
* aria-controls and the arrow keys; this is the only place in the app with those roles.
|
||||
*/
|
||||
function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn("flex flex-col gap-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar: --muted with 4 px inner padding, tabs 4 px apart. It wraps on a narrow screen
|
||||
* instead of overflowing. Arrow keys move the focus, Enter or Space switch the tab (manual
|
||||
* activation, APG: a panel may fetch on mount), Home/End jump, focus loops at the ends.
|
||||
* Pass `activateOnFocus` only where every panel shows data that is already loaded.
|
||||
*/
|
||||
function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant="segment"
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
"inline-flex w-fit max-w-full flex-wrap items-center gap-1 rounded-xl bg-muted p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,45 +38,21 @@ function Tabs({
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-xl p-1 text-muted-foreground group-data-horizontal/tabs:h-10 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted/70",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab: 44 px for a thumb (36 px with a fine pointer), 16 px text. Inactive in
|
||||
* --muted-foreground on --muted (≥ 4.5:1 in all four sets). Active on --card, --foreground,
|
||||
* semibold, with a 3 px --primary-ink bar at the bottom: surface, weight and bar, never colour
|
||||
* alone. The top border keeps the label centred against the bar. Focus is the global ring.
|
||||
*/
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-card data-active:text-primary-ink",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
"inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border-t-3 border-b-3 border-transparent px-4 text-base font-medium whitespace-nowrap text-muted-foreground pointer-fine:h-9",
|
||||
"hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
||||
"data-active:border-b-primary-ink data-active:bg-card data-active:font-semibold data-active:text-foreground",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -73,10 +64,10 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm", className)}
|
||||
className={cn("flex-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
Reference in new issue
Block a user