{
event.preventDefault(); event.stopPropagation(); onClose()
} : undefined}
- onMouseOver={collapseSubmenuFrom}
+ onPointerOver={(event) => { if (hoverAllowed(event)) collapseSubmenuFrom(event) }}
onFocus={collapseSubmenuFrom}
>
@@ -629,7 +759,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
ref={rootRef}
className={clsx(css.root, className)}
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
- onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
+ onPointerLeave={closeOnPointerLeave ? (event) => { if (open && hoverAllowed(event)) armClose() } : undefined}
>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
diff --git a/packages/client/ui-primitives/src/menu-submenu-position.ts b/packages/client/ui-primitives/src/menu-submenu-position.ts
new file mode 100644
index 0000000000..df958d87a8
--- /dev/null
+++ b/packages/client/ui-primitives/src/menu-submenu-position.ts
@@ -0,0 +1,78 @@
+/** Viewport placement for Menu's portaled child card, independent of input device. */
+import { useLayoutEffect, useState, type RefObject } from 'react'
+import { overlayTopMargin } from './overlay-top-margin.ts'
+
+interface SubmenuPosition {
+ left: number
+ top: number
+ maxHeight: number
+ width?: number
+ page: boolean
+}
+
+/**
+ * Fit a child beside its parent, or over the parent's panel when neither side fits.
+ * CSS cannot choose a sibling portal's placement from both measured card widths.
+ * @param active - The mounted parent's row ID, or null while closed.
+ * @param parent - Parent card, including its scroll region.
+ * @param trigger - The parent row opening the child.
+ * @param child - Child card, measured before its first paint.
+ * @param contextual - Whether frame clearance is excluded for a context menu.
+ * @returns Viewport coordinates and the single-panel navigation mode, or null before placement.
+ */
+export function useMenuSubmenuPosition(
+ active: string | null,
+ parent: RefObject,
+ trigger: RefObject,
+ child: RefObject,
+ contextual: boolean,
+): SubmenuPosition | null {
+ const [position, setPosition] = useState(null)
+ useLayoutEffect(() => {
+ if (active === null) { setPosition(null); return }
+ const place = () => {
+ if (parent.current === null || trigger.current === null || child.current === null) return
+ const panel = parent.current.getBoundingClientRect()
+ const row = trigger.current.getBoundingClientRect()
+ const margin = 12
+ const gap = 4
+ const vw = window.innerWidth
+ const vh = window.innerHeight
+ const topMargin = contextual ? margin : overlayTopMargin(margin, row, gap)
+ const maxHeight = Math.max(0, vh - topMargin - margin)
+ // Keep natural width measurable after returning from a single-panel page.
+ child.current.style.width = ''
+ const width = child.current.offsetWidth
+ const right = panel.right + gap
+ const left = panel.left - gap - width
+ const page = right + width > vw - margin && left < margin
+ const pageWidth = Math.min(Math.max(panel.width, width), vw - margin * 2)
+ const actualWidth = page ? pageWidth : width
+ const x = page ? panel.left : right + width <= vw - margin ? right : left
+ const height = Math.min(child.current.offsetHeight, maxHeight)
+ const y = page ? panel.top : row.top
+ const next: SubmenuPosition = {
+ left: Math.max(margin, Math.min(x, vw - actualWidth - margin)),
+ top: Math.max(topMargin, Math.min(y, vh - height - margin)),
+ maxHeight,
+ ...(page ? { width: pageWidth } : {}),
+ page,
+ }
+ if (page) child.current.style.width = `${pageWidth}px`
+ setPosition(current => current?.left === next.left && current.top === next.top
+ && current.maxHeight === next.maxHeight && current.width === next.width && current.page === next.page ? current : next)
+ }
+ place()
+ let frame: number
+ const track = () => { place(); frame = requestAnimationFrame(track) }
+ frame = requestAnimationFrame(track)
+ window.addEventListener('resize', place)
+ window.addEventListener('scroll', place, true)
+ return () => {
+ cancelAnimationFrame(frame)
+ window.removeEventListener('resize', place)
+ window.removeEventListener('scroll', place, true)
+ }
+ }, [active, parent, trigger, child, contextual])
+ return position
+}
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
index 8b198e4365..c83f54d899 100644
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
@@ -173,6 +173,10 @@ describe('MenuItemButton', () => {
})
describe('Menu', () => {
+ beforeEach(() => {
+ vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
+ })
+ afterEach(() => { vi.unstubAllGlobals() })
const items = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta', disabled: true },
@@ -534,9 +538,9 @@ describe('Menu', () => {
expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined()
fireEvent.focus(screen.getByRole('menuitem', { name: 'Row' }))
expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull()
- fireEvent.mouseEnter(parent.parentElement as HTMLElement)
+ fireEvent.pointerEnter(parent.parentElement as HTMLElement)
expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined()
- fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Row' }))
+ fireEvent.pointerOver(screen.getByRole('menuitem', { name: 'Row' }))
expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull()
})
@@ -568,13 +572,13 @@ describe('Menu', () => {
fireEvent.click(parent)
expect(onSelect).not.toHaveBeenCalled()
fireEvent.focus(parent)
- fireEvent.mouseEnter(wrap)
+ fireEvent.pointerEnter(wrap)
expect(screen.getByTestId('sub-ic')).toBeDefined()
expect(screen.getByRole('menuitem', { name: 'Create ok' }).getAttribute('aria-keyshortcuts')).toBe('Meta+N')
expect(screen.getByRole('menuitem', { name: 'Create ok' }).querySelectorAll('kbd')).toHaveLength(2)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' }))
expect(onSelect).toHaveBeenCalledWith('ok')
- fireEvent.mouseLeave(wrap)
+ fireEvent.focus(plain)
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
})
@@ -689,7 +693,7 @@ describe('Menu', () => {
expect(onSelect).toHaveBeenCalledWith('new')
})
- it('caps the list height for internal scrolling unless a submenu row is present', () => {
+ it('caps the list height for internal scrolling including menus with submenus', () => {
const { rerender } = render(