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( + <> +
+
+ + Options menu + + First option + + + , + ) + 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( + <> +
+
+ + Options menu + [screen.getByTestId('custom-persist')]} + > + First option + + + , + ) + 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)} />