feat: adding baseui components to propel package (#7585)

* feat: adding baseui components

* fix: export from the package.json
This commit is contained in:
sriram veeraghanta 2025-08-18 19:35:34 +05:30 committed by GitHub
parent f142266bed
commit 9c21fd320c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 815 additions and 26 deletions

View 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>
);
};

View file

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

View 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",
}

View file

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

View 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 };

View file

@ -0,0 +1,2 @@
export * from "./menu";
export * from "./types";

View 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 };

View 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;
};

View 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>
);

View 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>
);
};