Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";

/* ----------------------------------------------------------------------------
 * Menu model
 * ------------------------------------------------------------------------- */

type Icon = React.ComponentType<{ className?: string }>;
type Base = { id: string; label: string; icon?: Icon; shortcut?: string; disabled?: boolean };

export type MenuEntry =
  | (Base & { type: "item"; destructive?: boolean; onSelect?: () => void })
  | (Base & { type: "checkbox"; checked: boolean; onCheckedChange: (checked: boolean) => void })
  | (Base & { type: "radio"; group: string; checked: boolean; onSelect: () => void })
  | (Base & { type: "sub"; items: MenuEntry[] })
  | { type: "separator"; id: string }
  | { type: "label"; id: string; label: string };

type Actionable = Exclude<MenuEntry, { type: "separator" } | { type: "label" }>;
const isActionable = (e: MenuEntry): e is Actionable => e.type !== "separator" && e.type !== "label";

export interface ContextMenuProps {
  items: MenuEntry[];
  children: React.ReactNode;
  /** Accessible name for the right-clickable area. */
  label?: string;
  className?: string;
  onOpenChange?: (open: boolean) => void;
  disabled?: boolean;
  /** Open once on mount, anchored inside the area (useful for demos and onboarding hints). */
  defaultOpen?: boolean;
}

type OpenState = { x: number; y: number; viaKeyboard: boolean } | null;

/* ----------------------------------------------------------------------------
 * Root
 * ------------------------------------------------------------------------- */

export function ContextMenu({ items, children, label = "Context area", className, onOpenChange, disabled, defaultOpen = false }: ContextMenuProps) {
  const uid = React.useId();
  const [open, setOpenState] = React.useState<OpenState>(null);
  const targetRef = React.useRef<HTMLDivElement>(null);
  const press = React.useRef<number | undefined>(undefined);

  const setOpen = React.useCallback(
    (v: OpenState) => {
      setOpenState(v);
      onOpenChange?.(!!v);
    },
    [onOpenChange],
  );

  const close = React.useCallback(
    (restore: boolean) => {
      setOpen(null);
      if (restore) targetRef.current?.focus({ preventScroll: true });
    },
    [setOpen],
  );

  const anchor = React.useMemo<Anchor | null>(() => (open ? { kind: "point", x: open.x, y: open.y } : null), [open]);

  const openAtElement = () => {
    const r = targetRef.current?.getBoundingClientRect();
    if (!r) return;
    setOpen({ x: r.left + Math.min(40, r.width / 2), y: r.top + Math.min(40, r.height / 2), viaKeyboard: true });
  };

  React.useEffect(() => {
    if (!defaultOpen) return;
    const r = targetRef.current?.getBoundingClientRect();
    if (r) setOpenState({ x: r.left + r.width * 0.42, y: r.top + r.height * 0.1, viaKeyboard: false });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- mount only
  }, []);

  // dismiss on outside press / resize / blur
  React.useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!(e.target as Element).closest?.(`[data-menu-root="${uid}"]`)) close(false);
    };
    const onBlur = () => close(false);
    document.addEventListener("pointerdown", onDown, true);
    window.addEventListener("resize", onBlur);
    window.addEventListener("blur", onBlur);
    return () => {
      document.removeEventListener("pointerdown", onDown, true);
      window.removeEventListener("resize", onBlur);
      window.removeEventListener("blur", onBlur);
    };
  }, [open, close, uid]);

  return (
    <>
      <div
        ref={targetRef}
        tabIndex={disabled ? -1 : 0}
        aria-label={label}
        aria-haspopup="menu"
        aria-expanded={!!open}
        aria-describedby={`${uid}-hint`}
        onContextMenu={(e) => {
          if (disabled) return;
          e.preventDefault();
          setOpen({ x: e.clientX, y: e.clientY, viaKeyboard: false });
        }}
        onKeyDown={(e) => {
          if (disabled) return;
          if ((e.shiftKey && e.key === "F10") || e.key === "ContextMenu") {
            e.preventDefault();
            openAtElement();
          }
        }}
        onPointerDown={(e) => {
          if (e.pointerType !== "touch" || disabled) return;
          const { clientX, clientY } = e;
          press.current = window.setTimeout(() => setOpen({ x: clientX, y: clientY, viaKeyboard: false }), 480);
        }}
        onPointerUp={() => window.clearTimeout(press.current)}
        onPointerCancel={() => window.clearTimeout(press.current)}
        onPointerMove={(e) => e.pointerType === "touch" && window.clearTimeout(press.current)}
        className={cn("outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background", className)}
      >
        {children}
      </div>
      <span id={`${uid}-hint`} className="sr-only">
        Right-click, long-press, or press Shift+F10 to open the menu.
      </span>
      <AnimatePresence>
        {open && anchor && (
          <MenuList
            key={`${open.x}:${open.y}`}
            rootId={uid}
            entries={items}
            anchor={anchor}
            autoFocusFirst={open.viaKeyboard}
            onCloseAll={close}
            level={0}
          />
        )}
      </AnimatePresence>
    </>
  );
}

/* ----------------------------------------------------------------------------
 * A menu surface (root or submenu)
 * ------------------------------------------------------------------------- */

type Anchor = { kind: "point"; x: number; y: number } | { kind: "item"; rect: DOMRect };

function MenuList({
  rootId,
  entries,
  anchor,
  autoFocusFirst,
  onCloseAll,
  onCloseSelf,
  level,
  labelledBy,
}: {
  rootId: string;
  entries: MenuEntry[];
  anchor: Anchor;
  autoFocusFirst: boolean;
  onCloseAll: (restore: boolean) => void;
  onCloseSelf?: () => void;
  level: number;
  labelledBy?: string;
}) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);
  const [active, setActive] = React.useState(-1);
  const [sub, setSubState] = React.useState<{ index: number; viaKeyboard: boolean; anchor: Anchor; n: number } | null>(null);
  const subTimer = React.useRef<number | undefined>(undefined);
  const typeahead = React.useRef({ text: "", t: 0 });
  const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);

  const openSub = (index: number, viaKeyboard: boolean) => {
    const el = itemRefs.current[index];
    if (!el) return;
    setSubState((prev) => ({ index, viaKeyboard, anchor: { kind: "item", rect: el.getBoundingClientRect() }, n: (prev?.n ?? 0) + 1 }));
  };
  const closeSub = () => setSubState(null);

  const enabled = entries.map((e, i) => (isActionable(e) && !e.disabled ? i : -1)).filter((i) => i >= 0);

  // viewport-aware placement (direct style writes, before paint)
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const vw = document.documentElement.clientWidth;
    const vh = window.innerHeight;
    const w = el.offsetWidth;
    const h = el.offsetHeight;
    let x: number;
    let y: number;
    let ox = "left";
    let oy = "top";
    if (anchor.kind === "point") {
      x = anchor.x;
      y = anchor.y;
      if (x + w > vw - 8) {
        x = Math.max(8, x - w);
        ox = "right";
      }
      if (y + h > vh - 8) {
        y = Math.max(8, y - h);
        oy = "bottom";
      }
    } else {
      x = anchor.rect.right - 4;
      y = anchor.rect.top - 5;
      if (x + w > vw - 8) {
        x = Math.max(8, anchor.rect.left - w + 4);
        ox = "right";
      }
      if (y + h > vh - 8) y = Math.max(8, vh - 8 - h);
    }
    el.style.left = `${x}px`;
    el.style.top = `${y}px`;
    el.style.transformOrigin = `${ox} ${oy}`;
  }, [anchor]);

  React.useEffect(() => {
    if (autoFocusFirst && enabled.length) setActive(enabled[0]);
    else ref.current?.focus({ preventScroll: true });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- mount only
  }, []);

  React.useEffect(() => {
    if (active >= 0) itemRefs.current[active]?.focus({ preventScroll: true });
  }, [active]);

  React.useEffect(() => () => window.clearTimeout(subTimer.current), []);

  const move = (dir: 1 | -1) => {
    if (!enabled.length) return;
    const pos = enabled.indexOf(active);
    const next = pos === -1 ? (dir === 1 ? 0 : enabled.length - 1) : (pos + dir + enabled.length) % enabled.length;
    setActive(enabled[next]);
    closeSub();
  };

  const activate = (i: number, viaKeyboard: boolean) => {
    const e = entries[i];
    if (!e || !isActionable(e) || e.disabled) return;
    if (e.type === "sub") {
      window.clearTimeout(subTimer.current);
      openSub(i, viaKeyboard);
      return;
    }
    if (e.type === "checkbox") e.onCheckedChange(!e.checked);
    else if (e.type === "radio") e.onSelect();
    else {
      e.onSelect?.();
      onCloseAll(true);
    }
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        move(1);
        break;
      case "ArrowUp":
        e.preventDefault();
        move(-1);
        break;
      case "Home":
        e.preventDefault();
        if (enabled.length) setActive(enabled[0]);
        break;
      case "End":
        e.preventDefault();
        if (enabled.length) setActive(enabled[enabled.length - 1]);
        break;
      case "ArrowRight":
        if (active >= 0 && entries[active]?.type === "sub") {
          e.preventDefault();
          activate(active, true);
        }
        break;
      case "ArrowLeft":
        if (level > 0) {
          e.preventDefault();
          e.stopPropagation();
          onCloseSelf?.();
        }
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        if (active >= 0) activate(active, true);
        break;
      case "Escape":
        e.preventDefault();
        e.stopPropagation();
        if (level > 0) onCloseSelf?.();
        else onCloseAll(true);
        break;
      case "Tab":
        e.preventDefault();
        onCloseAll(true);
        break;
      default:
        if (e.key.length === 1 && /\S/.test(e.key)) {
          const now = performance.now();
          const ta = typeahead.current;
          ta.text = now - ta.t > 600 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
          ta.t = now;
          const start = Math.max(0, enabled.indexOf(active));
          const order = [...enabled.slice(start + (ta.text.length === 1 ? 1 : 0)), ...enabled.slice(0, start + 1)];
          const hit = order.find((i) => {
            const en = entries[i];
            return isActionable(en) && en.label.toLowerCase().startsWith(ta.text);
          });
          if (hit !== undefined) setActive(hit);
        }
    }
  };

  const subEntry = sub ? entries[sub.index] : undefined;

  return (
    <>
      <motion.div
        ref={ref}
        data-menu-root={rootId}
        role="menu"
        aria-orientation="vertical"
        aria-labelledby={labelledBy}
        aria-label={labelledBy ? undefined : "Context menu"}
        tabIndex={-1}
        onKeyDown={onKeyDown}
        onContextMenu={(e) => e.preventDefault()}
        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}
        animate={{ opacity: 1, scale: 1 }}
        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, transition: { duration: 0.1 } }}
        transition={{ type: "spring", stiffness: 700, damping: 38 }}
        style={{ position: "fixed", left: 0, top: 0, zIndex: 50 + level }}
        className="max-h-[calc(100dvh-1rem)] min-w-56 max-w-[calc(100vw-1rem)] overflow-y-auto rounded-xl border bg-popover p-1 text-sm text-popover-foreground shadow-xl shadow-black/10 outline-none backdrop-blur dark:shadow-black/50"
      >
        {entries.map((e, i) => {
          if (e.type === "separator") return <div key={e.id} role="separator" className="-mx-1 my-1 h-px bg-border" />;
          if (e.type === "label")
            return (
              <div key={e.id} role="presentation" className="px-2.5 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
                {e.label}
              </div>
            );
          const isActive = active === i;
          const role = e.type === "checkbox" ? "menuitemcheckbox" : e.type === "radio" ? "menuitemradio" : "menuitem";
          const checked = e.type === "checkbox" || e.type === "radio" ? e.checked : undefined;
          const Icon = e.icon;
          return (
            <div
              key={e.id}
              ref={(el) => {
                itemRefs.current[i] = el;
              }}
              id={`${uid}-i-${i}`}
              role={role}
              tabIndex={-1}
              aria-disabled={e.disabled || undefined}
              aria-checked={checked}
              aria-haspopup={e.type === "sub" ? "menu" : undefined}
              aria-expanded={e.type === "sub" ? sub?.index === i : undefined}
              onPointerMove={() => {
                if (e.disabled) return;
                if (active !== i) setActive(i);
                window.clearTimeout(subTimer.current);
                if (e.type === "sub") {
                  if (sub?.index !== i) subTimer.current = window.setTimeout(() => openSub(i, false), 110);
                } else if (sub) subTimer.current = window.setTimeout(closeSub, 180);
              }}
              onPointerLeave={() => {
                if (!sub && active === i) setActive(-1);
              }}
              onClick={() => activate(i, false)}
              className={cn(
                "relative flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2.5 outline-none",
                e.disabled && "opacity-40",
                e.type === "item" && e.destructive ? "text-destructive" : "",
              )}
            >
              {(isActive || sub?.index === i) && (
                <motion.span
                  layoutId={reduce ? undefined : `${uid}-hl`}
                  className={cn("absolute inset-0 rounded-lg", e.type === "item" && e.destructive ? "bg-destructive/10" : "bg-accent")}
                  transition={{ type: "spring", stiffness: 800, damping: 50 }}
                />
              )}
              <span className="relative grid size-4 shrink-0 place-items-center text-muted-foreground">
                {e.type === "checkbox" ? (
                  <AnimatePresence initial={false}>
                    {e.checked && (
                      <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} transition={{ type: "spring", stiffness: 700, damping: 24 }}>
                        <Check className="size-4 text-foreground" strokeWidth={2.5} />
                      </motion.span>
                    )}
                  </AnimatePresence>
                ) : e.type === "radio" ? (
                  <span className="grid size-3.5 place-items-center rounded-full border border-foreground/30">
                    {e.checked && <motion.span layoutId={reduce ? undefined : `${uid}-radio-${e.group}`} className="size-1.5 rounded-full bg-foreground" />}
                  </span>
                ) : Icon ? (
                  <Icon className={cn("size-4", e.type === "item" && e.destructive && "text-destructive")} />
                ) : null}
              </span>
              <span className="relative flex-1 truncate">{e.label}</span>
              {e.shortcut && <span className="relative ml-4 text-xs tracking-wider text-muted-foreground">{e.shortcut}</span>}
              {e.type === "sub" && <ChevronRight className="relative size-3.5 text-muted-foreground" aria-hidden />}
            </div>
          );
        })}
      </motion.div>

      <AnimatePresence>
        {sub && subEntry?.type === "sub" && (
          <MenuList
            key={`${subEntry.id}-${sub.viaKeyboard ? sub.n : 0}`}
            rootId={rootId}
            entries={subEntry.items}
            anchor={sub.anchor}
            autoFocusFirst={sub.viaKeyboard}
            onCloseAll={onCloseAll}
            onCloseSelf={() => {
              const idx = sub.index;
              closeSub();
              setActive(idx);
              itemRefs.current[idx]?.focus({ preventScroll: true });
            }}
            level={level + 1}
            labelledBy={`${uid}-i-${sub.index}`}
          />
        )}
      </AnimatePresence>
    </>
  );
}

More in Navigation

View all →