feat: adding baseui components to propel package (#7585)
* feat: adding baseui components * fix: export from the package.json
This commit is contained in:
parent
f142266bed
commit
9c21fd320c
14 changed files with 815 additions and 26 deletions
121
packages/propel/src/avatar/avatar.tsx
Normal file
121
packages/propel/src/avatar/avatar.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from "react";
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui-components/react/avatar";
|
||||
// utils
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
|
||||
|
||||
type Props = {
|
||||
name?: string; //The name of the avatar which will be displayed on the tooltip
|
||||
fallbackBackgroundColor?: string; //The background color if the avatar image fails to load
|
||||
fallbackText?: string;
|
||||
fallbackTextColor?: string; //The text color if the avatar image fails to load
|
||||
showTooltip?: boolean;
|
||||
size?: TAvatarSize; //The size of the avatars
|
||||
shape?: "circle" | "square";
|
||||
src?: string; //The source of the avatar image
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the size details based on the size prop
|
||||
* @param size The size of the avatar
|
||||
* @returns The size details
|
||||
*/
|
||||
export const getSizeInfo = (size: TAvatarSize) => {
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return {
|
||||
avatarSize: "h-4 w-4",
|
||||
fontSize: "text-xs",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "md":
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-xs",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
case "base":
|
||||
return {
|
||||
avatarSize: "h-6 w-6",
|
||||
fontSize: "text-sm",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
case "lg":
|
||||
return {
|
||||
avatarSize: "h-7 w-7",
|
||||
fontSize: "text-sm",
|
||||
spacing: "-space-x-1.5",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
avatarSize: "h-5 w-5",
|
||||
fontSize: "text-xs",
|
||||
spacing: "-space-x-1",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the border radius based on the shape prop
|
||||
* @param shape The shape of the avatar
|
||||
* @returns The border radius
|
||||
*/
|
||||
export const getBorderRadius = (shape: "circle" | "square") => {
|
||||
switch (shape) {
|
||||
case "circle":
|
||||
return "rounded-full";
|
||||
case "square":
|
||||
return "rounded";
|
||||
default:
|
||||
return "rounded-full";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the value is a valid number
|
||||
* @param value The value to check
|
||||
* @returns Whether the value is a valid number or not
|
||||
*/
|
||||
export const isAValidNumber = (value: any) => typeof value === "number" && !isNaN(value);
|
||||
|
||||
export const Avatar: React.FC<Props> = (props) => {
|
||||
const {
|
||||
name,
|
||||
fallbackBackgroundColor,
|
||||
fallbackText,
|
||||
fallbackTextColor,
|
||||
showTooltip = true,
|
||||
size = "md",
|
||||
shape = "circle",
|
||||
src,
|
||||
className = "",
|
||||
} = props;
|
||||
|
||||
// get size details based on the size prop
|
||||
const sizeInfo = getSizeInfo(size);
|
||||
|
||||
const fallbackLetter = name?.[0]?.toUpperCase() ?? fallbackText ?? "?";
|
||||
return (
|
||||
<div
|
||||
className={cn("grid place-items-center overflow-hidden", getBorderRadius(shape), {
|
||||
[sizeInfo.avatarSize]: !isAValidNumber(size),
|
||||
})}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<AvatarPrimitive.Root className={cn("h-full w-full", getBorderRadius(shape), className)}>
|
||||
<AvatarPrimitive.Image src={src} width="48" height="48" />
|
||||
<AvatarPrimitive.Fallback
|
||||
className={cn(sizeInfo.fontSize, "grid h-full w-full place-items-center", getBorderRadius(shape), className)}
|
||||
style={{
|
||||
backgroundColor: fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
|
||||
color: fallbackTextColor ?? "#ffffff",
|
||||
}}
|
||||
>
|
||||
{fallbackLetter}
|
||||
</AvatarPrimitive.Fallback>
|
||||
</AvatarPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
packages/propel/src/avatar/index.ts
Normal file
1
packages/propel/src/avatar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./avatar";
|
||||
17
packages/propel/src/dialog/constants.ts
Normal file
17
packages/propel/src/dialog/constants.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export enum EDialogPosition {
|
||||
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
|
||||
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
|
||||
}
|
||||
|
||||
export enum EDialogWidth {
|
||||
SM = "sm:max-w-sm",
|
||||
MD = "sm:max-w-md",
|
||||
LG = "sm:max-w-lg",
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
VXL = "sm:max-w-5xl",
|
||||
VIXL = "sm:max-w-6xl",
|
||||
VIIXL = "sm:max-w-7xl",
|
||||
}
|
||||
1
packages/propel/src/dialog/index.ts
Normal file
1
packages/propel/src/dialog/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
77
packages/propel/src/dialog/root.tsx
Normal file
77
packages/propel/src/dialog/root.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Dialog as BaseDialog } from "@base-ui-components/react";
|
||||
import { cn } from "@plane/utils";
|
||||
import { EDialogWidth } from "./constants";
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof BaseDialog.Portal>) {
|
||||
return <BaseDialog.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof BaseDialog.Backdrop>) {
|
||||
return (
|
||||
<BaseDialog.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-30 bg-custom-backdrop transition-all duration-200 [&[data-ending-style]]:opacity-0 [&[data-starting-style]]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof BaseDialog.Root>) {
|
||||
return <BaseDialog.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof BaseDialog.Trigger>) {
|
||||
return <BaseDialog.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPanel({
|
||||
className,
|
||||
width = EDialogWidth.XXL,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Popup> & { width?: EDialogWidth }) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<BaseDialog.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed flex justify-center top-0 left-0 w-full z-30 px-4 sm:py-20 overflow-y-auto overflow-hidden outline-none"
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all w-full",
|
||||
width,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</BaseDialog.Popup>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof BaseDialog.Title>) {
|
||||
return (
|
||||
<BaseDialog.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// compound components
|
||||
Dialog.Trigger = DialogTrigger;
|
||||
Dialog.Panel = DialogPanel;
|
||||
Dialog.Title = DialogTitle;
|
||||
|
||||
export { Dialog, DialogTitle, DialogTrigger, DialogPanel };
|
||||
2
packages/propel/src/menu/index.ts
Normal file
2
packages/propel/src/menu/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./menu";
|
||||
export * from "./types";
|
||||
210
packages/propel/src/menu/menu.tsx
Normal file
210
packages/propel/src/menu/menu.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import * as React from "react";
|
||||
import { Menu as BaseMenu } from "@base-ui-components/react/menu";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
import { TMenuProps, TSubMenuProps, TMenuItemProps } from "./types";
|
||||
|
||||
// Context for main menu to communicate with submenus
|
||||
const MenuContext = React.createContext<{
|
||||
closeAllSubmenus: () => void;
|
||||
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||
} | null>(null);
|
||||
|
||||
// SubMenu context for closing submenu from nested items
|
||||
const SubMenuContext = React.createContext<{ closeSubmenu: () => void } | null>(null);
|
||||
|
||||
// Hook to use submenu context
|
||||
const useSubMenu = () => React.useContext(SubMenuContext);
|
||||
|
||||
// SubMenu implementation
|
||||
const SubMenu: React.FC<TSubMenuProps> = (props) => {
|
||||
const { children, trigger, disabled = false, className = "", contentClassName = "" } = props;
|
||||
|
||||
return (
|
||||
<BaseMenu.SubmenuRoot disabled={disabled}>
|
||||
<BaseMenu.SubmenuTrigger className={""}>
|
||||
<span className="flex-1">{trigger}</span>
|
||||
<ChevronRight />
|
||||
</BaseMenu.SubmenuTrigger>
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner className={""} alignOffset={-4} sideOffset={-4}>
|
||||
<BaseMenu.Popup className={className}>{children} </BaseMenu.Popup>
|
||||
</BaseMenu.Positioner>
|
||||
</BaseMenu.Portal>
|
||||
</BaseMenu.SubmenuRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<TMenuItemProps> = (props) => {
|
||||
const { children, disabled = false, onClick, className } = props;
|
||||
const submenuContext = useSubMenu();
|
||||
|
||||
return (
|
||||
<BaseMenu.Item
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 cursor-pointer outline-none focus:bg-custom-background-80",
|
||||
{
|
||||
"text-custom-text-400": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
close();
|
||||
onClick?.(e);
|
||||
submenuContext?.closeSubmenu();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
function Menu(props: TMenuProps) {
|
||||
const {
|
||||
ariaLabel,
|
||||
buttonClassName = "",
|
||||
customButtonClassName = "",
|
||||
customButtonTabIndex = 0,
|
||||
placement,
|
||||
children,
|
||||
className = "",
|
||||
customButton,
|
||||
disabled = false,
|
||||
ellipsis = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
noBorder = false,
|
||||
noChevron = false,
|
||||
optionsClassName = "",
|
||||
menuItemsClassName = "",
|
||||
verticalEllipsis = false,
|
||||
portalElement,
|
||||
menuButtonOnClick,
|
||||
onMenuClose,
|
||||
tabIndex,
|
||||
closeOnSelect,
|
||||
openOnHover = false,
|
||||
useCaptureForOutsideClick = false,
|
||||
handleOpenChange = () => {},
|
||||
} = props;
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
// refs
|
||||
const submenuClosersRef = React.useRef<Set<() => void>>(new Set());
|
||||
|
||||
const closeAllSubmenus = React.useCallback(() => {
|
||||
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
|
||||
}, []);
|
||||
|
||||
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
|
||||
submenuClosersRef.current.add(closeSubmenu);
|
||||
return () => {
|
||||
submenuClosersRef.current.delete(closeSubmenu);
|
||||
};
|
||||
}, []);
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const closeDropdown = React.useCallback(() => {
|
||||
if (isOpen) {
|
||||
closeAllSubmenus();
|
||||
onMenuClose?.();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [isOpen, closeAllSubmenus, onMenuClose]);
|
||||
|
||||
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseMenu.Root openOnHover={openOnHover} onOpenChange={handleOpenChange}>
|
||||
{customButton ? (
|
||||
<BaseMenu.Trigger
|
||||
type="button"
|
||||
onClick={handleMenuButtonClick}
|
||||
className={cn(customButtonClassName, "outline-none")}
|
||||
tabIndex={customButtonTabIndex}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{customButton}
|
||||
</BaseMenu.Trigger>
|
||||
) : (
|
||||
<>
|
||||
{ellipsis || verticalEllipsis ? (
|
||||
<BaseMenu.Trigger
|
||||
type="button"
|
||||
onClick={handleMenuButtonClick}
|
||||
disabled={disabled}
|
||||
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
tabIndex={customButtonTabIndex}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
|
||||
</BaseMenu.Trigger>
|
||||
) : (
|
||||
<BaseMenu.Trigger
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 whitespace-nowrap rounded-md px-2.5 py-1 text-xs duration-300 outline-none ${
|
||||
isOpen ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
|
||||
} ${noBorder ? "" : "border border-custom-border-300 shadow-sm focus:outline-none"} ${
|
||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={handleMenuButtonClick}
|
||||
tabIndex={customButtonTabIndex}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</BaseMenu.Trigger>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner
|
||||
align={"start"}
|
||||
className={cn(
|
||||
"fixed z-30 translate-y-0",
|
||||
menuItemsClassName
|
||||
)} /** translate-y-0 is a hack to create new stacking context. Required for safari */
|
||||
>
|
||||
<BaseMenu.Popup
|
||||
tabIndex={tabIndex}
|
||||
className={cn(
|
||||
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
|
||||
{
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
},
|
||||
optionsClassName
|
||||
)}
|
||||
data-main-menu="true"
|
||||
>
|
||||
<MenuContext.Provider value={{ closeAllSubmenus, registerSubmenu }}>{children}</MenuContext.Provider>
|
||||
</BaseMenu.Popup>
|
||||
</BaseMenu.Positioner>
|
||||
</BaseMenu.Portal>
|
||||
</BaseMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
Menu.MenuItem = MenuItem;
|
||||
Menu.SubMenu = SubMenu;
|
||||
|
||||
export { Menu };
|
||||
49
packages/propel/src/menu/types.ts
Normal file
49
packages/propel/src/menu/types.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export type TPlacement = "top" | "bottom" | "left" | "right";
|
||||
|
||||
export type TMenuProps = {
|
||||
customButtonClassName?: string;
|
||||
customButtonTabIndex?: number;
|
||||
buttonClassName?: string;
|
||||
className?: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
input?: boolean;
|
||||
label?: string | React.ReactNode;
|
||||
maxHeight?: "sm" | "rg" | "md" | "lg";
|
||||
noChevron?: boolean;
|
||||
chevronClassName?: string;
|
||||
onOpen?: () => void;
|
||||
optionsClassName?: string;
|
||||
placement?: TPlacement;
|
||||
tabIndex?: number;
|
||||
useCaptureForOutsideClick?: boolean;
|
||||
children: React.ReactNode;
|
||||
ellipsis?: boolean;
|
||||
noBorder?: boolean;
|
||||
verticalEllipsis?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuButtonOnClick?: (...args: any) => void;
|
||||
menuItemsClassName?: string;
|
||||
onMenuClose?: () => void;
|
||||
closeOnSelect?: boolean;
|
||||
portalElement?: Element | null;
|
||||
openOnHover?: boolean;
|
||||
ariaLabel?: string;
|
||||
handleOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export type TSubMenuProps = {
|
||||
children: React.ReactNode;
|
||||
trigger: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
placement?: TPlacement;
|
||||
};
|
||||
|
||||
export type TMenuItemProps = {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: (args?: any) => void;
|
||||
className?: string;
|
||||
};
|
||||
58
packages/propel/src/tabs/list.tsx
Normal file
58
packages/propel/src/tabs/list.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { FC } from "react";
|
||||
import { Tabs as BaseTabs } from "@base-ui-components/react/tabs";
|
||||
import { LucideProps } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
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>
|
||||
);
|
||||
91
packages/propel/src/tabs/tabs.tsx
Normal file
91
packages/propel/src/tabs/tabs.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React, { FC, useEffect, useState } from "react";
|
||||
import { Tabs as BaseTabs } from "@base-ui-components/react/tabs";
|
||||
// helpers
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
// types
|
||||
import { TabList, TabListItem } from "./list";
|
||||
|
||||
export type TabContent = {
|
||||
content: React.ReactNode;
|
||||
};
|
||||
|
||||
export type TabItem = TabListItem & TabContent;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 { storedValue, setValue } = useLocalStorage(
|
||||
storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`,
|
||||
defaultTab
|
||||
);
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(() => {
|
||||
const initialTab = storedValue ?? defaultTab;
|
||||
return tabs.findIndex((tab) => tab.key === initialTab);
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue