diff --git a/components/ui/__tests__/context-menu-keyboard.test.tsx b/components/ui/__tests__/context-menu-keyboard.test.tsx new file mode 100644 index 000000000..bca3883cd --- /dev/null +++ b/components/ui/__tests__/context-menu-keyboard.test.tsx @@ -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(); + const pick = (what: string) => { closeContextMenu(); onPick(what); }; + return ( + <> + + + + pick('open')} /> + pick('rename')} /> + pick('disabled')} disabled /> + {withSub && ( + + pick('inbox')} /> + pick('spam')} /> + + )} + pick('delete')} destructive /> + + + ); +} + +async function openByKeyboard(props: Parameters[0] = {}) { + render(); + 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 = {}) { + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 79cd0be79..053ab1c3b 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -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 ) 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(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(":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(null); +const neverTouched = () => false; + +function useMenuLevel(recentTouch: () => boolean): MenuLevel { + const [openId, setOpenId] = useState(null); + const timer = useRef | 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( - ({ isOpen, position, onClose: _onClose, children }, ref) => { + ({ isOpen, position, onClose, children }, ref) => { const [mounted, setMounted] = useState(false); const [adjustedPosition, setAdjustedPosition] = useState(null); const localRef = useRef(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( 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( 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(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( if (!mounted || !isOpen) return null; const renderPosition = adjustedPosition ?? position; - const isPositioned = adjustedPosition !== null; return createPortal( -
- {/* Taller than the window: scroll inside the menu. */} -
- {children} + +
{ + if (e.pointerType === "touch" || e.pointerType === "pen") lastTouchAt.current = Date.now(); + }} + > + {/* Taller than the window: scroll inside the menu. */} +
+ {children} +
-
, + , document.body ); } @@ -112,6 +475,8 @@ export const ContextMenu = forwardRef( 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 (