[WEB-4208]chore: refactored work item quick actions (#7136)
* chore: refactored work item quick actions * chore: update event handling for menu * chore: reverted unwanted changes * fix: update archive copy link * chore: handled undefined function implementation
This commit is contained in:
parent
14d2d69120
commit
6be3f0ea73
21 changed files with 1602 additions and 517 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// types
|
||||
import { TContextMenuItem } from "./root";
|
||||
import { TContextMenuItem, ContextMenuContext, Portal } from "./root";
|
||||
|
||||
type ContextMenuItemProps = {
|
||||
handleActiveItem: () => void;
|
||||
|
|
@ -14,45 +16,230 @@ type ContextMenuItemProps = {
|
|||
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||
|
||||
// Nested menu state
|
||||
const [isNestedOpen, setIsNestedOpen] = useState(false);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [activeNestedIndex, setActiveNestedIndex] = useState<number>(0);
|
||||
const nestedMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const contextMenuContext = useContext(ContextMenuContext);
|
||||
const hasNestedItems = item.nestedMenuItems && item.nestedMenuItems.length > 0;
|
||||
const renderedNestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) || [];
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right-start",
|
||||
strategy: "fixed",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 4],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const closeNestedMenu = React.useCallback(() => {
|
||||
setIsNestedOpen(false);
|
||||
setActiveNestedIndex(0);
|
||||
}, []);
|
||||
|
||||
// Register this nested menu with the main context
|
||||
React.useEffect(() => {
|
||||
if (contextMenuContext && hasNestedItems) {
|
||||
return contextMenuContext.registerSubmenu(closeNestedMenu);
|
||||
}
|
||||
}, [contextMenuContext, hasNestedItems, closeNestedMenu]);
|
||||
|
||||
const handleItemClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasNestedItems) {
|
||||
// Toggle nested menu
|
||||
if (!isNestedOpen && contextMenuContext) {
|
||||
contextMenuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsNestedOpen(!isNestedOpen);
|
||||
} else {
|
||||
// Execute action for regular items
|
||||
item.action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
handleActiveItem();
|
||||
|
||||
if (hasNestedItems) {
|
||||
// Close other submenus and open this one
|
||||
if (contextMenuContext) {
|
||||
contextMenuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsNestedOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNestedItemClick = (nestedItem: TContextMenuItem, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
nestedItem.action();
|
||||
if (nestedItem.closeOnClick !== false) {
|
||||
handleClose(); // Close the entire context menu
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation for nested items
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isNestedOpen || !hasNestedItems) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveNestedIndex((prev) => (prev + 1) % renderedNestedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveNestedIndex((prev) => (prev - 1 + renderedNestedItems.length) % renderedNestedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const nestedItem = renderedNestedItems[activeNestedIndex];
|
||||
if (!nestedItem.disabled) {
|
||||
handleNestedItemClick(nestedItem);
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
closeNestedMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (isNestedOpen && nestedMenuRef.current) {
|
||||
const menuElement = nestedMenuRef.current;
|
||||
menuElement.addEventListener("keydown", handleKeyDown);
|
||||
// Ensure the menu can receive keyboard events
|
||||
menuElement.setAttribute("tabindex", "-1");
|
||||
menuElement.focus();
|
||||
return () => {
|
||||
menuElement.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [isNestedOpen, activeNestedIndex, renderedNestedItems, hasNestedItems, closeNestedMenu]);
|
||||
|
||||
if (item.shouldRender === false) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
|
||||
{
|
||||
"bg-custom-background-90": isActive,
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}}
|
||||
onMouseEnter={handleActiveItem}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
<>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
|
||||
{
|
||||
"bg-custom-background-90": isActive,
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
onClick={handleItemClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div className="flex-1">
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{hasNestedItems && <ChevronRight className="h-3 w-3 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Nested Menu */}
|
||||
{hasNestedItems && isNestedOpen && (
|
||||
<Portal container={contextMenuContext?.portalContainer}>
|
||||
<div
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className={cn(
|
||||
"fixed z-[35] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-lg",
|
||||
"ring-1 ring-black ring-opacity-5"
|
||||
)}
|
||||
data-context-submenu="true"
|
||||
>
|
||||
<div ref={nestedMenuRef} className="max-h-72 overflow-y-scroll vertical-scrollbar scrollbar-sm">
|
||||
{renderedNestedItems.map((nestedItem, index) => (
|
||||
<button
|
||||
key={nestedItem.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
|
||||
{
|
||||
"bg-custom-background-90": index === activeNestedIndex,
|
||||
"text-custom-text-400": nestedItem.disabled,
|
||||
},
|
||||
nestedItem.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleNestedItemClick(nestedItem, e);
|
||||
}}
|
||||
onMouseEnter={() => setActiveNestedIndex(index)}
|
||||
disabled={nestedItem.disabled}
|
||||
data-context-submenu="true"
|
||||
>
|
||||
{nestedItem.customContent ?? (
|
||||
<>
|
||||
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{nestedItem.title}</h5>
|
||||
{nestedItem.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": nestedItem.disabled,
|
||||
})}
|
||||
>
|
||||
{nestedItem.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Portal>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,15 +21,46 @@ export type TContextMenuItem = {
|
|||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
nestedMenuItems?: TContextMenuItem[];
|
||||
};
|
||||
|
||||
// Portal component for nested menus
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
}
|
||||
|
||||
export const Portal: React.FC<PortalProps> = ({ children, container }) => {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetContainer = container || document.body;
|
||||
return ReactDOM.createPortal(children, targetContainer);
|
||||
};
|
||||
|
||||
// Context for managing nested menus
|
||||
export const ContextMenuContext = React.createContext<{
|
||||
closeAllSubmenus: () => void;
|
||||
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||
portalContainer?: Element | null;
|
||||
} | null>(null);
|
||||
|
||||
type ContextMenuProps = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
items: TContextMenuItem[];
|
||||
portalContainer?: Element | null;
|
||||
};
|
||||
|
||||
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||
const { parentRef, items } = props;
|
||||
const { parentRef, items, portalContainer } = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
|
|
@ -39,11 +70,24 @@ const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
|||
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||
// refs
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const submenuClosersRef = useRef<Set<() => void>>(new Set());
|
||||
// derived values
|
||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
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 handleClose = () => {
|
||||
closeAllSubmenus();
|
||||
setIsOpen(false);
|
||||
setActiveItemIndex(0);
|
||||
};
|
||||
|
|
@ -121,13 +165,42 @@ const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
|||
};
|
||||
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||
|
||||
// close on clicking outside
|
||||
useOutsideClickDetector(contextMenuRef, handleClose);
|
||||
// Custom handler for nested menu portal clicks
|
||||
React.useEffect(() => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if the click is on a nested menu element
|
||||
const isNestedMenuClick = target.closest('[data-context-submenu="true"]');
|
||||
const isMainMenuClick = contextMenuRef.current?.contains(target);
|
||||
|
||||
// Also check if the target itself has the data attribute
|
||||
const isNestedMenuElement = target.hasAttribute("data-context-submenu");
|
||||
|
||||
// If it's a nested menu click, main menu click, or nested menu element, don't close
|
||||
if (isNestedMenuClick || isMainMenuClick || isNestedMenuElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If menu is open and it's an outside click, close it
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
// Use capture phase to ensure we handle the event before other handlers
|
||||
document.addEventListener("mousedown", handleDocumentClick, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentClick, true);
|
||||
};
|
||||
}
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed h-screen w-screen top-0 left-0 cursor-default z-20 opacity-0 pointer-events-none transition-opacity",
|
||||
"fixed h-screen w-screen top-0 left-0 cursor-default z-30 opacity-0 pointer-events-none transition-opacity",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isOpen,
|
||||
}
|
||||
|
|
@ -140,16 +213,19 @@ const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
|||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
data-context-menu="true"
|
||||
>
|
||||
{renderedItems.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.key}
|
||||
handleActiveItem={() => setActiveItemIndex(index)}
|
||||
handleClose={handleClose}
|
||||
isActive={index === activeItemIndex}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
<ContextMenuContext.Provider value={{ closeAllSubmenus, registerSubmenu, portalContainer }}>
|
||||
{renderedItems.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.key}
|
||||
handleActiveItem={() => setActiveItemIndex(index)}
|
||||
handleClose={handleClose}
|
||||
isActive={index === activeItemIndex}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Menu } from "@headlessui/react";
|
||||
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
|
|
@ -10,7 +10,46 @@ import { cn } from "../../helpers";
|
|||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
// types
|
||||
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
|
||||
import {
|
||||
ICustomMenuDropdownProps,
|
||||
ICustomMenuItemProps,
|
||||
ICustomSubMenuProps,
|
||||
ICustomSubMenuTriggerProps,
|
||||
ICustomSubMenuContentProps,
|
||||
} from "./helper";
|
||||
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Portal: React.FC<PortalProps> = ({ children, container, asChild = false }) => {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetContainer = container || document.body;
|
||||
|
||||
if (asChild) {
|
||||
return ReactDOM.createPortal(children, targetContainer);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(<div data-radix-portal="">{children}</div>, targetContainer);
|
||||
};
|
||||
|
||||
// Context for main menu to communicate with submenus
|
||||
const MenuContext = React.createContext<{
|
||||
closeAllSubmenus: () => void;
|
||||
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||
} | null>(null);
|
||||
|
||||
const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
const {
|
||||
|
|
@ -45,19 +84,35 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
// refs
|
||||
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const submenuClosersRef = React.useRef<Set<() => void>>(new Set());
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "auto",
|
||||
});
|
||||
|
||||
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);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
const closeDropdown = () => {
|
||||
if (isOpen) onMenuClose?.();
|
||||
|
||||
const closeDropdown = React.useCallback(() => {
|
||||
if (isOpen) {
|
||||
closeAllSubmenus();
|
||||
onMenuClose?.();
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
}, [isOpen, closeAllSubmenus, onMenuClose]);
|
||||
|
||||
const selectActiveItem = () => {
|
||||
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
||||
|
|
@ -75,8 +130,12 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
isOpen ? closeDropdown() : openDropdown();
|
||||
menuButtonOnClick?.();
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
|
|
@ -86,13 +145,43 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||
const handleMouseLeave = () => {
|
||||
if (openOnHover && isOpen) {
|
||||
setTimeout(() => {
|
||||
closeDropdown();
|
||||
}, 500);
|
||||
// Only close if menu is still open
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
}, 150); // Small delay to allow moving to submenu
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
|
||||
|
||||
// Custom handler for submenu portal clicks
|
||||
React.useEffect(() => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isSubmenuClick = target.closest('[data-prevent-outside-click="true"]');
|
||||
const isMainMenuClick = dropdownRef.current?.contains(target);
|
||||
|
||||
// If it's a submenu click or main menu click, don't close
|
||||
if (isSubmenuClick || isMainMenuClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If menu is open and it's an outside click, close it
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||
};
|
||||
}
|
||||
}, [isOpen, closeDropdown, useCaptureForOutsideClick]);
|
||||
|
||||
let menuItems = (
|
||||
<Menu.Items
|
||||
data-prevent-outside-click={!!portalElement}
|
||||
|
|
@ -117,7 +206,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{children}
|
||||
<MenuContext.Provider value={{ closeAllSubmenus, registerSubmenu }}>{children}</MenuContext.Provider>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
);
|
||||
|
|
@ -136,6 +225,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||
onClick={handleOnClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-main-menu="true"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
|
|
@ -202,8 +292,161 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
// 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<ICustomSubMenuProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
trigger,
|
||||
disabled = false,
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
placement = "right-start",
|
||||
} = props;
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [referenceElement, setReferenceElement] = React.useState<HTMLSpanElement | null>(null);
|
||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||
const submenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const menuContext = React.useContext(MenuContext);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
strategy: "fixed", // Use fixed positioning to escape overflow constraints
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 4],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const closeSubmenu = React.useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Register this submenu with the main menu context
|
||||
React.useEffect(() => {
|
||||
if (menuContext) {
|
||||
return menuContext.registerSubmenu(closeSubmenu);
|
||||
}
|
||||
}, [menuContext, closeSubmenu]);
|
||||
|
||||
const toggleSubmenu = () => {
|
||||
if (!disabled) {
|
||||
// Close other submenus when opening this one
|
||||
if (!isOpen && menuContext) {
|
||||
menuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleSubmenu();
|
||||
};
|
||||
|
||||
// Close submenu when clicking on other menu items
|
||||
React.useEffect(() => {
|
||||
const handleMenuItemClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Check if the click is on a menu item that's not part of this submenu
|
||||
if (target.closest('[role="menuitem"]') && !submenuRef.current?.contains(target)) {
|
||||
closeSubmenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleMenuItemClick);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleMenuItemClick);
|
||||
};
|
||||
}, [closeSubmenu]);
|
||||
|
||||
return (
|
||||
<div ref={submenuRef} className={cn("relative", className)}>
|
||||
<span ref={setReferenceElement} className="w-full">
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full select-none rounded px-1 py-1.5 text-left text-custom-text-200 flex items-center justify-between cursor-pointer",
|
||||
{
|
||||
"bg-custom-background-80": active && !disabled,
|
||||
"text-custom-text-400": disabled,
|
||||
"cursor-not-allowed": disabled,
|
||||
}
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="flex-1">{trigger}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</span>
|
||||
|
||||
{isOpen && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className={cn(
|
||||
"fixed z-[20] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-1 text-xs shadow-custom-shadow-lg",
|
||||
"ring-1 ring-black ring-opacity-5", // Additional styling to make it stand out
|
||||
contentClassName
|
||||
)}
|
||||
data-prevent-outside-click="true"
|
||||
onMouseEnter={() => {
|
||||
// Notify parent menu that we're hovering over submenu
|
||||
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
|
||||
if (mainMenuElement) {
|
||||
const mouseEnterEvent = new MouseEvent("mouseenter", { bubbles: true });
|
||||
mainMenuElement.dispatchEvent(mouseEnterEvent);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Notify parent menu that we're leaving submenu
|
||||
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
|
||||
if (mainMenuElement) {
|
||||
const mouseLeaveEvent = new MouseEvent("mouseleave", { bubbles: true });
|
||||
mainMenuElement.dispatchEvent(mouseLeaveEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SubMenuContext.Provider value={{ closeSubmenu }}>{children}</SubMenuContext.Provider>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
const { children, disabled = false, onClick, className } = props;
|
||||
const submenuContext = useSubMenu();
|
||||
|
||||
return (
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
|
|
@ -221,6 +464,8 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
|||
onClick={(e) => {
|
||||
close();
|
||||
onClick?.(e);
|
||||
// Close submenu if this item is inside a submenu
|
||||
submenuContext?.closeSubmenu();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
|
@ -231,6 +476,52 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
const SubMenuTrigger: React.FC<ICustomSubMenuTriggerProps> = (props) => {
|
||||
const { children, disabled = false, className } = props;
|
||||
|
||||
return (
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full select-none rounded px-1 py-1.5 text-left text-custom-text-200 flex items-center justify-between",
|
||||
{
|
||||
"bg-custom-background-80": active && !disabled,
|
||||
"text-custom-text-400": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
"cursor-not-allowed": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{children}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const SubMenuContent: React.FC<ICustomSubMenuContentProps> = (props) => {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-[15] min-w-[12rem] overflow-hidden rounded-md border border-custom-border-300 bg-custom-background-100 p-1 text-xs shadow-custom-shadow-rg",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Add all components as static properties for external use
|
||||
CustomMenu.Portal = Portal;
|
||||
CustomMenu.MenuItem = MenuItem;
|
||||
CustomMenu.SubMenu = SubMenu;
|
||||
CustomMenu.SubMenuTrigger = SubMenuTrigger;
|
||||
CustomMenu.SubMenuContent = SubMenuContent;
|
||||
|
||||
export { CustomMenu };
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ export interface IDropdownProps {
|
|||
useCaptureForOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
export interface IPortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
children: React.ReactNode;
|
||||
ellipsis?: boolean;
|
||||
|
|
@ -75,3 +81,27 @@ export interface ICustomSelectItemProps {
|
|||
value: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Submenu interfaces
|
||||
export interface ICustomSubMenuProps {
|
||||
children: React.ReactNode;
|
||||
trigger: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
export interface ICustomSubMenuTriggerProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSubMenuContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
placement?: Placement;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue