[WEB-4686] feat: propel tabs (#7620)

* chore: global css file added to tailwind config package

* chore: tailwind config updated

* chore: cn utility function added to propel package

* chore: storybook init

* fix: format error

* feat: added base ui tabs

* fix: add missing newline at end of package.json in propel package

* fix: reorder import statement for Tabs component in propel package

* feat: refactor Tabs component to support compound structure with forward refs

* fix: lint

* chore: code refactor

* chore: code refactor

* fix: lock file

* chore: added stories for tabs

* refactor: clean up

* fix: lint

* fix: lint

* fix: Remove duplicate storybook ESLint config

* fix: lint

* fix: update classname import path in Tabs component

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Jayash Tripathy 2025-09-01 19:58:40 +05:30 committed by GitHub
parent e679dc3d12
commit f42eeec2c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 165 additions and 150 deletions

View File

@ -0,0 +1 @@
export * from "./tabs";

View File

@ -1,58 +0,0 @@
import React, { FC } from "react";
import { Tabs as BaseTabs } from "@base-ui-components/react/tabs";
import { LucideProps } from "lucide-react";
// helpers
import { cn } from "../utils/classname";
export type TabListItem = {
key: string;
icon?: FC<LucideProps>;
label?: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
};
type TTabListProps = {
tabs: TabListItem[];
tabListClassName?: string;
tabClassName?: string;
size?: "sm" | "md" | "lg";
selectedTab?: string;
};
export const TabList: FC<TTabListProps> = ({ tabs, tabListClassName, tabClassName, size = "md", selectedTab }) => (
<BaseTabs.List
className={cn(
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative",
tabListClassName
)}
>
{tabs.map((tab) => (
<BaseTabs.Tab
className={({ selected }) =>
cn(
"flex items-center justify-center p-1 min-w-fit w-full font-medium text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all rounded",
(selectedTab ? selectedTab === tab.key : selected)
? "bg-custom-background-100 text-custom-text-100 shadow-sm"
: tab.disabled
? "text-custom-text-400 cursor-not-allowed"
: "text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60",
{
"text-xs": size === "sm",
"text-sm": size === "md",
"text-base": size === "lg",
},
tabClassName
)
}
key={tab.key}
disabled={tab.disabled}
>
{tab.icon && <tab.icon className="size-4" />}
{tab.label}
</BaseTabs.Tab>
))}
<BaseTabs.Indicator className="absolute left-0 top-[50%] z-[-1] h-6 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] -translate-y-[50%] rounded-sm bg-custom-background-100 shadow-sm transition-[width,transform] duration-200 ease-in-out" />
</BaseTabs.List>
);

View File

@ -0,0 +1,66 @@
import { Fragment } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Tabs } from "./tabs";
const meta: Meta<typeof Tabs> = {
title: "Components/Tabs",
component: Tabs,
parameters: {
layout: "centered",
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
render: () => (
<div className="w-[400px]">
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Overview</Tabs.Trigger>
<Tabs.Trigger value="password">Settings</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="account" className="p-4">
Overview settings go here
</Tabs.Content>
<Tabs.Content value="password" className="p-4">
Settings settings go here
</Tabs.Content>
</Tabs>
</div>
),
};
export const Sizes: Story = {
render: () => {
const sizes = ["sm", "md", "lg"] as const;
const labels = {
sm: "Small",
md: "Medium",
lg: "Large",
};
return (
<div className="w-[400px]">
{sizes.map((size, index) => (
<Fragment key={size}>
{index > 0 && <div className="h-4" />}
<div className="text-lg">{labels[size]}</div>
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview" size={size}>
Overview
</Tabs.Trigger>
<Tabs.Trigger value="settings" size={size}>
Settings
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Fragment>
))}
</div>
);
},
};

View File

@ -1,89 +1,96 @@
import React, { FC, useEffect, useState } from "react";
import { Tabs as BaseTabs } from "@base-ui-components/react/tabs";
import { useLocalStorage } from "@plane/hooks";
import * as React from "react";
import { Tabs as TabsPrimitive } from "@base-ui-components/react/tabs";
import { cn } from "../utils/classname";
import { TabList, TabListItem } from "./list";
export type TabContent = {
content: React.ReactNode;
type TabsCompound = React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.Root> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.Root>>
> & {
List: React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.List> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.List>>
>;
Trigger: React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.Tab> & { size?: "sm" | "md" | "lg" } & React.RefAttributes<
React.ElementRef<typeof TabsPrimitive.Tab>
>
>;
Content: React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.Panel> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.Panel>>
>;
Indicator: React.ForwardRefExoticComponent<React.ComponentProps<"div"> & React.RefAttributes<HTMLDivElement>>;
};
export type TabItem = TabListItem & TabContent;
const TabsRoot = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentProps<typeof TabsPrimitive.Root>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col w-full h-full", className)} {...props} ref={ref} />
));
type TTabsProps = {
tabs: TabItem[];
storageKey?: string;
actions?: React.ReactNode;
defaultTab?: string;
containerClassName?: string;
tabListContainerClassName?: string;
tabListClassName?: string;
tabClassName?: string;
tabPanelClassName?: string;
size?: "sm" | "md" | "lg";
storeInLocalStorage?: boolean;
};
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentProps<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative overflow-auto",
className
)}
{...props}
ref={ref}
/>
));
export const Tabs: FC<TTabsProps> = (props: TTabsProps) => {
const {
tabs,
storageKey,
actions,
defaultTab = tabs[0]?.key,
containerClassName = "",
tabListContainerClassName = "",
tabListClassName = "",
tabClassName = "",
tabPanelClassName = "",
size = "md",
storeInLocalStorage = true,
} = props;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Tab>,
React.ComponentProps<typeof TabsPrimitive.Tab> & { size?: "sm" | "md" | "lg" }
>(({ className, size = "md", ...props }, ref) => (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"flex items-center justify-center p-1 min-w-fit w-full font-medium text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all duration-200 ease-in-out rounded",
"data-[selected]:bg-custom-background-100 data-[selected]:text-custom-text-100 data-[selected]:shadow-sm",
"text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60",
"disabled:text-custom-text-400 disabled:cursor-not-allowed",
{
"text-xs": size === "sm",
"text-sm": size === "md",
"text-base": size === "lg",
},
className
)}
{...props}
ref={ref}
/>
));
const { storedValue, setValue } = useLocalStorage(
storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`,
defaultTab
);
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Panel>,
React.ComponentProps<typeof TabsPrimitive.Panel>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("relative outline-none", className)}
{...props}
ref={ref}
/>
));
const TabsIndicator = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => (
<div
className={cn(
"absolute left-0 top-[50%] z-[-1] h-6 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] -translate-y-[50%] rounded-sm bg-custom-background-100 shadow-sm transition-[width,transform] duration-200 ease-in-out",
className
)}
{...props}
ref={ref}
/>
));
const [activeIndex, setActiveIndex] = useState(() => {
const initialTab = storedValue ?? defaultTab;
return tabs.findIndex((tab) => tab.key === initialTab);
});
export const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Trigger: TabsTrigger,
Content: TabsContent,
Indicator: TabsIndicator,
}) satisfies TabsCompound;
useEffect(() => {
if (storeInLocalStorage && tabs[activeIndex]) {
setValue(tabs[activeIndex].key);
}
}, [activeIndex, setValue, storeInLocalStorage, tabs]);
const handleTabChange = (index: number) => {
setActiveIndex(index);
if (!tabs[index].disabled) {
tabs[index].onClick?.();
}
};
return (
<BaseTabs.Root
value={activeIndex}
onValueChange={handleTabChange}
className={cn("flex flex-col w-full h-full overflow-hidden", containerClassName)}
>
<div className={cn("flex w-full items-center gap-4", tabListContainerClassName)}>
<TabList
tabs={tabs}
tabListClassName={tabListClassName}
tabClassName={tabClassName}
size={size}
selectedTab={tabs[activeIndex]?.key}
/>
{actions && <div className="flex-grow">{actions}</div>}
</div>
{tabs.map((tab) => (
<BaseTabs.Panel key={tab.key} className={cn("relative h-full overflow-auto", tabPanelClassName)}>
{tab.content}
</BaseTabs.Panel>
))}
</BaseTabs.Root>
);
};
export { TabsList, TabsTrigger, TabsContent, TabsIndicator };

View File

@ -854,7 +854,7 @@ importers:
version: 2.31.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-react:
specifier: ^7.33.2
version: 7.37.3(eslint@8.57.1)
version: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks:
specifier: ^5.2.0
version: 5.2.0(eslint@8.57.1)
@ -1076,7 +1076,7 @@ importers:
version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)))
autoprefixer:
specifier: ^10.4.14
version: 10.4.20(postcss@8.5.6)
version: 10.4.21(postcss@8.5.6)
postcss:
specifier: ^8.4.38
version: 8.5.6
@ -1248,7 +1248,7 @@ importers:
version: 18.3.1
autoprefixer:
specifier: ^10.4.19
version: 10.4.20(postcss@8.5.6)
version: 10.4.21(postcss@8.5.6)
postcss-cli:
specifier: ^11.0.0
version: 11.0.1(jiti@1.21.7)(postcss@8.5.6)
@ -3970,8 +3970,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
autoprefixer@10.4.20:
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
autoprefixer@10.4.21:
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
@ -4854,8 +4854,8 @@ packages:
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
eslint-plugin-react@7.37.3:
resolution: {integrity: sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==}
eslint-plugin-react@7.37.5:
resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
engines: {node: '>=4'}
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
@ -10893,7 +10893,7 @@ snapshots:
postcss: 8.5.6
postcss-value-parser: 4.2.0
autoprefixer@10.4.20(postcss@8.5.6):
autoprefixer@10.4.21(postcss@8.5.6):
dependencies:
browserslist: 4.25.2
caniuse-lite: 1.0.30001735
@ -11788,7 +11788,7 @@ snapshots:
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.3(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
optionalDependencies:
typescript: 5.8.3
@ -11896,7 +11896,7 @@ snapshots:
dependencies:
eslint: 8.57.1
eslint-plugin-react@7.37.3(eslint@8.57.1):
eslint-plugin-react@7.37.5(eslint@8.57.1):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@ -14308,7 +14308,6 @@ snapshots:
'@rollup/rollup-win32-ia32-msvc': 4.50.0
'@rollup/rollup-win32-x64-msvc': 4.50.0
fsevents: 2.3.3
optional: true
rope-sequence@1.3.4: {}
@ -15285,7 +15284,7 @@ snapshots:
fdir: 6.4.6(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.45.1
rollup: 4.50.0
tinyglobby: 0.2.14
optionalDependencies:
'@types/node': 22.17.2