chore: move tab component to storybook (#6214)

This commit is contained in:
Jakob Schott
2025-07-17 11:26:31 +02:00
committed by GitHub
parent 58213969e8
commit 23d38b4c5b
12 changed files with 1066 additions and 0 deletions

View File

@@ -201,6 +201,8 @@
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern",
"finish": "Fertigstellen",
"follow_these": "Folge diesen",

View File

@@ -201,6 +201,8 @@
"error": "Error",
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
"error_component_title": "Error loading resources",
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows",
"finish": "Finish",
"follow_these": "Follow these",

View File

@@ -201,6 +201,8 @@
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
"error_component_title": "Erreur de chargement des ressources",
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
"finish": "Terminer",
"follow_these": "Suivez ceci",

View File

@@ -201,6 +201,8 @@
"error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
"error_component_title": "Erro ao carregar recursos",
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"finish": "Terminar",
"follow_these": "Siga esses",

View File

@@ -201,6 +201,8 @@
"error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
"error_component_title": "Erro ao carregar recursos",
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"finish": "Concluir",
"follow_these": "Siga estes",

View File

@@ -201,6 +201,8 @@
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
"error_component_title": "載入資源錯誤",
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
"finish": "完成",
"follow_these": "按照這些步驟",

View File

@@ -0,0 +1,62 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TabNav } from "./index";
describe("TabNav", () => {
afterEach(() => {
cleanup();
});
const mockTabs = [
{ id: "tab1", label: "Tab One" },
{ id: "tab2", label: "Tab Two" },
{ id: "tab3", label: "Tab Three" },
];
test("calls setActiveId when tab is clicked", async () => {
const handleSetActiveId = vi.fn();
const user = userEvent.setup();
render(<TabNav tabs={mockTabs} activeId="tab1" setActiveId={handleSetActiveId} />);
await user.click(screen.getByText("Tab Two"));
expect(handleSetActiveId).toHaveBeenCalledTimes(1);
expect(handleSetActiveId).toHaveBeenCalledWith("tab2");
});
test("renders tabs with icons", () => {
const tabsWithIcons = [
{ id: "tab1", label: "Tab One", icon: <span data-testid="icon1">🔍</span> },
{ id: "tab2", label: "Tab Two", icon: <span data-testid="icon2">📁</span> },
];
render(<TabNav tabs={tabsWithIcons} activeId="tab1" setActiveId={() => {}} />);
expect(screen.getByTestId("icon1")).toBeInTheDocument();
expect(screen.getByTestId("icon2")).toBeInTheDocument();
});
test("applies activeTabClassName to active tab", () => {
render(
<TabNav
tabs={mockTabs}
activeId="tab1"
setActiveId={() => {}}
activeTabClassName="custom-active-class"
/>
);
const activeTab = screen.getByText("Tab One").closest("button");
expect(activeTab).toHaveClass("custom-active-class");
});
test("renders navigation container", () => {
render(<TabNav tabs={mockTabs} activeId="tab1" setActiveId={() => {}} />);
const navContainer = screen.getByRole("navigation");
expect(navContainer).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
"use client";
import { cn } from "@/lib/cn";
interface TabNavProps {
tabs: { id: string; label: string; icon?: React.ReactNode }[];
activeId: string;
setActiveId: (id: string) => void;
activeTabClassName?: string;
disabled?: boolean;
}
interface NavProps {
tabs: { id: string; label: string; icon?: React.ReactNode }[];
activeId: string;
setActiveId: (id: string) => void;
activeTabClassName?: string;
disabled?: boolean;
}
const Nav: React.FC<NavProps> = ({ tabs, activeId, setActiveId, activeTabClassName, disabled }) => {
return (
<nav className="flex h-full items-center space-x-3" aria-label="Tabs">
{tabs.map((tab) => (
<button
type="button"
key={tab.id}
onClick={() => setActiveId(tab.id)}
disabled={disabled}
className={cn(
"flex h-full items-center px-3 text-sm font-medium",
disabled
? "cursor-not-allowed text-slate-400"
: tab.id === activeId
? `border-brand-dark text-primary border-b-2 font-semibold ${activeTabClassName}`
: "text-slate-500 hover:text-slate-700"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
{tab.label}
</button>
))}
</nav>
);
};
export const TabNav: React.FC<TabNavProps> = ({
tabs,
activeId,
setActiveId,
activeTabClassName,
disabled,
}) => {
return (
<div className={cn("flex h-14 w-full items-center justify-center rounded-t-md")}>
<Nav
tabs={tabs}
activeId={activeId}
setActiveId={setActiveId}
activeTabClassName={activeTabClassName}
disabled={disabled}
/>
</div>
);
};

View File

@@ -0,0 +1,143 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BarChart, FileText, Home, InfoIcon, KeyRound, Settings, User, UserIcon } from "lucide-react";
import { useState } from "react";
import { TabNav } from "./index";
// Story options separate from component props
interface StoryOptions {
showIcons: boolean;
numberOfTabs: number;
tabTexts: string;
}
type StoryProps = React.ComponentProps<typeof TabNav> & StoryOptions;
// Available icons for tabs
const availableIcons = [Home, User, Settings, UserIcon, KeyRound, InfoIcon, FileText, BarChart];
const meta: Meta<StoryProps> = {
title: "UI/TabNav",
component: TabNav,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: {
sort: "requiredFirst",
exclude: [],
},
docs: {
description: {
component: `
The **TabNav** component provides a navigation interface with tabs. It displays a horizontal bar with underline styling for the active tab. Each tab can include an optional icon.
`,
},
},
},
argTypes: {
// Story Options - Appearance Category
showIcons: {
control: "boolean",
description: "Whether to show icons in tabs",
table: {
category: "Appearance",
type: { summary: "boolean" },
defaultValue: { summary: "true" },
},
order: 1,
},
// Story Options - Content Category
numberOfTabs: {
control: { type: "number", min: 2, max: 6, step: 1 },
description: "Number of tabs to display",
table: {
category: "Content",
type: { summary: "number" },
defaultValue: { summary: "3" },
},
order: 1,
},
tabTexts: {
control: "text",
description: "Comma-separated tab labels (e.g., 'Home,Profile,Settings')",
table: {
category: "Content",
type: { summary: "string" },
defaultValue: { summary: "Home,Profile,Settings" },
},
order: 2,
},
},
};
export default meta;
type Story = StoryObj<typeof TabNav> & { args: StoryOptions };
// Create a render function to handle dynamic tab generation
const renderTabNav = (args: StoryProps) => {
const { showIcons = true, numberOfTabs = 3, tabTexts = "Home,Profile,Settings", activeTabClassName } = args;
// Parse tab texts from comma-separated string
const tabLabels = tabTexts
.split(",")
.map((text) => text.trim())
.filter(Boolean);
// Ensure we have enough labels for the number of tabs
const finalTabLabels = Array.from({ length: numberOfTabs }, (_, i) => tabLabels[i] || `Tab ${i + 1}`);
// Generate tabs array
const tabs = finalTabLabels.map((label, index) => {
const IconComponent = availableIcons[index % availableIcons.length];
return {
id: `tab-${index + 1}`,
label,
icon: showIcons ? <IconComponent size={16} /> : undefined,
};
});
// Wrapper component to handle state for stories
const TabNavWithState = () => {
const [activeId, setActiveId] = useState(tabs[0]?.id || "tab-1");
return (
// <div className="w-[60dvw]">
<TabNav
tabs={tabs}
activeId={activeId}
setActiveId={setActiveId}
activeTabClassName={activeTabClassName}
/>
// </div>
);
};
return <TabNavWithState />;
};
export const Default: Story = {
render: renderTabNav,
args: {
showIcons: false,
numberOfTabs: 3,
tabTexts: "Home,Profile,Settings",
},
};
export const WithIcons: Story = {
render: renderTabNav,
args: {
showIcons: true,
numberOfTabs: 3,
tabTexts: "Home,Profile,Settings",
},
parameters: {
docs: {
description: {
story: "Tab nav with icons alongside text labels.",
},
},
},
};

View File

@@ -0,0 +1,389 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Home, Settings, User } from "lucide-react";
import { afterEach, describe, expect, test } from "vitest";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./index";
describe("Tabs", () => {
afterEach(() => {
cleanup();
});
test("renders tabs with default variant and size", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
</Tabs>
);
expect(screen.getByText("Tab 1")).toBeInTheDocument();
expect(screen.getByText("Tab 2")).toBeInTheDocument();
expect(screen.getByText("Content 1")).toBeInTheDocument();
expect(screen.queryByText("Content 2")).not.toBeInTheDocument();
});
test("switches tabs when clicked", async () => {
const user = userEvent.setup();
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
</Tabs>
);
await user.click(screen.getByText("Tab 2"));
expect(screen.getByText("Content 2")).toBeInTheDocument();
expect(screen.queryByText("Content 1")).not.toBeInTheDocument();
});
test("renders with disabled variant", () => {
render(
<Tabs defaultValue="tab1">
<TabsList variant="disabled">
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const tabsList = screen.getByRole("tablist");
expect(tabsList).toHaveClass("opacity-50");
expect(tabsList).toHaveClass("pointer-events-none");
});
test("renders with big size", () => {
render(
<Tabs defaultValue="tab1">
<TabsList size="big">
<TabsTrigger value="tab1" size="big">
Tab 1
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const tabsList = screen.getByRole("tablist");
expect(tabsList).toHaveClass("h-auto");
const trigger = screen.getByRole("tab");
expect(trigger).toHaveClass("px-3");
expect(trigger).toHaveClass("py-2");
});
test("renders triggers with icons", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" icon={<Home data-testid="home-icon" />}>
Home
</TabsTrigger>
<TabsTrigger value="tab2" icon={<User data-testid="user-icon" />}>
Profile
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Home Content</TabsContent>
<TabsContent value="tab2">Profile Content</TabsContent>
</Tabs>
);
expect(screen.getByTestId("home-icon")).toBeInTheDocument();
expect(screen.getByTestId("user-icon")).toBeInTheDocument();
});
test("hides icons when showIcon is false", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" icon={<Home data-testid="home-icon" />} showIcon={false}>
Home
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Home Content</TabsContent>
</Tabs>
);
expect(screen.queryByTestId("home-icon")).not.toBeInTheDocument();
expect(screen.getByText("Home")).toBeInTheDocument();
});
test("shows icons when showIcon is true", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" icon={<Home data-testid="home-icon" />} showIcon={true}>
Home
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Home Content</TabsContent>
</Tabs>
);
expect(screen.getByTestId("home-icon")).toBeInTheDocument();
});
test("renders with column layout", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" layout="column">
Tab 1
</TabsTrigger>
<TabsTrigger value="tab2" layout="column">
Tab 2
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const triggers = screen.getAllByRole("tab");
triggers.forEach((trigger) => {
expect(trigger).toHaveClass("flex-col");
expect(trigger).toHaveClass("gap-1");
});
});
test("renders with row layout", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" layout="row">
Tab 1
</TabsTrigger>
<TabsTrigger value="tab2" layout="row">
Tab 2
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const triggers = screen.getAllByRole("tab");
triggers.forEach((trigger) => {
expect(trigger).toHaveClass("flex-row");
expect(trigger).toHaveClass("gap-2");
});
});
test("applies custom className to Tabs component", () => {
const { container } = render(
<Tabs defaultValue="tab1" className="custom-tabs-class">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const tabsContainer = container.firstChild as HTMLElement;
expect(tabsContainer).toHaveClass("custom-tabs-class");
});
test("applies custom className to TabsList", () => {
render(
<Tabs defaultValue="tab1">
<TabsList className="custom-list-class">
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const tabsList = screen.getByRole("tablist");
expect(tabsList).toHaveClass("custom-list-class");
});
test("applies custom className to TabsTrigger", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" className="custom-trigger-class">
Tab 1
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const trigger = screen.getByRole("tab");
expect(trigger).toHaveClass("custom-trigger-class");
});
test("applies custom className to TabsContent", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
</TabsList>
<TabsContent value="tab1" className="custom-content-class">
Content 1
</TabsContent>
</Tabs>
);
const content = screen.getByText("Content 1");
expect(content).toHaveClass("custom-content-class");
});
test("renders with disabled trigger variant", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" variant="disabled">
Tab 1
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
const trigger = screen.getByRole("tab");
expect(trigger).toHaveClass("opacity-50");
expect(trigger).toHaveClass("pointer-events-none");
});
test("handles keyboard navigation", async () => {
const user = userEvent.setup();
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
<TabsTrigger value="tab3">Tab 3</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
<TabsContent value="tab3">Content 3</TabsContent>
</Tabs>
);
const allTabs = screen.getAllByRole("tab");
const firstTab = allTabs[0];
const secondTab = allTabs[1];
await user.tab();
expect(firstTab).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(secondTab).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(firstTab).toHaveFocus();
});
test("renders with big size trigger and correct icon sizing", () => {
render(
<Tabs defaultValue="tab1">
<TabsList size="big">
<TabsTrigger value="tab1" size="big" icon={<Settings data-testid="settings-icon" />}>
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Settings Content</TabsContent>
</Tabs>
);
const trigger = screen.getByRole("tab");
expect(trigger).toHaveClass("[&_svg]:size-8");
expect(trigger).toHaveClass("[&_svg]:stroke-[1.5]");
});
test("renders with default size trigger and correct icon sizing", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1" icon={<Settings data-testid="settings-icon" />}>
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Settings Content</TabsContent>
</Tabs>
);
const trigger = screen.getByRole("tab");
expect(trigger).toHaveClass("[&_svg]:size-4");
expect(trigger).toHaveClass("[&_svg]:stroke-2");
});
test("passes through additional props to components", () => {
render(
<Tabs defaultValue="tab1" data-testid="tabs-root">
<TabsList data-testid="tabs-list">
<TabsTrigger value="tab1" data-testid="tabs-trigger">
Tab 1
</TabsTrigger>
</TabsList>
<TabsContent value="tab1" data-testid="tabs-content">
Content 1
</TabsContent>
</Tabs>
);
expect(screen.getByTestId("tabs-root")).toBeInTheDocument();
expect(screen.getByTestId("tabs-list")).toBeInTheDocument();
expect(screen.getByTestId("tabs-trigger")).toBeInTheDocument();
expect(screen.getByTestId("tabs-content")).toBeInTheDocument();
});
test("renders with active state styling", () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
</Tabs>
);
const allTabs = screen.getAllByRole("tab");
const activeTab = allTabs[0];
const inactiveTab = allTabs[1];
expect(activeTab).toHaveClass("data-[state=active]:bg-white");
expect(activeTab).toHaveClass("data-[state=active]:text-slate-900");
expect(activeTab).toHaveClass("data-[state=active]:shadow-sm");
expect(inactiveTab).toHaveClass("data-[state=inactive]:text-slate-600");
});
test("renders multiple tabs with complex layout", () => {
render(
<Tabs defaultValue="home">
<TabsList>
<TabsTrigger value="home" icon={<Home />} layout="column" size="big">
Home
</TabsTrigger>
<TabsTrigger value="profile" icon={<User />} layout="column" size="big">
Profile
</TabsTrigger>
<TabsTrigger value="settings" icon={<Settings />} layout="column" size="big">
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="home">Home Content</TabsContent>
<TabsContent value="profile">Profile Content</TabsContent>
<TabsContent value="settings">Settings Content</TabsContent>
</Tabs>
);
expect(screen.getByText("Home")).toBeInTheDocument();
expect(screen.getByText("Profile")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
expect(screen.getByText("Home Content")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,129 @@
"use client";
import { cn } from "@/lib/cn";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
const tabsVariants = cva(
"bg-slate-100 rounded-lg p-1 inline-flex items-center overflow-x-auto [scrollbar-width:none]",
{
variants: {
variant: {
default: "",
disabled: "opacity-50 pointer-events-none",
},
size: {
default: "h-9",
big: "h-auto",
},
width: {
fill: "w-full",
fit: "w-fit max-w-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
width: "fit",
},
}
);
const tabsTriggerVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-sm data-[state=inactive]:text-slate-600",
disabled: "opacity-50 pointer-events-none",
},
size: {
default: "px-3 py-1 [&_svg]:size-4 [&_svg]:stroke-2",
big: "px-3 py-2 [&_svg]:size-8 [&_svg]:stroke-[1.5]",
},
layout: {
row: "flex-row gap-2",
column: "flex-col gap-1",
},
},
defaultVariants: {
variant: "default",
size: "default",
layout: "row",
},
}
);
interface TabsProps extends React.ComponentProps<typeof TabsPrimitive.Root> {}
function Tabs({ className, ...props }: TabsProps) {
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
}
interface TabsListProps
extends React.ComponentProps<typeof TabsPrimitive.List>,
VariantProps<typeof tabsVariants> {}
interface TabsTriggerProps
extends React.ComponentProps<typeof TabsPrimitive.Trigger>,
VariantProps<typeof tabsTriggerVariants> {
readonly icon?: React.ReactNode;
readonly showIcon?: boolean;
}
function TabsList({ className, variant, size, width, ...props }: TabsListProps) {
const isGridLayout = width === "fill";
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
tabsVariants({ variant, size, width }),
isGridLayout ? "grid grid-cols-[repeat(var(--tabs-count),1fr)]" : "flex",
className
)}
style={
isGridLayout
? ({ "--tabs-count": React.Children.count(props.children) } as React.CSSProperties)
: undefined
}
{...props}
/>
);
}
function TabsTrigger({
className,
variant,
size,
layout,
icon,
showIcon = true,
children,
...props
}: TabsTriggerProps) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(tabsTriggerVariants({ variant, size, layout }), "h-full min-w-max", className)}
{...props}>
{showIcon && icon}
<span className="text-center text-sm font-medium leading-5">{children}</span>
</TabsPrimitive.Trigger>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
export type { TabsProps, TabsListProps, TabsTriggerProps };

View File

@@ -0,0 +1,266 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BarChart, FileText, InfoIcon, KeyRound, Settings, UserIcon } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./index";
// Story options separate from component props
interface StoryOptions {
size?: "default" | "big";
showIcons?: boolean;
numberOfTabs?: number;
tabTexts?: string;
width?: "fill" | "fit";
variant?: "default" | "disabled";
}
type StoryProps = React.ComponentProps<typeof Tabs> & StoryOptions;
// Available icons for tabs
const availableIcons = [UserIcon, KeyRound, InfoIcon, Settings, FileText, BarChart];
const meta: Meta<StoryProps> = {
title: "UI/Tabs",
component: Tabs,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: {
sort: "requiredFirst",
exclude: [],
},
},
argTypes: {
// Story Options - Appearance Category
size: {
control: "select",
options: ["default", "big"],
description: "Size of the tabs",
table: {
category: "Appearance",
type: { summary: "string" },
defaultValue: { summary: "default" },
},
order: 1,
},
showIcons: {
control: "boolean",
description: "Whether to show icons in tabs",
table: {
category: "Appearance",
type: { summary: "boolean" },
defaultValue: { summary: "true" },
},
order: 2,
},
width: {
control: "select",
options: ["fill", "fit"],
description: "Width behavior of the tabs component",
table: {
category: "Appearance",
type: { summary: "string" },
defaultValue: { summary: "fit" },
},
order: 3,
},
// Story Options - Content Category
numberOfTabs: {
control: { type: "number", min: 2, max: 6, step: 1 },
description: "Number of tabs to display",
table: {
category: "Content",
type: { summary: "number" },
defaultValue: { summary: "2" },
},
order: 1,
},
tabTexts: {
control: "text",
description: "Comma-separated tab labels (e.g., 'Account,Password,Settings')",
table: {
category: "Content",
type: { summary: "string" },
defaultValue: { summary: "Account,Password" },
},
order: 2,
},
// Story Options - Behaviour Category
variant: {
control: "select",
options: ["default", "disabled"],
description: "Variant of the tabs",
table: {
category: "Behaviour",
type: { summary: "string" },
defaultValue: { summary: "default" },
},
order: 1,
},
},
};
export default meta;
type Story = StoryObj<StoryProps>;
// Create a render function to handle dynamic tab generation
const renderTabs = (args: StoryProps) => {
const {
variant = "default",
size = "default",
width = "fit",
showIcons = true,
numberOfTabs = 2,
tabTexts = "Account,Password",
defaultValue,
...tabsProps
} = args;
// Parse tab texts from comma-separated string
const tabLabels = tabTexts
.split(",")
.map((text) => text.trim())
.filter(Boolean);
// Ensure we have enough labels for the number of tabs
const finalTabLabels = Array.from({ length: numberOfTabs }, (_, i) => tabLabels[i] || `Tab ${i + 1}`);
// Generate tab values
const tabValues = finalTabLabels.map((_, i) => `tab${i + 1}`);
const layout = size === "big" ? "column" : "row";
return (
<div className="w-full max-w-sm">
<Tabs defaultValue={defaultValue || tabValues[0]} {...tabsProps}>
<TabsList variant={variant} size={size} width={width}>
{finalTabLabels.map((label, index) => {
const IconComponent = availableIcons[index % availableIcons.length];
return (
<TabsTrigger
key={tabValues[index]}
value={tabValues[index]}
layout={layout}
size={size}
icon={showIcons ? <IconComponent /> : undefined}
showIcon={showIcons}>
{label}
</TabsTrigger>
);
})}
</TabsList>
{finalTabLabels.map((label, index) => (
<TabsContent key={tabValues[index]} value={tabValues[index]} className="mt-4">
<div className="rounded-lg border p-6 text-sm">
Content for {label} tab. This content can be of varying lengths to demonstrate how the tabs
component handles different content widths.
</div>
</TabsContent>
))}
</Tabs>
</div>
);
};
export const Default: Story = {
render: renderTabs,
args: {
size: "default",
width: "fit",
showIcons: false,
numberOfTabs: 2,
tabTexts: "Account,Password",
},
};
export const WithIcons: Story = {
render: renderTabs,
args: {
size: "default",
width: "fit",
showIcons: true,
numberOfTabs: 2,
tabTexts: "Account,Password",
},
parameters: {
docs: {
description: {
story: "Tabs with icons for enhanced visual navigation.",
},
},
},
};
export const FillWidth: Story = {
render: renderTabs,
args: {
size: "default",
width: "fill",
showIcons: true,
numberOfTabs: 2,
tabTexts: "Account,Password",
},
parameters: {
docs: {
description: {
story: "Tabs that stretch to fill the full width of the parent, with triggers evenly distributed.",
},
},
},
};
export const FitWidth: Story = {
render: renderTabs,
args: {
size: "default",
width: "fit",
showIcons: true,
numberOfTabs: 2,
tabTexts: "Account,Password",
},
parameters: {
docs: {
description: {
story: "Tabs that fit their content and are centered, with all triggers having equal width.",
},
},
},
};
export const BigSize: Story = {
render: renderTabs,
args: {
size: "big",
width: "fit",
showIcons: true,
numberOfTabs: 2,
tabTexts: "Account,Password",
},
parameters: {
docs: {
description: {
story: "Larger tabs with column layout, useful for more prominent navigation.",
},
},
},
};
export const Disabled: Story = {
render: renderTabs,
args: {
variant: "disabled",
size: "default",
width: "fit",
showIcons: true,
numberOfTabs: 2,
tabTexts: "Account,Password",
},
parameters: {
docs: {
description: {
story: "Disabled tabs that cannot be interacted with.",
},
},
},
};