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
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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue