[WEB-1110] dev: custom context menu for issues, cycles, modules, views, pages and projects (#4267)
* dev: context menu * chore: handle menu position on close * chore: project quick actions * chore: add more options to the project context menu * chore: cycle item context menu * refactor: context menu folder structure * chore: module custom context menu * chore: view custom context menu * chore: issues custom context menu * chore: reorder options * chore: issues custom context menu * chore: render the context menu in a portal
This commit is contained in:
parent
cb6ecc86cc
commit
d2717a221c
56 changed files with 1411 additions and 815 deletions
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./item";
|
||||
export * from "./root";
|
||||
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal file
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// types
|
||||
import { TContextMenuItem } from "./root";
|
||||
|
||||
type ContextMenuItemProps = {
|
||||
handleActiveItem: () => void;
|
||||
handleClose: () => void;
|
||||
isActive: boolean;
|
||||
item: TContextMenuItem;
|
||||
};
|
||||
|
||||
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||
|
||||
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.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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal file
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "../../hooks/use-outside-click-detector";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.FC<any>;
|
||||
action: () => void;
|
||||
shouldRender?: boolean;
|
||||
closeOnClick?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
type ContextMenuProps = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
items: TContextMenuItem[];
|
||||
};
|
||||
|
||||
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||
const { parentRef, items } = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||
// refs
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
// derived values
|
||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setActiveItemIndex(0);
|
||||
};
|
||||
|
||||
// calculate position of context menu
|
||||
useEffect(() => {
|
||||
const parentElement = parentRef.current;
|
||||
const contextMenu = contextMenuRef.current;
|
||||
if (!parentElement || !contextMenu) return;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const contextMenuWidth = contextMenu.clientWidth;
|
||||
const contextMenuHeight = contextMenu.clientHeight;
|
||||
|
||||
const clickX = e?.pageX || 0;
|
||||
const clickY = e?.pageY || 0;
|
||||
|
||||
// check if there's enough space at the bottom, otherwise show at the top
|
||||
let top = clickY;
|
||||
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
|
||||
|
||||
// check if there's enough space on the right, otherwise show on the left
|
||||
let left = clickX;
|
||||
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
|
||||
|
||||
setPosition({ x: left, y: top });
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideContextMenu = (e: KeyboardEvent) => {
|
||||
if (isOpen && e.key === "Escape") handleClose();
|
||||
};
|
||||
|
||||
parentElement.addEventListener("contextmenu", handleContextMenu);
|
||||
window.addEventListener("keydown", hideContextMenu);
|
||||
|
||||
return () => {
|
||||
parentElement.removeEventListener("contextmenu", handleContextMenu);
|
||||
window.removeEventListener("keydown", hideContextMenu);
|
||||
};
|
||||
}, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]);
|
||||
|
||||
// handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const item = renderedItems[activeItemIndex];
|
||||
if (!item.disabled) {
|
||||
renderedItems[activeItemIndex].action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||
|
||||
// close on clicking outside
|
||||
useOutsideClickDetector(contextMenuRef, 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",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg rounded-md px-2 py-2.5 max-h-72 min-w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
{renderedItems.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.key}
|
||||
handleActiveItem={() => setActiveItemIndex(index)}
|
||||
handleClose={handleClose}
|
||||
isActive={index === activeItemIndex}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
let contextMenu = <ContextMenuWithoutPortal {...props} />;
|
||||
const portal = document.querySelector("#context-menu-portal");
|
||||
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
|
||||
return contextMenu;
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./context-menu";
|
||||
export * from "./custom-menu";
|
||||
export * from "./custom-select";
|
||||
export * from "./custom-search-select";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue