diff --git a/.changeset/add-tabs-component.md b/.changeset/add-tabs-component.md new file mode 100644 index 00000000..7951d861 --- /dev/null +++ b/.changeset/add-tabs-component.md @@ -0,0 +1,18 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `Tabs` compound component for tab-based navigation, backed by Base UI's Tabs primitive. + +```tsx +import { Tabs } from "@tailor-platform/app-shell"; + + + + Overview + Projects + + Overview content + Projects content +; +``` diff --git a/docs/components/tabs.md b/docs/components/tabs.md new file mode 100644 index 00000000..37ebd659 --- /dev/null +++ b/docs/components/tabs.md @@ -0,0 +1,107 @@ +--- +title: Tabs +description: Tab navigation with a compound component API +--- + +# Tabs + +The `Tabs` component provides tab-based navigation for toggling between related panels on the same page. It is backed by Base UI's Tabs primitive. + +## Import + +```tsx +import { Tabs } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx + + + Overview + Projects + Account + + Overview content + Projects content + Account content + +``` + +## Sub-components + +| Sub-component | Description | +| ------------- | -------------------------------------------------------------- | +| `Tabs.Root` | Manages tab selection state | +| `Tabs.List` | Groups the individual tab buttons | +| `Tabs.Tab` | An interactive tab button that toggles the corresponding panel | +| `Tabs.Panel` | A panel displayed when the corresponding tab is active | + +## Props + +### Tabs.Root Props + +| Prop | Type | Default | Description | +| --------------- | ----------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defaultValue` | `Tabs.Tab.Value` | `0` | Initial active tab value (uncontrolled) | +| `value` | `Tabs.Tab.Value` | - | Controlled active tab value | +| `onValueChange` | `(value: Tabs.Tab.Value, eventDetails: Tabs.Root.ChangeEventDetails) => void` | - | Callback when the active tab changes. The second argument includes `activationDirection` and standard event details (`event`, `reason`, `cancel()`). | +| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Orientation of the tabs | +| `className` | `string` | - | Additional CSS classes for root | +| `children` | `React.ReactNode` | - | Tabs sub-components | + +### Tabs.List Props + +Accepts `className` and all standard HTML `
` props. + +### Tabs.Tab Props + +| Prop | Type | Default | Description | +| ---------- | ---------------- | ------- | ---------------------------------- | +| `value` | `Tabs.Tab.Value` | - | **Required.** The value of the tab | +| `disabled` | `boolean` | - | Whether the tab is disabled | + +Also accepts `className` and all standard HTML `, + , +]; + +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 */} +
+

Input

+
+ setInputValue(e.target.value)} + /> + + +
+
+ + {/* 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) */} +
+
Basic
+ ({ + label: f.name, + key: f.id, + render: ( + + {f.emoji} {f.name} + + ), + })} + placeholder="With emoji" + /> +
+ + {/* Multiple selection */} +
+
Multiple
+ ({ label: f.name, key: f.id })} + placeholder="Grouped select" + /> +
+ + {/* Disabled */} +
+
Disabled
+ ({ label: f.name, key: f.id })} + placeholder="Choose a fruit" + /> + Must be "Mango". + + + + { + 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. +

+
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const url = String(formData.get("url") ?? ""); + + // Simulate a server-side error + if (url && !url.startsWith("https://")) { + setServerErrors({ url: "URL must start with https://" }); + return; + } + + setServerErrors({}); + alert(`Submitted URL: ${url}`); + }} + style={{ + display: "flex", + flexDirection: "column", + gap: "1rem", + maxWidth: "480px", + }} + > + + Homepage URL + + Must start with https:// + URL is required. + Please enter a valid URL. + +
+ +
+
+
+
+
+
+ ); +}; + +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

+ +
+ + Contact Information + + ( + + Name + + + {fieldState.error?.message} + + + )} + /> + + ( + + Email + + We will never share your email. + + {fieldState.error?.message} + + + )} + /> + + ( + + Age + ) => + onChange(e.target.value === "" ? undefined : Number(e.target.value)) + } + /> + + {fieldState.error?.message} + + + )} + /> + + ( + + Website + + Optional + + {fieldState.error?.message} + + + )} + /> + + ( + + Message + + At least 10 characters + + {fieldState.error?.message} + + + )} + /> + + +
+ + +
+
+ + {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`] = `"
Content 1
"`; + +exports[`Tabs > snapshots > tabs with disabled tab 1`] = `"
Content 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";