mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
fix: make menus work from the keyboard and keep tapped submenus open
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, isContextMenuOpen } from '../context-menu';
|
||||
import { isContextMenuKey, useContextMenu } from '@/hooks/use-context-menu';
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function Menu({ onPick = () => {}, withSub = false }: { onPick?: (what: string) => void; withSub?: boolean }) {
|
||||
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<string>();
|
||||
const pick = (what: string) => { closeContextMenu(); onPick(what); };
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(e) => openContextMenu(e, 'row')}>More</button>
|
||||
<button
|
||||
type="button"
|
||||
onContextMenu={(e) => openContextMenu(e, 'row')}
|
||||
onKeyDown={(e) => { if (isContextMenuKey(e)) { e.preventDefault(); openContextMenuFrom(e.currentTarget, 'row', 'end'); } }}
|
||||
>
|
||||
Row
|
||||
</button>
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<ContextMenuItem label="Open" onClick={() => pick('open')} />
|
||||
<ContextMenuItem label="Rename" onClick={() => pick('rename')} />
|
||||
<ContextMenuItem label="Disabled" onClick={() => pick('disabled')} disabled />
|
||||
{withSub && (
|
||||
<ContextMenuSubMenu label="Move to" testId="move">
|
||||
<ContextMenuItem label="Inbox" onClick={() => pick('inbox')} />
|
||||
<ContextMenuItem label="Spam" onClick={() => pick('spam')} />
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
<ContextMenuItem label="Delete" onClick={() => pick('delete')} destructive />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function openByKeyboard(props: Parameters<typeof Menu>[0] = {}) {
|
||||
render(<Menu {...props} />);
|
||||
const trigger = screen.getByText('More');
|
||||
trigger.focus();
|
||||
// Enter or Space on a button fires a click with detail 0.
|
||||
fireEvent.click(trigger, { detail: 0 });
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByText('Open').closest('button')));
|
||||
return trigger;
|
||||
}
|
||||
|
||||
function key(k: string, init: Partial<KeyboardEventInit> = {}) {
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, { key: k, ...init });
|
||||
}
|
||||
|
||||
const item = (label: string) => screen.getByText(label).closest('[role="menuitem"]') as HTMLElement;
|
||||
|
||||
describe('ContextMenu keyboard', () => {
|
||||
it('focuses the first item and moves with the arrows, Home and End, skipping disabled items', async () => {
|
||||
await openByKeyboard();
|
||||
key('ArrowDown');
|
||||
expect(document.activeElement).toBe(item('Rename'));
|
||||
key('ArrowDown');
|
||||
expect(document.activeElement).toBe(item('Delete'));
|
||||
key('ArrowDown');
|
||||
expect(document.activeElement).toBe(item('Open'));
|
||||
key('ArrowUp');
|
||||
expect(document.activeElement).toBe(item('Delete'));
|
||||
key('Home');
|
||||
expect(document.activeElement).toBe(item('Open'));
|
||||
key('End');
|
||||
expect(document.activeElement).toBe(item('Delete'));
|
||||
});
|
||||
|
||||
it('closes on Escape and on Tab and gives the focus back to the trigger', async () => {
|
||||
const trigger = await openByKeyboard();
|
||||
key('Escape');
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
fireEvent.click(trigger, { detail: 0 });
|
||||
await waitFor(() => expect(document.activeElement).toBe(item('Open')));
|
||||
key('Tab');
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('keeps Escape, arrows, Enter and letters from the app while open', async () => {
|
||||
const appKeys = vi.fn();
|
||||
document.addEventListener('keydown', appKeys);
|
||||
try {
|
||||
await openByKeyboard();
|
||||
expect(isContextMenuOpen()).toBe(true);
|
||||
expect(document.documentElement).toHaveAttribute('data-context-menu-open');
|
||||
key('ArrowDown');
|
||||
key('r');
|
||||
key('Enter');
|
||||
key('Escape');
|
||||
expect(appKeys).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(isContextMenuOpen()).toBe(false);
|
||||
expect(document.documentElement).not.toHaveAttribute('data-context-menu-open');
|
||||
key('Escape');
|
||||
expect(appKeys).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
document.removeEventListener('keydown', appKeys);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextMenu placement', () => {
|
||||
function mockRects() {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.getAttribute('role') === 'menu') return rect(0, 0, 200, 120);
|
||||
if (this.textContent === 'More') return rect(300, 100, 40, 32);
|
||||
if (this.textContent === 'Row') return rect(500, 400, 300, 40);
|
||||
return rect(0, 0, 0, 0);
|
||||
});
|
||||
}
|
||||
const menuStyle = () => screen.getByRole('menu').style;
|
||||
|
||||
it('anchors under the trigger when opened by keyboard', async () => {
|
||||
mockRects();
|
||||
await openByKeyboard();
|
||||
expect(menuStyle().left).toBe('300px');
|
||||
expect(menuStyle().top).toBe('136px');
|
||||
});
|
||||
|
||||
it('opens at the pointer when clicked with a mouse', async () => {
|
||||
mockRects();
|
||||
render(<Menu />);
|
||||
fireEvent.click(screen.getByText('More'), { detail: 1, clientX: 50, clientY: 60 });
|
||||
await waitFor(() => expect(menuStyle().visibility).toBe('visible'));
|
||||
expect(menuStyle().left).toBe('50px');
|
||||
expect(menuStyle().top).toBe('60px');
|
||||
});
|
||||
|
||||
it('anchors a keyboard contextmenu (no pointer position) to its element', async () => {
|
||||
mockRects();
|
||||
render(<Menu />);
|
||||
fireEvent.contextMenu(screen.getByText('Row'), { clientX: 0, clientY: 0 });
|
||||
await waitFor(() => expect(menuStyle().visibility).toBe('visible'));
|
||||
expect(menuStyle().left).toBe('500px');
|
||||
expect(menuStyle().top).toBe('444px');
|
||||
});
|
||||
|
||||
it('opens from Shift+F10 through the helper, lined up with the end edge', async () => {
|
||||
mockRects();
|
||||
render(<Menu />);
|
||||
const row = screen.getByText('Row');
|
||||
row.focus();
|
||||
fireEvent.keyDown(row, { key: 'F10', shiftKey: true });
|
||||
await waitFor(() => expect(menuStyle().visibility).toBe('visible'));
|
||||
expect(menuStyle().left).toBe('600px');
|
||||
expect(menuStyle().top).toBe('444px');
|
||||
expect(document.activeElement).toBe(item('Open'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextMenuSubMenu', () => {
|
||||
it('opens on a tap and stays open after the emulated mouse events', async () => {
|
||||
render(<Menu withSub />);
|
||||
fireEvent.click(screen.getByText('More'), { detail: 1, clientX: 10, clientY: 10 });
|
||||
await screen.findByText('Move to');
|
||||
vi.useFakeTimers();
|
||||
const trigger = screen.getByTestId('move');
|
||||
fireEvent.pointerDown(trigger, { pointerType: 'touch' });
|
||||
fireEvent.mouseEnter(trigger.parentElement!);
|
||||
fireEvent.mouseLeave(trigger.parentElement!);
|
||||
// The emulated mouse lands on a neighbour as the submenu panel opens.
|
||||
fireEvent.mouseEnter(item('Delete'));
|
||||
fireEvent.click(trigger);
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
expect(screen.getByText('Spam')).toBeInTheDocument();
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('closes when a mouse hovers another entry of its menu', async () => {
|
||||
render(<Menu withSub />);
|
||||
fireEvent.click(screen.getByText('More'), { detail: 1, clientX: 10, clientY: 10 });
|
||||
await screen.findByText('Move to');
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(screen.getByTestId('move'));
|
||||
expect(screen.getByText('Spam')).toBeInTheDocument();
|
||||
fireEvent.mouseLeave(screen.getByTestId('move').parentElement!);
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
expect(screen.getByText('Spam')).toBeInTheDocument();
|
||||
fireEvent.mouseEnter(item('Delete'));
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
expect(screen.queryByText('Spam')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens with → and Enter, focuses its first item, and ← or Escape closes only the submenu', async () => {
|
||||
await openByKeyboard({ withSub: true });
|
||||
key('End');
|
||||
key('ArrowUp');
|
||||
const trigger = screen.getByTestId('move');
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
key('ArrowRight');
|
||||
await waitFor(() => expect(document.activeElement).toBe(item('Inbox')));
|
||||
key('ArrowDown');
|
||||
expect(document.activeElement).toBe(item('Spam'));
|
||||
key('ArrowLeft');
|
||||
expect(screen.queryByText('Spam')).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
key('Enter');
|
||||
await waitFor(() => expect(document.activeElement).toBe(item('Inbox')));
|
||||
key('Escape');
|
||||
expect(screen.queryByText('Inbox')).toBeNull();
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('runs a submenu item chosen by keyboard', async () => {
|
||||
const onPick = vi.fn();
|
||||
await openByKeyboard({ withSub: true, onPick });
|
||||
key('End');
|
||||
key('ArrowUp');
|
||||
key('ArrowRight');
|
||||
await waitFor(() => expect(document.activeElement).toBe(item('Inbox')));
|
||||
// Enter's default action on a button is a click.
|
||||
fireEvent.click(document.activeElement!);
|
||||
expect(onPick).toHaveBeenCalledWith('inbox');
|
||||
});
|
||||
});
|
||||
+499
-64
@@ -1,6 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "@/components/icons";
|
||||
@@ -10,25 +21,219 @@ interface Position {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** The element a menu opened from, in viewport coordinates. */
|
||||
export interface ContextMenuAnchor {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
/** Line the menu up with the anchor's start (default) or end edge; flips in RTL. */
|
||||
align?: "start" | "end";
|
||||
}
|
||||
|
||||
export interface ContextMenuPosition extends Position {
|
||||
/** Opened from a button or by keyboard: placed under (or over) this rect instead of at x/y. */
|
||||
anchor?: ContextMenuAnchor;
|
||||
/** Gets the focus back when the menu closes. Default: what had the focus when it opened. */
|
||||
trigger?: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
isOpen: boolean;
|
||||
position: Position;
|
||||
position: ContextMenuPosition;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 10;
|
||||
const ANCHOR_GAP = 4;
|
||||
const HOVER_CLOSE_DELAY = 150;
|
||||
|
||||
// ─── Which menu is open ────────────────────────────────────────
|
||||
|
||||
// Open menus, the one opened last at the end. Only that one answers keys.
|
||||
const openMenus: object[] = [];
|
||||
|
||||
function syncOpenFlag() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (openMenus.length > 0) document.documentElement.dataset.contextMenuOpen = "";
|
||||
else delete document.documentElement.dataset.contextMenuOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a ContextMenu is open. App shortcut hooks check it (or the
|
||||
* `data-context-menu-open` attribute on <html>) and stand down: the menu
|
||||
* owns the keyboard until it closes.
|
||||
*/
|
||||
export function isContextMenuOpen(): boolean {
|
||||
return openMenus.length > 0;
|
||||
}
|
||||
|
||||
// ─── Items of a menu level ─────────────────────────────────────
|
||||
|
||||
const ITEM_SELECTOR = [
|
||||
'[role="menuitem"]',
|
||||
'[role="menuitemradio"]',
|
||||
'[role="menuitemcheckbox"]',
|
||||
"button",
|
||||
"a[href]",
|
||||
'input:not([type="hidden"])',
|
||||
"select",
|
||||
"textarea",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(",");
|
||||
|
||||
function isUsable(level: HTMLElement, el: HTMLElement): boolean {
|
||||
if ((el as HTMLButtonElement).disabled || el.getAttribute("aria-disabled") === "true") return false;
|
||||
if (typeof window.getComputedStyle !== "function") return true;
|
||||
// Only display: a menu that just turned visible can still compute as
|
||||
// hidden for a frame (under reduced motion every property transitions).
|
||||
for (let node: HTMLElement | null = el; node && node !== level; node = node.parentElement) {
|
||||
if (window.getComputedStyle(node).display === "none") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The controls of one menu level, in order: not those of a submenu inside it. */
|
||||
export function menuLevelItems(level: HTMLElement): HTMLElement[] {
|
||||
const all = Array.from(level.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter(
|
||||
(el) => el.closest('[role="menu"]') === level
|
||||
);
|
||||
// A control inside a menu item (a plugin entry's button) is not a stop of its own.
|
||||
return all.filter((el) => !all.some((other) => other !== el && other.contains(el)) && isUsable(level, el));
|
||||
}
|
||||
|
||||
/** Single-line text fields keep ←/→/Home/End; ↑/↓ still move through the menu. */
|
||||
function isSingleLineText(el: Element): boolean {
|
||||
return el instanceof HTMLInputElement && /^(text|search|email|url|tel|password|)$/.test(el.type);
|
||||
}
|
||||
|
||||
/** Fields that need every key but Escape and Tab. */
|
||||
function needsKeys(el: Element): boolean {
|
||||
if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true;
|
||||
if (el instanceof HTMLElement && el.isContentEditable) return true;
|
||||
return el instanceof HTMLInputElement && !isSingleLineText(el) && !/^(button|submit|reset|checkbox|radio|color|file|image)$/.test(el.type);
|
||||
}
|
||||
|
||||
function focusEl(el: HTMLElement | undefined | null) {
|
||||
el?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the first item of a menu level once it can take the focus: entries
|
||||
* can render a moment after the menu (the mail menu waits for its data), and
|
||||
* a panel that just turned visible can count as hidden for a frame. Gives
|
||||
* up when the focus goes somewhere else meanwhile. Returns a cancel.
|
||||
*/
|
||||
function focusFirstItem(level: HTMLElement): () => void {
|
||||
const from = document.activeElement;
|
||||
let frame = 0;
|
||||
let tries = 0;
|
||||
let done = false;
|
||||
const attempt = () => {
|
||||
if (done) return;
|
||||
const active = document.activeElement;
|
||||
if ((active && level.contains(active)) || active !== from) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
const first = menuLevelItems(level)[0];
|
||||
focusEl(first);
|
||||
if (first && document.activeElement === first) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
if (++tries < 30) frame = requestAnimationFrame(attempt);
|
||||
};
|
||||
attempt();
|
||||
return () => {
|
||||
done = true;
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}
|
||||
|
||||
// Events the menu sends to a submenu's entry.
|
||||
const SUBMENU_OPEN = "contextmenu:submenu-open";
|
||||
const SUBMENU_CLOSE = "contextmenu:submenu-close";
|
||||
|
||||
function submenuTriggerOf(el: Element | null): HTMLElement | null {
|
||||
return el instanceof HTMLElement && el.dataset.submenuTrigger !== undefined ? el : null;
|
||||
}
|
||||
|
||||
/** Close the submenu whose panel this is and put the focus on its entry. */
|
||||
function closeSubmenuPanel(panel: HTMLElement) {
|
||||
const trigger = panel.parentElement?.querySelector<HTMLElement>(":scope > [data-submenu-trigger]");
|
||||
trigger?.dispatchEvent(new CustomEvent(SUBMENU_CLOSE));
|
||||
}
|
||||
|
||||
// ─── Hover and open state of one level ─────────────────────────
|
||||
|
||||
interface MenuLevel {
|
||||
/** The submenu of this level that is open. */
|
||||
openId: string | null;
|
||||
/** Open a submenu (or close them all with null), after `delay` ms unless asked again. */
|
||||
request: (id: string | null, delay?: number) => void;
|
||||
/** A touch happened just now: the mouse events that follow are emulated. */
|
||||
recentTouch: () => boolean;
|
||||
}
|
||||
|
||||
const MenuLevelContext = createContext<MenuLevel | null>(null);
|
||||
const neverTouched = () => false;
|
||||
|
||||
function useMenuLevel(recentTouch: () => boolean): MenuLevel {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
|
||||
const request = useCallback((id: string | null, delay = 0) => {
|
||||
clearTimeout(timer.current ?? undefined);
|
||||
timer.current = null;
|
||||
if (delay <= 0) {
|
||||
setOpenId(id);
|
||||
return;
|
||||
}
|
||||
timer.current = setTimeout(() => {
|
||||
// A field in the open submenu has the focus, e.g. a color input whose
|
||||
// system dialog is up: closing would drop its answer (#1366).
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLInputElement && active.closest("[data-submenu-panel]")) return;
|
||||
setOpenId(id);
|
||||
}, delay);
|
||||
}, []);
|
||||
return useMemo(() => ({ openId, request, recentTouch }), [openId, request, recentTouch]);
|
||||
}
|
||||
|
||||
// ─── The menu ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A menu in a portal: right-click menus, ⋮ menus, popover menus (R6).
|
||||
*
|
||||
* Keyboard: the focus moves to the first item on open; ↑/↓/Home/End move,
|
||||
* Enter/Space activate, → (← in RTL) or Enter opens a submenu, ← or Escape
|
||||
* closes it; Escape and Tab close the menu and give the focus back to the
|
||||
* element it opened from. While it is open it owns the keyboard: keys don't
|
||||
* reach the app's shortcut handlers.
|
||||
*/
|
||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
||||
({ isOpen, position, onClose, children }, ref) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
|
||||
const localRef = useRef<HTMLDivElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
});
|
||||
const lastTouchAt = useRef(0);
|
||||
const recentTouch = useCallback(() => Date.now() - lastTouchAt.current < 800, []);
|
||||
const level = useMenuLevel(recentTouch);
|
||||
const { request: requestLevel } = level;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const anchor = position.anchor;
|
||||
const anchorKey = anchor ? `${anchor.left},${anchor.top},${anchor.right},${anchor.bottom},${anchor.align ?? ""}` : "";
|
||||
|
||||
// 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.
|
||||
@@ -54,6 +259,18 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
if (anchor) {
|
||||
// Under the anchor, lined up with its start edge (end edge for
|
||||
// align="end"); above it when there is no room below.
|
||||
const rtl = getComputedStyle(node).direction === "rtl";
|
||||
const alignEnd = (anchor.align === "end") !== rtl;
|
||||
x = alignEnd ? anchor.right - rect.width : anchor.left;
|
||||
y = anchor.bottom + ANCHOR_GAP;
|
||||
if (y + rect.height > vh - VIEWPORT_MARGIN && anchor.top - ANCHOR_GAP - rect.height >= VIEWPORT_MARGIN) {
|
||||
y = anchor.top - ANCHOR_GAP - rect.height;
|
||||
}
|
||||
}
|
||||
|
||||
if (x + rect.width > vw - VIEWPORT_MARGIN) {
|
||||
x = vw - rect.width - VIEWPORT_MARGIN;
|
||||
}
|
||||
@@ -70,7 +287,148 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
const observer = new ResizeObserver(clamp);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [isOpen, mounted, position.x, position.y]);
|
||||
// anchorKey stands for anchor, which callers build anew on each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, mounted, position.x, position.y, anchorKey]);
|
||||
|
||||
const isPositioned = adjustedPosition !== null;
|
||||
|
||||
// Where the focus goes back to: the trigger the opener named, else what
|
||||
// had the focus when the menu opened. Read in a layout effect, before
|
||||
// the menu takes the focus.
|
||||
const returnFocusTo = useRef<HTMLElement | null>(null);
|
||||
const trigger = position.trigger;
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const active = document.activeElement;
|
||||
returnFocusTo.current =
|
||||
trigger ?? (active instanceof HTMLElement && active !== document.body ? active : null);
|
||||
}, [isOpen, trigger]);
|
||||
|
||||
// Register as the open menu; give the focus back when it closes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const token = {};
|
||||
openMenus.push(token);
|
||||
syncOpenFlag();
|
||||
const menuNode = localRef.current;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (openMenus[openMenus.length - 1] !== token) return;
|
||||
const root = localRef.current;
|
||||
if (!root) return;
|
||||
const active = document.activeElement;
|
||||
const inside = active instanceof HTMLElement && root.contains(active);
|
||||
const current = ((inside ? active.closest('[role="menu"]') : null) as HTMLElement | null) ?? root;
|
||||
const consume = (preventDefault = true) => {
|
||||
e.stopImmediatePropagation();
|
||||
if (preventDefault) e.preventDefault();
|
||||
};
|
||||
|
||||
if (e.key === "Escape") {
|
||||
consume();
|
||||
if (current !== root) closeSubmenuPanel(current);
|
||||
else onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
consume();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.isComposing) return;
|
||||
|
||||
if (!inside) {
|
||||
// The menu opened without taking the focus (or lost it): the
|
||||
// arrows bring it in instead of moving the list behind.
|
||||
if (e.key === "ArrowDown" || e.key === "Home") {
|
||||
consume();
|
||||
focusEl(menuLevelItems(root)[0]);
|
||||
} else if (e.key === "ArrowUp" || e.key === "End") {
|
||||
consume();
|
||||
const items = menuLevelItems(root);
|
||||
focusEl(items[items.length - 1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsKeys(active)) return;
|
||||
const text = isSingleLineText(active);
|
||||
const rtl = getComputedStyle(root).direction === "rtl";
|
||||
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
|
||||
const backKey = rtl ? "ArrowRight" : "ArrowLeft";
|
||||
const submenuTrigger = submenuTriggerOf(active);
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
case "ArrowUp": {
|
||||
consume();
|
||||
const items = menuLevelItems(current);
|
||||
if (items.length === 0) return;
|
||||
const index = items.indexOf(active);
|
||||
const delta = e.key === "ArrowDown" ? 1 : -1;
|
||||
const next = index === -1 ? (delta === 1 ? 0 : items.length - 1) : (index + delta + items.length) % items.length;
|
||||
focusEl(items[next]);
|
||||
return;
|
||||
}
|
||||
case "Home":
|
||||
case "End": {
|
||||
if (text) return;
|
||||
consume();
|
||||
const items = menuLevelItems(current);
|
||||
focusEl(e.key === "Home" ? items[0] : items[items.length - 1]);
|
||||
return;
|
||||
}
|
||||
case forwardKey:
|
||||
if (text) return;
|
||||
consume();
|
||||
submenuTrigger?.dispatchEvent(new CustomEvent(SUBMENU_OPEN));
|
||||
return;
|
||||
case backKey:
|
||||
if (text) return;
|
||||
consume();
|
||||
if (current !== root) closeSubmenuPanel(current);
|
||||
return;
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (submenuTrigger) {
|
||||
consume();
|
||||
submenuTrigger.dispatchEvent(new CustomEvent(SUBMENU_OPEN));
|
||||
return;
|
||||
}
|
||||
// The item activates itself (the browser's default action); the
|
||||
// app must not also see the key (Enter opens the selected row).
|
||||
if (!text) consume(false);
|
||||
return;
|
||||
default:
|
||||
// Letters and the rest: the app's shortcuts are suspended.
|
||||
if (!text) consume(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
const index = openMenus.indexOf(token);
|
||||
if (index !== -1) openMenus.splice(index, 1);
|
||||
syncOpenFlag();
|
||||
requestLevel(null);
|
||||
const target = returnFocusTo.current;
|
||||
returnFocusTo.current = null;
|
||||
const active = document.activeElement;
|
||||
const focusLeftWithMenu = !active || active === document.body || (menuNode?.contains(active) ?? false);
|
||||
if (target && target.isConnected && focusLeftWithMenu) focusEl(target);
|
||||
};
|
||||
}, [isOpen, requestLevel]);
|
||||
|
||||
// Once placed (it is invisible, and can't take the focus, before), the
|
||||
// first item gets the focus.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isPositioned) return;
|
||||
const root = localRef.current;
|
||||
if (!root || root.contains(document.activeElement)) return;
|
||||
return focusFirstItem(root);
|
||||
}, [isOpen, isPositioned]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
localRef.current = node;
|
||||
@@ -84,27 +442,32 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
if (!mounted || !isOpen) return null;
|
||||
|
||||
const renderPosition = adjustedPosition ?? position;
|
||||
const isPositioned = adjustedPosition !== null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={setRefs}
|
||||
className={cn(
|
||||
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border"
|
||||
)}
|
||||
style={{
|
||||
left: renderPosition.x,
|
||||
top: renderPosition.y,
|
||||
visibility: isPositioned ? "visible" : "hidden",
|
||||
}}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
{/* Taller than the window: scroll inside the menu. */}
|
||||
<div className="py-1 overflow-y-auto" style={{ maxHeight: `calc(100vh - ${2 * VIEWPORT_MARGIN + 2}px)` }}>
|
||||
{children}
|
||||
<MenuLevelContext.Provider value={level}>
|
||||
<div
|
||||
ref={setRefs}
|
||||
className={cn(
|
||||
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border outline-none"
|
||||
)}
|
||||
style={{
|
||||
left: renderPosition.x,
|
||||
top: renderPosition.y,
|
||||
visibility: isPositioned ? "visible" : "hidden",
|
||||
}}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
tabIndex={-1}
|
||||
onPointerDownCapture={(e) => {
|
||||
if (e.pointerType === "touch" || e.pointerType === "pen") lastTouchAt.current = Date.now();
|
||||
}}
|
||||
>
|
||||
{/* Taller than the window: scroll inside the menu. */}
|
||||
<div className="py-1 overflow-y-auto" style={{ maxHeight: `calc(100vh - ${2 * VIEWPORT_MARGIN + 2}px)` }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
</MenuLevelContext.Provider>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -112,6 +475,8 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
|
||||
ContextMenu.displayName = "ContextMenu";
|
||||
|
||||
// ─── Items ─────────────────────────────────────────────────────
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
@@ -138,8 +503,10 @@ export function ContextMenuItem({
|
||||
title,
|
||||
hint,
|
||||
}: ContextMenuItemProps) {
|
||||
const level = useContext(MenuLevelContext);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
@@ -147,11 +514,19 @@ export function ContextMenuItem({
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2",
|
||||
"transition-colors duration-150",
|
||||
"focus:outline-none focus:bg-muted",
|
||||
"focus:outline-none focus-visible:bg-muted",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
!disabled && "hover:bg-muted cursor-pointer",
|
||||
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus:bg-destructive/10"
|
||||
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus-visible:bg-destructive/10"
|
||||
)}
|
||||
// Hovering another entry closes an open submenu of this level, after
|
||||
// a moment, so a diagonal move into the submenu doesn't.
|
||||
onMouseEnter={() => {
|
||||
if (level && level.openId !== null && !level.recentTouch()) level.request(null, HOVER_CLOSE_DELAY);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (level && level.openId !== null) level.request(null);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (disabled) return;
|
||||
e.stopPropagation();
|
||||
@@ -192,6 +567,11 @@ interface ContextMenuSubMenuProps {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry with a submenu. It opens on hover (mouse), click or tap, Enter,
|
||||
* Space and → (← in RTL), and stays open until another entry of its menu is
|
||||
* hovered or chosen, ← or Escape, or the menu closes.
|
||||
*/
|
||||
export function ContextMenuSubMenu({
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -201,51 +581,80 @@ export function ContextMenuSubMenu({
|
||||
title,
|
||||
hint,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const parent = useContext(MenuLevelContext);
|
||||
const id = useId();
|
||||
// Outside a ContextMenu (a host's own panel) it keeps its own state.
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
const isOpen = parent ? parent.openId === id : localOpen;
|
||||
const setOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
if (parent) parent.request(open ? id : null);
|
||||
else setLocalOpen(open);
|
||||
},
|
||||
[parent, id]
|
||||
);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const focusFirstOnOpen = useRef(false);
|
||||
const ownLevel = useMenuLevel(parent?.recentTouch ?? neverTouched);
|
||||
|
||||
// The menu's keyboard handler talks to the entry through DOM events.
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(closeTimerRef.current ?? undefined);
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
const onOpen = () => {
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
if (panelRef.current) focusFirstItem(panelRef.current);
|
||||
else focusFirstOnOpen.current = true;
|
||||
};
|
||||
const onClose = () => {
|
||||
setOpen(false);
|
||||
focusEl(trigger);
|
||||
};
|
||||
trigger.addEventListener(SUBMENU_OPEN, onOpen);
|
||||
trigger.addEventListener(SUBMENU_CLOSE, onClose);
|
||||
return () => {
|
||||
trigger.removeEventListener(SUBMENU_OPEN, onOpen);
|
||||
trigger.removeEventListener(SUBMENU_CLOSE, onClose);
|
||||
};
|
||||
}, [disabled, setOpen]);
|
||||
|
||||
const onPanelPlaced = useCallback(() => {
|
||||
if (!focusFirstOnOpen.current || !panelRef.current) return;
|
||||
focusFirstOnOpen.current = false;
|
||||
focusFirstItem(panelRef.current);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (disabled) return;
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
// A control in the submenu that has the focus may have opened a system
|
||||
// dialog (a color input): closing would drop its answer (#1366).
|
||||
if (rootRef.current?.contains(document.activeElement)) return;
|
||||
setIsOpen(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) setOpen(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
data-submenu-trigger=""
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm flex items-center gap-2",
|
||||
"w-full px-3 py-1.5 text-sm flex items-center gap-2 outline-none",
|
||||
"transition-colors duration-150",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-muted"
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:bg-muted focus-visible:bg-muted",
|
||||
isOpen && !disabled && "bg-muted"
|
||||
)}
|
||||
role="menuitem"
|
||||
aria-haspopup="true"
|
||||
tabIndex={-1}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-disabled={disabled || undefined}
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
onClick={(e) => {
|
||||
// A click or tap opens it. It doesn't toggle: the tap that also
|
||||
// fired an emulated mouseenter would close it again.
|
||||
e.stopPropagation();
|
||||
if (!disabled) setOpen(true);
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
{hint ? (
|
||||
@@ -260,14 +669,19 @@ export function ContextMenuSubMenu({
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<SubMenuPanel
|
||||
className="min-w-[180px] bg-background rounded-md shadow-lg border border-border"
|
||||
role="menu"
|
||||
>
|
||||
<div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</SubMenuPanel>
|
||||
<MenuLevelContext.Provider value={ownLevel}>
|
||||
<SubMenuPanel
|
||||
ref={panelRef}
|
||||
data-submenu-panel=""
|
||||
onPlaced={onPanelPlaced}
|
||||
className="min-w-[180px] bg-background rounded-md shadow-lg border border-border"
|
||||
role="menu"
|
||||
>
|
||||
<div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</SubMenuPanel>
|
||||
</MenuLevelContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -276,6 +690,9 @@ export function ContextMenuSubMenu({
|
||||
interface SubMenuPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** The side of the entry to open on when it fits; flips with the text direction. */
|
||||
side?: "start" | "end";
|
||||
/** Called once the panel is placed and visible. */
|
||||
onPlaced?: () => void;
|
||||
"data-submenu-panel"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,9 +701,12 @@ interface SubMenuPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* else clamped inside the window. It is `position: fixed`, so a pane that
|
||||
* clips its overflow can't cut it off.
|
||||
*/
|
||||
export function SubMenuPanel({ side = "end", className, style, ...rest }: SubMenuPanelProps) {
|
||||
export const SubMenuPanel = forwardRef<HTMLDivElement, SubMenuPanelProps>(function SubMenuPanel(
|
||||
{ side = "end", onPlaced, className, style, ...rest },
|
||||
ref
|
||||
) {
|
||||
const [pos, setPos] = useState<Position | null>(null);
|
||||
const subMenuRef = useRef<HTMLDivElement>(null);
|
||||
const subMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const subEl = subMenuRef.current;
|
||||
@@ -320,10 +740,25 @@ export function SubMenuPanel({ side = "end", className, style, ...rest }: SubMen
|
||||
setPos({ x: x - subRect.left, y: top - subRect.top });
|
||||
}, [side]);
|
||||
|
||||
const placed = pos !== null;
|
||||
const onPlacedRef = useRef(onPlaced);
|
||||
useEffect(() => {
|
||||
onPlacedRef.current = onPlaced;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (placed) onPlacedRef.current?.();
|
||||
}, [placed]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
subMenuRef.current = node;
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={subMenuRef}
|
||||
ref={setRefs}
|
||||
className={cn("fixed z-50", className)}
|
||||
style={{
|
||||
...style,
|
||||
@@ -333,7 +768,7 @@ export function SubMenuPanel({ side = "end", className, style, ...rest }: SubMen
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface ContextMenuHeaderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
import type { ContextMenuPosition } from "@/components/ui/context-menu";
|
||||
|
||||
interface ContextMenuState<T> {
|
||||
isOpen: boolean;
|
||||
position: Position;
|
||||
position: ContextMenuPosition;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
interface UseContextMenuReturn<T> {
|
||||
contextMenu: ContextMenuState<T>;
|
||||
/**
|
||||
* Open from a contextmenu or click event. Opened by keyboard (a click
|
||||
* from Enter/Space, or Shift+F10 / the Menu key) it anchors to the
|
||||
* element the handler sits on instead of the pointer.
|
||||
*/
|
||||
openContextMenu: (e: React.MouseEvent, data: T) => void;
|
||||
/** Open under an element, e.g. a ⋮ button: lined up with its start or end edge. */
|
||||
openContextMenuFrom: (element: HTMLElement, data: T, align?: "start" | "end") => void;
|
||||
closeContextMenu: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
@@ -24,6 +27,30 @@ const MENU_WIDTH = 200;
|
||||
const MENU_HEIGHT = 320; // Approximate max height
|
||||
const VIEWPORT_MARGIN = 10;
|
||||
|
||||
/** Shift+F10 or the Menu key: open the focused element's context menu. */
|
||||
export function isContextMenuKey(e: { key: string; shiftKey: boolean }): boolean {
|
||||
return e.key === "ContextMenu" || (e.shiftKey && e.key === "F10");
|
||||
}
|
||||
|
||||
/** Whether a mouse event came from the keyboard rather than a pointer. */
|
||||
function openedByKeyboard(e: React.MouseEvent): boolean {
|
||||
if (e.type === "click") return e.detail === 0;
|
||||
// Shift+F10 and the Menu key fire contextmenu at 0,0 (or without a point).
|
||||
if (e.type === "contextmenu") return !e.clientX && !e.clientY;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The position that opens a menu under an element. */
|
||||
export function anchorPosition(element: HTMLElement, align: "start" | "end" = "start"): ContextMenuPosition {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.bottom,
|
||||
anchor: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, align },
|
||||
trigger: element,
|
||||
};
|
||||
}
|
||||
|
||||
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
||||
isOpen: false,
|
||||
@@ -33,7 +60,7 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const calculatePosition = useCallback((clientX: number, clientY: number): Position => {
|
||||
const calculatePosition = useCallback((clientX: number, clientY: number): ContextMenuPosition => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
@@ -61,7 +88,10 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const position = calculatePosition(e.clientX, e.clientY);
|
||||
const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null;
|
||||
const position = target && openedByKeyboard(e)
|
||||
? anchorPosition(target)
|
||||
: { ...calculatePosition(e.clientX, e.clientY), trigger: target };
|
||||
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
@@ -70,6 +100,10 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
});
|
||||
}, [calculatePosition]);
|
||||
|
||||
const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => {
|
||||
setContextMenu({ isOpen: true, position: anchorPosition(element, align), data });
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu((prev) => ({
|
||||
...prev,
|
||||
@@ -77,7 +111,8 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Close on escape key, click outside, and scroll
|
||||
// Close on escape key, click outside, and scroll. (ContextMenu answers
|
||||
// Escape itself and keeps it from the app; this one is a fallback.)
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isOpen) return;
|
||||
|
||||
@@ -127,6 +162,7 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
return {
|
||||
contextMenu,
|
||||
openContextMenu,
|
||||
openContextMenuFrom,
|
||||
closeContextMenu,
|
||||
menuRef,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user