diff --git a/src/menu/menu.test.tsx b/src/menu/menu.test.tsx
index 2eb8e850..4384e4cd 100644
--- a/src/menu/menu.test.tsx
+++ b/src/menu/menu.test.tsx
@@ -395,4 +395,63 @@ describe('Menu', () => {
})
})
})
+
+ describe('persistent elements', () => {
+ it('keeps elements marked with data-reactist-persist interactive while a modal menu is open', async () => {
+ render(
+ <>
+
+
+
+ >,
+ )
+ const user = userEvent.setup()
+
+ await user.click(screen.getByRole('button', { name: 'Options menu' }))
+ await flushMicrotasks()
+ expect(screen.getByRole('menu')).toBeInTheDocument()
+
+ // Ariakit disables the tree outside a modal menu (via `inert` where supported, and
+ // `aria-hidden` + `pointer-events: none` as a fallback in jsdom). The persistent
+ // element is exempted, so it stays interactive while a sibling without the attribute
+ // does not.
+ await waitFor(() => {
+ expect(screen.getByTestId('other-content')).toHaveAttribute('aria-hidden', 'true')
+ })
+ expect(screen.getByTestId('drag-region')).not.toHaveAttribute('aria-hidden')
+ })
+
+ it('still honors a consumer-provided getPersistentElements callback', async () => {
+ render(
+ <>
+
+
+
+ >,
+ )
+ const user = userEvent.setup()
+
+ await user.click(screen.getByRole('button', { name: 'Options menu' }))
+ await flushMicrotasks()
+ expect(screen.getByRole('menu')).toBeInTheDocument()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('other-content')).toHaveAttribute('aria-hidden', 'true')
+ })
+ expect(screen.getByTestId('custom-persist')).not.toHaveAttribute('aria-hidden')
+ })
+ })
})
diff --git a/src/menu/menu.tsx b/src/menu/menu.tsx
index 9fca203f..3aacce5b 100644
--- a/src/menu/menu.tsx
+++ b/src/menu/menu.tsx
@@ -148,11 +148,29 @@ interface MenuListProps
extends Omit,
ObfuscatedClassName {}
+/**
+ * Selector for elements that should remain interactive while a modal menu is open.
+ *
+ * A modal `MenuList` marks the entire element tree outside of it as `inert`, which removes those
+ * elements from hit-testing. On desktop apps (e.g. Electron) this breaks the native window
+ * drag region (`-webkit-app-region: drag`) of the title bar, since `inert` elements no longer
+ * receive the OS drag gesture. Marking such elements with `data-reactist-persist` keeps them
+ * interactive while the menu stays modal in every other respect.
+ */
+const PERSISTENT_ELEMENTS_SELECTOR = '[data-reactist-persist]'
+
+function getPersistentElementsDefault(): Element[] {
+ if (typeof document === 'undefined') {
+ return []
+ }
+ return Array.from(document.querySelectorAll(PERSISTENT_ELEMENTS_SELECTOR))
+}
+
/**
* The dropdown menu itself, containing a list of menu items.
*/
const MenuList = React.forwardRef(function MenuList(
- { exceptionallySetClassName, modal = true, flip, ...props },
+ { exceptionallySetClassName, modal = true, flip, getPersistentElements, ...props },
ref,
) {
const { menuStore, getAnchorRect } = React.useContext(MenuContext)
@@ -164,6 +182,16 @@ const MenuList = React.forwardRef(function MenuLi
const isOpen = menuStore.useState('open')
+ const mergedGetPersistentElements = React.useCallback(
+ function mergedGetPersistentElements(): Element[] {
+ const consumerElements = getPersistentElements
+ ? Array.from(getPersistentElements())
+ : []
+ return [...getPersistentElementsDefault(), ...consumerElements]
+ },
+ [getPersistentElements],
+ )
+
return isOpen ? (
(function MenuLi
className={classNames('reactist_menulist', exceptionallySetClassName)}
getAnchorRect={getAnchorRect ?? undefined}
modal={modal}
+ getPersistentElements={mergedGetPersistentElements}
flip={flip ?? (isSubMenu ? 'left bottom' : undefined)}
/>