{
event.preventDefault(); event.stopPropagation(); onClose()
} : undefined}
- onMouseOver={collapseSubmenuFrom}
- onFocus={collapseSubmenuFrom}
+ onPointerOver={(event) => { if (menuHoverAllowed(event)) submenu.collapseFrom(event) }}
+ onFocus={submenu.collapseFrom}
>
+ {/* Child cards anchor to this unscrolled box, which matches the card itself. */}
+ {submenu.entry !== undefined && }
{items.map(entry => renderEntry(entry, itemColumns))}
{children}
@@ -628,10 +541,13 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
position: 'fixed', left: point.x, top: point.y, width: 0, height: 0,
}) }}
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
- onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
+ onPointerLeave={closeOnPointerLeave ? (event) => { if (open && menuHoverAllowed(event)) armClose() } : undefined}
>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
+ {open && exiting === undefined && submenu.entry !== undefined &&
}
)
}
diff --git a/packages/client/ui-primitives/src/MenuRow.tsx b/packages/client/ui-primitives/src/MenuRow.tsx
new file mode 100644
index 0000000000..cde502b6da
--- /dev/null
+++ b/packages/client/ui-primitives/src/MenuRow.tsx
@@ -0,0 +1,89 @@
+/** Package-internal row presentation shared by component, data, and child menu entries. */
+import { useId } from 'react'
+import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
+import clsx from 'clsx'
+import type { MenuEntry, MenuItemButtonProps, MenuLabel, MenuSeparator } from './Menu.tsx'
+import { IconCheckOutlineRegular, IconChevronRightOutlineRegular } from './icons/index.tsx'
+import { ShortcutKeys } from './ShortcutKeys.tsx'
+import { Tooltip } from './Tooltip.tsx'
+import css from './Menu.module.css'
+
+/** Optional props that also accept `undefined`, so data rows can forward absent `MenuItem` fields. */
+type OptionalProps
= { [P in K]?: T[P] | undefined }
+
+/** Package-internal row props: the `MenuItemButton` row fields plus data- and child-row presentation. */
+type MenuRowProps = Omit, 'children'>
+ & Pick
+ & OptionalProps
+ & {
+ /** Whether this data row is the menu's current selection. */
+ selected?: boolean
+ /** Selection marker: a trailing check, or the row fill. */
+ selection?: 'check' | 'fill'
+ /** Keep an empty icon column so grouped rows stay aligned. */
+ reserveIcon?: boolean
+ /** Hide the icon from assistive technology; component rows keep it exposed. */
+ decorativeIcon?: boolean
+ /** Pointer handlers on the row wrapper, used by submenu parents. */
+ wrapperProps?: HTMLAttributes | undefined
+ }
+
+/** Render a focus-stable row with optional selection, shortcut, and disabled explanation.
+ * @param props - row content, native button events, and optional pointer wrapper events.
+ * @returns one menu item; explained disabled rows remain focusable but never activate.
+ */
+export function MenuRow({ children, icon, shortcut, current, disabled = false, disabledReason, danger,
+ separatorBefore, selected, selection = 'check', reserveIcon, decorativeIcon = true, wrapperProps, ...button }: MenuRowProps) {
+ const reasonId = useId()
+ const explained = disabled && disabledReason !== undefined
+ // Tooltip remains mounted when the explanation changes, preserving button focus.
+ return
+ {separatorBefore &&
}
+
+
+
+ {explained &&
{disabledReason}}
+
+}
+
+/** Mark icon columns within each separator-delimited data group.
+ * @param entries - entries in one menu area.
+ * @param contextual - whether to align grouped context-menu icons.
+ * @returns entries that reserve a leading icon column.
+ */
+export function menuIconColumns(entries: readonly MenuEntry[], contextual: boolean): ReadonlySet {
+ const aligned = new Set()
+ if (!contextual) return aligned
+ let group: MenuEntry[] = []
+ const finish = (): void => {
+ if (group.some(entry => !('type' in entry) && entry.icon !== undefined)) {
+ for (const entry of group) aligned.add(entry)
+ }
+ group = []
+ }
+ for (const entry of entries) {
+ if ('type' in entry && entry.type === 'separator') finish()
+ else group.push(entry)
+ }
+ finish()
+ return aligned
+}
+
+/** Render a separator or group heading outside the keyboard walk.
+ * @param entry - non-interactive menu entry.
+ * @returns the corresponding element.
+ */
+export function renderMenuDecoration(entry: MenuSeparator | MenuLabel): ReactNode {
+ return entry.type === 'separator' ?
+ : {entry.text}
+}
diff --git a/packages/client/ui-primitives/src/SubmenuPanel.tsx b/packages/client/ui-primitives/src/SubmenuPanel.tsx
new file mode 100644
index 0000000000..1f627ed48f
--- /dev/null
+++ b/packages/client/ui-primitives/src/SubmenuPanel.tsx
@@ -0,0 +1,51 @@
+/** Portaled child menu presentation; interaction state belongs to useMenuSubmenu. */
+import type { CSSProperties, ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import clsx from 'clsx'
+import { menuHoverAllowed, type useMenuSubmenu } from './useMenuSubmenu.ts'
+import { menuIconColumns, MenuRow, renderMenuDecoration } from './MenuRow.tsx'
+import { IconChevronLeftOutlineRegular } from './icons/index.tsx'
+import { MenuSurface } from './MenuSurface.tsx'
+import css from './Menu.module.css'
+
+/** Name the parent card anchor, and hide the child card until its layout is known. */
+function anchors(submenu: ReturnType, hidden: boolean): CSSProperties & Record<`--dsh-submenu-${string}`, string> {
+ return { '--dsh-submenu-parent': submenu.parentAnchor, ...hidden ? { visibility: 'hidden' } : {} }
+}
+
+/** Render one child list with a non-selecting back row in single-page placement.
+ * @param props - active entry, submenu operations, and shared row presentation options.
+ * @returns a body portal that preserves child DOM identity across placement changes.
+ */
+export function SubmenuPanel({ entry, submenu, compact, dense, contextual, backLabel, onSelect, onClose, afterSelection }: {
+ entry: NonNullable['entry']>
+ submenu: ReturnType
+ compact: boolean
+ dense: boolean
+ contextual: boolean
+ backLabel: ReactNode
+ onSelect: ((id: string) => void) | undefined
+ onClose: () => void
+ afterSelection: () => void
+}) {
+ const columns = menuIconColumns(entry.submenu, contextual)
+ return createPortal( { if (menuHoverAllowed(event)) submenu.leave() }}
+ onContextMenu={contextual ? (event) => { event.preventDefault(); event.stopPropagation(); onClose() } : undefined}
+ onClick={(event) => { event.stopPropagation(); afterSelection() }}>
+ {submenu.page && <>
+ }
+ onClick={submenu.returnToParent}>{backLabel}
+
+ >}
+
+ {entry.submenu.map(sub => 'type' in sub ? renderMenuDecoration(sub)
+ : { onSelect?.(sub.id) }}>{sub.label})}
+
+ , document.body)
+}
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..cfa3a748c2
--- /dev/null
+++ b/packages/client/ui-primitives/src/menu-submenu-position.ts
@@ -0,0 +1,20 @@
+/** Choose between a side child card and a single-panel child page from layout-viewport space. */
+
+/** Gap between the parent card and a side child card, matching `.submenu` in Menu.module.css. */
+export const SUBMENU_GAP = 4
+/** Layout-viewport inset shared with every portalled Menu card. */
+export const SUBMENU_MARGIN = 12
+
+/**
+ * Report whether neither side of the parent card can hold the child card.
+ * CSS anchors place the side card and flip it; this decision only selects the page layout.
+ * @param parent - Parent card rectangle in client coordinates.
+ * @param width - Natural child card width.
+ * @param viewportWidth - Layout viewport width.
+ * @returns true when the child must replace the parent page.
+ */
+export function submenuNeedsPage(parent: DOMRect, width: number, viewportWidth: number): boolean {
+ const right = parent.right + SUBMENU_GAP + width <= viewportWidth - SUBMENU_MARGIN
+ const left = parent.left - SUBMENU_GAP - width >= SUBMENU_MARGIN
+ return !right && !left
+}
diff --git a/packages/client/ui-primitives/src/useMenuSubmenu.ts b/packages/client/ui-primitives/src/useMenuSubmenu.ts
new file mode 100644
index 0000000000..2ef471c930
--- /dev/null
+++ b/packages/client/ui-primitives/src/useMenuSubmenu.ts
@@ -0,0 +1,182 @@
+/** Owns one menu's child layout mode, pointer grace, and focus transitions. */
+import { useId, useLayoutEffect, useRef, useState } from 'react'
+import type { RefObject, SyntheticEvent } from 'react'
+import type { MenuEntry, MenuItem } from './Menu.tsx'
+import { submenuNeedsPage } from './menu-submenu-position.ts'
+import { usePointerGrace } from './pointer-grace.ts'
+
+/** Whether a pointer event can request hover interactions.
+ * @param event - current pointer, including mixed-input touch events.
+ * @returns true for non-touch input with an available hover device.
+ */
+export function menuHoverAllowed(event: { pointerType: string }): boolean {
+ return event.pointerType !== 'touch' && window.matchMedia('(any-hover: hover)').matches
+}
+
+interface MenuSubmenu {
+ entry: (MenuItem & { submenu: readonly MenuEntry[] }) | undefined
+ panelRef: RefObject
+ /** CSS anchor name of the root card's anchor box; the child card is placed against it. */
+ parentAnchor: string
+ /** Whether the layout mode for the current child is known; the card stays hidden until it is. */
+ measured: boolean
+ page: boolean
+ hidden: boolean
+ contains: (node: Node | null) => boolean
+ activeList: (focused: Node | null) => HTMLDivElement | null
+ /** Returns false when no visible child can handle the navigation. */
+ returnToParent: () => boolean
+ openChild: (id: string, target: HTMLButtonElement, enter?: boolean) => void
+ focusParent: (id: string, target: HTMLButtonElement) => void
+ leave: () => void
+ cancelClose: () => void
+ collapseFrom: (event: SyntheticEvent) => void
+}
+
+/** Manage a single child card without owning the root menu's document listeners.
+ * @param options - current entries, root lifetime, and fallback focus destination.
+ * @returns child rendering data and narrow interaction operations.
+ */
+export function useMenuSubmenu(options: {
+ open: boolean
+ items: readonly MenuEntry[]
+ footer: readonly MenuEntry[] | undefined
+ listRef: RefObject
+ refocusAnchor: () => void
+}): MenuSubmenu {
+ const { open, items, footer, listRef, refocusAnchor } = options
+ const [expanded, setExpanded] = useState<{ id: string; trigger: HTMLButtonElement; activated: boolean } | null>(null)
+ const panelRef = useRef(null)
+ // A DOM target suppresses its focus-open handler until focus restoration completes.
+ const pendingFocus = useRef<'child' | HTMLButtonElement | null>(null)
+ const entry = [...items, ...(footer ?? [])].find((item): item is MenuItem & { submenu: readonly MenuEntry[] } =>
+ !('type' in item) && item.id === expanded?.id && item.disabled !== true
+ && item.submenu !== undefined && item.submenu.length > 0)
+ const trigger = expanded?.trigger ?? null
+ const anchorId = useId().replaceAll(':', '')
+ const parentAnchor = `--dsh-submenu-parent-${anchorId}`
+ // Null until the active child has been measured against the layout viewport.
+ const [needsPage, setNeedsPage] = useState(null)
+ const contains = (node: Node | null): boolean => panelRef.current?.contains(node) === true
+ const invalidated = expanded !== null && entry === undefined
+ // These reads precede React removing an invalidated panel or a page's return row.
+ const active = document.activeElement
+ const restoreInvalidatedFocus = open && invalidated && (contains(active) || trigger === active)
+ const backWasFocused = contains(active) && active?.hasAttribute('data-menu-back') === true
+ const measured = needsPage !== null
+ const page = entry !== undefined && expanded?.activated === true && needsPage === true
+ const hidden = !measured || (needsPage && !page)
+ // CSS anchors place the card against the parent card. JavaScript selects the layout and
+ // supplies two lengths: the card's natural height and the active row's offset from the
+ // parent card's top. Both are relative sizes, so page zoom and panning leave them valid.
+ const visible = open && entry !== undefined
+ useLayoutEffect(() => {
+ const panel = panelRef.current
+ const parent = listRef.current
+ if (!visible || panel === null || parent === null || trigger === null) { setNeedsPage(null); return }
+ const measureRow = (): void => {
+ panel.style.setProperty('--dsh-submenu-row-offset', `${trigger.getBoundingClientRect().top - parent.getBoundingClientRect().top}px`)
+ }
+ // The layout uses the side card's natural size, not the size a page layout gives it.
+ const measure = (): void => {
+ const layout = panel.getAttribute('data-submenu-layout')
+ panel.removeAttribute('data-submenu-layout')
+ const width = panel.offsetWidth
+ panel.style.setProperty('--dsh-submenu-height', `${panel.offsetHeight}px`)
+ if (layout !== null) panel.setAttribute('data-submenu-layout', layout)
+ measureRow()
+ const viewport = document.documentElement.clientWidth || window.innerWidth
+ setNeedsPage(submenuNeedsPage(parent.getBoundingClientRect(), width, viewport))
+ }
+ measure()
+ window.addEventListener('resize', measure)
+ parent.addEventListener('scroll', measureRow, true)
+ const observer = new ResizeObserver(measure)
+ for (const element of [parent, trigger, panel]) observer.observe(element)
+ return () => {
+ window.removeEventListener('resize', measure)
+ parent.removeEventListener('scroll', measureRow, true)
+ observer.disconnect()
+ }
+ }, [visible, entry, trigger, listRef])
+ const focusFirstChild = (): void => {
+ const panel = panelRef.current
+ const first = panel?.querySelector('button[role="menuitem"]:not(:disabled):not([data-menu-back])')
+ ?? panel?.querySelector('button:not(:disabled)')
+ first?.focus()
+ }
+ const returnToParent = (): void => {
+ cancelClose()
+ pendingFocus.current = trigger
+ setExpanded(null)
+ }
+ const collapse = (): void => {
+ cancelClose()
+ if (contains(document.activeElement)) returnToParent()
+ else setExpanded(null)
+ }
+ const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { if (!page) collapse() })
+ const openChild = (id: string, target: HTMLButtonElement, enter = false): void => {
+ cancelClose()
+ if (trigger !== target && contains(document.activeElement)) {
+ pendingFocus.current = target
+ target.focus()
+ pendingFocus.current = null
+ }
+ pendingFocus.current = enter ? 'child' : null
+ setExpanded(current => current?.id === id
+ ? enter && !current.activated ? { ...current, activated: true } : current
+ : { id, trigger: target, activated: enter })
+ if (enter) focusFirstChild()
+ }
+ useLayoutEffect(() => {
+ if (!open || invalidated) {
+ cancelClose()
+ pendingFocus.current = null
+ setExpanded(null)
+ if (restoreInvalidatedFocus && (document.activeElement === document.body || document.activeElement === trigger)) {
+ const target = trigger?.isConnected === true && !trigger.disabled ? trigger
+ : listRef.current?.querySelector('button:not(:disabled)')
+ pendingFocus.current = target ?? null
+ if (target != null) target.focus()
+ else refocusAnchor()
+ pendingFocus.current = null
+ }
+ return
+ }
+ if (entry === undefined && pendingFocus.current instanceof HTMLButtonElement) {
+ pendingFocus.current.focus()
+ pendingFocus.current = null
+ }
+ if (entry === undefined || hidden) return
+ if (pendingFocus.current === 'child' || (page && listRef.current?.contains(document.activeElement))
+ || (!page && backWasFocused)) {
+ focusFirstChild()
+ pendingFocus.current = null
+ }
+ }, [open, entry, needsPage, page, hidden, invalidated, restoreInvalidatedFocus, backWasFocused, cancelClose])
+
+ const collapseFrom = (event: SyntheticEvent): void => {
+ // React DOM normalizes text-node event targets to their parent element.
+ const row = (event.target as Element).closest('button[role="menuitem"]')
+ if (row === null || row.getAttribute('aria-haspopup') === 'menu') return
+ if (row.closest('[role="menu"]') === event.currentTarget) collapse()
+ }
+ return {
+ entry, panelRef, parentAnchor, measured, page, hidden,
+ contains,
+ activeList: (focused: Node | null) => page || contains(focused) ? panelRef.current : listRef.current,
+ returnToParent: (): boolean => {
+ if (entry === undefined || hidden) return false
+ returnToParent()
+ return true
+ },
+ openChild,
+ focusParent: (id: string, target: HTMLButtonElement): void => {
+ if (pendingFocus.current !== target) openChild(id, target)
+ },
+ leave: (): void => { if (!page) armClose() },
+ cancelClose,
+ collapseFrom,
+ }
+}
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
index 98f0215c13..0eb672f5fc 100644
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
@@ -174,6 +174,7 @@ describe('MenuItemButton', () => {
describe('Menu', () => {
beforeEach(() => {
+ vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' })
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
@@ -600,9 +601,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()
})
@@ -634,13 +635,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()
})
@@ -805,7 +806,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(