feat(t5): ein Tabs-Baustein für Listen, Einstellungen und Statistik (T5a A8)
Backend E2E / e2e (push) Failing after 13m2s
Backend Image / build-and-push (push) Successful in 7m55s
Frontend Image / build-and-push (push) Successful in 27m46s

- 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:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 20:08:36 +02:00
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/);
}
});
});
+35 -44
View File
@@ -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 }