https://chatgpt.com/share/6a155e06-9860-83e8-81c5-e26d1a207d9c
types
export type ContextMenuItem = {
id: string;
label: string;
onClick?: () => void;
disabled?: boolean;
children?: ContextMenuItem[];
};
export type ContextMenuState = {
isOpen: boolean;
x: number;
y: number;
items: ContextMenuItem[];
};
Store
import { create } from "zustand";
import type { ContextMenuItem, ContextMenuState } from "./types";
import { BASE_MENU_RIGHT_CLICK } from "./base-menu-right-click";
type Store = ContextMenuState & {
open: (x: number, y: number, items?: ContextMenuItem[]) => void;
close: () => void;
};
export const useContextMenuStore = create<Store>((set) => ({
isOpen: false,
x: 0,
y: 0,
items: BASE_MENU_RIGHT_CLICK,
open: (x, y, items) =>
set({
isOpen: true,
x,
y,
items: items ?? BASE_MENU_RIGHT_CLICK,
}),
close: () =>
set({
isOpen: false,
}),
}));
page
import type { ContextMenuItem } from "./types";
import { BASE_MENU_RIGHT_CLICK } from "./base-menu-right-click";
type PageMenuMode = "base" | "replace" | "extend";
type PageConfig = {
mode: PageMenuMode;
items?: ContextMenuItem[];
};
export function getPageContextMenu(pathname: string): PageConfig {
switch (pathname) {
case "/expense":
return {
mode: "replace",
items: [
{
id: "add-expense",
label: "Add Expense",
onClick: () => console.log("Add Expense"),
},
{
id: "export-expense",
label: "Export",
children: [
{
id: "pdf",
label: "PDF",
onClick: () => {},
},
{
id: "csv",
label: "CSV",
onClick: () => {},
},
],
},
],
};
case "/income":
return {
mode: "extend",
items: [
{
id: "add-income",
label: "Add Income",
onClick: () => console.log("Add Income"),
},
{
id: "export-income",
label: "Export",
onClick: () => console.log("Export Expense"),
},
],
};
default:
return {
mode: "base",
};
}
}
Element
import type { ContextMenuItem } from "./types";
type ElementMode = "replace" | "extend";
type ElementConfig = {
mode: ElementMode;
items: ContextMenuItem[];
};
const registry = new WeakMap<HTMLElement, ElementConfig>();
export function registerElementMenu(
element: HTMLElement,
config: ElementConfig,
) {
registry.set(element, config);
}
export function getElementContextMenu(
target: EventTarget | null,
): ElementConfig | null {
if (!(target instanceof HTMLElement)) {
return null;
}
let current: HTMLElement | null = target;
while (current) {
const config = registry.get(current);
if (config) {
return config;
}
current = current.parentElement;
}
return null;
}
Element actions
import type { ContextMenuItem } from "./types";
type ExpensePayload = {
expenseId: number;
expense?: unknown;
};
type ExpenseHandlers = {
onEdit: (payload: ExpensePayload) => void;
onDelete: (payload: ExpensePayload) => void;
};
export function getExpenseMenuItems(
payload: ExpensePayload,
handlers: ExpenseHandlers,
): ContextMenuItem[] {
return [
{
id: "edit-expense",
label: "Edit Expense",
onClick: () => {
handlers.onEdit(payload);
},
},
{
id: "delete-expense",
label: "Delete Expense",
onClick: () => {
handlers.onDelete(payload);
},
},
];
}
Use-contectMenu
import { useEffect } from "react";
import { useContextMenuStore } from "./store";
import { getPageContextMenu } from "./page";
import { BASE_MENU_RIGHT_CLICK } from "./base-menu-right-click";
import { getElementContextMenu } from "./element";
export function useContextMenu() {
const open = useContextMenuStore((s) => s.open);
const close = useContextMenuStore((s) => s.close);
useEffect(() => {
const handleRightClick = (e: MouseEvent) => {
e.preventDefault();
// Since we use createHashRouter, the path is in the hash (e.g. '#/expense').
// window.location.pathname will always be "/" in a hash router.
const hash = window.location.hash;
const path = hash.replace(/^#/, "").split("?")[0] || "/";
const pageConfig = getPageContextMenu(path);
let finalMenu = BASE_MENU_RIGHT_CLICK;
if (pageConfig.mode === "replace") {
finalMenu = pageConfig.items || [];
}
if (pageConfig.mode === "extend") {
const baseIds = new Set(BASE_MENU_RIGHT_CLICK.map((i) => i.id));
const extraItems = (pageConfig.items || []).filter(
(item) => !baseIds.has(item.id),
);
finalMenu = [...BASE_MENU_RIGHT_CLICK, ...extraItems];
}
// Element Context Menu
const elementConfig = getElementContextMenu(e.target);
if (elementConfig) {
if (elementConfig.mode === "replace") {
finalMenu = elementConfig.items;
}
if (elementConfig.mode === "extend") {
finalMenu = [
...finalMenu,
...elementConfig.items.filter(
(item) => !finalMenu.some((existing) => existing.id === item.id),
),
];
}
}
open(e.clientX, e.clientY, finalMenu);
};
const handleClick = () => close();
window.addEventListener("contextmenu", handleRightClick);
window.addEventListener("click", handleClick);
return () => {
window.removeEventListener("contextmenu", handleRightClick);
window.removeEventListener("click", handleClick);
};
}, [open, close]);
}
use-element-CM
import { useCallback, useRef } from "react";
import { registerElementMenu } from "./element";
import type { ContextMenuItem } from "./types";
type Config<TPayload = unknown> = {
mode: "replace" | "extend";
payload?: TPayload;
items: ContextMenuItem[] | ((payload?: TPayload) => ContextMenuItem[]);
};
export function useElementContextMenu<
T extends HTMLElement = HTMLElement,
TPayload = unknown,
>(config: Config<TPayload>) {
const elementRef = useRef<T | null>(null);
const setRef = useCallback(
(node: T | null) => {
if (node) {
elementRef.current = node;
const resolvedItems =
typeof config.items === "function"
? config.items(config.payload)
: config.items;
registerElementMenu(node, {
...config,
items: resolvedItems,
});
} else {
elementRef.current = null;
}
},
[config],
);
return setRef;
}
render-richt click menu
import { useState, useEffect, useCallback } from "react";
import { useFloating, autoUpdate, offset, flip, shift } from "@floating-ui/react";
import { useContextMenuStore } from "./store";
import { Potral } from "../overlay/portal";
import { cn } from "@/src/utils";
import type { ContextMenuItem } from "./types";
function SubMenuItem({ item, close }: { item: ContextMenuItem; close: () => void }) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: "right-start",
middleware: [
offset({ mainAxis: 4, crossAxis: -4 }),
flip({ fallbackPlacements: ["left-start"] }),
shift({ padding: 8 }),
],
whileElementsMounted: autoUpdate,
});
return (
<div
ref={refs.setReference}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
className="relative"
>
<div
className={cn(
"hover:bg-gray-100 active:bg-gray-200 px-3 py-2 rounded text-gray-700 text-sm cursor-pointer flex justify-between items-center",
item.disabled && "opacity-50 cursor-not-allowed hover:bg-transparent active:bg-transparent",
isOpen && "bg-gray-100",
)}
>
<span>{item.label}</span>
<span className="text-xs text-gray-400">></span>
</div>
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
className="z-[10000] bg-white shadow-lg p-1 border border-gray-200 rounded-md min-w-[180px]"
>
{item.children?.map((child) => (
<div
key={child.id}
onClick={(e) => {
e.stopPropagation();
if (child.disabled) return;
child.onClick?.();
close();
}}
className={cn(
"rounded px-3 py-2 text-sm cursor-pointer hover:bg-gray-100",
child.disabled && "cursor-not-allowed text-gray-400 hover:bg-transparent",
)}
>
{child.label}
</div>
))}
</div>
)}
</div>
);
}
export function RenderRightClickMenu() {
const { isOpen, x, y, items, close } = useContextMenuStore();
const { refs, floatingStyles, update } = useFloating({
placement: "bottom-start",
middleware: [
offset(0),
flip({
fallbackPlacements: ["bottom-end", "top-start", "top-end"],
}),
shift({ padding: 8 }),
],
whileElementsMounted: autoUpdate,
});
useEffect(() => {
if (isOpen) {
refs.setReference({
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: x,
y: y,
top: y,
left: x,
right: x,
bottom: y,
};
},
});
update();
}
}, [isOpen, x, y, refs, update]);
if (!isOpen) return null;
return (
<Potral>
<div
ref={refs.setFloating}
style={floatingStyles}
className="z-[9999] bg-white shadow-lg p-1 border border-gray-200 rounded-md min-w-[180px]"
>
{items.map((item) => {
if (item.children?.length) {
return <SubMenuItem key={item.id} item={item} close={close} />;
}
return (
<div
key={item.id}
onClick={() => {
if (item.disabled) return;
item.onClick?.();
close();
}}
className={cn(
"hover:bg-gray-100 active:bg-gray-200 px-3 py-2 rounded text-gray-700 text-sm cursor-pointer",
item.disabled && "opacity-50 cursor-not-allowed hover:bg-transparent active:bg-transparent",
)}
>
{item.label}
</div>
);
})}
</div>
</Potral>
);
}
base menu
import type { ContextMenuItem } from "./types";
export const BASE_MENU_RIGHT_CLICK: ContextMenuItem[] = [
{
id: "refresh",
label: "Refresh",
onClick: () => window.location.reload(),
},
{
id: "back",
label: "Back",
onClick: () => window.history.back(),
},
{
id: "forward",
label: "Forward",
onClick: () => window.history.forward(),
},
];
INDEX
export { useContextMenu } from "./use-context-menu";
export { RenderRightClickMenu } from "./render-right-click-menu";
export { useContextMenuStore } from "./store";
export {useElementContextMenu} from "./use-element-context-menu";
Top comments (0)