Tabs component

Implements accessible Tabs with List, Tab, Panel parts. Supports controlled/uncontrolled value, keyboard navigation (ArrowRight/Left/Home/End), automatic/manual activation mode, and full WAI-ARIA compliance (role=tablist/tab/tabpanel, aria-selected strings, aria-controls/labelledby wiring).
This commit is contained in:
Mats Bosson 2026-03-29 08:26:03 +07:00
parent 576001f89f
commit ddc5aa3d7f
7 changed files with 348 additions and 0 deletions

View File

@ -0,0 +1,18 @@
import { useTabsContext } from "./tabs-context";
import { TabsList } from "./tabs-list";
import { TabsPanel } from "./tabs-panel";
import { TabsRoot } from "./tabs-root";
import { TabsTab } from "./tabs-tab";
export const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Tab: TabsTab,
Panel: TabsPanel,
useContext: useTabsContext,
});
export type { TabsRootProps } from "./tabs-root";
export type { TabsListProps } from "./tabs-list";
export type { TabsTabProps } from "./tabs-tab";
export type { TabsPanelProps } from "./tabs-panel";
export type { TabsContextValue } from "./tabs-context";

View File

@ -0,0 +1,29 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
/** Context shared between all Tabs parts. */
export interface TabsContextValue {
activeTab: Accessor<string | undefined>;
setActiveTab: (value: string) => void;
baseId: string;
orientation: Accessor<"horizontal" | "vertical">;
activationMode: Accessor<"automatic" | "manual">;
}
const TabsContext = createContext<TabsContextValue>();
/**
* Returns the Tabs context. Throws if used outside <Tabs>.
*/
export function useTabsContext(): TabsContextValue {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error(
"[PettyUI] Tabs parts must be used inside <Tabs>.\n" +
" Fix: Wrap Tabs.List, Tabs.Tab, and Tabs.Panel inside <Tabs>.",
);
}
return ctx;
}
export const TabsContextProvider = TabsContext.Provider;

View File

@ -0,0 +1,59 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useTabsContext } from "./tabs-context";
/** Props for Tabs.List. */
export interface TabsListProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** Container for Tab elements. Handles arrow key navigation. */
export function TabsList(props: TabsListProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onKeyDown"]);
const ctx = useTabsContext();
const handleKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (e) => {
if (typeof local.onKeyDown === "function") local.onKeyDown(e);
const isHorizontal = ctx.orientation() === "horizontal";
const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown";
const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp";
const tabs = Array.from(
(e.currentTarget as HTMLDivElement).querySelectorAll<HTMLButtonElement>(
"[role='tab']:not([disabled])",
),
);
const focused = document.activeElement as HTMLButtonElement;
const index = tabs.indexOf(focused);
if (index === -1) return;
let next: HTMLButtonElement | undefined;
if (e.key === nextKey) {
e.preventDefault();
next = tabs[(index + 1) % tabs.length];
} else if (e.key === prevKey) {
e.preventDefault();
next = tabs[(index - 1 + tabs.length) % tabs.length];
} else if (e.key === "Home") {
e.preventDefault();
next = tabs[0];
} else if (e.key === "End") {
e.preventDefault();
next = tabs[tabs.length - 1];
}
if (next) {
next.focus();
if (ctx.activationMode() === "automatic") {
const value = next.getAttribute("data-value");
if (value) ctx.setActiveTab(value);
}
}
};
return (
<div role="tablist" aria-orientation={ctx.orientation()} onKeyDown={handleKeyDown} {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,32 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useTabsContext } from "./tabs-context";
/** Props for a Tab content panel. */
export interface TabsPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
children?: JSX.Element;
}
/** Content panel associated with a Tab. Hidden when its tab is not active. */
export function TabsPanel(props: TabsPanelProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "children"]);
const ctx = useTabsContext();
const isActive = () => ctx.activeTab() === local.value;
return (
<div
role="tabpanel"
id={`${ctx.baseId}-panel-${local.value}`}
aria-labelledby={`${ctx.baseId}-tab-${local.value}`}
data-state={isActive() ? "active" : "inactive"}
hidden={!isActive() || undefined}
// biome-ignore lint/a11y/noNoninteractiveTabindex: tabpanel role requires tabIndex per WAI-ARIA spec
tabIndex={0}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,59 @@
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
import { createControllableSignal } from "../../primitives/create-controllable-signal";
import { TabsContextProvider, type TabsContextValue } from "./tabs-context";
/** Props for the Tabs root component. */
export interface TabsRootProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Controlled active tab value. */
value?: string;
/** Initial active tab value (uncontrolled). */
defaultValue?: string;
/** Called when the active tab changes. */
onValueChange?: ((value: string) => void) | undefined;
/** @default "horizontal" */
orientation?: "horizontal" | "vertical";
/** automatic: activates on focus; manual: activates on click/Enter/Space. @default "automatic" */
activationMode?: "automatic" | "manual";
children: JSX.Element;
}
/**
* Root container for the Tabs component. Manages which tab is active.
*/
export function TabsRoot(props: TabsRootProps): JSX.Element {
const [local, rest] = splitProps(props, [
"value",
"defaultValue",
"onValueChange",
"orientation",
"activationMode",
"children",
]);
const baseId = createUniqueId();
const [activeTab, setActiveTab] = createControllableSignal<string | undefined>({
value: () => local.value,
defaultValue: () => local.defaultValue,
onChange: (v) => {
if (v !== undefined) local.onValueChange?.(v);
},
});
const ctx: TabsContextValue = {
activeTab,
setActiveTab: (v) => setActiveTab(v),
baseId,
orientation: () => local.orientation ?? "horizontal",
activationMode: () => local.activationMode ?? "automatic",
};
return (
<TabsContextProvider value={ctx}>
<div data-orientation={local.orientation ?? "horizontal"} {...rest}>
{local.children}
</div>
</TabsContextProvider>
);
}

View File

@ -0,0 +1,41 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useTabsContext } from "./tabs-context";
/** Props for a single Tab button. */
export interface TabsTabProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
disabled?: boolean;
children?: JSX.Element;
}
/** A single tab button. Activates the corresponding panel when clicked. */
export function TabsTab(props: TabsTabProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "children", "onClick"]);
const ctx = useTabsContext();
const isActive = () => ctx.activeTab() === local.value;
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!local.disabled) ctx.setActiveTab(local.value);
};
return (
<button
type="button"
role="tab"
id={`${ctx.baseId}-tab-${local.value}`}
aria-selected={isActive() ? "true" : "false"}
aria-controls={`${ctx.baseId}-panel-${local.value}`}
data-value={local.value}
data-state={isActive() ? "active" : "inactive"}
tabIndex={isActive() ? 0 : -1}
disabled={local.disabled}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,110 @@
// packages/core/tests/components/tabs/tabs.test.tsx
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Tabs } from "../../../src/components/tabs/index";
describe("Tabs", () => {
it("first tab is selected when defaultValue matches", () => {
render(() => (
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
<Tabs.Tab value="b">Tab B</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a">Panel A</Tabs.Panel>
<Tabs.Panel value="b">Panel B</Tabs.Panel>
</Tabs>
));
expect(screen.getByText("Tab A").getAttribute("aria-selected")).toBe("true");
expect(screen.getByText("Tab B").getAttribute("aria-selected")).toBe("false");
});
it("clicking a tab activates it", () => {
render(() => (
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
<Tabs.Tab value="b">Tab B</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a">Panel A</Tabs.Panel>
<Tabs.Panel value="b">Panel B</Tabs.Panel>
</Tabs>
));
fireEvent.click(screen.getByText("Tab B"));
expect(screen.getByText("Tab B").getAttribute("aria-selected")).toBe("true");
expect(screen.getByText("Tab A").getAttribute("aria-selected")).toBe("false");
});
it("inactive panels are hidden", () => {
render(() => (
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
<Tabs.Tab value="b">Tab B</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a" data-testid="panel-a">Panel A</Tabs.Panel>
<Tabs.Panel value="b" data-testid="panel-b">Panel B</Tabs.Panel>
</Tabs>
));
expect(screen.getByTestId("panel-a")).not.toHaveAttribute("hidden");
expect(screen.getByTestId("panel-b")).toHaveAttribute("hidden");
});
it("tab has role=tab and panel has role=tabpanel", () => {
render(() => (
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a">Panel A</Tabs.Panel>
</Tabs>
));
expect(screen.getByRole("tab")).toBeTruthy();
expect(screen.getByRole("tabpanel")).toBeTruthy();
});
it("tab aria-controls matches panel id", () => {
render(() => (
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a" data-testid="panel">Panel A</Tabs.Panel>
</Tabs>
));
const tab = screen.getByRole("tab");
const panel = screen.getByTestId("panel");
expect(tab.getAttribute("aria-controls")).toBe(panel.id);
});
it("ArrowRight moves to next tab", () => {
render(() => (
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
<Tabs.Tab value="b">Tab B</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a">Panel A</Tabs.Panel>
<Tabs.Panel value="b">Panel B</Tabs.Panel>
</Tabs>
));
const tabA = screen.getByText("Tab A");
tabA.focus();
fireEvent.keyDown(tabA, { key: "ArrowRight" });
expect(document.activeElement).toBe(screen.getByText("Tab B"));
});
it("controlled value", () => {
render(() => (
<Tabs value="b" onValueChange={() => {}}>
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
<Tabs.Tab value="b">Tab B</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a">Panel A</Tabs.Panel>
<Tabs.Panel value="b">Panel B</Tabs.Panel>
</Tabs>
));
expect(screen.getByText("Tab B").getAttribute("aria-selected")).toBe("true");
});
});