diff --git a/packages/core/src/components/tabs/index.ts b/packages/core/src/components/tabs/index.ts new file mode 100644 index 0000000..0c8c895 --- /dev/null +++ b/packages/core/src/components/tabs/index.ts @@ -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"; diff --git a/packages/core/src/components/tabs/tabs-context.ts b/packages/core/src/components/tabs/tabs-context.ts new file mode 100644 index 0000000..0be580e --- /dev/null +++ b/packages/core/src/components/tabs/tabs-context.ts @@ -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; + setActiveTab: (value: string) => void; + baseId: string; + orientation: Accessor<"horizontal" | "vertical">; + activationMode: Accessor<"automatic" | "manual">; +} + +const TabsContext = createContext(); + +/** + * Returns the Tabs context. Throws if used outside . + */ +export function useTabsContext(): TabsContextValue { + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error( + "[PettyUI] Tabs parts must be used inside .\n" + + " Fix: Wrap Tabs.List, Tabs.Tab, and Tabs.Panel inside .", + ); + } + return ctx; +} + +export const TabsContextProvider = TabsContext.Provider; diff --git a/packages/core/src/components/tabs/tabs-list.tsx b/packages/core/src/components/tabs/tabs-list.tsx new file mode 100644 index 0000000..dcb8ff7 --- /dev/null +++ b/packages/core/src/components/tabs/tabs-list.tsx @@ -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 { + 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 = (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( + "[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 ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/tabs/tabs-panel.tsx b/packages/core/src/components/tabs/tabs-panel.tsx new file mode 100644 index 0000000..f57f15d --- /dev/null +++ b/packages/core/src/components/tabs/tabs-panel.tsx @@ -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 { + 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 ( + + ); +} diff --git a/packages/core/src/components/tabs/tabs-root.tsx b/packages/core/src/components/tabs/tabs-root.tsx new file mode 100644 index 0000000..5a151bd --- /dev/null +++ b/packages/core/src/components/tabs/tabs-root.tsx @@ -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 { + /** 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({ + 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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/tabs/tabs-tab.tsx b/packages/core/src/components/tabs/tabs-tab.tsx new file mode 100644 index 0000000..946dbf7 --- /dev/null +++ b/packages/core/src/components/tabs/tabs-tab.tsx @@ -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 { + 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 = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!local.disabled) ctx.setActiveTab(local.value); + }; + + return ( + + ); +} diff --git a/packages/core/tests/components/tabs/tabs.test.tsx b/packages/core/tests/components/tabs/tabs.test.tsx new file mode 100644 index 0000000..94f4671 --- /dev/null +++ b/packages/core/tests/components/tabs/tabs.test.tsx @@ -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(() => ( + + + Tab A + Tab B + + Panel A + Panel B + + )); + 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(() => ( + + + Tab A + Tab B + + Panel A + Panel B + + )); + 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(() => ( + + + Tab A + Tab B + + Panel A + Panel B + + )); + 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(() => ( + + + Tab A + + Panel A + + )); + expect(screen.getByRole("tab")).toBeTruthy(); + expect(screen.getByRole("tabpanel")).toBeTruthy(); + }); + + it("tab aria-controls matches panel id", () => { + render(() => ( + + + Tab A + + Panel A + + )); + 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(() => ( + + + Tab A + Tab B + + Panel A + Panel B + + )); + 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(() => ( + {}}> + + Tab A + Tab B + + Panel A + Panel B + + )); + expect(screen.getByText("Tab B").getAttribute("aria-selected")).toBe("true"); + }); +});