From ee92ccf79a4ae372c2e53b42df0875ce4ef92c39 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Sun, 7 Jun 2026 16:22:35 -0700 Subject: [PATCH 1/9] select: fix typeahead search focusing an element that no longer exists Closes #3133 --- .changeset/slow-shirts-turn.md | 6 ++++++ packages/react/select/src/select.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/slow-shirts-turn.md diff --git a/.changeset/slow-shirts-turn.md b/.changeset/slow-shirts-turn.md new file mode 100644 index 0000000000..2c70bbeea5 --- /dev/null +++ b/.changeset/slow-shirts-turn.md @@ -0,0 +1,6 @@ +--- +"@radix-ui/react-select": patch +"radix-ui": patch +--- + +Fixed a bug where typeahead search resulted in focusing an element that no longer exists diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 2ddd96e26d..4cdacc5ac9 100644 --- a/packages/react/select/src/select.tsx +++ b/packages/react/select/src/select.tsx @@ -774,7 +774,7 @@ const SelectContentImpl = React.forwardRef (nextItem.ref.current as HTMLElement).focus()); + setTimeout(() => nextItem.ref.current?.focus()); } }); From 818ae0dedbee3e62d873a27afbdba343c054e0bf Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 8 Jun 2026 03:15:09 +0300 Subject: [PATCH 2/9] toggle-group: Use `radiogroup` role for single value group (#3189) --- .yarn/versions/fa6477bc.yml | 7 +++++++ packages/react/toggle-group/src/toggle-group.test.tsx | 8 ++++++++ packages/react/toggle-group/src/toggle-group.tsx | 6 +++--- 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .yarn/versions/fa6477bc.yml diff --git a/.yarn/versions/fa6477bc.yml b/.yarn/versions/fa6477bc.yml new file mode 100644 index 0000000000..e18060a985 --- /dev/null +++ b/.yarn/versions/fa6477bc.yml @@ -0,0 +1,7 @@ +releases: + "@radix-ui/react-toggle-group": patch + "@radix-ui/react-toolbar": patch + radix-ui: patch + +declined: + - primitives diff --git a/packages/react/toggle-group/src/toggle-group.test.tsx b/packages/react/toggle-group/src/toggle-group.test.tsx index 03aee21408..dbab94ceca 100644 --- a/packages/react/toggle-group/src/toggle-group.test.tsx +++ b/packages/react/toggle-group/src/toggle-group.test.tsx @@ -34,6 +34,10 @@ describe('given a single ToggleGroup', () => { expect(await axe(rendered.container)).toHaveNoViolations(); }); + it('should have radiogroup role', () => { + expect(rendered.getByRole('radiogroup')).toBeInTheDocument(); + }); + it('should change value to `One`', () => { expect(handleValueChange).toHaveBeenCalledWith('One'); }); @@ -79,6 +83,10 @@ describe('given a multiple ToggleGroup', () => { expect(await axe(rendered.container)).toHaveNoViolations(); }); + it('should have group role', () => { + expect(rendered.getByRole('group')).toBeInTheDocument(); + }); + describe('when clicking `One`', () => { beforeEach(() => { fireEvent.click(one); diff --git a/packages/react/toggle-group/src/toggle-group.tsx b/packages/react/toggle-group/src/toggle-group.tsx index 74b4901253..531ed26422 100644 --- a/packages/react/toggle-group/src/toggle-group.tsx +++ b/packages/react/toggle-group/src/toggle-group.tsx @@ -37,12 +37,12 @@ const ToggleGroup = React.forwardRef< if (type === 'single') { const singleProps = toggleGroupProps as ToggleGroupImplSingleProps; - return ; + return ; } if (type === 'multiple') { const multipleProps = toggleGroupProps as ToggleGroupImplMultipleProps; - return ; + return ; } throw new Error(`Missing prop \`type\` expected on \`${TOGGLE_GROUP_NAME}\``); @@ -210,7 +210,7 @@ const ToggleGroupImpl = React.forwardRef {rovingFocus ? ( From 14cf55f965c7b161f972f7f8e703b694ded42163 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Sun, 7 Jun 2026 17:23:07 -0700 Subject: [PATCH 3/9] update toggle-group roles --- .changeset/kind-hornets-fly.md | 6 ++++++ .yarn/versions/fa6477bc.yml | 7 ------- packages/react/toggle-group/src/toggle-group.test.tsx | 4 ++-- packages/react/toggle-group/src/toggle-group.tsx | 9 +++------ 4 files changed, 11 insertions(+), 15 deletions(-) create mode 100644 .changeset/kind-hornets-fly.md delete mode 100644 .yarn/versions/fa6477bc.yml diff --git a/.changeset/kind-hornets-fly.md b/.changeset/kind-hornets-fly.md new file mode 100644 index 0000000000..935d8bdb0a --- /dev/null +++ b/.changeset/kind-hornets-fly.md @@ -0,0 +1,6 @@ +--- +"@radix-ui/react-toggle-group": patch +"radix-ui": patch +--- + +Updated single-select and multi-select toggle groups to use the `radiogroup` and `toolbar` roles, respectively. diff --git a/.yarn/versions/fa6477bc.yml b/.yarn/versions/fa6477bc.yml deleted file mode 100644 index e18060a985..0000000000 --- a/.yarn/versions/fa6477bc.yml +++ /dev/null @@ -1,7 +0,0 @@ -releases: - "@radix-ui/react-toggle-group": patch - "@radix-ui/react-toolbar": patch - radix-ui: patch - -declined: - - primitives diff --git a/packages/react/toggle-group/src/toggle-group.test.tsx b/packages/react/toggle-group/src/toggle-group.test.tsx index dbab94ceca..3998ea691a 100644 --- a/packages/react/toggle-group/src/toggle-group.test.tsx +++ b/packages/react/toggle-group/src/toggle-group.test.tsx @@ -83,8 +83,8 @@ describe('given a multiple ToggleGroup', () => { expect(await axe(rendered.container)).toHaveNoViolations(); }); - it('should have group role', () => { - expect(rendered.getByRole('group')).toBeInTheDocument(); + it('should have toolbar role', () => { + expect(rendered.getByRole('toolbar')).toBeInTheDocument(); }); describe('when clicking `One`', () => { diff --git a/packages/react/toggle-group/src/toggle-group.tsx b/packages/react/toggle-group/src/toggle-group.tsx index 531ed26422..184add3668 100644 --- a/packages/react/toggle-group/src/toggle-group.tsx +++ b/packages/react/toggle-group/src/toggle-group.tsx @@ -42,7 +42,7 @@ const ToggleGroup = React.forwardRef< if (type === 'multiple') { const multipleProps = toggleGroupProps as ToggleGroupImplMultipleProps; - return ; + return ; } throw new Error(`Missing prop \`type\` expected on \`${TOGGLE_GROUP_NAME}\``); @@ -303,16 +303,13 @@ const ToggleGroupItemImpl = React.forwardRef Date: Sun, 7 Jun 2026 17:55:20 -0700 Subject: [PATCH 4/9] slot: Add type param to `createSlot` and `SlotProps` (#3948) --- .changeset/chilly-squids-buy.md | 10 ++++++++++ packages/react/slot/src/slot.tsx | 11 +++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .changeset/chilly-squids-buy.md diff --git a/.changeset/chilly-squids-buy.md b/.changeset/chilly-squids-buy.md new file mode 100644 index 0000000000..fc4556f744 --- /dev/null +++ b/.changeset/chilly-squids-buy.md @@ -0,0 +1,10 @@ +--- +"@radix-ui/react-slot": minor +"radix-ui": minor +--- + +Added generic type arguments for `SlotProps` and `createSlot` to specify the type of element a slot should render, as well as its props. + +```tsx +const Slot = createSlot('Slot'); +``` diff --git a/packages/react/slot/src/slot.tsx b/packages/react/slot/src/slot.tsx index 739a2a2fc3..87fefe1baf 100644 --- a/packages/react/slot/src/slot.tsx +++ b/packages/react/slot/src/slot.tsx @@ -13,12 +13,15 @@ declare module 'react' { export type Usable = PromiseLike | React.Context; -interface SlotProps extends React.HTMLAttributes { +type SlotProps> = Props & { children?: React.ReactNode; -} +}; -/* @__NO_SIDE_EFFECTS__ */ export function createSlot(ownerName: string) { - const Slot = React.forwardRef((props, forwardedRef) => { +/* @__NO_SIDE_EFFECTS__ */ export function createSlot< + Elem extends Element = HTMLElement, + Props = React.HTMLAttributes, +>(ownerName: string) { + const Slot = React.forwardRef>((props, forwardedRef) => { let { children, ...slotProps } = props; let slottableElement: React.ReactElement | null = null; let hasSlottable = false; From 9cec9c0c428851e822659a5dcd3b972fbd4c6bca Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Sun, 7 Jun 2026 17:55:45 -0700 Subject: [PATCH 5/9] password-toggle-field: rename misspelled prop (#3944) --- .changeset/big-candles-poke.md | 6 ++++++ apps/storybook/stories/password-toggle-field.stories.tsx | 4 ++-- .../password-toggle-field/src/password-toggle-field.tsx | 6 +++--- 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 .changeset/big-candles-poke.md diff --git a/.changeset/big-candles-poke.md b/.changeset/big-candles-poke.md new file mode 100644 index 0000000000..12491dadb3 --- /dev/null +++ b/.changeset/big-candles-poke.md @@ -0,0 +1,6 @@ +--- +"@radix-ui/react-password-toggle-field": minor +"radix-ui": minor +--- + +Renamed misspelled `onVisiblityChange` prop to `onVisibilityChange` diff --git a/apps/storybook/stories/password-toggle-field.stories.tsx b/apps/storybook/stories/password-toggle-field.stories.tsx index b2913e8138..869a2acaac 100644 --- a/apps/storybook/stories/password-toggle-field.stories.tsx +++ b/apps/storybook/stories/password-toggle-field.stories.tsx @@ -54,7 +54,7 @@ export const Controlled = { updateArgs({ visible })} + onVisibilityChange={(visible) => updateArgs({ visible })} >
@@ -96,7 +96,7 @@ export const InsideForm = { > updateArgs({ visible })} + onVisibilityChange={(visible) => updateArgs({ visible })} {...args} >
diff --git a/packages/react/password-toggle-field/src/password-toggle-field.tsx b/packages/react/password-toggle-field/src/password-toggle-field.tsx index c9be5ab89b..812cfc858e 100644 --- a/packages/react/password-toggle-field/src/password-toggle-field.tsx +++ b/packages/react/password-toggle-field/src/password-toggle-field.tsx @@ -45,7 +45,7 @@ interface PasswordToggleFieldProps { id?: string; visible?: boolean; defaultVisible?: boolean; - onVisiblityChange?: (visible: boolean) => void; + onVisibilityChange?: (visible: boolean) => void; children?: React.ReactNode; } @@ -69,12 +69,12 @@ const PasswordToggleField: React.FC = ({ [], ); - const { visible: visibleProp, defaultVisible, onVisiblityChange, children } = props; + const { visible: visibleProp, defaultVisible, onVisibilityChange, children } = props; const [visible = false, setVisible] = useControllableState({ caller: PASSWORD_TOGGLE_FIELD_NAME, prop: visibleProp, defaultProp: defaultVisible ?? false, - onChange: onVisiblityChange, + onChange: onVisibilityChange, }); const inputRef = React.useRef(null); From 9ed706f5131359e0eea7a790eb802b72c3c7a718 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Sun, 7 Jun 2026 18:25:04 -0700 Subject: [PATCH 6/9] dialog: remove title/description warnings (#3946) --- .changeset/open-doodles-remain.md | 7 ++ packages/react/alert-dialog/package.json | 3 +- .../react/alert-dialog/src/alert-dialog.tsx | 73 +++----------- packages/react/dialog/src/dialog.test.tsx | 55 ----------- packages/react/dialog/src/dialog.tsx | 99 ++++--------------- packages/react/dialog/src/index.ts | 2 +- pnpm-lock.yaml | 3 - 7 files changed, 46 insertions(+), 196 deletions(-) create mode 100644 .changeset/open-doodles-remain.md diff --git a/.changeset/open-doodles-remain.md b/.changeset/open-doodles-remain.md new file mode 100644 index 0000000000..f1ed4c2690 --- /dev/null +++ b/.changeset/open-doodles-remain.md @@ -0,0 +1,7 @@ +--- +"@radix-ui/react-alert-dialog": patch +"@radix-ui/react-dialog": patch +"radix-ui": patch +--- + +Removed dev-only warnings for dialogs when title and/or description is not rendered. diff --git a/packages/react/alert-dialog/package.json b/packages/react/alert-dialog/package.json index c266a92fbd..99e6c3e340 100644 --- a/packages/react/alert-dialog/package.json +++ b/packages/react/alert-dialog/package.json @@ -39,8 +39,7 @@ "@radix-ui/react-compose-refs": "workspace:*", "@radix-ui/react-context": "workspace:*", "@radix-ui/react-dialog": "workspace:*", - "@radix-ui/react-primitive": "workspace:*", - "@radix-ui/react-slot": "workspace:*" + "@radix-ui/react-primitive": "workspace:*" }, "devDependencies": { "@repo/builder": "workspace:*", diff --git a/packages/react/alert-dialog/src/alert-dialog.tsx b/packages/react/alert-dialog/src/alert-dialog.tsx index af74b37731..ad57cc1874 100644 --- a/packages/react/alert-dialog/src/alert-dialog.tsx +++ b/packages/react/alert-dialog/src/alert-dialog.tsx @@ -4,7 +4,6 @@ import { useComposedRefs } from '@radix-ui/react-compose-refs'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { createDialogScope } from '@radix-ui/react-dialog'; import { composeEventHandlers } from '@radix-ui/primitive'; -import { createSlottable } from '@radix-ui/react-slot'; import type { Scope } from '@radix-ui/react-context'; @@ -109,8 +108,6 @@ interface AlertDialogContentProps extends Omit< 'onPointerDownOutside' | 'onInteractOutside' > {} -const Slottable = createSlottable('AlertDialogContent'); - const AlertDialogContent = React.forwardRef( (props: ScopedProps, forwardedRef) => { const { __scopeAlertDialog, children, ...contentProps } = props; @@ -120,37 +117,22 @@ const AlertDialogContent = React.forwardRef(null); return ( - - - { - event.preventDefault(); - cancelRef.current?.focus({ preventScroll: true }); - })} - onPointerDownOutside={(event) => event.preventDefault()} - onInteractOutside={(event) => event.preventDefault()} - > - {/** - * We have to use `Slottable` here as we cannot wrap the `AlertDialogContentProvider` - * around everything, otherwise the `DescriptionWarning` would be rendered straight away. - * This is because we want the accessibility checks to run only once the content is actually - * open and that behaviour is already encapsulated in `DialogContent`. - */} - {children} - {process.env.NODE_ENV === 'development' && ( - - )} - - - + + { + event.preventDefault(); + cancelRef.current?.focus({ preventScroll: true }); + })} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + {children} + + ); }, ); @@ -241,29 +223,6 @@ AlertDialogCancel.displayName = CANCEL_NAME; /* ---------------------------------------------------------------------------------------------- */ -type DescriptionWarningProps = { - contentRef: React.RefObject; -}; - -const DescriptionWarning: React.FC = ({ contentRef }) => { - const MESSAGE = `\`${CONTENT_NAME}\` requires a description for the component to be accessible for screen reader users. - -You can add a description to the \`${CONTENT_NAME}\` by passing a \`${DESCRIPTION_NAME}\` component as a child, which also benefits sighted users by adding visible context to the dialog. - -Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${CONTENT_NAME}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component. - -For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`; - - React.useEffect(() => { - const hasDescription = document.getElementById( - contentRef.current?.getAttribute('aria-describedby')!, - ); - if (!hasDescription) console.warn(MESSAGE); - }, [MESSAGE, contentRef]); - - return null; -}; - const Root = AlertDialog; const Trigger = AlertDialogTrigger; const Portal = AlertDialogPortal; diff --git a/packages/react/dialog/src/dialog.test.tsx b/packages/react/dialog/src/dialog.test.tsx index e3bcbf9094..b17ced77a4 100644 --- a/packages/react/dialog/src/dialog.test.tsx +++ b/packages/react/dialog/src/dialog.test.tsx @@ -10,27 +10,6 @@ const OPEN_TEXT = 'Open'; const CLOSE_TEXT = 'Close'; const TITLE_TEXT = 'Title'; -const NoLabelDialogTest = (props: React.ComponentProps) => ( - - {OPEN_TEXT} - - - {CLOSE_TEXT} - - -); - -const UndefinedDescribedByDialog = (props: React.ComponentProps) => ( - - {OPEN_TEXT} - - - {TITLE_TEXT} - {CLOSE_TEXT} - - -); - const DialogTest = (props: React.ComponentProps) => ( {OPEN_TEXT} @@ -42,10 +21,6 @@ const DialogTest = (props: React.ComponentProps) => ( ); -function renderAndClickDialogTrigger(Dialog: any) { - fireEvent.click(render(Dialog).getByText(OPEN_TEXT)); -} - describe('given a default Dialog', () => { let rendered: RenderResult; let trigger: HTMLElement; @@ -86,36 +61,6 @@ describe('given a default Dialog', () => { closeButton = rendered.getByText(CLOSE_TEXT); }); - describe('when no description has been provided', () => { - it('should warn to the console', () => { - expect(consoleWarnMockFunction).toHaveBeenCalledTimes(1); - }); - }); - - describe('when no title has been provided', () => { - beforeEach(() => { - cleanup(); - }); - it('should display an error in the console', () => { - consoleErrorMockFunction.mockClear(); - - renderAndClickDialogTrigger(); - expect(consoleErrorMockFunction).toHaveBeenCalled(); - }); - }); - - describe('when aria-describedby is set to undefined', () => { - beforeEach(() => { - cleanup(); - }); - it('should not warn to the console', () => { - consoleWarnMockFunction.mockClear(); - - renderAndClickDialogTrigger(); - expect(consoleWarnMockFunction).not.toHaveBeenCalled(); - }); - }); - it('should open the content', () => { expect(closeButton).toBeVisible(); }); diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index 7bd2784b93..bb86ed14ea 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { composeEventHandlers } from '@radix-ui/primitive'; import { useComposedRefs } from '@radix-ui/react-compose-refs'; -import { createContext, createContextScope } from '@radix-ui/react-context'; +import { createContextScope } from '@radix-ui/react-context'; import { useId } from '@radix-ui/react-id'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { DismissableLayer } from '@radix-ui/react-dismissable-layer'; @@ -414,12 +414,6 @@ const DialogContentImpl = React.forwardRef context.onOpenChange(false)} /> - {process.env.NODE_ENV !== 'production' && ( - <> - - - - )} ); }, @@ -491,73 +485,24 @@ const DialogClose = React.forwardRef( DialogClose.displayName = CLOSE_NAME; +/** @deprecated Noop component to avoid breaking changes. */ +export const WarningProvider: React.FC< + ScopedProps<{ + children?: React.ReactNode; + contentName: string; + titleName: string; + docsSlug: 'dialog'; + }> +> = (props) => { + return props.children; +}; + /* -----------------------------------------------------------------------------------------------*/ function getState(open: boolean) { return open ? 'open' : 'closed'; } -const TITLE_WARNING_NAME = 'DialogTitleWarning'; - -const [WarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, { - contentName: CONTENT_NAME, - titleName: TITLE_NAME, - docsSlug: 'dialog', -}); - -type TitleWarningProps = { titleId?: string }; - -const TitleWarning: React.FC = ({ titleId }) => { - const titleWarningContext = useWarningContext(TITLE_WARNING_NAME); - - const MESSAGE = `\`${titleWarningContext.contentName}\` requires a \`${titleWarningContext.titleName}\` for the component to be accessible for screen reader users. - -If you want to hide the \`${titleWarningContext.titleName}\`, you can wrap it with our VisuallyHidden component. - -For more information, see https://radix-ui.com/primitives/docs/components/${titleWarningContext.docsSlug}`; - - React.useEffect(() => { - if (titleId) { - const hasTitle = document.getElementById(titleId); - if (!hasTitle) console.error(MESSAGE); - } - }, [MESSAGE, titleId]); - - return null; -}; - -const DESCRIPTION_WARNING_NAME = 'DialogDescriptionWarning'; - -type DescriptionWarningProps = { - contentRef: React.RefObject; - descriptionId?: string; -}; - -const DescriptionWarning: React.FC = ({ contentRef, descriptionId }) => { - const descriptionWarningContext = useWarningContext(DESCRIPTION_WARNING_NAME); - const MESSAGE = `Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${descriptionWarningContext.contentName}}.`; - - React.useEffect(() => { - const describedById = contentRef.current?.getAttribute('aria-describedby'); - // if we have an id and the user hasn't set aria-describedby={undefined} - if (descriptionId && describedById) { - const hasDescription = document.getElementById(descriptionId); - if (!hasDescription) console.warn(MESSAGE); - } - }, [MESSAGE, contentRef, descriptionId]); - - return null; -}; - -const Root = Dialog; -const Trigger = DialogTrigger; -const Portal = DialogPortal; -const Overlay = DialogOverlay; -const Content = DialogContent; -const Title = DialogTitle; -const Description = DialogDescription; -const Close = DialogClose; - export { createDialogScope, // @@ -570,16 +515,14 @@ export { DialogDescription, DialogClose, // - Root, - Trigger, - Portal, - Overlay, - Content, - Title, - Description, - Close, - // - WarningProvider, + Dialog as Root, + DialogTrigger as Trigger, + DialogPortal as Portal, + DialogOverlay as Overlay, + DialogContent as Content, + DialogTitle as Title, + DialogDescription as Description, + DialogClose as Close, }; export type { DialogProps, diff --git a/packages/react/dialog/src/index.ts b/packages/react/dialog/src/index.ts index 099620a368..5c4660f595 100644 --- a/packages/react/dialog/src/index.ts +++ b/packages/react/dialog/src/index.ts @@ -19,7 +19,7 @@ export { Title, Description, Close, - // + /** @deprecated Noop component to avoid breaking changes. */ WarningProvider, } from './dialog'; export type { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5715fa5193..3143dd54d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,9 +357,6 @@ importers: '@radix-ui/react-primitive': specifier: workspace:* version: link:../primitive - '@radix-ui/react-slot': - specifier: workspace:* - version: link:../slot devDependencies: '@repo/builder': specifier: workspace:* From 7bc7f81007778e04ccd196e5d8677c0f54a07d97 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Sun, 7 Jun 2026 18:25:46 -0700 Subject: [PATCH 7/9] menu: close menus when window loses focus (#3938) --- .changeset/six-worlds-roll.md | 9 ++++ .../dropdown-menu/src/dropdown-menu.test.tsx | 46 ++++++++++++++++++- packages/react/menu/src/menu.tsx | 13 ++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .changeset/six-worlds-roll.md diff --git a/.changeset/six-worlds-roll.md b/.changeset/six-worlds-roll.md new file mode 100644 index 0000000000..18fe999886 --- /dev/null +++ b/.changeset/six-worlds-roll.md @@ -0,0 +1,9 @@ +--- +'@radix-ui/react-menu': patch +'@radix-ui/react-dropdown-menu': patch +'@radix-ui/react-context-menu': patch +'@radix-ui/react-menubar': patch +'radix-ui': patch +--- + +Fixed a bug where menus and submenus remained open after a window loses focus. diff --git a/packages/react/dropdown-menu/src/dropdown-menu.test.tsx b/packages/react/dropdown-menu/src/dropdown-menu.test.tsx index 5ec9608cec..c6ab867313 100644 --- a/packages/react/dropdown-menu/src/dropdown-menu.test.tsx +++ b/packages/react/dropdown-menu/src/dropdown-menu.test.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, it, expect } from 'vitest'; import * as DropdownMenu from './dropdown-menu'; const TRIGGER_TEXT = 'Open'; const ITEM_TEXT = 'Item'; +const SUB_TRIGGER_TEXT = 'Sub'; +const SUB_ITEM_TEXT = 'Sub Item'; const DropdownMenuTest = (props: React.ComponentProps) => ( @@ -17,6 +19,25 @@ const DropdownMenuTest = (props: React.ComponentProps) ); +const DropdownMenuWithSubTest = (props: React.ComponentProps) => ( + + {TRIGGER_TEXT} + + + {ITEM_TEXT} + + {SUB_TRIGGER_TEXT} + + + {SUB_ITEM_TEXT} + + + + + + +); + describe('aria-controls', () => { afterEach(cleanup); @@ -40,3 +61,26 @@ describe('aria-controls', () => { expect(document.getElementById(content.id)).toBe(content); }); }); + +describe('closing on window blur', () => { + afterEach(cleanup); + + it('should close the menu and any open submenus when the window loses focus', async () => { + render(); + const trigger = screen.getByText(TRIGGER_TEXT); + fireEvent.keyDown(trigger, { key: 'Enter' }); + + const subTrigger = await waitFor(() => screen.getByText(SUB_TRIGGER_TEXT)); + fireEvent.keyDown(subTrigger, { key: 'ArrowRight' }); + + await waitFor(() => expect(screen.getByText(SUB_ITEM_TEXT)).toBeInTheDocument()); + + act(() => { + window.dispatchEvent(new FocusEvent('blur')); + }); + + await waitFor(() => expect(screen.queryByText(SUB_ITEM_TEXT)).not.toBeInTheDocument()); + expect(screen.queryByText(ITEM_TEXT)).not.toBeInTheDocument(); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index c15dbd14dc..b6c4c9944a 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -109,6 +109,19 @@ const Menu: React.FC = (props: ScopedProps) => { }; }, []); + // Close the menu (and any open submenus) when the window loses focus, e.g. when + // switching to another browser tab or application. Without this, submenus would + // remain open when the page is re-focused, leaving the menu in an inconsistent state. + // See: https://github.com/radix-ui/primitives/issues/3257 + React.useEffect(() => { + if (!open) { + return; + } + const handleBlur = () => handleOpenChange(false); + window.addEventListener('blur', handleBlur); + return () => window.removeEventListener('blur', handleBlur); + }, [open, handleOpenChange]); + return ( Date: Sun, 7 Jun 2026 18:27:21 -0700 Subject: [PATCH 8/9] fix: dismissable layer intercepted outside interactions (#3928) --- .changeset/swift-bears-wave.md | 8 + .../stories/context-menu.stories.tsx | 21 ++ apps/storybook/stories/dialog.stories.tsx | 44 +++ .../stories/dropdown-menu.stories.tsx | 21 ++ apps/storybook/stories/external-overlay.tsx | 77 +++++ apps/storybook/stories/menubar.stories.tsx | 23 ++ apps/storybook/stories/popover.stories.tsx | 23 ++ apps/storybook/stories/select.stories.tsx | 45 +++ cypress/e2e/ContextMenu.cy.ts | 17 + cypress/e2e/Dialog.cy.ts | 30 ++ cypress/e2e/DropdownMenu.cy.ts | 17 + cypress/e2e/Menubar.cy.ts | 17 + cypress/e2e/Popover.cy.ts | 16 + cypress/e2e/Select.cy.ts | 17 + packages/react/dialog/src/dialog.tsx | 1 + .../src/dismissable-layer.test.tsx | 304 ++++++++++++++++++ .../src/dismissable-layer.tsx | 174 ++++++++-- packages/react/popover/src/popover.tsx | 1 + 18 files changed, 826 insertions(+), 30 deletions(-) create mode 100644 .changeset/swift-bears-wave.md create mode 100644 apps/storybook/stories/external-overlay.tsx create mode 100644 cypress/e2e/Popover.cy.ts create mode 100644 packages/react/dismissable-layer/src/dismissable-layer.test.tsx diff --git a/.changeset/swift-bears-wave.md b/.changeset/swift-bears-wave.md new file mode 100644 index 0000000000..06d35130ec --- /dev/null +++ b/.changeset/swift-bears-wave.md @@ -0,0 +1,8 @@ +--- +"@radix-ui/react-dialog": patch +"@radix-ui/react-dismissable-layer": patch +"@radix-ui/react-popover": patch +"radix-ui": patch +--- + +Fixed Dismissable Layer so outside interactions stopped by extension UI overlays do not dismiss dialogs or popovers diff --git a/apps/storybook/stories/context-menu.stories.tsx b/apps/storybook/stories/context-menu.stories.tsx index bb3a09e936..70475a281c 100644 --- a/apps/storybook/stories/context-menu.stories.tsx +++ b/apps/storybook/stories/context-menu.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { ContextMenu } from 'radix-ui'; import { foodGroups } from '@repo/test-data/foods'; import styles from './context-menu.stories.module.css'; +import { ExternalOverlayTrigger } from './external-overlay'; export default { title: 'Components/ContextMenu' }; @@ -473,6 +474,26 @@ export const Submenus = () => { ); }; +export const WithExtensionOverlay = () => { + const [open, setOpen] = React.useState(false); + + return ( +
+ + + Right Click Here + + + New Tab + New Window + + + +
{open ? 'open' : 'closed'}
+
+ ); +}; + export const WithLabels = () => (
diff --git a/apps/storybook/stories/dialog.stories.tsx b/apps/storybook/stories/dialog.stories.tsx index e6fb384a74..6b03ddef9a 100644 --- a/apps/storybook/stories/dialog.stories.tsx +++ b/apps/storybook/stories/dialog.stories.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Dialog } from 'radix-ui'; import styles from './dialog.stories.module.css'; +import { ExternalOverlayTrigger } from './external-overlay'; export default { title: 'Components/Dialog' }; @@ -536,3 +537,46 @@ export const Cypress = () => { ); }; + +export const WithExtensionOverlay = () => { + const [open, setOpen] = React.useState(false); + + return ( + <> + + open + + + + title + + Simulates extension UI interacting with a dialog. + + + close + + + + +
{open ? 'open' : 'closed'}
+ + ); +}; + +function InsideShadowSuggestion() { + const hostRef = React.useRef(null); + + React.useEffect(() => { + const host = hostRef.current; + if (!host || host.shadowRoot) return; + + const shadowRoot = host.attachShadow({ mode: 'open' }); + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'inside shadow suggestion'; + button.style.marginTop = '16px'; + shadowRoot.append(button); + }, []); + + return
; +} diff --git a/apps/storybook/stories/dropdown-menu.stories.tsx b/apps/storybook/stories/dropdown-menu.stories.tsx index 9c20c01341..f387437829 100644 --- a/apps/storybook/stories/dropdown-menu.stories.tsx +++ b/apps/storybook/stories/dropdown-menu.stories.tsx @@ -4,6 +4,7 @@ import { Dialog, DropdownMenu, Tooltip } from 'radix-ui'; import { Popper } from 'radix-ui/internal'; import { foodGroups } from '@repo/test-data/foods'; import styles from './dropdown-menu.stories.module.css'; +import { ExternalOverlayTrigger } from './external-overlay'; const { SIDE_OPTIONS, ALIGN_OPTIONS } = Popper; @@ -366,6 +367,26 @@ export const Submenus = () => { ); }; +export const WithExtensionOverlay = () => { + const [open, setOpen] = React.useState(false); + + return ( +
+ + + Open + + + New Tab + New Window + + + +
{open ? 'open' : 'closed'}
+
+ ); +}; + export const InvertedWithSubmenus = () => (
diff --git a/apps/storybook/stories/external-overlay.tsx b/apps/storybook/stories/external-overlay.tsx new file mode 100644 index 0000000000..d0aebff889 --- /dev/null +++ b/apps/storybook/stories/external-overlay.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +type ExternalOverlayTriggerProps = { + children?: React.ReactNode; +}; + +function ExternalOverlayTrigger({ children = 'Trigger overlay' }: ExternalOverlayTriggerProps) { + const cleanupRef = React.useRef<(() => void) | undefined>(undefined); + + React.useEffect(() => { + return () => cleanupRef.current?.(); + }, []); + + return ( + + ); +} + +function createExternalOverlay() { + const container = document.createElement('div'); + container.dataset.testid = 'external-overlay'; + container.style.position = 'fixed'; + container.style.top = '12px'; + container.style.right = '12px'; + container.style.zIndex = '2147483647'; + container.style.pointerEvents = 'auto'; + container.style.backgroundColor = 'hsl(0 0% 100%)'; + container.style.border = '1px solid hsl(0 0% 80%)'; + container.style.borderRadius = '4px'; + container.style.padding = '8px'; + container.style.boxShadow = '0 2px 8px hsl(0 0% 0% / 0.1)'; + + const button = document.createElement('button'); + button.type = 'button'; + button.dataset.testid = 'external-overlay-button'; + button.textContent = 'external overlay'; + + const dismissButton = document.createElement('button'); + dismissButton.type = 'button'; + dismissButton.dataset.testid = 'external-overlay-dismiss-button'; + dismissButton.textContent = 'dismiss'; + + const stopPropagation = (event: Event) => event.stopPropagation(); + const handleDismiss = (event: Event) => { + stopPropagation(event); + cleanup(); + }; + + button.addEventListener('mousedown', stopPropagation); + button.addEventListener('mouseup', stopPropagation); + button.addEventListener('click', stopPropagation); + dismissButton.addEventListener('click', handleDismiss); + + container.append(button); + container.append(dismissButton); + document.body.append(container); + + function cleanup() { + button.removeEventListener('mousedown', stopPropagation); + button.removeEventListener('mouseup', stopPropagation); + button.removeEventListener('click', stopPropagation); + dismissButton.removeEventListener('click', handleDismiss); + container.remove(); + } + + return cleanup; +} + +export { ExternalOverlayTrigger }; diff --git a/apps/storybook/stories/menubar.stories.tsx b/apps/storybook/stories/menubar.stories.tsx index f68667e28b..76784368c9 100644 --- a/apps/storybook/stories/menubar.stories.tsx +++ b/apps/storybook/stories/menubar.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Menubar } from 'radix-ui'; import { foodGroups } from '@repo/test-data/foods'; import styles from './menubar.stories.module.css'; +import { ExternalOverlayTrigger } from './external-overlay'; const subTriggerClass = [styles.item, styles.subTrigger].join(' '); @@ -194,6 +195,28 @@ export const Styled = () => { ); }; +export const WithExtensionOverlay = () => { + const [value, setValue] = React.useState(''); + + return ( +
+ + + + Edit + + + Undo + Redo + + + + +
{value === '' ? 'closed' : 'open'}
+
+ ); +}; + export const Cypress = () => { const [loop, setLoop] = React.useState(false); const [rtl, setRtl] = React.useState(false); diff --git a/apps/storybook/stories/popover.stories.tsx b/apps/storybook/stories/popover.stories.tsx index 7aaca13438..8d70ac0efd 100644 --- a/apps/storybook/stories/popover.stories.tsx +++ b/apps/storybook/stories/popover.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Popover } from 'radix-ui'; import { Popper } from 'radix-ui/internal'; import styles from './popover.stories.module.css'; +import { ExternalOverlayTrigger } from './external-overlay'; const { SIDE_OPTIONS, ALIGN_OPTIONS } = Popper; @@ -128,6 +129,28 @@ export const Controlled = () => { ); }; +export const WithExtensionOverlay = () => { + const [open, setOpen] = React.useState(false); + + return ( +
+ + open + + + + close + + + + +
{open ? 'open' : 'closed'}
+
+ ); +}; + export const Animated = () => { return (
{ ); }; +export const WithExtensionOverlay = () => { + const [open, setOpen] = React.useState(false); + + return ( +
+ + +
{open ? 'open' : 'closed'}
+
+ ); +}; + export const Position = () => (
{ .wait(750); } }); + +describe('ContextMenu extension overlay interactions', () => { + beforeEach(() => { + cy.visitStory('contextmenu--with-extension-overlay'); + }); + + it('should close when an external overlay stops later mouse events', () => { + cy.findByText('Trigger overlay').click(); + cy.findByTestId('external-overlay').should('exist'); + cy.findByText('Right Click Here').rightclick(); + cy.findByText('New Tab').should('be.visible'); + + cy.findByTestId('external-overlay-button').realClick(); + + cy.findByText('New Tab').should('not.exist'); + }); +}); diff --git a/cypress/e2e/Dialog.cy.ts b/cypress/e2e/Dialog.cy.ts index 8a79ff81b8..d1c6797e2d 100644 --- a/cypress/e2e/Dialog.cy.ts +++ b/cypress/e2e/Dialog.cy.ts @@ -206,3 +206,33 @@ describe('Dialog', () => { }); }); }); + +describe('Dialog extension overlay interactions', () => { + beforeEach(() => { + cy.visitStory('dialog--with-extension-overlay'); + }); + + it('keeps the dialog open when interacting with a shadow tree inside the dialog', () => { + cy.findByText('open').click(); + cy.findByTestId('dialog-state').should('have.text', 'open'); + + cy.get('[data-testid="inside-shadow-host"]') + .shadow() + .find('button') + .should('have.text', 'inside shadow suggestion') + .realClick(); + + cy.findByTestId('dialog-state').should('have.text', 'open'); + }); + + it('keeps the dialog open when an outside overlay stops later mouse events', () => { + cy.findByText('open').click(); + cy.findByTestId('dialog-state').should('have.text', 'open'); + cy.findByText('Trigger overlay').click(); + cy.findByTestId('external-overlay').should('exist'); + + cy.findByTestId('external-overlay-button').realClick(); + + cy.findByTestId('dialog-state').should('have.text', 'open'); + }); +}); diff --git a/cypress/e2e/DropdownMenu.cy.ts b/cypress/e2e/DropdownMenu.cy.ts index 846425ad89..1bdfced1a1 100644 --- a/cypress/e2e/DropdownMenu.cy.ts +++ b/cypress/e2e/DropdownMenu.cy.ts @@ -249,3 +249,20 @@ describe('DropdownMenu', () => { return cy.findByText(elementText).should('be.visible').realHover(); } }); + +describe('DropdownMenu extension overlay interactions', () => { + beforeEach(() => { + cy.visitStory('dropdownmenu--with-extension-overlay'); + }); + + it('should close when an external overlay stops later mouse events', () => { + cy.findByText('Trigger overlay').click(); + cy.findByTestId('external-overlay').should('exist'); + cy.findByText('Open').click(); + cy.findByText('New Tab').should('be.visible'); + + cy.findByTestId('external-overlay-button').realClick(); + + cy.findByText('New Tab').should('not.exist'); + }); +}); diff --git a/cypress/e2e/Menubar.cy.ts b/cypress/e2e/Menubar.cy.ts index 6c7b1d0bf8..14a52629db 100644 --- a/cypress/e2e/Menubar.cy.ts +++ b/cypress/e2e/Menubar.cy.ts @@ -271,3 +271,20 @@ describe('Menubar', () => { return cy.findByText(elementText).should('be.visible').realHover(); } }); + +describe('Menubar extension overlay interactions', () => { + beforeEach(() => { + cy.visitStory('menubar--with-extension-overlay'); + }); + + it('should close when an external overlay stops later mouse events', () => { + cy.findByText('Trigger overlay').click(); + cy.findByTestId('external-overlay').should('exist'); + cy.findByText('Edit').click(); + cy.findByText('Redo').should('be.visible'); + + cy.findByTestId('external-overlay-button').realClick(); + + cy.findByText('Redo').should('not.exist'); + }); +}); diff --git a/cypress/e2e/Popover.cy.ts b/cypress/e2e/Popover.cy.ts new file mode 100644 index 0000000000..604b51d7bf --- /dev/null +++ b/cypress/e2e/Popover.cy.ts @@ -0,0 +1,16 @@ +describe('Popover', () => { + beforeEach(() => { + cy.visitStory('popover--with-extension-overlay'); + }); + + it('keeps the popover open when an external overlay stops later mouse events', () => { + cy.findByText('open').click(); + cy.findByText('close').should('be.visible'); + cy.findByText('Trigger overlay').click(); + cy.findByTestId('external-overlay').should('exist'); + + cy.findByTestId('external-overlay-button').realClick(); + + cy.findByText('close').should('be.visible'); + }); +}); diff --git a/cypress/e2e/Select.cy.ts b/cypress/e2e/Select.cy.ts index 484f78f5b1..f902b252d1 100644 --- a/cypress/e2e/Select.cy.ts +++ b/cypress/e2e/Select.cy.ts @@ -64,6 +64,23 @@ describe('Select', () => { }); }); +describe('Select extension overlay interactions', () => { + beforeEach(() => { + cy.visitStory('select--with-extension-overlay'); + }); + + it('closes when an external overlay stops later mouse events', () => { + cy.findByText('Trigger overlay').click(); + cy.findByTestId('external-overlay').should('exist'); + cy.findByText(/choose a number/i).click(); + cy.findByRole('listbox').should('exist'); + + cy.findByTestId('external-overlay-button').realClick(); + + cy.findByRole('listbox').should('not.exist'); + }); +}); + describe('Select (shadow DOM)', () => { beforeEach(() => { cy.visitStory('select--cypress-shadow-dom'); diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index bb86ed14ea..ed0f414fea 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -411,6 +411,7 @@ const DialogContentImpl = React.forwardRef context.onOpenChange(false)} /> diff --git a/packages/react/dismissable-layer/src/dismissable-layer.test.tsx b/packages/react/dismissable-layer/src/dismissable-layer.test.tsx new file mode 100644 index 0000000000..83d6e6787b --- /dev/null +++ b/packages/react/dismissable-layer/src/dismissable-layer.test.tsx @@ -0,0 +1,304 @@ +import * as React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as DismissableLayer from './dismissable-layer'; + +async function waitForDocumentPointerDownListener() { + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function renderDismissableLayer( + props: React.ComponentProps = {}, + extraContent?: React.ReactNode, +) { + return render( + <> + + + + {extraContent} + + , + ); +} + +function dispatchComposedPointerDown(target: Element) { + target.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + composed: true, + pointerType: 'mouse', + }), + ); +} + +function firePointerMouseClick(target: Element) { + fireEvent.pointerDown(target, { pointerType: 'mouse' }); + fireEvent.mouseDown(target); + fireEvent.pointerUp(target, { pointerType: 'mouse' }); + fireEvent.mouseUp(target); + fireEvent.click(target); +} + +function ShadowButton() { + const hostRef = React.useRef(null); + + React.useEffect(() => { + const host = hostRef.current; + if (!host || host.shadowRoot) { + return; + } + + const shadowRoot = host.attachShadow({ mode: 'open' }); + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'inside shadow'; + shadowRoot.append(button); + }, []); + + return
; +} + +describe('DismissableLayer', () => { + afterEach(cleanup); + + it('dismisses on an outside pointer interaction', async () => { + const onPointerDownOutside = vi.fn(); + const onInteractOutside = vi.fn(); + const onDismiss = vi.fn(); + + renderDismissableLayer({ onPointerDownOutside, onInteractOutside, onDismiss }); + await waitForDocumentPointerDownListener(); + + firePointerMouseClick(screen.getByText('outside')); + + expect(onPointerDownOutside).toHaveBeenCalledTimes(1); + expect(onInteractOutside).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('dismisses immediately on pointer down outside by default', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ onDismiss }); + await waitForDocumentPointerDownListener(); + + fireEvent.pointerDown(screen.getByText('outside')); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss on pointer down inside', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ onDismiss }); + await waitForDocumentPointerDownListener(); + + fireEvent.pointerDown(screen.getByText('inside')); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not dismiss when pointer down outside is prevented', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ + onPointerDownOutside: (event) => event.preventDefault(), + onDismiss, + }); + await waitForDocumentPointerDownListener(); + + firePointerMouseClick(screen.getByText('outside')); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not dismiss when interact outside is prevented for a pointer down outside event', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ + onInteractOutside: (event) => event.preventDefault(), + onDismiss, + }); + await waitForDocumentPointerDownListener(); + + firePointerMouseClick(screen.getByText('outside')); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('dismisses on focus outside', () => { + const onFocusOutside = vi.fn(); + const onInteractOutside = vi.fn(); + const onDismiss = vi.fn(); + + renderDismissableLayer({ onFocusOutside, onInteractOutside, onDismiss }); + + fireEvent.focusIn(screen.getByText('outside')); + + expect(onFocusOutside).toHaveBeenCalledTimes(1); + expect(onInteractOutside).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss on focus inside', () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ onDismiss }); + + fireEvent.focusIn(screen.getByText('inside')); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not dismiss when interacting with a branch', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer( + { onDismiss }, + + + , + ); + await waitForDocumentPointerDownListener(); + + fireEvent.pointerDown(screen.getByText('branch')); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('defers touch pointer down outside dismissal until click', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ deferPointerDownOutside: true, onDismiss }); + await waitForDocumentPointerDownListener(); + + fireEvent.pointerDown(screen.getByText('outside'), { pointerType: 'touch' }); + expect(onDismiss).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('outside')); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('dismisses immediately on non-primary mouse pointer down outside', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ onDismiss }); + await waitForDocumentPointerDownListener(); + + fireEvent.pointerDown(screen.getByText('outside'), { button: 2, pointerType: 'mouse' }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('dismisses immediately on non-primary pen pointer down outside', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ onDismiss }); + await waitForDocumentPointerDownListener(); + + fireEvent.pointerDown(screen.getByText('outside'), { button: 2, pointerType: 'pen' }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('cancels pending touch outside dismissal when pointer down moves back inside', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ deferPointerDownOutside: true, onDismiss }); + await waitForDocumentPointerDownListener(); + + fireEvent.pointerDown(screen.getByText('outside'), { pointerType: 'touch' }); + fireEvent.pointerDown(screen.getByText('inside')); + fireEvent.click(screen.getByText('outside')); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('treats a shadow tree inside the layer as inside', async () => { + const onDismiss = vi.fn(); + + render( + <> + + + + + , + ); + await waitForDocumentPointerDownListener(); + + const shadowButton = screen + .getByTestId('inside-shadow-host') + .shadowRoot?.querySelector('button'); + expect(shadowButton).toBeInstanceOf(HTMLButtonElement); + + dispatchComposedPointerDown(shadowButton!); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('dismisses when a later outside interaction event is stopped by default', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ onDismiss }); + await waitForDocumentPointerDownListener(); + + const outside = screen.getByText('outside'); + const stopPropagation = (event: Event) => event.stopPropagation(); + outside.addEventListener('mousedown', stopPropagation); + outside.addEventListener('mouseup', stopPropagation); + outside.addEventListener('click', stopPropagation); + + fireEvent.pointerDown(outside); + fireEvent.mouseDown(outside); + fireEvent.mouseUp(outside); + fireEvent.click(outside); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss when a later outside interaction event is stopped and pointer down outside is deferred', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ deferPointerDownOutside: true, onDismiss }); + await waitForDocumentPointerDownListener(); + + const outside = screen.getByText('outside'); + const stopPropagation = (event: Event) => event.stopPropagation(); + outside.addEventListener('mousedown', stopPropagation); + outside.addEventListener('mouseup', stopPropagation); + outside.addEventListener('click', stopPropagation); + + fireEvent.pointerDown(outside); + fireEvent.mouseDown(outside); + fireEvent.mouseUp(outside); + fireEvent.click(outside); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not dismiss when focus moves outside during a deferred stopped interaction', async () => { + const onDismiss = vi.fn(); + + renderDismissableLayer({ deferPointerDownOutside: true, onDismiss }); + await waitForDocumentPointerDownListener(); + + const outside = screen.getByText('outside'); + const stopPropagation = (event: Event) => event.stopPropagation(); + outside.addEventListener('mousedown', stopPropagation); + outside.addEventListener('mouseup', stopPropagation); + outside.addEventListener('click', stopPropagation); + + fireEvent.pointerDown(outside); + fireEvent.mouseDown(outside); + fireEvent.focusIn(outside); + fireEvent.mouseUp(outside); + fireEvent.click(outside); + + expect(onDismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/dismissable-layer/src/dismissable-layer.tsx b/packages/react/dismissable-layer/src/dismissable-layer.tsx index a59c734799..eac7f33a35 100644 --- a/packages/react/dismissable-layer/src/dismissable-layer.tsx +++ b/packages/react/dismissable-layer/src/dismissable-layer.tsx @@ -31,6 +31,12 @@ interface DismissableLayerProps extends PrimitiveDivProps { * interact with them: once to close the `DismissableLayer`, and again to trigger the element. */ disableOutsidePointerEvents?: boolean; + /** + * When `true`, a `'pointerdown'` event outside of the layered element will + * wait for the interaction's click event before dispatching, allowing + * third-party code to stop propagation of later events and cancel dismissal. + */ + deferPointerDownOutside?: boolean; /** * Event handler called when the escape key is down. * Can be prevented. @@ -62,6 +68,7 @@ const DismissableLayer = React.forwardRef { const { disableOutsidePointerEvents = false, + deferPointerDownOutside = false, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, @@ -80,17 +87,35 @@ const DismissableLayer = React.forwardRef 0; const isPointerEventsEnabled = index >= highestLayerWithOutsidePointerEventsDisabledIndex; + const isDeferredPointerDownOutsideRef = React.useRef(false); - const pointerDownOutside = usePointerDownOutside((event) => { - const target = event.target as HTMLElement; - const isPointerDownOnBranch = [...context.branches].some((branch) => branch.contains(target)); - if (!isPointerEventsEnabled || isPointerDownOnBranch) return; - onPointerDownOutside?.(event); - onInteractOutside?.(event); - if (!event.defaultPrevented) onDismiss?.(); - }, ownerDocument); + const pointerDownOutside = usePointerDownOutside( + (event) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + const isPointerDownOnBranch = [...context.branches].some((branch) => + branch.contains(target), + ); + if (!isPointerEventsEnabled || isPointerDownOnBranch) return; + onPointerDownOutside?.(event); + onInteractOutside?.(event); + if (!event.defaultPrevented) onDismiss?.(); + }, + { + ownerDocument, + deferPointerDownOutside, + isDeferredPointerDownOutsideRef, + }, + ); const focusOutside = useFocusOutside((event) => { + if (deferPointerDownOutside && isDeferredPointerDownOutsideRef.current) { + return; + } + const target = event.target as HTMLElement; const isFocusInBranch = [...context.branches].some((branch) => branch.contains(target)); if (isFocusInBranch) return; @@ -220,58 +245,138 @@ type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>; type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>; /** - * Listens for `pointerdown` outside a react subtree. We use `pointerdown` rather than `pointerup` - * to mimic layer dismissing behaviour present in OS. + * Listens for `pointerdown` outside a react subtree. We detect the start of the interaction on + * `pointerdown`, then wait for `click` so external code can intercept later mouse events. * Returns props to pass to the node we want to check for outside events. */ function usePointerDownOutside( - onPointerDownOutside?: (event: PointerDownOutsideEvent) => void, - ownerDocument: Document = globalThis?.document, + onPointerDownOutside: ((event: PointerDownOutsideEvent) => void) | undefined, + args: { + ownerDocument: Document | undefined; + deferPointerDownOutside: boolean; + isDeferredPointerDownOutsideRef: React.RefObject; + }, ) { + const { + ownerDocument = globalThis?.document, + deferPointerDownOutside = false, + isDeferredPointerDownOutsideRef, + } = args; const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener; const isPointerInsideReactTreeRef = React.useRef(false); + const isPointerDownOutsideRef = React.useRef(false); + const interceptedOutsideInteractionEventsRef = React.useRef>(new Map()); const handleClickRef = React.useRef(() => {}); React.useEffect(() => { + function resetOutsideInteraction() { + isPointerDownOutsideRef.current = false; + isDeferredPointerDownOutsideRef.current = false; + interceptedOutsideInteractionEventsRef.current.clear(); + } + + function isOutsideInteractionIntercepted() { + return Array.from(interceptedOutsideInteractionEventsRef.current.values()).some(Boolean); + } + + function handleInteractionCapture(event: Event) { + if (isPointerDownOutsideRef.current) { + interceptedOutsideInteractionEventsRef.current.set(event.type, true); + + if (event.type === 'click') { + window.setTimeout(() => { + if (isPointerDownOutsideRef.current) { + handleClickRef.current(); + } + }, 0); + } + } + } + + function handleInteractionBubble(event: Event) { + if (isPointerDownOutsideRef.current) { + interceptedOutsideInteractionEventsRef.current.set(event.type, false); + } + } + const handlePointerDown = (event: PointerEvent) => { if (event.target && !isPointerInsideReactTreeRef.current) { const eventDetail = { originalEvent: event }; + isPointerDownOutsideRef.current = true; + isDeferredPointerDownOutsideRef.current = deferPointerDownOutside && event.button === 0; + interceptedOutsideInteractionEventsRef.current.clear(); function handleAndDispatchPointerDownOutsideEvent() { - handleAndDispatchCustomEvent( - POINTER_DOWN_OUTSIDE, - handlePointerDownOutside, - eventDetail, - { discrete: true }, - ); + ownerDocument.removeEventListener('click', handleClickRef.current); + const wasOutsideInteractionIntercepted = isOutsideInteractionIntercepted(); + resetOutsideInteraction(); + + if (!wasOutsideInteractionIntercepted) { + handleAndDispatchCustomEvent( + POINTER_DOWN_OUTSIDE, + handlePointerDownOutside, + eventDetail, + { discrete: true }, + ); + } } /** - * On touch devices, we need to wait for a click event because browsers implement - * a ~350ms delay between the time the user stops touching the display and when the - * browser executres events. We need to ensure we don't reactivate pointer-events within - * this timeframe otherwise the browser may execute events that should have been prevented. + * When deferring, we need to wait for a click event because: + * + * 1. On touch devices, browsers implement a ~350ms delay between the + * time the user stops touching the display and when the browser + * executes events. We need to ensure we don't reactivate + * pointer-events within this timeframe otherwise the browser may + * execute events that should have been prevented. + * + * 2. Browser extensions and other third-party code may call + * `stopPropagation` on later mouse events like `mousedown`, + * `mouseup`, or `click`. Waiting lets those intercepted events + * cancel the outside interaction before we dismiss the layer. See + * https://github.com/radix-ui/primitives/issues/2055 * - * Additionally, this also lets us deal automatically with cancellations when a click event - * isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc. + * Additionally, this also lets us deal automatically with cancellations + * when a click event isn't raised because the page was considered + * scrolled/drag-scrolled, long-pressed, etc. * - * This is why we also continuously remove the previous listener, because we cannot be - * certain that it was raised, and therefore cleaned-up. + * This is why we also continuously remove the previous listener, + * because we cannot be certain that it was raised, and therefore + * cleaned-up. + * + * For non-primary buttons, we dispatch the event immediately because we + * cannot be certain that the event was canceled. */ - if (event.pointerType === 'touch') { + if (!deferPointerDownOutside || event.button !== 0) { + handleAndDispatchPointerDownOutsideEvent(); + } else { ownerDocument.removeEventListener('click', handleClickRef.current); handleClickRef.current = handleAndDispatchPointerDownOutsideEvent; ownerDocument.addEventListener('click', handleClickRef.current, { once: true }); - } else { - handleAndDispatchPointerDownOutsideEvent(); } } else { // We need to remove the event listener in case the outside click has been canceled. // See: https://github.com/radix-ui/primitives/issues/2171 ownerDocument.removeEventListener('click', handleClickRef.current); + resetOutsideInteraction(); } isPointerInsideReactTreeRef.current = false; }; + + const outsideInteractionEvents = [ + 'pointerup', + 'mousedown', + 'mouseup', + 'touchstart', + 'touchend', + 'click', + ]; + + for (const eventName of outsideInteractionEvents) { + ownerDocument.addEventListener(eventName, handleInteractionCapture, true); + ownerDocument.addEventListener(eventName, handleInteractionBubble); + } + /** * if this hook executes in a component that mounts via a `pointerdown` event, the event * would bubble up to the document and trigger a `pointerDownOutside` event. We avoid @@ -292,8 +397,17 @@ function usePointerDownOutside( window.clearTimeout(timerId); ownerDocument.removeEventListener('pointerdown', handlePointerDown); ownerDocument.removeEventListener('click', handleClickRef.current); + for (const eventName of outsideInteractionEvents) { + ownerDocument.removeEventListener(eventName, handleInteractionCapture, true); + ownerDocument.removeEventListener(eventName, handleInteractionBubble); + } }; - }, [ownerDocument, handlePointerDownOutside]); + }, [ + ownerDocument, + handlePointerDownOutside, + deferPointerDownOutside, + isDeferredPointerDownOutsideRef, + ]); return { // ensures we check React component tree (not just DOM tree) diff --git a/packages/react/popover/src/popover.tsx b/packages/react/popover/src/popover.tsx index 1d8c78f38b..fd19974644 100644 --- a/packages/react/popover/src/popover.tsx +++ b/packages/react/popover/src/popover.tsx @@ -416,6 +416,7 @@ const PopoverContentImpl = React.forwardRef context.onOpenChange(false)} + deferPointerDownOutside > Date: Sun, 7 Jun 2026 18:29:43 -0700 Subject: [PATCH 9/9] cleanup --- packages/react/dialog/src/dialog.test.tsx | 18 +----------------- packages/react/dialog/src/dialog.tsx | 4 +--- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/react/dialog/src/dialog.test.tsx b/packages/react/dialog/src/dialog.test.tsx index b17ced77a4..9707398322 100644 --- a/packages/react/dialog/src/dialog.test.tsx +++ b/packages/react/dialog/src/dialog.test.tsx @@ -3,7 +3,7 @@ import { axe } from 'vitest-axe'; import type { RenderResult } from '@testing-library/react'; import { render, fireEvent, cleanup, screen } from '@testing-library/react'; import * as Dialog from './dialog'; -import type { Mock, MockInstance } from 'vitest'; +import type { MockInstance } from 'vitest'; import { describe, it, afterEach, beforeEach, vi, expect } from 'vitest'; const OPEN_TEXT = 'Open'; @@ -25,30 +25,14 @@ describe('given a default Dialog', () => { let rendered: RenderResult; let trigger: HTMLElement; let closeButton: HTMLElement; - let consoleWarnMock: MockInstance; - let consoleWarnMockFunction: Mock; - let consoleErrorMock: MockInstance; - let consoleErrorMockFunction: Mock; beforeEach(() => { - // This surpresses React error boundary logs for testing intentionally - // thrown errors, like in some test cases in this suite. See discussion of - // this here: https://github.com/facebook/react/issues/11098 - consoleWarnMockFunction = vi.fn(); - consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(consoleWarnMockFunction); - consoleErrorMockFunction = vi.fn(); - consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(consoleErrorMockFunction); - rendered = render(); trigger = rendered.getByText(OPEN_TEXT); }); afterEach(() => { cleanup(); - consoleWarnMock.mockRestore(); - consoleWarnMockFunction.mockClear(); - consoleErrorMock.mockRestore(); - consoleErrorMockFunction.mockClear(); }); it('should have no accessibility violations in default state', async () => { diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index ed0f414fea..f993e2e1e9 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -387,8 +387,6 @@ const DialogContentImpl = React.forwardRef, forwardedRef) => { const { __scopeDialog, trapFocus, onOpenAutoFocus, onCloseAutoFocus, ...contentProps } = props; const context = useDialogContext(CONTENT_NAME, __scopeDialog); - const contentRef = React.useRef(null); - const composedRefs = useComposedRefs(forwardedRef, contentRef); // Make sure the whole tree has focus guards as our `Dialog` will be // the last element in the DOM (because of the `Portal`) @@ -410,7 +408,7 @@ const DialogContentImpl = React.forwardRef context.onOpenChange(false)} />