mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 16:19:55 -06:00
chore: move tab component to storybook (#6214)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -201,6 +201,8 @@
|
||||
"error": "錯誤",
|
||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||
"error_component_title": "載入資源錯誤",
|
||||
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
||||
"error_rate_limit_title": "限流超過",
|
||||
"expand_rows": "展開列",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
|
||||
62
apps/web/modules/ui/components/tab-nav/index.test.tsx
Normal file
62
apps/web/modules/ui/components/tab-nav/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
65
apps/web/modules/ui/components/tab-nav/index.tsx
Normal file
65
apps/web/modules/ui/components/tab-nav/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
143
apps/web/modules/ui/components/tab-nav/stories.tsx
Normal file
143
apps/web/modules/ui/components/tab-nav/stories.tsx
Normal 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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
389
apps/web/modules/ui/components/tabs/index.test.tsx
Normal file
389
apps/web/modules/ui/components/tabs/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
129
apps/web/modules/ui/components/tabs/index.tsx
Normal file
129
apps/web/modules/ui/components/tabs/index.tsx
Normal 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 };
|
||||
266
apps/web/modules/ui/components/tabs/stories.tsx
Normal file
266
apps/web/modules/ui/components/tabs/stories.tsx
Normal 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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user