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:
parent
576001f89f
commit
ddc5aa3d7f
18
packages/core/src/components/tabs/index.ts
Normal file
18
packages/core/src/components/tabs/index.ts
Normal 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";
|
||||
29
packages/core/src/components/tabs/tabs-context.ts
Normal file
29
packages/core/src/components/tabs/tabs-context.ts
Normal 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;
|
||||
59
packages/core/src/components/tabs/tabs-list.tsx
Normal file
59
packages/core/src/components/tabs/tabs-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
packages/core/src/components/tabs/tabs-panel.tsx
Normal file
32
packages/core/src/components/tabs/tabs-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
packages/core/src/components/tabs/tabs-root.tsx
Normal file
59
packages/core/src/components/tabs/tabs-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
packages/core/src/components/tabs/tabs-tab.tsx
Normal file
41
packages/core/src/components/tabs/tabs-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
packages/core/tests/components/tabs/tabs.test.tsx
Normal file
110
packages/core/tests/components/tabs/tabs.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user