diff --git a/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/css-modules/index.module.css b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/css-modules/index.module.css new file mode 100644 index 00000000000..9ce135eeab8 --- /dev/null +++ b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/css-modules/index.module.css @@ -0,0 +1,236 @@ +.Button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + padding: 0 0.75rem; + margin: 0; + border: 1px solid oklch(14.5% 0 0deg); + background-color: white; + font-family: inherit; + font-size: 0.875rem; + font-weight: 400; + line-height: 1; + white-space: nowrap; + color: oklch(14.5% 0 0deg); + -webkit-user-select: none; + user-select: none; + + @media (prefers-color-scheme: dark) { + border: 1px solid white; + background-color: oklch(14.5% 0 0deg); + color: white; + } + + &[data-color='red'] { + color: oklch(50.5% 0.213 27.518deg); + + @media (prefers-color-scheme: dark) { + color: oklch(70.4% 0.191 22.216deg); + } + } + + @media (hover: hover) { + &:hover:not([data-disabled]) { + background-color: oklch(97% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: oklch(26.9% 0 0deg); + } + } + } + + &:active:not([data-disabled]) { + background-color: oklch(92.2% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: oklch(37.1% 0 0deg); + } + } + + &[data-pressed] { + background-color: oklch(97% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: oklch(26.9% 0 0deg); + } + } + + &:focus-visible { + outline: 2px solid oklch(14.5% 0 0deg); + outline-offset: -1px; + + @media (prefers-color-scheme: dark) { + outline-color: white; + } + } +} + +.Positioner { + outline: 0; +} + +.MenuPopup { + box-sizing: border-box; + position: relative; + outline: 0; + padding-block: 0.25rem; + border: 1px solid oklch(14.5% 0 0deg); + border-radius: 0; + background-color: white; + color: oklch(14.5% 0 0deg); + box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%); + transform-origin: var(--transform-origin); + transition: + transform 100ms ease-out, + opacity 100ms ease-out; + + @media (prefers-color-scheme: dark) { + border: 1px solid white; + background-color: oklch(14.5% 0 0deg); + color: white; + box-shadow: none; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: scale(0.98); + } +} + +.MenuItem { + outline: 0; + cursor: default; + -webkit-user-select: none; + user-select: none; + padding-block: 0.5rem; + padding-left: 1rem; + padding-right: 2rem; + display: flex; + font-size: 0.875rem; + line-height: 1rem; + + &[data-highlighted] { + z-index: 0; + position: relative; + color: white; + + @media (prefers-color-scheme: dark) { + color: oklch(14.5% 0 0deg); + } + } + + &[data-highlighted]::before { + content: ''; + z-index: -1; + position: absolute; + inset-block: 0; + inset-inline: 0.25rem; + background-color: oklch(14.5% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: white; + } + } +} + +.Separator { + margin: 0.25rem; + height: 1px; + background-color: oklch(14.5% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: white; + } +} + +.Backdrop { + position: fixed; + min-height: 100dvh; + inset: 0; + background-color: black; + opacity: 0.2; + transition: opacity 150ms; + + /* iOS 26+: Ensure the backdrop covers the entire visible viewport. */ + @supports (-webkit-touch-callout: none) { + position: absolute; + } + + @media (prefers-color-scheme: dark) { + opacity: 0.5; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + } +} + +.DialogPopup { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1rem; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 24rem; + max-width: calc(100vw - 3rem); + margin-top: -2rem; + padding: 1rem; + border: 1px solid oklch(14.5% 0 0deg); + background-color: white; + color: oklch(14.5% 0 0deg); + box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%); + transition: + transform 100ms ease-out, + opacity 100ms ease-out; + + @media (prefers-color-scheme: dark) { + border: 1px solid white; + background-color: oklch(14.5% 0 0deg); + color: white; + box-shadow: none; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: translate(-50%, -50%) scale(0.98); + } +} + +.Intro { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.Title { + margin: 0; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 700; +} + +.Description { + margin: 0; + font-size: 0.875rem; + line-height: 1.25rem; + color: oklch(43.9% 0 0deg); + + @media (prefers-color-scheme: dark) { + color: oklch(70.8% 0 0deg); + } +} + +.Actions { + display: flex; + justify-content: end; + gap: 0.75rem; +} diff --git a/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/css-modules/index.tsx b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/css-modules/index.tsx new file mode 100644 index 00000000000..2bd0957a3d4 --- /dev/null +++ b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/css-modules/index.tsx @@ -0,0 +1,68 @@ +'use client'; +import * as React from 'react'; +import { AlertDialog } from '@base-ui/react/alert-dialog'; +import { Menu } from '@base-ui/react/menu'; +import styles from './index.module.css'; + +export default function ExampleAlertDialog() { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + + + + Playlist + + + + + Play + Share + + {/* Open the alert dialog when the menu item is clicked */} + setDialogOpen(true)}> + Delete… + + + + + + + {/* Control the alert dialog state */} + + + + + + Delete playlist? + + You can't undo this action. + + + + Cancel + + Delete + + + + + + + ); +} + +function CaretDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} diff --git a/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/index.ts b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/index.ts new file mode 100644 index 00000000000..2bb49bc4773 --- /dev/null +++ b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/index.ts @@ -0,0 +1,8 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import CssModules from './css-modules'; +import Tailwind from './tailwind'; + +export const DemoAlertDialogOpenFromMenu = createDemoWithVariants(import.meta.url, { + CssModules, + Tailwind, +}); diff --git a/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/tailwind/index.tsx b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/tailwind/index.tsx new file mode 100644 index 00000000000..6f4f10c902a --- /dev/null +++ b/docs/src/app/(docs)/react/components/alert-dialog/demos/open-from-menu/tailwind/index.tsx @@ -0,0 +1,75 @@ +'use client'; +import * as React from 'react'; +import { AlertDialog } from '@base-ui/react/alert-dialog'; +import { Menu } from '@base-ui/react/menu'; + +export default function ExampleAlertDialog() { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + + + + Playlist + + + + + Play + Share + + {/* Open the alert dialog when the menu item is clicked */} + setDialogOpen(true)}> + Delete… + + + + + + + {/* Control the alert dialog state */} + + + + + + + Delete playlist? + + + You can't undo this action. + + + + Cancel + + Delete + + + + + + + ); +} + +const buttonClass = + 'flex h-8 items-center justify-center gap-1.5 border border-neutral-950 bg-white px-3 text-sm leading-none whitespace-nowrap font-normal text-neutral-950 select-none hover:not-data-disabled:bg-neutral-100 active:not-data-disabled:bg-neutral-200 data-pressed:bg-neutral-100 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-disabled:bg-neutral-800 dark:active:not-data-disabled:bg-neutral-700 dark:data-pressed:bg-neutral-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white'; + +const itemClass = + "flex cursor-default py-2 pr-8 pl-4 text-sm leading-4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-white data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:bg-neutral-950 data-highlighted:before:content-[''] dark:data-highlighted:text-neutral-950 dark:data-highlighted:before:bg-white"; + +function CaretDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} diff --git a/docs/src/app/(docs)/react/components/alert-dialog/page.mdx b/docs/src/app/(docs)/react/components/alert-dialog/page.mdx index 729c67b0604..15705c210dd 100644 --- a/docs/src/app/(docs)/react/components/alert-dialog/page.mdx +++ b/docs/src/app/(docs)/react/components/alert-dialog/page.mdx @@ -38,46 +38,9 @@ import { AlertDialog } from '@base-ui/react/alert-dialog'; In order to open a dialog using a menu, control the dialog state and open it imperatively using the `onClick` handler on the menu item. -```tsx title="Connecting a dialog to a menu" -import * as React from 'react'; -import { AlertDialog } from '@base-ui/react/alert-dialog'; -import { Menu } from '@base-ui/react/menu'; - -function ExampleMenu() { - const [dialogOpen, setDialogOpen] = React.useState(false); - - return ( - - - Open menu - - - - {/* @highlight-start */} - {/* Open the dialog when the menu item is clicked */} - setDialogOpen(true)}>Open dialog - {/* @highlight-end */} - - - - - - {/* @highlight-start */} - {/* Control the dialog state */} - - {/* @highlight-end */} - - - - {/* prettier-ignore */} - {/* Rest of the dialog */} - - - - - ); -} -``` +import { DemoAlertDialogOpenFromMenu } from './demos/open-from-menu'; + + ### Close confirmation diff --git a/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/css-modules/index.module.css b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/css-modules/index.module.css new file mode 100644 index 00000000000..eb836a23993 --- /dev/null +++ b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/css-modules/index.module.css @@ -0,0 +1,228 @@ +.Button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + padding: 0 0.75rem; + margin: 0; + border: 1px solid oklch(14.5% 0 0deg); + background-color: white; + font-family: inherit; + font-size: 0.875rem; + font-weight: 400; + line-height: 1; + white-space: nowrap; + color: oklch(14.5% 0 0deg); + -webkit-user-select: none; + user-select: none; + + @media (prefers-color-scheme: dark) { + border: 1px solid white; + background-color: oklch(14.5% 0 0deg); + color: white; + } + + @media (hover: hover) { + &:hover:not([data-disabled]) { + background-color: oklch(97% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: oklch(26.9% 0 0deg); + } + } + } + + &:active:not([data-disabled]) { + background-color: oklch(92.2% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: oklch(37.1% 0 0deg); + } + } + + &[data-pressed] { + background-color: oklch(97% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: oklch(26.9% 0 0deg); + } + } + + &:focus-visible { + outline: 2px solid oklch(14.5% 0 0deg); + outline-offset: -1px; + + @media (prefers-color-scheme: dark) { + outline-color: white; + } + } +} + +.Positioner { + outline: 0; +} + +.MenuPopup { + box-sizing: border-box; + position: relative; + outline: 0; + padding-block: 0.25rem; + border: 1px solid oklch(14.5% 0 0deg); + border-radius: 0; + background-color: white; + color: oklch(14.5% 0 0deg); + box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%); + transform-origin: var(--transform-origin); + transition: + transform 100ms ease-out, + opacity 100ms ease-out; + + @media (prefers-color-scheme: dark) { + border: 1px solid white; + background-color: oklch(14.5% 0 0deg); + color: white; + box-shadow: none; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: scale(0.98); + } +} + +.MenuItem { + outline: 0; + cursor: default; + -webkit-user-select: none; + user-select: none; + padding-block: 0.5rem; + padding-left: 1rem; + padding-right: 2rem; + display: flex; + font-size: 0.875rem; + line-height: 1rem; + + &[data-highlighted] { + z-index: 0; + position: relative; + color: white; + + @media (prefers-color-scheme: dark) { + color: oklch(14.5% 0 0deg); + } + } + + &[data-highlighted]::before { + content: ''; + z-index: -1; + position: absolute; + inset-block: 0; + inset-inline: 0.25rem; + background-color: oklch(14.5% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: white; + } + } +} + +.Separator { + margin: 0.25rem; + height: 1px; + background-color: oklch(14.5% 0 0deg); + + @media (prefers-color-scheme: dark) { + background-color: white; + } +} + +.Backdrop { + position: fixed; + min-height: 100dvh; + inset: 0; + background-color: black; + opacity: 0.2; + transition: opacity 150ms; + + /* iOS 26+: Ensure the backdrop covers the entire visible viewport. */ + @supports (-webkit-touch-callout: none) { + position: absolute; + } + + @media (prefers-color-scheme: dark) { + opacity: 0.5; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + } +} + +.DialogPopup { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1rem; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 24rem; + max-width: calc(100vw - 3rem); + margin-top: -2rem; + padding: 1rem; + border: 1px solid oklch(14.5% 0 0deg); + background-color: white; + color: oklch(14.5% 0 0deg); + box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%); + transition: + transform 100ms ease-out, + opacity 100ms ease-out; + + @media (prefers-color-scheme: dark) { + border: 1px solid white; + background-color: oklch(14.5% 0 0deg); + color: white; + box-shadow: none; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: translate(-50%, -50%) scale(0.98); + } +} + +.Intro { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.Title { + margin: 0; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 700; +} + +.Description { + margin: 0; + font-size: 0.875rem; + line-height: 1.25rem; + color: oklch(43.9% 0 0deg); + + @media (prefers-color-scheme: dark) { + color: oklch(70.8% 0 0deg); + } +} + +.Actions { + display: flex; + justify-content: end; + gap: 0.75rem; +} diff --git a/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/css-modules/index.tsx b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/css-modules/index.tsx new file mode 100644 index 00000000000..cb5b400296c --- /dev/null +++ b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/css-modules/index.tsx @@ -0,0 +1,65 @@ +'use client'; +import * as React from 'react'; +import { Dialog } from '@base-ui/react/dialog'; +import { Menu } from '@base-ui/react/menu'; +import styles from './index.module.css'; + +export default function ExampleDialog() { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + + + + Playlist + + + + + Play + Share + + {/* Open the dialog when the menu item is clicked */} + setDialogOpen(true)}> + Details… + + + + + + + {/* Control the dialog state */} + + + + + + Playlist details + + This playlist contains 24 songs and was last updated today. + + + + Close + + + + + + ); +} + +function CaretDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} diff --git a/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/index.ts b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/index.ts new file mode 100644 index 00000000000..f7da10ff3eb --- /dev/null +++ b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/index.ts @@ -0,0 +1,8 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import CssModules from './css-modules'; +import Tailwind from './tailwind'; + +export const DemoDialogOpenFromMenu = createDemoWithVariants(import.meta.url, { + CssModules, + Tailwind, +}); diff --git a/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/tailwind/index.tsx b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/tailwind/index.tsx new file mode 100644 index 00000000000..f77d3e96210 --- /dev/null +++ b/docs/src/app/(docs)/react/components/dialog/demos/open-from-menu/tailwind/index.tsx @@ -0,0 +1,70 @@ +'use client'; +import * as React from 'react'; +import { Dialog } from '@base-ui/react/dialog'; +import { Menu } from '@base-ui/react/menu'; + +export default function ExampleDialog() { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + + + + Playlist + + + + + Play + Share + + {/* Open the dialog when the menu item is clicked */} + setDialogOpen(true)}> + Details… + + + + + + + {/* Control the dialog state */} + + + + + + Playlist details + + This playlist contains 24 songs and was last updated today. + + + + Close + + + + + + ); +} + +const buttonClass = + 'flex h-8 items-center justify-center gap-1.5 border border-neutral-950 bg-white px-3 text-sm leading-none whitespace-nowrap font-normal text-neutral-950 select-none hover:not-data-disabled:bg-neutral-100 active:not-data-disabled:bg-neutral-200 data-pressed:bg-neutral-100 dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-disabled:bg-neutral-800 dark:active:not-data-disabled:bg-neutral-700 dark:data-pressed:bg-neutral-800 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white'; + +const itemClass = + "flex cursor-default py-2 pr-8 pl-4 text-sm leading-4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-white data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:bg-neutral-950 data-highlighted:before:content-[''] dark:data-highlighted:text-neutral-950 dark:data-highlighted:before:bg-white"; + +function CaretDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} diff --git a/docs/src/app/(docs)/react/components/dialog/page.mdx b/docs/src/app/(docs)/react/components/dialog/page.mdx index 784a3e70a17..be9bacd48fc 100644 --- a/docs/src/app/(docs)/react/components/dialog/page.mdx +++ b/docs/src/app/(docs)/react/components/dialog/page.mdx @@ -99,46 +99,9 @@ It's also common to use `onOpenChange` if your app needs to do something when th In order to open a dialog using a menu, control the dialog state and open it imperatively using the `onClick` handler on the menu item. -```tsx title="Connecting a dialog to a menu" -import * as React from 'react'; -import { Dialog } from '@base-ui/react/dialog'; -import { Menu } from '@base-ui/react/menu'; - -function ExampleMenu() { - const [dialogOpen, setDialogOpen] = React.useState(false); - - return ( - - - Open menu - - - - {/* @highlight-start */} - {/* Open the dialog when the menu item is clicked */} - setDialogOpen(true)}>Open dialog - {/* @highlight-end */} - - - - - - {/* @highlight-start */} - {/* Control the dialog state */} - - {/* @highlight-end */} - - - - {/* prettier-ignore */} - {/* Rest of the dialog */} - - - - - ); -} -``` +import { DemoDialogOpenFromMenu } from './demos/open-from-menu'; + + ### Nested dialogs diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx index 3566f840133..a1818f5a1aa 100644 --- a/packages/react/src/dialog/root/DialogRoot.test.tsx +++ b/packages/react/src/dialog/root/DialogRoot.test.tsx @@ -1,4 +1,5 @@ import { expect, vi } from 'vitest'; +import type { CDPSession } from '@vitest/browser-playwright'; import * as React from 'react'; import { act, fireEvent, screen, waitFor, flushMicrotasks } from '@mui/internal-test-utils'; import { AlertDialog } from '@base-ui/react/alert-dialog'; @@ -10,6 +11,7 @@ import { NumberField } from '@base-ui/react/number-field'; import { ScrollArea } from '@base-ui/react/scroll-area'; import { useRefWithInit } from '@base-ui/utils/useRefWithInit'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; +import { platform } from '@base-ui/utils/platform'; import { useTimeout } from '@base-ui/utils/useTimeout'; import { REASONS } from '../../internals/reasons'; import { useDialogRootContext } from './DialogRootContext'; @@ -37,6 +39,108 @@ describe('', () => { expectedPopupRole: 'dialog', }); + it.skipIf(isJSDOM || !platform.engine.blink)( + 'ignores a native click whose pointerdown opened the dialog', + async () => { + const { cdp } = await import('vitest/browser'); + const openChangeSpy = vi.fn(); + const documentClicks: MouseEvent[] = []; + + function App() { + const [open, setOpen] = React.useState(false); + + return ( + + setOpen(true)}> + Open + + { + openChangeSpy(nextOpen, eventDetails.reason); + setOpen(nextOpen); + }} + > + + + Dialog + + + + ); + } + + await render(); + + const openButton = screen.getByRole('button', { name: 'Open' }); + const frame = window.frameElement as HTMLIFrameElement | null; + const frameRect = frame?.getBoundingClientRect(); + const buttonRect = openButton.getBoundingClientRect(); + const buttonCenter = { + x: + (frameRect?.left ?? 0) + + (frame?.clientLeft ?? 0) + + buttonRect.left + + buttonRect.width / 2, + y: (frameRect?.top ?? 0) + (frame?.clientTop ?? 0) + buttonRect.top + buttonRect.height / 2, + }; + const session = cdp() as CDPSession; + + function recordClick(event: MouseEvent) { + documentClicks.push(event); + } + + document.addEventListener('click', recordClick, true); + + try { + await act(async () => { + await session.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + ...buttonCenter, + }); + await session.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + ...buttonCenter, + button: 'left', + buttons: 1, + clickCount: 1, + }); + }); + + await waitFor(() => { + expect(screen.queryByTestId('popup')).not.toBe(null); + }); + + await act(async () => { + await session.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + ...buttonCenter, + button: 'left', + buttons: 0, + clickCount: 1, + }); + }); + + // The browser synthesizes the gesture's click asynchronously after + // the release; wait for it instead of sleeping a fixed amount. + await waitFor(() => { + expect(documentClicks.some((event) => event.isTrusted)).toBe(true); + }); + + const trustedClick = documentClicks.find((event) => event.isTrusted); + expect(trustedClick?.target).not.toBe(openButton); + // The click must land outside the popup subtree so it is evaluated as + // an outside press; a click inside the popup would be ignored for a + // different reason and stop covering the press-observed guard. + expect(screen.getByTestId('popup').contains(trustedClick?.target as Node)).toBe(false); + expect(screen.queryByTestId('popup')).not.toBe(null); + expect(openChangeSpy).not.toHaveBeenCalledWith(false, REASONS.outsidePress); + } finally { + document.removeEventListener('click', recordClick, true); + } + }, + ); + it('reports nested drawer counts before passive effects', async () => { const childStore = new DialogStore( { diff --git a/packages/react/src/floating-ui-react/hooks/useDismiss.test.tsx b/packages/react/src/floating-ui-react/hooks/useDismiss.test.tsx index 627acabd760..8ca3bfb2820 100644 --- a/packages/react/src/floating-ui-react/hooks/useDismiss.test.tsx +++ b/packages/react/src/floating-ui-react/hooks/useDismiss.test.tsx @@ -18,6 +18,7 @@ import { useClick, } from '../index'; import { REASONS } from '../../internals/reasons'; +import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import type { UseDismissProps } from './useDismiss'; import { normalizeProp } from './useDismiss'; @@ -1015,6 +1016,10 @@ describe.skipIf(!isJSDOM)('useDismiss', () => { const floatingEl = screen.getByRole('tooltip'); fireEvent.mouseDown(document.body); fireEvent.mouseUp(floatingEl); + // The browser fires the gesture's click on the common ancestor of the + // mousedown and mouseup targets; the mouseup inside the floating element + // marks the React tree so this click must not dismiss. + fireEvent.click(document.body, { detail: 1 }); expect(screen.getByRole('tooltip')).toBeInTheDocument(); await flushMicrotasks(); }); @@ -1030,6 +1035,235 @@ describe.skipIf(!isJSDOM)('useDismiss', () => { expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); + test('dragging outside the floating element then clicking outside closes with mouse clicks', async () => { + render(); + const floatingEl = screen.getByRole('tooltip'); + fireEvent.pointerDown(floatingEl, { pointerType: 'mouse' }); + fireEvent.mouseDown(floatingEl); + fireEvent.mouseUp(document.body); + + // Real mouse clicks carry `detail: 1`. The drag's pointerdown was + // observed while open, so the gesture's own click passes the + // press-observed guard and is consumed by the one-shot drag + // suppression, exactly once. + fireEvent.click(document.body, { detail: 1 }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // The next press-backed mouse click closes. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('mouse click whose press started before open does not close', async () => { + render(); + + // Simulates the trailing click the browser fires after a press-drag-release + // gesture that began before the floating element opened (e.g. a menu item + // activated by drag-release opening a dialog): no pointerdown/mousedown + // was observed while open, and the gesture's click lands on the common + // ancestor of the mousedown and mouseup targets. + fireEvent.click(document.body, { detail: 1 }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // A press observed while open still closes. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('compatibility events whose pointerdown opened the floating element do not count as a new press', async () => { + function OpenOnPointerDownApp() { + const [open, setOpen] = React.useState(false); + const { refs, context } = useFloating({ open, onOpenChange: setOpen }); + const { getFloatingProps } = useTestInteractions([ + useDismiss(context, { outsidePressEvent: 'intentional' }), + ]); + + return ( + + setOpen(true)}>Open + {open && } + + ); + } + + render(); + + const openButton = screen.getByRole('button', { name: 'Open' }); + fireEvent.pointerDown(openButton, { pointerType: 'mouse' }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // The pointerdown happened before the floating element opened. Its + // compatibility events arrive after opening but belong to the same press. + fireEvent.mouseDown(openButton); + fireEvent.mouseUp(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // A new pointer press that begins while open still dismisses. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('keyboard-generated outside click without a prior press closes', async () => { + render(); + + // Keyboard activations produce `detail: 0` clicks with no press. + fireEvent.click(document.body, { detail: 0 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('press seen in a previous open session does not leak into a reopen', async () => { + function ReopenApp() { + const [open, setOpen] = React.useState(true); + const { refs, context } = useFloating({ open, onOpenChange: setOpen }); + const { getReferenceProps, getFloatingProps } = useTestInteractions([ + useDismiss(context, { outsidePressEvent: 'intentional' }), + ]); + + return ( + + setOpen(true) })} + /> + {open && } + + ); + } + + render(); + + // A genuine outside press closes and leaves a press on record. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // The reopened session must not inherit the previous session's press: + // a press-less trailing click still must not count as an outside press. + fireEvent.click(document.body, { detail: 1 }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // A press observed in the new session still closes. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('press seen before a same-batch close and reopen does not leak into the new session', async () => { + let context!: ReturnType['context']; + + function BatchReopenApp() { + const [open, setOpen] = React.useState(true); + const floating = useFloating({ open, onOpenChange: setOpen }); + context = floating.context; + const { getReferenceProps, getFloatingProps } = useTestInteractions([ + useDismiss(floating.context, { outsidePressEvent: 'intentional' }), + ]); + + return ( + + + {open && ( + + )} + + ); + } + + render(); + + // A press lands while the first session is open. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + + // Close and reopen in one batch: React never renders `open === false`, + // so only the store's `openchange` events can observe the session + // boundary. + act(() => { + context.rootStore.setOpen(false, createChangeEventDetails(REASONS.none)); + context.rootStore.setOpen(true, createChangeEventDetails(REASONS.none)); + }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // The gesture's trailing click belongs to the previous session and must + // not dismiss the reopened floating element. + fireEvent.click(document.body, { detail: 1 }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // A press observed in the new session still closes. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('press survives listener re-attachment while open', async () => { + const { rerender } = render(); + + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.mouseDown(document.body); + + // Changing a listener effect dependency mid-gesture detaches and + // re-attaches the document listeners. The press observed during the + // current gesture must survive the re-run, so the gesture's click can + // still dismiss. + rerender(); + + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('pointerdown-only press while open allows the outside click to close', async () => { + render(); + + // Pointer-event browsers may deliver `pointerdown` without a compat + // `mousedown`; it must count as an observed press on its own. + fireEvent.pointerDown(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('non-primary-button press does not count as an outside press', async () => { + render(); + + // A right-button press produces `contextmenu`, not `click`, so it must + // not vouch for a later press-less click. + fireEvent.pointerDown(document.body, { pointerType: 'mouse', button: 2 }); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // A primary-button press still closes. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + test('cancelled press does not count as an outside press', async () => { + render(); + + // A press whose gesture is cancelled produces no click, so it must not + // vouch for a later press-less click. + fireEvent.pointerDown(document.body, { pointerType: 'touch' }); + fireEvent.pointerCancel(document.body); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + // A completed press still closes. + fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); + fireEvent.click(document.body, { detail: 1 }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + test('inside click then programmatic outside click closes', async () => { render(); const insideInput = screen.getByRole('textbox'); diff --git a/packages/react/src/floating-ui-react/hooks/useDismiss.ts b/packages/react/src/floating-ui-react/hooks/useDismiss.ts index 2037b39c096..f88ea5560f7 100644 --- a/packages/react/src/floating-ui-react/hooks/useDismiss.ts +++ b/packages/react/src/floating-ui-react/hooks/useDismiss.ts @@ -22,7 +22,7 @@ import { createChangeEventDetails } from '../../internals/createBaseUIEventDetai import { REASONS } from '../../internals/reasons'; import { createAttribute } from '../utils/createAttribute'; import { contains, getTarget, isEventTargetWithin, isRootElement } from '../utils/element'; -import { isReactEvent } from '../utils/event'; +import { isReactEvent, isVirtualClick } from '../utils/event'; import { getNodeChildren } from '../utils/nodes'; type PressType = 'intentional' | 'sloppy'; @@ -79,7 +79,7 @@ export interface UseDismissProps { outsidePress?: boolean | ((event: MouseEvent | TouchEvent) => boolean) | undefined; /** * The type of event to use to determine an outside "press". - * - `intentional` requires the user to click outside intentionally, firing on `pointerup` for mouse, and requiring minimal `touchmove`s for touch. + * - `intentional` dismisses on an outside `click` whose press was observed while the floating element was open; a trailing click from a press that began before it opened (such as the drag-release gesture that opened it) is ignored. Touch requires minimal `touchmove`s. Virtual clicks — keyboard and assistive technology activations as well as programmatic `element.click()` — are accepted without a press. * - `sloppy` fires on `pointerdown` for mouse, while for touch it fires on `touchend` (within 1 second) or while scrolling away after `touchstart`. */ outsidePressEvent?: @@ -132,7 +132,7 @@ export function useDismiss( const open = store.useState('open'); const floatingElement = store.useState('floatingElement'); - const { dataRef } = store.context; + const { dataRef, events } = store.context; const tree = useFloatingTree(externalTree); const outsidePressFn = useStableCallback( @@ -148,6 +148,12 @@ export function useDismiss( const pressStartPreventedRef = React.useRef(false); // Ignore only the very next outside click after dragging from inside to outside. const suppressNextOutsideClickRef = React.useRef(false); + // Whether a press has started (anywhere) while the floating element was open. + // A `click` whose press began before the floating element opened must not count + // as an outside press: e.g. when a menu item activated by a press-drag-release + // gesture opens a dialog, the browser fires the gesture's click on the common + // ancestor of the mousedown and mouseup targets after the dialog is open. + const sawPressWhileOpenRef = React.useRef(false); const isComposingRef = React.useRef(false); const currentPointerTypeRef = React.useRef(''); @@ -272,8 +278,35 @@ export function useDismiss( }, ); + // A synchronous close+reopen pair (`setOpen(false); setOpen(true)`) never + // renders `open === false`, so the effect below cannot observe that session + // boundary. Every open change dispatched through the store emits + // `openchange` synchronously, including both halves of a same-batch pair, + // so the press latch is reset there as well. Controlled `open` flips that + // bypass the store entirely are only observable when they render, which the + // effect below covers. + React.useEffect(() => { + function handleOpenChange() { + sawPressWhileOpenRef.current = false; + } + + events.on('openchange', handleOpenChange); + return () => { + events.off('openchange', handleOpenChange); + }; + }, [events]); + React.useEffect(() => { if (!open || !enabled) { + // Reset per open session, in the effect body rather than the cleanup: the + // cleanup also runs when other dependencies change while the floating + // element stays open, which must not erase a press observed during the + // current gesture. The ref cannot change while closed (the listeners + // below are detached), so resetting on close is equivalent to resetting + // on open. + if (!open) { + sawPressWhileOpenRef.current = false; + } return clearInsideReactTree; } @@ -282,6 +315,7 @@ export function useDismiss( const compositionTimeout = new Timeout(); const preventedPressSuppressionTimeout = new Timeout(); + const doc = ownerDocument(floatingElement); function handleCompositionStart() { compositionTimeout.clear(); @@ -453,13 +487,26 @@ export function useDismiss( return; } - // In intentional mode, a press that starts inside and ends outside gets - // one suppressed outside click. Run this after inside-target checks so - // inside clicks don't consume the one-shot suppression. - if (getOutsidePressEvent() === 'intentional' && suppressNextOutsideClickRef.current) { - preventedPressSuppressionTimeout.clear(); - suppressNextOutsideClickRef.current = false; - return; + // Only `click` events reach this point in intentional mode: + // `shouldIgnoreEvent` drops other event types in it, as well as clicks + // in sloppy mode. + if (getOutsidePressEvent() === 'intentional') { + // Only a click whose press began while the floating element was open + // counts as an outside press. Virtual clicks — keyboard and assistive + // technology activations as well as programmatic `element.click()` — + // have no press and are still accepted. + if (!isVirtualClick(event as MouseEvent) && !sawPressWhileOpenRef.current) { + return; + } + + // A press that starts inside and ends outside gets one suppressed + // outside click. Run this after inside-target checks so inside clicks + // don't consume the one-shot suppression. + if (suppressNextOutsideClickRef.current) { + preventedPressSuppressionTimeout.clear(); + suppressNextOutsideClickRef.current = false; + return; + } } if (typeof outsidePress === 'function' && !outsidePress(event)) { @@ -541,7 +588,18 @@ export function useDismiss( function closeOnPressOutsideCapture(event: PointerEvent | MouseEvent) { cancelDismissOnEndTimeout.clear(); + // This handler only receives `click`, `pointerdown`, and `mousedown`. + // Only `pointerdown` marks a press: `mousedown` is a compatibility event + // that follows `pointerdown`, so if the pointerdown opened the floating + // element, attributing its mousedown to the new open session would make + // the gesture's trailing click look like a new outside press. if (event.type === 'pointerdown') { + // Only a primary-button press can produce a `click`; a right- or + // middle-button press must not vouch for a later click it did not + // generate. + if (event.button === 0) { + sawPressWhileOpenRef.current = true; + } currentPointerTypeRef.current = (event as PointerEvent).pointerType; } @@ -563,6 +621,14 @@ export function useDismiss( } function handlePressEndCapture(event: PointerEvent | MouseEvent) { + // A cancelled gesture produces no click, so its press can no longer + // vouch for one; drop it so a later press-less click is not attributed + // to it. The latch is not cleared on `pointerup`: the gesture's click + // fires after it and must still find the press on record. + if (event.type === 'pointercancel') { + sawPressWhileOpenRef.current = false; + } + if (!pressStartedInsideRef.current) { return; } @@ -658,7 +724,6 @@ export function useDismiss( addTargetEventListenerOnce(event, handleTouchEnd); } - const doc = ownerDocument(floatingElement); const unsubscribe = mergeCleanups( escapeKey && mergeCleanups( diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx index 8c8ea4aea7e..c23d97507d8 100644 --- a/packages/react/src/menu/root/MenuRoot.test.tsx +++ b/packages/react/src/menu/root/MenuRoot.test.tsx @@ -1,4 +1,5 @@ import { expect, vi } from 'vitest'; +import type { CDPSession } from '@vitest/browser-playwright'; import * as React from 'react'; import { act, @@ -13,6 +14,7 @@ import { useRefWithInit } from '@base-ui/utils/useRefWithInit'; import { Menu } from '@base-ui/react/menu'; import { Dialog } from '@base-ui/react/dialog'; import { AlertDialog } from '@base-ui/react/alert-dialog'; +import { platform } from '@base-ui/utils/platform'; import userEvent from '@testing-library/user-event'; import { createRenderer, @@ -2743,6 +2745,138 @@ describe('', () => { expect(menuItem).toHaveAttribute('tabindex', '-1'); }); + describe.skipIf(isJSDOM || !platform.engine.blink)('opening a dialog from an item', () => { + it('keeps the dialog open after a press-drag-release activation', async () => { + ignoreActWarnings(); + const { cdp } = await import('vitest/browser'); + const dialogOpenChangeSpy = vi.fn(); + const documentClicks: MouseEvent[] = []; + + function App() { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + + + Open menu + {/* `keepMounted` keeps the released item connected, like a real + closing transition does: the browser only synthesizes the + gesture's click on the common ancestor when the release + target is still in the DOM. */} + + + + setDialogOpen(true)}>Open dialog + + + + + { + dialogOpenChangeSpy(nextOpen, eventDetails.reason); + setDialogOpen(nextOpen); + }} + > + + + Dialog + + + + ); + } + + await render(); + + const trigger = screen.getByRole('button', { name: 'Open menu' }); + const frame = window.frameElement as HTMLIFrameElement | null; + const frameRect = frame?.getBoundingClientRect(); + const frameOffset = { + x: (frameRect?.left ?? 0) + (frame?.clientLeft ?? 0), + y: (frameRect?.top ?? 0) + (frame?.clientTop ?? 0), + }; + + function centerOf(element: Element) { + const rect = element.getBoundingClientRect(); + return { + x: frameOffset.x + rect.left + rect.width / 2, + y: frameOffset.y + rect.top + rect.height / 2, + }; + } + + const session = cdp() as CDPSession; + + function recordClick(event: MouseEvent) { + documentClicks.push(event); + } + + document.addEventListener('click', recordClick, true); + + try { + const triggerCenter = centerOf(trigger); + await act(async () => { + await session.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + ...triggerCenter, + }); + await session.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + ...triggerCenter, + button: 'left', + buttons: 1, + clickCount: 1, + }); + }); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBe(null); + }); + + // Exceed the impatient-click threshold so releasing over the item + // activates it instead of being treated as part of a quick click. + await wait(200); + + const item = screen.getByRole('menuitem', { name: 'Open dialog' }); + const itemCenter = centerOf(item); + await act(async () => { + await session.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + ...itemCenter, + buttons: 1, + }); + await session.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + ...itemCenter, + button: 'left', + buttons: 0, + clickCount: 1, + }); + }); + + // The item activates on release: the menu closes and the dialog opens. + await waitFor(() => { + expect(screen.queryByRole('menu')).toBe(null); + }); + await waitFor(() => { + expect(screen.queryByTestId('dialog-popup')).not.toBe(null); + }); + + // The browser fires the gesture's native click on the common ancestor + // of the press and release targets after the dialog is open. It must + // not be treated as an intentional outside press on the dialog. + await waitFor(() => { + expect(documentClicks.some((event) => event.isTrusted)).toBe(true); + }); + + expect(screen.queryByTestId('dialog-popup')).not.toBe(null); + expect(dialogOpenChangeSpy).not.toHaveBeenCalledWith(false, REASONS.outsidePress); + } finally { + document.removeEventListener('click', recordClick, true); + } + }); + }); + describe('prop: highlightItemOnHover', () => { it('highlights an item on mouse move by default', async () => { await render(