diff --git a/docs/src/app/(private)/experiments/popups-in-popups.tsx b/docs/src/app/(private)/experiments/popups-in-popups.tsx index 92dbafcd22..f8b7aad6d6 100644 --- a/docs/src/app/(private)/experiments/popups-in-popups.tsx +++ b/docs/src/app/(private)/experiments/popups-in-popups.tsx @@ -31,15 +31,17 @@ export default function PopupsInPopups() { {withBackdrop && } />} - -
- - -
- - Cancel - -
+ + +
+ + +
+ + Cancel + +
+
@@ -48,7 +50,7 @@ export default function PopupsInPopups() { function SelectDemo({ modal }: Props) { return ( - + - - }> - }>Choose a font - - + + }> + }>Choose a font + + - }> - - - } /> - System font - - - } /> - Arial - - - } /> - Roboto - - - + + }> + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + ); } @@ -283,8 +287,6 @@ const SelectDropdownArrow = styled(Select.Icon)` `; const Positioner = styled('div')` - z-index: 2900; - &:focus-visible { outline: 0; } @@ -364,7 +366,6 @@ const MenuPopup = styled(Menu.Popup)( border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]}; - z-index: 1; transform-origin: var(--transform-origin); opacity: 1; transform: scale(1, 1); @@ -460,7 +461,6 @@ const DialogPopup = styled(Dialog.Popup)( font-family: "IBM Plex Sans", sans-serif; transform: translate(-50%, -50%); padding: 16px; - z-index: 2100; `, ); @@ -491,9 +491,7 @@ const DialogCloseButton = styled(Dialog.Close)( `, ); -const TooltipPositioner = styled('div')` - z-index: 3000; -`; +const TooltipPositioner = styled('div')``; const TooltipPopup = styled('div')` box-sizing: border-box; @@ -537,5 +535,4 @@ const Backdrop = styled('div')` position: fixed; inset: 0; backdrop-filter: blur(4px); - z-index: 2000; `; diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx index d261e83390..785a99da62 100644 --- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx +++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx @@ -14,6 +14,7 @@ import { useForkRef } from '../../utils/useForkRef'; import { InteractionType } from '../../utils/useEnhancedClickHandler'; import { transitionStatusMapping } from '../../utils/styleHookMapping'; import { AlertDialogPopupDataAttributes } from './AlertDialogPopupDataAttributes'; +import { InternalBackdrop } from '../../utils/InternalBackdrop'; const customStyleHookMapping: CustomStyleHookMapping = { ...baseMapping, @@ -50,6 +51,7 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( setPopupElementId, titleElementId, transitionStatus, + modal, } = useAlertDialogRootContext(); const mergedRef = useForkRef(forwardedRef, popupRef); @@ -101,16 +103,19 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( } return ( - - {renderElement()} - + + {mounted && modal && } + + {renderElement()} + + ); }); diff --git a/packages/react/src/dialog/popup/DialogPopup.tsx b/packages/react/src/dialog/popup/DialogPopup.tsx index 2b722dafc4..be21ee3681 100644 --- a/packages/react/src/dialog/popup/DialogPopup.tsx +++ b/packages/react/src/dialog/popup/DialogPopup.tsx @@ -15,6 +15,7 @@ import { InteractionType } from '../../utils/useEnhancedClickHandler'; import { transitionStatusMapping } from '../../utils/styleHookMapping'; import { DialogPopupCssVars } from './DialogPopupCssVars'; import { DialogPopupDataAttributes } from './DialogPopupDataAttributes'; +import { InternalBackdrop } from '../../utils/InternalBackdrop'; const customStyleHookMapping: CustomStyleHookMapping = { ...baseMapping, @@ -98,17 +99,20 @@ const DialogPopup = React.forwardRef(function DialogPopup( } return ( - - {renderElement()} - + + {mounted && modal && } + + {renderElement()} + + ); }); diff --git a/packages/react/src/dialog/popup/useDialogPopup.tsx b/packages/react/src/dialog/popup/useDialogPopup.tsx index c79e5e90f7..4f16712eb5 100644 --- a/packages/react/src/dialog/popup/useDialogPopup.tsx +++ b/packages/react/src/dialog/popup/useDialogPopup.tsx @@ -55,7 +55,7 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog const id = useBaseUiId(idParam); const handleRef = useForkRef(ref, popupRef, setPopupElement); - useScrollLock(modal && open, elements.floating); + useScrollLock(open && modal, elements.floating); // Default initial focus logic: // If opened by touch, focus the popup element to prevent the virtual keyboard from opening @@ -91,7 +91,7 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog mergeReactProps<'div'>(externalProps, { 'aria-labelledby': titleElementId ?? undefined, 'aria-describedby': descriptionElementId ?? undefined, - 'aria-modal': open && modal ? true : undefined, + 'aria-modal': mounted && modal ? true : undefined, role: 'dialog', tabIndex: -1, ...getPopupProps(), diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx index abc19740e2..6faa878510 100644 --- a/packages/react/src/dialog/root/DialogRoot.test.tsx +++ b/packages/react/src/dialog/root/DialogRoot.test.tsx @@ -4,6 +4,8 @@ import { spy } from 'sinon'; import { act, fireEvent, screen, waitFor, describeSkipIf } from '@mui/internal-test-utils'; import { Dialog } from '@base-ui-components/react/dialog'; import { createRenderer } from '#test-utils'; +import { Menu } from '@base-ui-components/react/menu'; +import { Select } from '@base-ui-components/react/select'; const isJSDOM = /jsdom/.test(window.navigator.userAgent); @@ -388,4 +390,183 @@ describe('', () => { expect(notifyTransitionEnd.callCount).to.equal(1); }); + + describe('prop: modal', () => { + it('should render an internal backdrop when `true`', async () => { + const { user } = await render( +
+ + Open + + + + + +
, + ); + + const trigger = screen.getByTestId('trigger'); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + const popup = screen.getByRole('dialog'); + + // focus guard -> internal backdrop + expect(popup.previousElementSibling?.previousElementSibling).to.have.attribute( + 'role', + 'presentation', + ); + }); + + it('should not render an internal backdrop when `false`', async () => { + const { user } = await render( +
+ + Open + + + + + +
, + ); + + const trigger = screen.getByTestId('trigger'); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + const popup = screen.getByRole('dialog'); + + // focus guard -> internal backdrop + expect(popup.previousElementSibling?.previousElementSibling).to.equal(null); + }); + }); + + describeSkipIf(isJSDOM)('nested popups', () => { + it('should not dismiss the dialog when dismissing outside a nested modal menu', async () => { + const { user } = await render( + + Open dialog + + + + Open menu + + + + Item + + + + + + + , + ); + + const dialogTrigger = screen.getByRole('button', { name: 'Open dialog' }); + await user.click(dialogTrigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + const menuTrigger = screen.getByRole('button', { name: 'Open menu' }); + + await user.click(menuTrigger); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + const menuPositioner = screen.getByTestId('menu-positioner'); + const menuInternalBackdrop = menuPositioner.previousElementSibling as HTMLElement; + + await user.click(menuInternalBackdrop); + + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + const dialogPopup = screen.getByTestId('dialog-popup'); + const dialogInternalBackdrop = dialogPopup.previousElementSibling + ?.previousElementSibling as HTMLElement; + + await user.click(dialogInternalBackdrop); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).to.equal(null); + }); + }); + + it('should not dismiss the dialog when dismissing outside a nested select menu', async () => { + const { user } = await render( + + Open dialog + + + + Open select + + + + Item + + + + + + + , + ); + + const dialogTrigger = screen.getByRole('button', { name: 'Open dialog' }); + await user.click(dialogTrigger); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + const selectTrigger = screen.getByTestId('select-trigger'); + + await user.click(selectTrigger); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.to.equal(null); + }); + + const selectPositioner = screen.getByTestId('select-positioner'); + const selectInternalBackdrop = selectPositioner.previousElementSibling as HTMLElement; + + await user.click(selectInternalBackdrop); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).to.equal(null); + }); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.to.equal(null); + }); + + const dialogPopup = screen.getByTestId('dialog-popup'); + const dialogInternalBackdrop = dialogPopup.previousElementSibling + ?.previousElementSibling as HTMLElement; + + await user.click(dialogInternalBackdrop); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).to.equal(null); + }); + }); + }); }); diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx index e737fff836..d39bd9d53d 100644 --- a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx +++ b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; -import userEvent from '@testing-library/user-event'; import { fireEvent, act, waitFor } from '@mui/internal-test-utils'; import { FloatingRootContext, FloatingTree } from '@floating-ui/react'; import { Menu } from '@base-ui-components/react/menu'; @@ -33,8 +32,13 @@ const testRootContext: MenuRootContext = { }; describe('', () => { - const { render } = createRenderer(); - const user = userEvent.setup(); + const { render, clock } = createRenderer({ + clockOptions: { + shouldAdvanceTime: true, + }, + }); + + clock.withFakeTimers(); describeConformance(, () => ({ render: (node) => { @@ -133,7 +137,7 @@ describe('', () => { ] as const ).forEach(([checked, ariaChecked, dataState]) => it('adds the state and ARIA attributes when checked', async () => { - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -154,7 +158,7 @@ describe('', () => { ); it('toggles the checked state when clicked', async () => { - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -181,7 +185,7 @@ describe('', () => { }); it(`toggles the checked state when Space is pressed`, async () => { - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -217,7 +221,7 @@ describe('', () => { this?.skip?.() || t?.skip(); } - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -246,7 +250,7 @@ describe('', () => { it('calls `onCheckedChange` when the item is clicked', async () => { const onCheckedChange = spy(); - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -273,7 +277,7 @@ describe('', () => { }); it('keeps the state when closed and reopened', async () => { - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -302,7 +306,7 @@ describe('', () => { describe('prop: closeOnClick', () => { it('when `closeOnClick=true`, closes the menu when the item is clicked', async () => { - const { getByRole, queryByRole } = await render( + const { getByRole, queryByRole, user } = await render( Open @@ -323,7 +327,7 @@ describe('', () => { }); it('does not close the menu when the item is clicked by default', async () => { - const { getByRole, queryByRole } = await render( + const { getByRole, queryByRole, user } = await render( Open diff --git a/packages/react/src/menu/item/MenuItem.test.tsx b/packages/react/src/menu/item/MenuItem.test.tsx index f1a978052e..d9fecfcee1 100644 --- a/packages/react/src/menu/item/MenuItem.test.tsx +++ b/packages/react/src/menu/item/MenuItem.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; -import userEvent from '@testing-library/user-event'; import { act, screen, waitFor } from '@mui/internal-test-utils'; import { FloatingRootContext, FloatingTree } from '@floating-ui/react'; import { Menu } from '@base-ui-components/react/menu'; @@ -33,8 +32,13 @@ const testRootContext: MenuRootContext = { }; describe('', () => { - const { render } = createRenderer(); - const user = userEvent.setup(); + const { render, clock } = createRenderer({ + clockOptions: { + shouldAdvanceTime: true, + }, + }); + + clock.withFakeTimers(); describeConformance(, () => ({ render: (node) => { @@ -49,7 +53,7 @@ describe('', () => { it('calls the onClick handler when clicked', async () => { const onClick = spy(); - await render( + const { user } = await render( @@ -88,7 +92,7 @@ describe('', () => { return
  • ; }); - const { getAllByRole } = await render( + const { getAllByRole, user } = await render( @@ -146,7 +150,7 @@ describe('', () => { describe('prop: closeOnClick', () => { it('closes the menu when the item is clicked by default', async () => { - const { getByRole, queryByRole } = await render( + const { getByRole, queryByRole, user } = await render( Open @@ -167,7 +171,7 @@ describe('', () => { }); it('when `closeOnClick=false` does not close the menu when the item is clicked', async () => { - const { getByRole, queryByRole } = await render( + const { getByRole, queryByRole, user } = await render( Open diff --git a/packages/react/src/menu/item/useMenuItem.ts b/packages/react/src/menu/item/useMenuItem.ts index b1f836fd0e..43ea05b2f2 100644 --- a/packages/react/src/menu/item/useMenuItem.ts +++ b/packages/react/src/menu/item/useMenuItem.ts @@ -54,7 +54,7 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV }), ); }, - [closeOnClick, getButtonProps, highlighted, id, menuEvents, allowMouseUpTriggerRef, typingRef], + [getButtonProps, id, highlighted, typingRef, closeOnClick, menuEvents, allowMouseUpTriggerRef], ); return React.useMemo( diff --git a/packages/react/src/menu/popup/MenuPopup.tsx b/packages/react/src/menu/popup/MenuPopup.tsx index a4d9899a71..00e093d482 100644 --- a/packages/react/src/menu/popup/MenuPopup.tsx +++ b/packages/react/src/menu/popup/MenuPopup.tsx @@ -32,7 +32,7 @@ const MenuPopup = React.forwardRef(function MenuPopup( ) { const { render, className, ...other } = props; - const { open, setOpen, popupRef, transitionStatus, nested, mounted, getPopupProps, modal } = + const { open, setOpen, popupRef, transitionStatus, nested, getPopupProps, modal, mounted } = useMenuRootContext(); const { side, align, floatingContext } = useMenuPositionerContext(); @@ -75,11 +75,8 @@ const MenuPopup = React.forwardRef(function MenuPopup( {renderElement()} diff --git a/packages/react/src/menu/positioner/MenuPositioner.tsx b/packages/react/src/menu/positioner/MenuPositioner.tsx index 4b68ed9fb6..d2ef2f12f3 100644 --- a/packages/react/src/menu/positioner/MenuPositioner.tsx +++ b/packages/react/src/menu/positioner/MenuPositioner.tsx @@ -17,6 +17,7 @@ import { HTMLElementType } from '../../utils/proptypes'; import { BaseUIComponentProps } from '../../utils/types'; import { popupStateMapping } from '../../utils/popupStateMapping'; import { CompositeList } from '../../composite/list/CompositeList'; +import { InternalBackdrop } from '../../utils/InternalBackdrop'; /** * Positions the menu popup against the trigger. @@ -54,6 +55,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( mounted, nested, setOpen, + modal, } = useMenuRootContext(); const { events: menuEvents } = useFloatingTree()!; @@ -139,6 +141,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( return ( + {mounted && modal && parentNodeId === null && } {renderElement()} diff --git a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx index 87c4ea51a9..012555604d 100644 --- a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx +++ b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; -import userEvent from '@testing-library/user-event'; import { fireEvent, act, waitFor } from '@mui/internal-test-utils'; import { FloatingRootContext, FloatingTree } from '@floating-ui/react'; import { Menu } from '@base-ui-components/react/menu'; @@ -39,8 +38,13 @@ const testRadioGroupContext = { }; describe('', () => { - const { render } = createRenderer(); - const user = userEvent.setup(); + const { render, clock } = createRenderer({ + clockOptions: { + shouldAdvanceTime: true, + }, + }); + + clock.withFakeTimers(); describeConformance(, () => ({ render: (node) => { @@ -155,7 +159,7 @@ describe('', () => { describe('state management', () => { it('adds the state and ARIA attributes when selected', async () => { - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -180,7 +184,7 @@ describe('', () => { ['Space', 'Enter'].forEach((key) => { it(`selects the item when ${key} is pressed`, async () => { - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -209,7 +213,7 @@ describe('', () => { it('calls `onValueChange` when the item is clicked', async () => { const onValueChange = spy(); - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -233,7 +237,7 @@ describe('', () => { }); it('keeps the state when closed and reopened', async () => { - const { getByRole } = await render( + const { getByRole, user } = await render( Open @@ -264,7 +268,7 @@ describe('', () => { describe('prop: closeOnClick', () => { it('when `closeOnClick=true`, closes the menu when the item is clicked', async () => { - const { getByRole, queryByRole } = await render( + const { getByRole, queryByRole, user } = await render( Open @@ -289,7 +293,7 @@ describe('', () => { }); it('does not close the menu when the item is clicked by default', async () => { - const { getByRole, queryByRole } = await render( + const { getByRole, queryByRole, user } = await render( Open diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx index 926cb8bc1d..f13f33540f 100644 --- a/packages/react/src/menu/root/MenuRoot.test.tsx +++ b/packages/react/src/menu/root/MenuRoot.test.tsx @@ -7,8 +7,6 @@ import userEvent from '@testing-library/user-event'; import { spy } from 'sinon'; import { createRenderer } from '#test-utils'; -const isJSDOM = /jsdom/.test(window.navigator.userAgent); - describe('', () => { beforeEach(() => { (globalThis as any).BASE_UI_ANIMATIONS_DISABLED = true; @@ -595,72 +593,6 @@ describe('', () => { }); }); - describe('prop: modal', () => { - it('makes outside elements inaccessible to mouse when a modal menu is open', async function test(t = {}) { - if (isJSDOM) { - // @ts-expect-error to support mocha and vitest - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this?.skip?.() || t?.skip(); - } - - await render( -
    - , - - Toggle - - - 1 - - - - -
    , - ); - - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await user.click(trigger); - - const outsideInput = screen.getByTestId('outside-input'); - const outsideButton = screen.getByTestId('outside-button'); - - expect(window.getComputedStyle(outsideInput).pointerEvents).to.equal('none'); - expect(window.getComputedStyle(outsideButton).pointerEvents).to.equal('none'); - }); - - it('does not make outside elements inaccessible to mouse when a nonmodal menu is open', async function test(t = {}) { - if (isJSDOM) { - // @ts-expect-error to support mocha and vitest - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this?.skip?.() || t?.skip(); - } - - await render( -
    - , - - Toggle - - - 1 - - - - -
    , - ); - - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await user.click(trigger); - - const outsideInput = screen.getByTestId('outside-input'); - const outsideButton = screen.getByTestId('outside-button'); - - expect(window.getComputedStyle(outsideInput).pointerEvents).not.to.equal('none'); - expect(window.getComputedStyle(outsideButton).pointerEvents).not.to.equal('none'); - }); - }); - describe('prop: closeParentOnEsc', () => { it('closes the parent menu when the Escape key is pressed by default', async () => { const { getByRole, queryByRole } = await render( @@ -856,4 +788,68 @@ describe('', () => { expect(animationFinished).to.equal(true); }); }); + + describe('prop: modal', () => { + it('should render an internal backdrop when `true`', async () => { + await render( +
    + + Open + + + + 1 + + + + + +
    , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + const positioner = screen.getByTestId('positioner'); + + // eslint-disable-next-line testing-library/no-node-access + expect(positioner.previousElementSibling).to.have.attribute('role', 'presentation'); + }); + + it('should not render an internal backdrop when `false`', async () => { + await render( +
    + + Open + + + + 1 + + + + + +
    , + ); + + const trigger = screen.getByRole('button', { name: 'Open' }); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + const positioner = screen.getByTestId('positioner'); + + // eslint-disable-next-line testing-library/no-node-access + expect(positioner.previousElementSibling).to.equal(null); + }); + }); }); diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx index ed692209a4..c56b0129e1 100644 --- a/packages/react/src/menu/root/MenuRoot.tsx +++ b/packages/react/src/menu/root/MenuRoot.tsx @@ -7,12 +7,6 @@ import { MenuRootContext, useMenuRootContext } from './MenuRootContext'; import { MenuOrientation, useMenuRoot } from './useMenuRoot'; import { PortalContext } from '../../portal/PortalContext'; -const inertStyle = ` - [data-floating-ui-inert] { - pointer-events: none !important; - } -`; - /** * Groups all parts of the menu. * Doesn’t render its own HTML element. @@ -80,8 +74,6 @@ const MenuRoot: React.FC = function MenuRoot(props) { // set up a FloatingTree to provide the context to nested menus return ( - {/* eslint-disable-next-line react/no-danger */} - {menuRoot.open && modal &&