fix: prevent context menu jump and animation on open
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { forwardRef, useState, useRef, useEffect } from "react";
|
import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
@@ -17,26 +17,72 @@ interface ContextMenuProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 10;
|
||||||
|
|
||||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||||
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
|
||||||
|
const localRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Measure the rendered menu and clamp it inside the viewport before the
|
||||||
|
// browser paints. We hide the element until this runs so the user never
|
||||||
|
// sees the menu jump from an unclamped position to a clamped one.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setAdjustedPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = localRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
let x = position.x;
|
||||||
|
let y = position.y;
|
||||||
|
|
||||||
|
if (x + rect.width > vw - VIEWPORT_MARGIN) {
|
||||||
|
x = vw - rect.width - VIEWPORT_MARGIN;
|
||||||
|
}
|
||||||
|
if (y + rect.height > vh - VIEWPORT_MARGIN) {
|
||||||
|
y = vh - rect.height - VIEWPORT_MARGIN;
|
||||||
|
}
|
||||||
|
x = Math.max(VIEWPORT_MARGIN, x);
|
||||||
|
y = Math.max(VIEWPORT_MARGIN, y);
|
||||||
|
|
||||||
|
setAdjustedPosition({ x, y });
|
||||||
|
}, [isOpen, position.x, position.y]);
|
||||||
|
|
||||||
|
const setRefs = (node: HTMLDivElement | null) => {
|
||||||
|
localRef.current = node;
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!mounted || !isOpen) return null;
|
if (!mounted || !isOpen) return null;
|
||||||
|
|
||||||
|
const renderPosition = adjustedPosition ?? position;
|
||||||
|
const isPositioned = adjustedPosition !== null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={setRefs}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
|
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border"
|
||||||
"animate-in fade-in-0 zoom-in-95 duration-100"
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: position.x,
|
left: renderPosition.x,
|
||||||
top: position.y,
|
top: renderPosition.y,
|
||||||
|
visibility: isPositioned ? "visible" : "hidden",
|
||||||
}}
|
}}
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -21,7 +21,7 @@ interface UseContextMenuReturn<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MENU_WIDTH = 200;
|
const MENU_WIDTH = 200;
|
||||||
const MENU_HEIGHT = 320; // Initial estimate; refined after mount via layout effect
|
const MENU_HEIGHT = 320; // Approximate max height
|
||||||
const VIEWPORT_MARGIN = 10;
|
const VIEWPORT_MARGIN = 10;
|
||||||
|
|
||||||
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||||
@@ -57,32 +57,6 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Re-clamp position once we can measure the actual rendered menu — the
|
|
||||||
// initial estimate uses a fixed height which can be too small for menus
|
|
||||||
// with many items, causing the bottom to be clipped off-screen.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!contextMenu.isOpen || !menuRef.current) return;
|
|
||||||
const rect = menuRef.current.getBoundingClientRect();
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
let x = contextMenu.position.x;
|
|
||||||
let y = contextMenu.position.y;
|
|
||||||
|
|
||||||
if (x + rect.width > viewportWidth - VIEWPORT_MARGIN) {
|
|
||||||
x = viewportWidth - rect.width - VIEWPORT_MARGIN;
|
|
||||||
}
|
|
||||||
if (y + rect.height > viewportHeight - VIEWPORT_MARGIN) {
|
|
||||||
y = viewportHeight - rect.height - VIEWPORT_MARGIN;
|
|
||||||
}
|
|
||||||
x = Math.max(VIEWPORT_MARGIN, x);
|
|
||||||
y = Math.max(VIEWPORT_MARGIN, y);
|
|
||||||
|
|
||||||
if (x !== contextMenu.position.x || y !== contextMenu.position.y) {
|
|
||||||
setContextMenu((prev) => ({ ...prev, position: { x, y } }));
|
|
||||||
}
|
|
||||||
}, [contextMenu.isOpen, contextMenu.position.x, contextMenu.position.y]);
|
|
||||||
|
|
||||||
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
|
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
Reference in New Issue
Block a user