[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:
Vamsi Krishna 2025-06-06 13:21:00 +05:30 committed by GitHub
parent 14d2d69120
commit 6be3f0ea73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1602 additions and 517 deletions

View file

@ -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>
</>
);
};

View file

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