DEV Community

pjdev2d
pjdev2d

Posted on

context menu

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[];
};

Enter fullscreen mode Exit fullscreen mode

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,
    }),
}));

Enter fullscreen mode Exit fullscreen mode

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",
      };
  }
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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);
      },
    },
  ];
}

Enter fullscreen mode Exit fullscreen mode

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]);
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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">&gt;</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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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(),
  },
];

Enter fullscreen mode Exit fullscreen mode

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";

Enter fullscreen mode Exit fullscreen mode

Top comments (0)