base
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./menu";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Settings, User, LogOut, Mail, Bell, HelpCircle } from "lucide-react";
|
||||
import { Menu } from "./menu";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Menu",
|
||||
component: Menu,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
subcomponents: {
|
||||
MenuItem: Menu.MenuItem,
|
||||
SubMenu: Menu.SubMenu,
|
||||
},
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
} satisfies Meta<typeof Menu>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Options">
|
||||
<Menu.MenuItem onClick={() => alert("Option 1 clicked")}>Option 1</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Option 2 clicked")}>Option 2</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Option 3 clicked")}>Option 3</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Account">
|
||||
<Menu.MenuItem onClick={() => alert("Profile")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Settings")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Messages")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
<span>Messages</span>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Logout")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Ellipsis: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu ellipsis>
|
||||
<Menu.MenuItem onClick={() => alert("Edit")}>Edit</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Duplicate")}>Duplicate</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Delete")}>Delete</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const VerticalEllipsis: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu verticalEllipsis>
|
||||
<Menu.MenuItem onClick={() => alert("Edit")}>Edit</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Duplicate")}>Duplicate</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Delete")}>Delete</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoBorder: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Actions" noBorder>
|
||||
<Menu.MenuItem onClick={() => alert("Action 1")}>Action 1</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Action 2")}>Action 2</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Action 3")}>Action 3</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoChevron: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Menu" noChevron>
|
||||
<Menu.MenuItem onClick={() => alert("Item 1")}>Item 1</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Item 2")}>Item 2</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Item 3")}>Item 3</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Disabled Menu" disabled>
|
||||
<Menu.MenuItem onClick={() => alert("Item 1")}>Item 1</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Item 2")}>Item 2</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledItems: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Options">
|
||||
<Menu.MenuItem onClick={() => alert("Enabled")}>Enabled Item</Menu.MenuItem>
|
||||
<Menu.MenuItem disabled>Disabled Item</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Enabled")}>Another Enabled Item</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomButton: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu
|
||||
customButton={
|
||||
<button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">Custom Button</button>
|
||||
}
|
||||
>
|
||||
<Menu.MenuItem onClick={() => alert("Option 1")}>Option 1</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Option 2")}>Option 2</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Option 3")}>Option 3</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSubmenu: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="File">
|
||||
<Menu.MenuItem onClick={() => alert("New File")}>New File</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Open")}>Open</Menu.MenuItem>
|
||||
<Menu.SubMenu
|
||||
trigger="Export"
|
||||
className="min-w-[12rem] rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200"
|
||||
>
|
||||
<Menu.MenuItem onClick={() => alert("Export as PDF")}>Export as PDF</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Export as CSV")}>Export as CSV</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Export as JSON")}>Export as JSON</Menu.MenuItem>
|
||||
</Menu.SubMenu>
|
||||
<Menu.MenuItem onClick={() => alert("Close")}>Close</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MaxHeightSmall: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Small Height" maxHeight="sm">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<Menu.MenuItem key={i} onClick={() => alert(`Item ${i + 1}`)}>
|
||||
Item {i + 1}
|
||||
</Menu.MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MaxHeightLarge: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="Large Height" maxHeight="lg">
|
||||
{Array.from({ length: 15 }, (_, i) => (
|
||||
<Menu.MenuItem key={i} onClick={() => alert(`Item ${i + 1}`)}>
|
||||
Item {i + 1}
|
||||
</Menu.MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ComplexMenu: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu label="More Actions" buttonClassName="bg-gray-100">
|
||||
<Menu.MenuItem onClick={() => alert("Notifications")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
<span>Notifications</span>
|
||||
<span className="bg-red-500 ml-auto rounded-sm px-2 py-0.5 text-11 text-on-color">3</span>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Help")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span>Help Center</span>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
<Menu.SubMenu
|
||||
trigger="Settings"
|
||||
className="min-w-[12rem] rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200"
|
||||
>
|
||||
<Menu.MenuItem onClick={() => alert("General Settings")}>General</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Privacy Settings")}>Privacy</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Security Settings")}>Security</Menu.MenuItem>
|
||||
</Menu.SubMenu>
|
||||
<div className="border-gray-200 my-1 border-t" />
|
||||
<Menu.MenuItem onClick={() => alert("Logout")}>
|
||||
<div className="flex items-center gap-2 text-danger-primary">
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</div>
|
||||
</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyles: Story = {
|
||||
render() {
|
||||
return (
|
||||
<Menu
|
||||
label="Styled Menu"
|
||||
buttonClassName="bg-purple-500 text-on-color hover:bg-purple-600"
|
||||
optionsClassName="bg-purple-50 border-purple-300"
|
||||
>
|
||||
<Menu.MenuItem onClick={() => alert("Item 1")} className="hover:bg-purple-200">
|
||||
Item 1
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Item 2")} className="hover:bg-purple-200">
|
||||
Item 2
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem onClick={() => alert("Item 3")} className="hover:bg-purple-200">
|
||||
Item 3
|
||||
</Menu.MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Menu as BaseMenu } from "@base-ui-components/react/menu";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "../icons";
|
||||
import { cn } from "../utils/classname";
|
||||
import type { 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
|
||||
function SubMenu(props: TSubMenuProps) {
|
||||
const { children, trigger, disabled = false, className = "" } = props;
|
||||
|
||||
return (
|
||||
<BaseMenu.SubmenuRoot disabled={disabled}>
|
||||
<BaseMenu.SubmenuTrigger className={""}>
|
||||
<span className="flex-1">{trigger}</span>
|
||||
<ChevronRightIcon />
|
||||
</BaseMenu.SubmenuTrigger>
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner className={""} alignOffset={-4} sideOffset={-4}>
|
||||
<BaseMenu.Popup className={className}>{children} </BaseMenu.Popup>
|
||||
</BaseMenu.Positioner>
|
||||
</BaseMenu.Portal>
|
||||
</BaseMenu.SubmenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props: TMenuItemProps) {
|
||||
const { children, disabled = false, onClick, className } = props;
|
||||
const submenuContext = useSubMenu();
|
||||
|
||||
return (
|
||||
<BaseMenu.Item
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full cursor-pointer truncate rounded-sm px-1 py-1.5 text-left text-secondary outline-none select-none hover:bg-layer-1",
|
||||
{
|
||||
"text-placeholder": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
close();
|
||||
onClick?.(e);
|
||||
submenuContext?.closeSubmenu();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function Menu(props: TMenuProps) {
|
||||
const {
|
||||
ariaLabel,
|
||||
buttonClassName = "",
|
||||
customButtonClassName = "",
|
||||
customButtonTabIndex = 0,
|
||||
children,
|
||||
customButton,
|
||||
disabled = false,
|
||||
ellipsis = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
noBorder = false,
|
||||
noChevron = false,
|
||||
optionsClassName = "",
|
||||
menuItemsClassName = "",
|
||||
verticalEllipsis = false,
|
||||
menuButtonOnClick,
|
||||
onMenuClose,
|
||||
tabIndex,
|
||||
openOnHover = 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-sm p-1 text-secondary outline-none hover:text-primary ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-layer-1"
|
||||
} ${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 rounded-md px-2.5 py-1 text-11 whitespace-nowrap duration-300 outline-none ${
|
||||
isOpen ? "bg-surface-2 text-primary" : "text-secondary"
|
||||
} ${noBorder ? "" : "shadow-sm border border-strong focus:outline-none"} ${
|
||||
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-1"
|
||||
} ${buttonClassName}`}
|
||||
onClick={handleMenuButtonClick}
|
||||
tabIndex={customButtonTabIndex}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDownIcon 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 min-w-[12rem] overflow-y-scroll rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap shadow-raised-200 focus:outline-none",
|
||||
{
|
||||
"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 };
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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;
|
||||
menuButtonOnClick?: (..._args: unknown[]) => 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?: unknown) => void;
|
||||
className?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user