` props.
+
+## Controlled Usage
+
+```tsx
+const [activeTab, setActiveTab] = useState("overview");
+
+
+
+ Overview
+ Details
+
+ Overview content
+ Details content
+;
+```
+
+## Examples
+
+### With Disabled Tab
+
+```tsx
+
+
+ Active
+ Pending
+
+ Archived
+
+
+ Active items
+ Pending items
+ Archived items
+
+```
diff --git a/examples/app-module/src/custom-module.tsx b/examples/app-module/src/custom-module.tsx
index 20d79f07..83029a8c 100644
--- a/examples/app-module/src/custom-module.tsx
+++ b/examples/app-module/src/custom-module.tsx
@@ -1,24 +1,2517 @@
-import { defineModule, Link, ResourceComponentProps } from "@tailor-platform/app-shell";
-import { useT, labels } from "./i18n-labels";
-import { ZapIcon } from "./pages/metric-card-demo";
-import { actionPanelDemoResource } from "./pages/action-panel-demo";
-import { metricCardDemoResource } from "./pages/metric-card-demo";
-import { activityCardDemoResource } from "./pages/activity-card-demo";
-import {
- purchaseOrderDemoResource,
- subPageResource,
- hiddenResource,
-} from "./pages/purchase-order-demo";
-import { adminOnlyResource } from "./pages/admin-only";
import {
- oneColumnLayoutResource,
- twoColumnLayoutResource,
- threeColumnLayoutResource,
- layoutSlotsDemoResource,
-} from "./pages/layout-demos";
-import { primitiveComponentsDemoResource } from "./pages/primitives-demo";
-import { dropdownComponentsDemoResource } from "./pages/dropdown-demo";
-import { formComponentsDemoResource, zodRHFFormDemoResource } from "./pages/form-demo";
+ defineModule,
+ defineResource,
+ Link,
+ ResourceComponentProps,
+ useNavigate,
+ useParams,
+ hidden,
+ pass,
+ DescriptionCard,
+ ActionPanel,
+ ActivityCard,
+ Layout,
+ Button,
+ Input,
+ Table,
+ Dialog,
+ Menu,
+ Sheet,
+ Tooltip,
+ Badge,
+ Select,
+ Combobox,
+ Autocomplete,
+ MetricCard,
+ Tabs,
+ Field,
+ Fieldset,
+ Form,
+ type Guard,
+} from "@tailor-platform/app-shell";
+import type { SVGProps } from "react";
+import { useT, labels } from "./i18n-labels";
+import * as React from "react";
+import { useForm, Controller } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod/v4";
+
+const ZapIcon = (props: SVGProps
) => {
+ return (
+
+ );
+};
+
+// Small icons for ActionPanel demo (16px)
+const ReceiptIcon = (props: SVGProps) => (
+
+);
+const FileTextIcon = (props: SVGProps) => (
+
+);
+const ExternalLinkIcon = (props: SVGProps) => (
+
+);
+
+// ============================================================================
+// DEMO: Action Panel Page
+// ============================================================================
+
+const ActionPanelDemoPage = () => {
+ const navigate = useNavigate();
+ return (
+
+
Action Panel Demo
+
+ This panel fills the width of its container. All actions use onClick; for
+ navigation use useNavigate() inside the callback.
+
+
,
+ onClick: () => alert("Create invoice clicked"),
+ },
+ {
+ key: "delivery-note",
+ label: "Create new delivery note",
+ icon:
,
+ onClick: () => alert("Create delivery note clicked"),
+ },
+ {
+ key: "view-po-demo",
+ label: "View Purchase Order Demo",
+ icon:
,
+ onClick: () => navigate("/custom-page/purchase-order-demo"),
+ },
+ ]}
+ />
+
+ );
+};
+
+const actionPanelDemoResource = defineResource({
+ path: "action-panel-demo",
+ meta: { title: "Action Panel Demo" },
+ component: ActionPanelDemoPage,
+});
+
+// ============================================================================
+// DEMO: MetricCard (KPI row)
+// ============================================================================
+
+const MetricCardDemoPage = () => (
+
+
+
+
+ Dashboard KPI cards: title, value, optional trend and description.
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+);
+
+const metricCardDemoResource = defineResource({
+ path: "metric-card-demo",
+ meta: { title: "MetricCard Demo" },
+ component: MetricCardDemoPage,
+});
+
+// ============================================================================
+// DEMO: ActivityCard (document updates / activity timeline)
+// ============================================================================
+
+const activityCardDemoActivities = [
+ {
+ id: "1",
+ actor: { name: "Hanna" },
+ description: "changed the status from DRAFT to CONFIRMED",
+ timestamp: new Date("2025-03-21T09:00:00"),
+ },
+ {
+ id: "2",
+ actor: { name: "Pradeep Kumar" },
+ description: "created this PO",
+ timestamp: new Date("2025-03-21T15:16:00"),
+ },
+ {
+ id: "3",
+ actor: { name: "Pradeep Kumar" },
+ description: "added a note",
+ timestamp: new Date("2025-03-21T15:16:00"),
+ },
+ {
+ id: "4",
+ actor: { name: "Hanna" },
+ description: "updated delivery date",
+ timestamp: new Date("2025-03-20T14:00:00"),
+ },
+ {
+ id: "5",
+ actor: { name: "Pradeep Kumar" },
+ description: "created this PO",
+ timestamp: new Date("2025-03-20T15:16:00"),
+ },
+ {
+ id: "6",
+ description: "sent confirmation email",
+ timestamp: new Date("2025-03-20T10:00:00"),
+ },
+ {
+ id: "7",
+ actor: { name: "Hanna" },
+ description: "approved the order",
+ timestamp: new Date("2025-03-19T11:30:00"),
+ },
+ {
+ id: "8",
+ actor: { name: "Pradeep Kumar" },
+ description: "added a note",
+ timestamp: new Date("2025-03-19T09:00:00"),
+ },
+];
+
+const ActivityCardDemoPage = () => (
+
+
+
+ ActivityCard Demo
+
+
+ Timeline of recent activities on a document (e.g. PO, SO, GR). Click "N more
+ activities" to open the full list in a dialog.
+
+
+
+
+);
+
+const activityCardDemoResource = defineResource({
+ path: "activity-card-demo",
+ meta: { title: "ActivityCard Demo" },
+ component: ActivityCardDemoPage,
+});
+
+// ============================================================================
+// DEMO: Purchase Order Detail Page
+// ============================================================================
+
+// Simulated backend data (like what you'd get from GraphQL/REST API)
+const mockPurchaseOrder = {
+ id: "po-2024-0042",
+ docNumber: "PO-10000041",
+ status: "CONFIRMED",
+ billingStatus: "PARTIALLY_BILLED",
+ deliveryStatus: "NOT_RECEIVED",
+ supplierName: "Acme Industrial Supplies",
+ supplierID: "supplier-123",
+ expectedDeliveryDate: "2024-02-15T00:00:00Z",
+ createdAt: "2024-01-20T10:30:00Z",
+ updatedAt: "2024-01-22T14:45:00Z",
+ confirmedAt: "2024-01-21T09:00:00Z",
+ note: "Rush order - priority shipping requested. Please ensure all Please ensure all Please ensure all",
+ externalReference: "P00594",
+ currency: { code: "USD", symbol: "$" },
+ shipToLocation: {
+ name: "Main Warehouse",
+ address: {
+ line1: "1234 Industrial Blvd",
+ line2: "Building C",
+ city: "Austin",
+ state: "TX",
+ zip: "78701",
+ country: "United States",
+ },
+ },
+ // Computed totals (would come from backend)
+ subtotal: 12500.0,
+ tax: 1031.25,
+ total: 13531.25,
+};
+
+const PurchaseOrderDetailPage = () => {
+ // In a real app, you'd fetch this data:
+ // const { data: purchaseOrder } = useQuery(GET_PURCHASE_ORDER, { variables: { id } });
+ const purchaseOrder = mockPurchaseOrder;
+
+ return (
+
+
+ Purchase Order: {purchaseOrder.docNumber}
+
+
+ {/* Status Overview Card */}
+
+
+ {/* Order Details Card */}
+
+
+ {/* Financial Summary Card */}
+
+
+ );
+};
+
+const purchaseOrderDemoResource = defineResource({
+ path: "purchase-order-demo",
+ meta: {
+ title: "Purchase Order Demo",
+ },
+ component: PurchaseOrderDetailPage,
+});
+
+const dynamicPageResource = defineResource({
+ path: ":id",
+ meta: {
+ title: labels.t("dynamicPageTitle"),
+ },
+ component: () => {
+ const params = useParams<{ id: string }>();
+ const t = useT();
+
+ return (
+
+
{t("dynamicPageDescription", { id: params.id! })}
+
+ );
+ },
+});
+
+const subSubPageResource = defineResource({
+ path: "sub1-1",
+ meta: {
+ title: labels.t("subSubPageTitle"),
+ },
+ subResources: [dynamicPageResource],
+ component: () => {
+ const t = useT();
+
+ return (
+
+
{t("subSubPageDescription")}
+
+ );
+ },
+});
+
+const subPageResource = defineResource({
+ path: "sub1",
+ meta: {
+ title: labels.t("subPageTitle"),
+ },
+ subResources: [subSubPageResource],
+ component: () => {
+ const t = useT();
+
+ return (
+
+
{t("subPageDescription")}
+
+ );
+ },
+});
+
+const hiddenResource = defineResource({
+ path: "hidden",
+ meta: {
+ title: "Hidden Page",
+ },
+ guards: [() => hidden()],
+ component: () => {
+ return (
+
+
This page should be hidden from navigation.
+
+ );
+ },
+});
+
+// ============================================================================
+// DEMO: Admin Only Restricted Resource
+// ============================================================================
+
+/**
+ * Guard that checks if the user has admin role.
+ * Uses contextData passed from AppShell to determine access.
+ *
+ * The module augmentation in role-switcher-context.tsx defines the
+ * expected contextData type, so context.role is properly typed here.
+ */
+const adminOnlyGuard: Guard = ({ context }) => {
+ if (context.role !== "admin") {
+ return hidden();
+ }
+ return pass();
+};
+
+const ShieldIcon = (props: SVGProps) => {
+ return (
+
+ );
+};
+
+const adminOnlyResource = defineResource({
+ path: "admin-only",
+ meta: {
+ title: "Admin Only",
+ icon: ,
+ },
+ guards: [adminOnlyGuard],
+ component: () => {
+ return (
+
+
+
+
Admin Only Page
+
+
+
+ 🎉 Congratulations! You have admin access.
+
+
+ This page is only visible when you select "Admin" role from the
+ sidebar.
+
+
+ Try switching to "Staff" role - this page will disappear from the navigation and become
+ inaccessible.
+
+
+
+
+ How it works:
+
+
+ -
+ The{" "}
+
+ RoleSwitcherContext
+ {" "}
+ manages the current role state
+
+ -
+ The role is passed to AppShell via{" "}
+
+ contextData
+ {" "}
+ prop
+
+ -
+ A route guard checks{" "}
+
+ context.role
+ {" "}
+ and returns{" "}
+
+ hidden()
+ {" "}
+ for non-admins
+
+ - The navigation automatically hides resources that are guarded
+
+
+
+ );
+ },
+});
+
+// ============================================================================
+// PLACEHOLDER COMPONENT
+// ============================================================================
+
+/**
+ * Placeholder component with subtle diagonal lines pattern for empty content areas
+ */
+const Placeholder = ({ columnNumber }: { columnNumber: number }) => {
+ return (
+
+ {/* Pattern overlay - different for light and dark mode */}
+
+
+ {/* Column number */}
+
+ {columnNumber}
+
+
+ );
+};
+
+// ============================================================================
+// DEMO: Layout Component Examples
+// ============================================================================
+
+const layoutHeaderActions = [
+ ,
+ ,
+];
+
+const oneColumnLayoutResource = defineResource({
+ path: "layout-1-column",
+ meta: {
+ title: "1 Column",
+ },
+ component: () => {
+ return (
+
+
+
+
+
+
+ );
+ },
+});
+
+const twoColumnLayoutResource = defineResource({
+ path: "layout-2-columns",
+ meta: {
+ title: "2 Columns",
+ },
+ component: () => {
+ const navigate = useNavigate();
+ const [loadingKey, setLoadingKey] = React.useState(null);
+
+ const handleCreateInvoice = () => {
+ setLoadingKey("create-invoice");
+ setTimeout(() => {
+ setLoadingKey(null);
+ alert("Create invoice clicked");
+ }, 1500);
+ };
+
+ const actions = [
+ {
+ key: "create-invoice",
+ label: "Create new sales invoice",
+ icon: ,
+ onClick: handleCreateInvoice,
+ loading: loadingKey === "create-invoice",
+ },
+ {
+ key: "delivery-note",
+ label: "Create new delivery note",
+ icon: ,
+ onClick: () => alert("Create delivery note clicked"),
+ },
+ {
+ key: "view-po-demo",
+ label: "View Purchase Order",
+ icon: ,
+ onClick: () => navigate("/custom-page/purchase-order-demo"),
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
+
+const threeColumnLayoutResource = defineResource({
+ path: "layout-3-columns",
+ meta: {
+ title: "3 Columns",
+ },
+ component: () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+});
+
+// ============================================================================
+// DEMO: Layout Slots (composition API)
+// ============================================================================
+
+const layoutSlotsDemoResource = defineResource({
+ path: "layout-patterns",
+ meta: {
+ title: "Layout Patterns Demo",
+ },
+ component: () => (
+
+ {/* 2 columns (area: left + main) */}
+
+
+
+
+
+
+
+
+
+
+ {/* More than 3 columns */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+});
+
+// ============================================================================
+// DEMO: Primitive Components
+// ============================================================================
+
+const primitiveComponentsDemoResource = defineResource({
+ path: "primitives-demo",
+ meta: {
+ title: "Primitive Components Demo",
+ },
+ component: () => {
+ const [inputValue, setInputValue] = React.useState("");
+ const [showToolbar, setShowToolbar] = React.useState(true);
+ const [showSidebar, setShowSidebar] = React.useState(false);
+ const [sortOrder, setSortOrder] = React.useState("date");
+
+ const cardStyle: React.CSSProperties = {
+ padding: "1.5rem",
+ borderRadius: "0.75rem",
+ border: "1px solid var(--border)",
+ backgroundColor: "var(--card)",
+ color: "var(--card-foreground)",
+ };
+ const headingStyle: React.CSSProperties = {
+ fontWeight: "bold",
+ marginBottom: "0.5rem",
+ };
+ const labelStyle: React.CSSProperties = {
+ fontSize: "0.875rem",
+ color: "var(--muted-foreground)",
+ marginBottom: "0.5rem",
+ };
+ const rowStyle: React.CSSProperties = {
+ display: "flex",
+ gap: "0.5rem",
+ flexWrap: "wrap",
+ };
+
+ return (
+
+
+
+ {/* Button variants */}
+
+
Button
+
+
+
Variant
+
+
+
+
+
+
+
+
+
+
+
Size
+
+
+
+
+
+
+
+
+
+
+ {/* Input */}
+
+
+ {/* Badge */}
+
+
Badge
+
+ Default
+ Success
+ Warning
+ Error
+ Neutral
+ Outline
+
+
+
+ {/* Tooltip */}
+
+
Tooltip
+
+
+
+ }>
+ Top (default)
+
+ Tooltip on top
+
+
+ }>Bottom
+ Tooltip on bottom
+
+
+ }>Left
+ Tooltip on left
+
+
+ }>Right
+ Tooltip on right
+
+
+
+
+
+ {/* Dialog */}
+
+
Dialog
+
+ }>Open Dialog
+
+
+ Dialog Title
+
+ This is a dialog description. You can put any content here.
+
+
+
+ }>Cancel
+ }>Confirm
+
+
+
+
+
+ {/* Sheet */}
+
+
Sheet
+
+
+ }>
+ Open Sheet (Right)
+
+
+
+ Sheet Title
+ This sheet slides in from the right.
+
+ Sheet content goes here.
+
+ }>Close
+
+
+
+
+ }>
+ Open Sheet (Left)
+
+
+
+ Left Sheet
+ This sheet slides in from the left.
+
+
+ }>Close
+
+
+
+
+ }>
+ Open Sheet (Bottom)
+
+
+
+ Bottom Sheet
+ This sheet slides in from the bottom.
+
+
+ }>Close
+
+
+
+
+
+
+ {/* Menu */}
+
+
Menu
+
+
+
Pattern
+
+
+ }>Basic
+
+ alert("Edit clicked")}>Edit
+ alert("Duplicate clicked")}>Duplicate
+ alert("Copy ID clicked")}>Copy ID
+
+ alert("Delete clicked")}
+ className="astw:text-destructive"
+ >
+ Delete
+
+
+
+
+ }>
+ Checkbox & Radio
+
+
+
+ Panels
+
+ ✓
+ Show Toolbar
+
+
+ ✓
+ Show Sidebar
+
+
+
+
+ Sort by
+
+
+ ●
+ Date
+
+
+ ●
+ Name
+
+
+ ●
+ Size
+
+
+
+
+
+
+ }>Submenu
+
+
+ Document
+ alert("New")}>New
+ alert("Open")}>Open
+ alert("Save")}>Save
+
+
+
+ Export as →
+
+ alert("PDF")}>PDF
+ alert("CSV")}>CSV
+ alert("JSON")}>JSON
+
+
+
+ Print (unavailable)
+
+
+
+
+
+
Direction
+
+
+ }>Bottom ↓
+
+ Item 1
+ Item 2
+ Item 3
+
+
+
+ }>Top ↑
+
+ Item 1
+ Item 2
+ Item 3
+
+
+
+ }>Right →
+
+ Item 1
+ Item 2
+ Item 3
+
+
+
+ }>Left ←
+
+ Item 1
+ Item 2
+ Item 3
+
+
+
+
+
+
+
+ {/* Tabs */}
+
+
Tabs
+
+
+ Overview
+ Projects
+ Settings
+
+ Archived
+
+
+
+ Overview content goes here.
+
+
+ Projects content goes here.
+
+
+ Settings content goes here.
+
+
+ Archived content goes here.
+
+
+
+
+ {/* Table */}
+
+
Table
+
+
+
+ Name
+ Status
+ Role
+ Amount
+
+
+
+
+ Alice Johnson
+
+ Active
+
+ Admin
+ $1,200.00
+
+
+ Bob Smith
+
+ Inactive
+
+ Editor
+ $800.00
+
+
+ Carol Lee
+
+ Active
+
+ Viewer
+ $350.00
+
+
+
+
+ Total
+ $2,350.00
+
+
+
+
+
+
+ );
+ },
+});
+
+// ============================================================================
+// DEMO: Select, Combobox, Autocomplete
+// ============================================================================
+
+interface Fruit {
+ id: string;
+ name: string;
+ emoji: string;
+}
+
+const fruits: Fruit[] = [
+ { id: "apple", name: "Apple", emoji: "🍎" },
+ { id: "banana", name: "Banana", emoji: "🍌" },
+ { id: "cherry", name: "Cherry", emoji: "🍒" },
+ { id: "grape", name: "Grape", emoji: "🍇" },
+ { id: "mango", name: "Mango", emoji: "🥭" },
+ { id: "orange", name: "Orange", emoji: "🍊" },
+ { id: "peach", name: "Peach", emoji: "🍑" },
+ { id: "strawberry", name: "Strawberry", emoji: "🍓" },
+];
+
+const groupedFruits = [
+ {
+ label: "Tropical",
+ items: [
+ { id: "banana", name: "Banana", emoji: "🍌" },
+ { id: "mango", name: "Mango", emoji: "🥭" },
+ { id: "pineapple", name: "Pineapple", emoji: "🍍" },
+ ],
+ },
+ {
+ label: "Berries",
+ items: [
+ { id: "cherry", name: "Cherry", emoji: "🍒" },
+ { id: "grape", name: "Grape", emoji: "🍇" },
+ { id: "strawberry", name: "Strawberry", emoji: "🍓" },
+ ],
+ },
+];
+
+const allProgrammingLanguages = [
+ "JavaScript",
+ "TypeScript",
+ "Python",
+ "Java",
+ "Go",
+ "Rust",
+ "C",
+ "C++",
+ "C#",
+ "Ruby",
+ "PHP",
+ "Swift",
+ "Kotlin",
+ "Scala",
+ "Haskell",
+ "Elixir",
+ "Clojure",
+ "Dart",
+ "Lua",
+ "R",
+ "Julia",
+ "Zig",
+ "Nim",
+ "OCaml",
+ "Erlang",
+ "Perl",
+ "Bash",
+ "SQL",
+ "HTML",
+ "CSS",
+];
+
+/**
+ * Example: Combobox creatable with a confirmation dialog.
+ * Demonstrates awaiting user input in onCreateItem via Promise.
+ */
+const CreatableWithDialog = ({
+ items,
+ onItemsChange,
+}: {
+ items: { id: string; name: string }[];
+ onItemsChange: React.Dispatch>;
+}) => {
+ const [dialogState, setDialogState] = React.useState<{
+ open: boolean;
+ value: string;
+ resolve: (result: { id: string; name: string } | false) => void;
+ } | null>(null);
+
+ return (
+ <>
+ ({ label: item.name, key: item.id })}
+ onCreateItem={(value) =>
+ new Promise<{ id: string; name: string } | false>((resolve) => {
+ setDialogState({ open: true, value, resolve });
+ })
+ }
+ placeholder="Search or create (with confirm)..."
+ />
+ {
+ if (!open && dialogState) {
+ dialogState.resolve(false);
+ setDialogState(null);
+ }
+ }}
+ >
+
+
+ Create new item
+
+ Are you sure you want to create "{dialogState?.value}"?
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+const DropdownComponentsDemoPage = () => {
+ const [selectedFruits, setSelectedFruits] = React.useState([]);
+ const [creatableItems, setCreatableItems] = React.useState<{ id: string; name: string }[]>([
+ { id: "1", name: "React" },
+ { id: "2", name: "Vue" },
+ { id: "3", name: "Angular" },
+ { id: "4", name: "Svelte" },
+ ]);
+
+ const cardStyle: React.CSSProperties = {
+ padding: "1.5rem",
+ borderRadius: "0.75rem",
+ border: "1px solid var(--border)",
+ backgroundColor: "var(--card)",
+ color: "var(--card-foreground)",
+ };
+ const headingStyle: React.CSSProperties = {
+ fontWeight: "bold",
+ marginBottom: "0.75rem",
+ fontSize: "1.125rem",
+ };
+ const subHeadingStyle: React.CSSProperties = {
+ fontWeight: 600,
+ marginBottom: "0.5rem",
+ fontSize: "0.875rem",
+ color: "var(--muted-foreground)",
+ };
+ const sectionStyle: React.CSSProperties = {
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.5rem",
+ };
+ const gridStyle: React.CSSProperties = {
+ display: "grid",
+ gridTemplateColumns: "repeat(3, 1fr)",
+ gap: "1.5rem",
+ };
+
+ return (
+
+
+
+ {/* ── Select ── */}
+
+
Select
+
+ {/* Basic (string items) */}
+
+
+ {/* Custom render */}
+
+
Custom render
+
+
+ {/* Multiple selection */}
+
+
Multiple
+
+
+ {/* Grouped items */}
+
+
Grouped
+
+
+ {/* Disabled */}
+
+
+
+
+ {/* ── Select.Async ── */}
+
+
Select.Async
+
+
+
Single
+
{
+ await new Promise((r) => setTimeout(r, 800));
+ return fruits;
+ }}
+ mapItem={(f) => ({
+ label: f.name,
+ key: f.id,
+ render: (
+
+ {f.emoji} {f.name}
+
+ ),
+ })}
+ placeholder="Async select..."
+ loadingText="Loading fruits..."
+ />
+
+
+
+
Multiple
+
{
+ await new Promise((r) => setTimeout(r, 800));
+ return fruits;
+ }}
+ mapItem={(f) => ({ label: f.name, key: f.id })}
+ multiple
+ placeholder="Async multi-select..."
+ />
+
+
+
+
+ {/* ── Combobox ── */}
+
+
Combobox
+
+ {/* Basic string items */}
+
+
+ {/* Custom render */}
+
+
Custom render
+
({
+ label: f.name,
+ key: f.id,
+ render: (
+
+ {f.emoji} {f.name}
+
+ ),
+ })}
+ placeholder="With emoji"
+ />
+
+
+ {/* Multiple */}
+
+
Multiple (chips)
+
({ label: f.name, key: f.id })}
+ multiple
+ placeholder="Add fruits..."
+ />
+
+
+ {/* Grouped */}
+
+
Grouped
+
({ label: f.name, key: f.id })}
+ placeholder="Search grouped..."
+ />
+
+
+ {/* Disabled */}
+
+
+
+
+ {/* ── Combobox.Async ── */}
+
+
Combobox.Async
+
+
+
Single
+
{
+ await new Promise((r) => setTimeout(r, 400));
+ return allProgrammingLanguages.filter((l) =>
+ l.toLowerCase().includes(query.toLowerCase()),
+ );
+ }}
+ placeholder="Search programming language..."
+ loadingText="Searching..."
+ emptyText="No programming languages found."
+ />
+
+
+
+
Multiple
+
{
+ await new Promise((r) => setTimeout(r, 400));
+ return allProgrammingLanguages.filter((l) =>
+ l.toLowerCase().includes(query.toLowerCase()),
+ );
+ }}
+ multiple
+ placeholder="Add programming languages..."
+ />
+
+
+
+
+ {/* ── Combobox (creatable) ── */}
+
+
Combobox (creatable)
+
+
+
Single
+
({ label: item.name, key: item.id })}
+ onCreateItem={async (value) => {
+ const item = { id: crypto.randomUUID(), name: value };
+ setCreatableItems((prev) => [...prev, item]);
+ return item;
+ }}
+ formatCreateLabel={(v) => `Create "${v}"`}
+ placeholder="Search or create..."
+ />
+
+
+
+
Multiple (async)
+
({ label: item.name, key: item.id })}
+ onCreateItem={async (value) => {
+ await new Promise((r) => setTimeout(r, 500));
+ const item = { id: crypto.randomUUID(), name: value };
+ setCreatableItems((prev) => [...prev, item]);
+ return item;
+ }}
+ multiple
+ placeholder="Add or create tags..."
+ />
+
+
+
+
With confirmation dialog
+
+
+
+
+
+ {/* ── Autocomplete ── */}
+
+
Autocomplete
+
+ {/* Basic string items */}
+
+
+ {/* Custom render */}
+
+
Custom render
+
({
+ label: f.name,
+ key: f.id,
+ render: (
+
+ {f.emoji} {f.name}
+
+ ),
+ })}
+ placeholder="With emoji"
+ />
+
+
+ {/* Grouped */}
+
+
Grouped
+
({ label: f.name, key: f.id })}
+ placeholder="Grouped autocomplete..."
+ />
+
+
+
+
+ {/* ── Autocomplete.Async ── */}
+
+
Autocomplete.Async
+
+
+
Async search
+
{
+ await new Promise((r) => setTimeout(r, 400));
+ return allProgrammingLanguages.filter((l) =>
+ l.toLowerCase().includes(query.toLowerCase()),
+ );
+ }}
+ placeholder="Search programming language..."
+ />
+
+
+
+
+
+ );
+};
+
+const dropdownComponentsDemoResource = defineResource({
+ path: "dropdown-demo",
+ meta: {
+ title: "Dropdown Components Demo",
+ },
+ component: DropdownComponentsDemoPage,
+});
+
+// ============================================================================
+// DEMO: Form Components (Field, Fieldset, Form)
+// ============================================================================
+
+type ProfileFormData = {
+ username: string;
+ email: string;
+ bio: string;
+};
+
+const FormComponentsDemoPage = () => {
+ const [serverErrors, setServerErrors] = React.useState>({});
+ const [submittedData, setSubmittedData] = React.useState(null);
+
+ // Select / Combobox / Autocomplete data
+ type FruitOption = { id: string; name: string };
+ const fruitOptions: FruitOption[] = [
+ { id: "apple", name: "Apple" },
+ { id: "banana", name: "Banana" },
+ { id: "cherry", name: "Cherry" },
+ { id: "grape", name: "Grape" },
+ { id: "mango", name: "Mango" },
+ { id: "orange", name: "Orange" },
+ ];
+ const comboItems = ["Apple", "Banana", "Cherry", "Grape", "Mango", "Orange"];
+
+ const cardStyle: React.CSSProperties = {
+ padding: "1.5rem",
+ borderRadius: "0.75rem",
+ border: "1px solid var(--border)",
+ backgroundColor: "var(--card)",
+ color: "var(--card-foreground)",
+ };
+ const headingStyle: React.CSSProperties = {
+ fontWeight: "bold",
+ marginBottom: "0.5rem",
+ };
+ const labelStyle: React.CSSProperties = {
+ fontSize: "0.875rem",
+ color: "var(--muted-foreground)",
+ marginBottom: "0.5rem",
+ };
+
+ const gridStyle: React.CSSProperties = {
+ display: "grid",
+ gridTemplateColumns: "repeat(2, 1fr)",
+ gap: "1rem",
+ };
+
+ return (
+
+
+
+
+ {/* Basic Field */}
+
+
Field
+
+ A compound component that groups label, control, description, and error message.
+ Wrapped in a Form to demonstrate submit.
+
+
+
+
{
+ if (!open) setSubmittedData(null);
+ }}
+ >
+
+
+ Submitted Data
+ The following values were submitted:
+
+
+
+
+ Field
+ Value
+
+
+
+ {submittedData && (
+ <>
+
+ Username
+
+ {submittedData.username || (
+ (empty)
+ )}
+
+
+
+ Email
+
+ {submittedData.email || (
+ (empty)
+ )}
+
+
+
+ Bio
+
+ {submittedData.bio || (
+ (empty)
+ )}
+
+
+ >
+ )}
+
+
+
+ }>Close
+
+
+
+
+
+ {/* Fieldset */}
+
+
Fieldset
+
+ Groups related fields under a semantic <fieldset> with a{" "}
+ <legend>.
+
+
+ Shipping Address
+
+
+ Street
+
+
+
+
+
+ City
+
+
+
+
+ ZIP
+
+
+
+
+
+
+ {/* Field with custom validation */}
+
+
Field — Custom Validation
+
+ Use the validate prop for custom validation logic.
+
+
{
+ const v = String(value ?? "");
+ if (v.length > 0 && v.length < 8) {
+ return "Password must be at least 8 characters.";
+ }
+ return null;
+ }}
+ >
+ Password
+
+
+
+
+
+ {/* Field.Validity */}
+
+
Field.Validity
+
+ Render-prop that exposes the field's ValidityState for custom rendering.
+
+
+ Age
+
+
+ {(state) =>
+ state.validity.rangeOverflow ? (
+
+ Age cannot exceed 150.
+
+ ) : null
+ }
+
+
+
+
+ {/* Field + Dropdown Components */}
+
+
Field + Dropdown Components
+
+ Select, Combobox, Autocomplete composed with Field for labeling, description, and
+ validation.
+
+
+
{
+ const v = value as FruitOption | null;
+ if (v?.name !== "Mango") {
+ return 'Please select "Mango".';
+ }
+ return null;
+ }}
+ validationMode="onChange"
+ >
+ Select
+
+
+
{
+ const v = value as string[] | undefined;
+ if (!v || v.length === 0) {
+ return "Select at least one fruit.";
+ }
+ return null;
+ }}
+ validationMode="onChange"
+ >
+ Combobox
+
+ Required — at least one.
+
+
+
+
{
+ const v = String(value ?? "");
+ if (v !== "" && v !== "Cherry") {
+ return 'Please type "Cherry".';
+ }
+ return null;
+ }}
+ validationMode="onChange"
+ >
+ Autocomplete
+ ({ label: f.name, key: f.id })}
+ placeholder="Type a fruit..."
+ />
+ Must be "Cherry".
+
+
+
+
+
+ {/* Form with submit — spans full width */}
+
+
Form — Submit & Server Errors
+
+ Wraps fields in a <form> with consolidated error handling.
+
+
+
+
+
+
+ );
+};
+
+const formComponentsDemoResource = defineResource({
+ path: "form-demo",
+ meta: {
+ title: "Form Components Demo",
+ },
+ component: FormComponentsDemoPage,
+});
+
+// ---------------------------------------------------------------------------
+// Zod + React Hook Form Demo
+// ---------------------------------------------------------------------------
+
+const contactSchema = z.object({
+ name: z.string().min(1, "Name is required").max(50, "Name must be 50 characters or less"),
+ email: z.string().email("Please enter a valid email address"),
+ age: z
+ .number({ error: "Age is required" })
+ .min(18, "Must be at least 18")
+ .max(120, "Must be 120 or less"),
+ website: z.union([z.url("Please enter a valid URL"), z.literal("")]).optional(),
+ message: z.string().min(10, "Message must be at least 10 characters"),
+});
+
+type ContactFormValues = z.infer;
+
+const ZodRHFFormDemoPage = () => {
+ const [submittedData, setSubmittedData] = React.useState(null);
+
+ const { control, handleSubmit, reset } = useForm({
+ resolver: zodResolver(contactSchema),
+ defaultValues: {
+ name: "",
+ email: "",
+ age: undefined,
+ website: "",
+ message: "",
+ },
+ });
+
+ const onSubmit = (data: ContactFormValues) => {
+ setSubmittedData(data);
+ };
+
+ return (
+
+
+
+
Zod + React Hook Form Demo
+
+
+
+ {submittedData && (
+
+
Submitted values:
+
+ {JSON.stringify(submittedData, null, 2)}
+
+
+ )}
+
+
+
+ );
+};
+
+const zodRHFFormDemoResource = defineResource({
+ path: "zod-rhf-form-demo",
+ meta: {
+ title: "Zod + RHF Form Demo",
+ },
+ component: ZodRHFFormDemoPage,
+});
export const customPageModule = defineModule({
path: "custom-page",
diff --git a/packages/core/__snapshots__/src__components__tabs.test.tsx.snap b/packages/core/__snapshots__/src__components__tabs.test.tsx.snap
new file mode 100644
index 00000000..13dce3b4
--- /dev/null
+++ b/packages/core/__snapshots__/src__components__tabs.test.tsx.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Tabs > snapshots > default tabs 1`] = `""`;
+
+exports[`Tabs > snapshots > tabs with disabled tab 1`] = `""`;
+
+exports[`Tabs > snapshots > tabs with three tabs 1`] = `"Overview content
"`;
diff --git a/packages/core/src/components/tabs.test.tsx b/packages/core/src/components/tabs.test.tsx
new file mode 100644
index 00000000..c5789280
--- /dev/null
+++ b/packages/core/src/components/tabs.test.tsx
@@ -0,0 +1,170 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Tabs } from "./tabs";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("Tabs", () => {
+ // ==========================================================================
+ // Snapshots — verify full DOM structure for tabs variations
+ // ==========================================================================
+
+ describe("snapshots", () => {
+ it("default tabs", () => {
+ const { container } = render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("tabs with three tabs", () => {
+ const { container } = render(
+
+
+ Overview
+ Projects
+ Account
+
+ Overview content
+ Projects content
+ Account content
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("tabs with disabled tab", () => {
+ const { container } = render(
+
+
+ Tab 1
+
+ Tab 2
+
+
+ Content 1
+ Content 2
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+ });
+
+ it("renders all tabs", () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ expect(screen.getByText("Tab 1")).toBeDefined();
+ expect(screen.getByText("Tab 2")).toBeDefined();
+ });
+
+ it("displays the active panel content", () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ expect(screen.getByText("Content 1")).toBeDefined();
+ });
+
+ it("switches panel on tab click", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ await user.click(screen.getByText("Tab 2"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Content 2")).toBeDefined();
+ });
+ });
+
+ it("calls onValueChange when tab is clicked", async () => {
+ const handleChange = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ await user.click(screen.getByText("Tab 2"));
+
+ await waitFor(() => {
+ expect(handleChange).toHaveBeenCalled();
+ expect(handleChange.mock.calls[0][0]).toBe("tab2");
+ });
+ });
+
+ it("supports controlled value", () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+ ,
+ );
+
+ expect(screen.getByText("Content 2")).toBeDefined();
+ });
+
+ it("keeps panel in DOM when keepMounted is set", () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+
+ Content 2
+
+ ,
+ );
+
+ // tab1 is active, but tab2's panel should remain in the DOM due to keepMounted
+ expect(screen.getByText("Content 2")).toBeDefined();
+ });
+});
diff --git a/packages/core/src/components/tabs.tsx b/packages/core/src/components/tabs.tsx
new file mode 100644
index 00000000..3a56a1bc
--- /dev/null
+++ b/packages/core/src/components/tabs.tsx
@@ -0,0 +1,115 @@
+import * as React from "react";
+import { Tabs as BaseTabs } from "@base-ui/react/tabs";
+
+import { cn } from "@/lib/utils";
+
+// Only the props relevant to the Tabs abstraction are picked from Base UI.
+// Base UI-internal props are intentionally excluded so that upstream changes
+// don't leak as breaking changes to consumers.
+type RootProps = Pick<
+ React.ComponentProps,
+ "defaultValue" | "value" | "onValueChange" | "orientation"
+> & {
+ children: React.ReactNode;
+ className?: string;
+};
+
+/**
+ * The root component that manages tab selection state.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * Overview
+ * Projects
+ * Account
+ *
+ * Overview content
+ * Projects content
+ * Account content
+ *
+ * ```
+ */
+function Root({ className, children, ...props }: RootProps) {
+ return (
+
+ {children}
+
+ );
+}
+Root.displayName = "Tabs.Root";
+
+type ListProps = Pick, "children" | "className">;
+
+/** Groups the individual tab buttons. */
+function List({ className, children, ...props }: ListProps) {
+ return (
+
+ {children}
+
+ );
+}
+List.displayName = "Tabs.List";
+
+type TabProps = Pick<
+ React.ComponentProps,
+ "value" | "disabled" | "children" | "className"
+>;
+
+/** An individual interactive tab button that toggles the corresponding panel. */
+function Tab({ className, children, ...props }: TabProps) {
+ return (
+
+ {children}
+
+ );
+}
+Tab.displayName = "Tabs.Tab";
+
+type PanelProps = Pick<
+ React.ComponentProps,
+ "value" | "keepMounted" | "children" | "className"
+>;
+
+/** A panel displayed when the corresponding tab is active. */
+function Panel({ className, children, ...props }: PanelProps) {
+ return (
+
+ {children}
+
+ );
+}
+Panel.displayName = "Tabs.Panel";
+
+export const Tabs = {
+ Root,
+ List,
+ Tab,
+ Panel,
+};
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 2111668e..8a2ca199 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -99,6 +99,7 @@ export { Fieldset } from "./components/fieldset";
export { Form, type FormProps } from "./components/form";
export { Menu } from "./components/menu";
export { Sheet } from "./components/sheet";
+export { Tabs } from "./components/tabs";
export { Tooltip } from "./components/tooltip";
export { Select, type SelectAsyncFetcher } from "./components/select-standalone";
export { Combobox, type ComboboxAsyncFetcher } from "./components/combobox-standalone";