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 ( + + + { + 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 ( + + + {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 ( + +