[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
5 changed files with 165 additions and 150 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue