mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45:33 +00:00
[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:
parent
e679dc3d12
commit
f42eeec2c0
@ -0,0 +1 @@
|
||||
export * from "./tabs";
|
||||
@ -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>
|
||||
);
|
||||
66
packages/propel/src/tabs/tabs.stories.tsx
Normal file
66
packages/propel/src/tabs/tabs.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -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 };
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user