diff --git a/docs/reference/generated/menu-popup.json b/docs/reference/generated/menu-popup.json
index 198aed3a64..0ce836710c 100644
--- a/docs/reference/generated/menu-popup.json
+++ b/docs/reference/generated/menu-popup.json
@@ -18,6 +18,10 @@
"data-closed": {
"description": "Present when the menu is closed."
},
+ "data-instant": {
+ "description": "Indicates the instant type of the menu popup.",
+ "type": "'click' | 'dismiss'"
+ },
"data-side": {
"description": "Indicates which side the menu is positioned relative to the trigger.",
"type": "'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start'"
diff --git a/docs/reference/generated/popover-popup.json b/docs/reference/generated/popover-popup.json
index 312782a02f..150af7f8ef 100644
--- a/docs/reference/generated/popover-popup.json
+++ b/docs/reference/generated/popover-popup.json
@@ -26,6 +26,10 @@
"data-closed": {
"description": "Present when the popup is closed."
},
+ "data-instant": {
+ "description": "Indicates the instant type of the popover popup.",
+ "type": "'click' | 'dismiss'"
+ },
"data-side": {
"description": "Indicates which side the popup is positioned relative to the trigger.",
"type": "'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start'"
diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx
index d39bd9d53d..d0f2be4e33 100644
--- a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx
+++ b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx
@@ -2,34 +2,8 @@ import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { fireEvent, act, waitFor } from '@mui/internal-test-utils';
-import { FloatingRootContext, FloatingTree } from '@floating-ui/react';
import { Menu } from '@base-ui-components/react/menu';
import { describeConformance, createRenderer } from '../../../test';
-import { MenuRootContext } from '../root/MenuRootContext';
-
-const testRootContext: MenuRootContext = {
- floatingRootContext: {} as FloatingRootContext,
- getPopupProps: (p) => ({ ...p }),
- getTriggerProps: (p) => ({ ...p }),
- getItemProps: (p) => ({ ...p }),
- parentContext: undefined,
- nested: false,
- setTriggerElement: () => {},
- setPositionerElement: () => {},
- activeIndex: null,
- disabled: false,
- itemDomElements: { current: [] },
- itemLabels: { current: [] },
- open: true,
- setOpen: () => {},
- popupRef: { current: null },
- mounted: true,
- transitionStatus: undefined,
- typingRef: { current: false },
- modal: false,
- positionerRef: { current: null },
- allowMouseUpTriggerRef: { current: false },
-};
describe('
', () => {
const { render, clock } = createRenderer({
@@ -42,11 +16,7 @@ describe('', () => {
describeConformance(, () => ({
render: (node) => {
- return render(
-
- {node}
- ,
- );
+ return render({node});
},
refInstanceof: window.HTMLDivElement,
}));
diff --git a/packages/react/src/menu/item/MenuItem.test.tsx b/packages/react/src/menu/item/MenuItem.test.tsx
index d9fecfcee1..3b1191d6ac 100644
--- a/packages/react/src/menu/item/MenuItem.test.tsx
+++ b/packages/react/src/menu/item/MenuItem.test.tsx
@@ -2,34 +2,8 @@ import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { act, screen, waitFor } from '@mui/internal-test-utils';
-import { FloatingRootContext, FloatingTree } from '@floating-ui/react';
import { Menu } from '@base-ui-components/react/menu';
import { describeConformance, createRenderer } from '#test-utils';
-import { MenuRootContext } from '../root/MenuRootContext';
-
-const testRootContext: MenuRootContext = {
- floatingRootContext: {} as FloatingRootContext,
- getPopupProps: (p) => ({ ...p }),
- getTriggerProps: (p) => ({ ...p }),
- getItemProps: (p) => ({ ...p }),
- parentContext: undefined,
- nested: false,
- setTriggerElement: () => {},
- setPositionerElement: () => {},
- activeIndex: null,
- disabled: false,
- itemDomElements: { current: [] },
- itemLabels: { current: [] },
- open: true,
- setOpen: () => {},
- popupRef: { current: null },
- mounted: true,
- transitionStatus: undefined,
- typingRef: { current: false },
- modal: false,
- positionerRef: { current: null },
- allowMouseUpTriggerRef: { current: false },
-};
describe('', () => {
const { render, clock } = createRenderer({
@@ -42,11 +16,7 @@ describe('', () => {
describeConformance(, () => ({
render: (node) => {
- return render(
-
- {node}
- ,
- );
+ return render({node});
},
refInstanceof: window.HTMLDivElement,
}));
diff --git a/packages/react/src/menu/popup/MenuPopup.tsx b/packages/react/src/menu/popup/MenuPopup.tsx
index 00e093d482..7e81a1d8a0 100644
--- a/packages/react/src/menu/popup/MenuPopup.tsx
+++ b/packages/react/src/menu/popup/MenuPopup.tsx
@@ -32,8 +32,17 @@ const MenuPopup = React.forwardRef(function MenuPopup(
) {
const { render, className, ...other } = props;
- const { open, setOpen, popupRef, transitionStatus, nested, getPopupProps, modal, mounted } =
- useMenuRootContext();
+ const {
+ open,
+ setOpen,
+ popupRef,
+ transitionStatus,
+ nested,
+ getPopupProps,
+ modal,
+ mounted,
+ instantType,
+ } = useMenuRootContext();
const { side, align, floatingContext } = useMenuPositionerContext();
const { events: menuEvents } = useFloatingTree()!;
@@ -52,8 +61,9 @@ const MenuPopup = React.forwardRef(function MenuPopup(
align,
open,
nested,
+ instant: instantType,
}),
- [transitionStatus, side, align, open, nested],
+ [transitionStatus, side, align, open, nested, instantType],
);
const { renderElement } = useComponentRenderer({
diff --git a/packages/react/src/menu/popup/MenuPopupDataAttributes.ts b/packages/react/src/menu/popup/MenuPopupDataAttributes.ts
index 1828cdcf7b..9d7734b13a 100644
--- a/packages/react/src/menu/popup/MenuPopupDataAttributes.ts
+++ b/packages/react/src/menu/popup/MenuPopupDataAttributes.ts
@@ -20,4 +20,9 @@ export enum MenuPopupDataAttributes {
* @type {'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start'}
*/
side = 'data-side',
+ /**
+ * Indicates the instant type of the menu popup.
+ * @type {'click' | 'dismiss'}
+ */
+ instant = 'data-instant',
}
diff --git a/packages/react/src/menu/positioner/MenuPositioner.test.tsx b/packages/react/src/menu/positioner/MenuPositioner.test.tsx
index 934fb73d2f..b669c1c600 100644
--- a/packages/react/src/menu/positioner/MenuPositioner.test.tsx
+++ b/packages/react/src/menu/positioner/MenuPositioner.test.tsx
@@ -1,46 +1,16 @@
import * as React from 'react';
import { expect } from 'chai';
-import { FloatingRootContext, FloatingTree } from '@floating-ui/react';
import userEvent from '@testing-library/user-event';
import { flushMicrotasks } from '@mui/internal-test-utils';
import { Menu } from '@base-ui-components/react/menu';
import { describeConformance, createRenderer } from '#test-utils';
-import { MenuRootContext } from '../root/MenuRootContext';
-
-const testRootContext: MenuRootContext = {
- floatingRootContext: undefined as unknown as FloatingRootContext,
- getPopupProps: (p) => ({ ...p }),
- getTriggerProps: (p) => ({ ...p }),
- getItemProps: (p) => ({ ...p }),
- parentContext: undefined,
- nested: false,
- setTriggerElement: () => {},
- setPositionerElement: () => {},
- activeIndex: null,
- disabled: false,
- itemDomElements: { current: [] },
- itemLabels: { current: [] },
- open: true,
- setOpen: () => {},
- popupRef: { current: null },
- mounted: true,
- transitionStatus: undefined,
- typingRef: { current: false },
- modal: false,
- positionerRef: { current: null },
- allowMouseUpTriggerRef: { current: false },
-};
describe('', () => {
const { render } = createRenderer();
describeConformance(, () => ({
render: (node) => {
- return render(
-
- {node}
- ,
- );
+ return render({node});
},
refInstanceof: window.HTMLDivElement,
}));
diff --git a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx
index 012555604d..a887465ee3 100644
--- a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx
+++ b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx
@@ -2,35 +2,9 @@ import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { fireEvent, act, waitFor } from '@mui/internal-test-utils';
-import { FloatingRootContext, FloatingTree } from '@floating-ui/react';
import { Menu } from '@base-ui-components/react/menu';
import { describeConformance, createRenderer } from '#test-utils';
import { MenuRadioGroupContext } from '../radio-group/MenuRadioGroupContext';
-import { MenuRootContext } from '../root/MenuRootContext';
-
-const testRootContext: MenuRootContext = {
- floatingRootContext: {} as FloatingRootContext,
- getPopupProps: (p) => ({ ...p }),
- getTriggerProps: (p) => ({ ...p }),
- getItemProps: (p) => ({ ...p }),
- parentContext: undefined,
- nested: false,
- setTriggerElement: () => {},
- setPositionerElement: () => {},
- activeIndex: null,
- disabled: false,
- itemDomElements: { current: [] },
- itemLabels: { current: [] },
- open: true,
- setOpen: () => {},
- popupRef: { current: null },
- mounted: true,
- transitionStatus: undefined,
- typingRef: { current: false },
- modal: false,
- positionerRef: { current: null },
- allowMouseUpTriggerRef: { current: false },
-};
const testRadioGroupContext = {
value: '0',
@@ -49,13 +23,11 @@ describe('', () => {
describeConformance(, () => ({
render: (node) => {
return render(
-
-
-
- {node}
-
-
- ,
+
+
+ {node}
+
+ ,
);
},
refInstanceof: window.HTMLDivElement,
diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx
index c56b0129e1..674e729d52 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -6,6 +6,7 @@ import { useDirection } from '../../direction-provider/DirectionContext';
import { MenuRootContext, useMenuRootContext } from './MenuRootContext';
import { MenuOrientation, useMenuRoot } from './useMenuRoot';
import { PortalContext } from '../../portal/PortalContext';
+import type { OpenChangeReason } from '../../utils/translateOpenChangeReason';
/**
* Groups all parts of the menu.
@@ -112,7 +113,7 @@ namespace MenuRoot {
/**
* Event handler called when the menu is opened or closed.
*/
- onOpenChange?: (open: boolean, event?: Event) => void;
+ onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void;
/**
* Whether the menu is currently open.
*/
diff --git a/packages/react/src/menu/root/MenuRootContext.ts b/packages/react/src/menu/root/MenuRootContext.ts
index 05d4f475cf..7fc06c9241 100644
--- a/packages/react/src/menu/root/MenuRootContext.ts
+++ b/packages/react/src/menu/root/MenuRootContext.ts
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import type { useMenuRoot } from './useMenuRoot';
+import type { OpenChangeReason } from '../../utils/translateOpenChangeReason';
export interface MenuRootContext extends useMenuRoot.ReturnValue {
disabled: boolean;
@@ -8,6 +9,7 @@ export interface MenuRootContext extends useMenuRoot.ReturnValue {
parentContext: MenuRootContext | undefined;
typingRef: React.RefObject;
modal: boolean;
+ openReason: OpenChangeReason | null;
}
export const MenuRootContext = React.createContext(undefined);
diff --git a/packages/react/src/menu/root/useMenuRoot.ts b/packages/react/src/menu/root/useMenuRoot.ts
index 72d0e7008d..3b9f142bc9 100644
--- a/packages/react/src/menu/root/useMenuRoot.ts
+++ b/packages/react/src/menu/root/useMenuRoot.ts
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
+import * as ReactDOM from 'react-dom';
import {
safePolygon,
useClick,
@@ -10,17 +11,21 @@ import {
useListNavigation,
useRole,
useTypeahead,
- FloatingRootContext,
+ type FloatingRootContext,
} from '@floating-ui/react';
import { mergeReactProps } from '../../utils/mergeReactProps';
import { GenericHTMLProps } from '../../utils/types';
import { useTransitionStatus, type TransitionStatus } from '../../utils/useTransitionStatus';
import { useEventCallback } from '../../utils/useEventCallback';
import { useControlled } from '../../utils/useControlled';
-import { TYPEAHEAD_RESET_MS } from '../../utils/constants';
+import { PATIENT_CLICK_THRESHOLD, TYPEAHEAD_RESET_MS } from '../../utils/constants';
import { useAfterExitAnimation } from '../../utils/useAfterExitAnimation';
import type { TextDirection } from '../../direction-provider/DirectionContext';
import { useScrollLock } from '../../utils/useScrollLock';
+import {
+ type OpenChangeReason,
+ translateOpenChangeReason,
+} from '../../utils/translateOpenChangeReason';
const EMPTY_ARRAY: never[] = [];
@@ -45,10 +50,15 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
const [positionerElement, setPositionerElementUnwrapped] = React.useState(
null,
);
- const popupRef = React.useRef(null);
- const positionerRef = React.useRef(null);
+ const [instantType, setInstantType] = React.useState<'dismiss' | 'click'>();
const [hoverEnabled, setHoverEnabled] = React.useState(true);
const [activeIndex, setActiveIndex] = React.useState(null);
+ const [openReason, setOpenReason] = React.useState(null);
+ const [stickIfOpen, setStickIfOpen] = React.useState(true);
+
+ const popupRef = React.useRef(null);
+ const positionerRef = React.useRef(null);
+ const stickIfOpenTimeoutRef = React.useRef(-1);
const [open, setOpenUnwrapped] = useControlled({
controlled: openParam,
@@ -68,30 +78,85 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
useScrollLock(open && modal, triggerElement);
- const setOpen = useEventCallback((nextOpen: boolean, event?: Event) => {
- onOpenChange?.(nextOpen, event);
- setOpenUnwrapped(nextOpen);
- });
+ const setOpen = useEventCallback(
+ (nextOpen: boolean, event?: Event, reason?: OpenChangeReason) => {
+ onOpenChange?.(nextOpen, event);
+ setOpenUnwrapped(nextOpen);
+
+ if (nextOpen) {
+ setOpenReason(reason ?? null);
+ }
+ },
+ );
useAfterExitAnimation({
open,
animatedElementRef: popupRef,
- onFinished: () => setMounted(false),
+ onFinished() {
+ setMounted(false);
+ setOpenReason(null);
+ setHoverEnabled(true);
+ setStickIfOpen(true);
+ },
});
+ const clearStickIfOpenTimeout = useEventCallback(() => {
+ clearTimeout(stickIfOpenTimeoutRef.current);
+ });
+
+ React.useEffect(() => {
+ if (!open) {
+ clearStickIfOpenTimeout();
+ }
+ }, [clearStickIfOpenTimeout, open]);
+
+ React.useEffect(() => {
+ return () => {
+ clearStickIfOpenTimeout();
+ };
+ }, [clearStickIfOpenTimeout]);
+
const floatingRootContext = useFloatingRootContext({
elements: {
reference: triggerElement,
floating: positionerElement,
},
open,
- onOpenChange: setOpen,
+ onOpenChange(openValue, eventValue, reasonValue) {
+ const isHover = reasonValue === 'hover' || reasonValue === 'safe-polygon';
+ const isKeyboardClick = reasonValue === 'click' && (eventValue as MouseEvent).detail === 0;
+ const isDismissClose = !openValue && (reasonValue === 'escape-key' || reasonValue == null);
+
+ function changeState() {
+ setOpen(openValue, eventValue, translateOpenChangeReason(reasonValue));
+ }
+
+ if (isHover) {
+ // Only allow "patient" clicks to close the popover if it's open.
+ // If they clicked within 500ms of the popover opening, keep it open.
+ clearStickIfOpenTimeout();
+ stickIfOpenTimeoutRef.current = window.setTimeout(() => {
+ setStickIfOpen(false);
+ }, PATIENT_CLICK_THRESHOLD);
+
+ ReactDOM.flushSync(changeState);
+ } else {
+ changeState();
+ }
+
+ if (isKeyboardClick || isDismissClose) {
+ setInstantType(isKeyboardClick ? 'click' : 'dismiss');
+ } else {
+ setInstantType(undefined);
+ }
+ },
});
const hover = useHover(floatingRootContext, {
- enabled: hoverEnabled && openOnHover && !disabled,
+ enabled: hoverEnabled && openOnHover && !disabled && openReason !== 'click',
handleClose: safePolygon({ blockPointerEvents: true }),
mouseOnly: true,
+ move: false,
delay: {
open: delay,
},
@@ -102,6 +167,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
event: 'mousedown',
toggle: !nested,
ignoreMouse: nested,
+ stickIfOpen,
});
const dismiss = useDismiss(floatingRootContext, {
@@ -149,28 +215,44 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
typeahead,
]);
+ const virtualEventHandler = listNavigation.reference?.onPointerDown;
+
const getTriggerProps = React.useCallback(
- (externalProps?: GenericHTMLProps) =>
- getReferenceProps(
+ (externalProps?: GenericHTMLProps) => {
+ const props = getReferenceProps(
mergeReactProps(externalProps, {
- onMouseEnter: () => {
+ onMouseEnter() {
setHoverEnabled(true);
},
}),
- ),
- [getReferenceProps],
+ );
+ return {
+ ...props,
+ // Floating UI doesn't check for virtual pointer on `onPointerEnter`, only
+ // `onPointerDown`. TODO: Fix internally in Floating UI.
+ onPointerEnter: virtualEventHandler,
+ };
+ },
+ [getReferenceProps, virtualEventHandler],
);
const getPopupProps = React.useCallback(
(externalProps?: GenericHTMLProps) =>
getFloatingProps(
mergeReactProps(externalProps, {
- onMouseEnter: () => {
- setHoverEnabled(false);
+ onMouseEnter() {
+ if (!openOnHover || nested) {
+ setHoverEnabled(false);
+ }
+ },
+ onClick() {
+ if (openOnHover) {
+ setHoverEnabled(false);
+ }
},
}),
),
- [getFloatingProps],
+ [getFloatingProps, openOnHover, nested],
);
return React.useMemo(
@@ -191,6 +273,8 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
setPositionerElement,
setTriggerElement,
transitionStatus,
+ openReason,
+ instantType,
}),
[
activeIndex,
@@ -206,6 +290,8 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
setOpen,
transitionStatus,
setPositionerElement,
+ openReason,
+ instantType,
],
);
}
@@ -221,7 +307,7 @@ export namespace useMenuRoot {
/**
* Event handler called when the menu is opened or closed.
*/
- onOpenChange: ((open: boolean, event?: Event) => void) | undefined;
+ onOpenChange: ((open: boolean, event?: Event, reason?: OpenChangeReason) => void) | undefined;
/**
* Whether the menu is initially open.
*
@@ -289,5 +375,7 @@ export namespace useMenuRoot {
setTriggerElement: (element: HTMLElement | null) => void;
transitionStatus: TransitionStatus;
allowMouseUpTriggerRef: React.RefObject;
+ openReason: OpenChangeReason | null;
+ instantType: 'dismiss' | 'click' | undefined;
}
}
diff --git a/packages/react/src/menu/trigger/MenuTrigger.test.tsx b/packages/react/src/menu/trigger/MenuTrigger.test.tsx
index b994e047fd..f19460d545 100644
--- a/packages/react/src/menu/trigger/MenuTrigger.test.tsx
+++ b/packages/react/src/menu/trigger/MenuTrigger.test.tsx
@@ -1,35 +1,10 @@
import * as React from 'react';
import { expect } from 'chai';
-import { FloatingRootContext, FloatingTree } from '@floating-ui/react';
import userEvent from '@testing-library/user-event';
-import { act, screen } from '@mui/internal-test-utils';
+import { act, fireEvent, screen } from '@mui/internal-test-utils';
import { Menu } from '@base-ui-components/react/menu';
import { describeConformance, createRenderer } from '#test-utils';
-import { MenuRootContext } from '../root/MenuRootContext';
-
-const testRootContext: MenuRootContext = {
- floatingRootContext: {} as FloatingRootContext,
- getPopupProps: (p) => ({ ...p }),
- getTriggerProps: (p) => ({ ...p }),
- getItemProps: (p) => ({ ...p }),
- parentContext: undefined,
- nested: false,
- setTriggerElement: () => {},
- setPositionerElement: () => {},
- activeIndex: null,
- disabled: false,
- itemDomElements: { current: [] },
- itemLabels: { current: [] },
- open: true,
- setOpen: () => {},
- popupRef: { current: null },
- mounted: true,
- transitionStatus: undefined,
- typingRef: { current: false },
- modal: false,
- positionerRef: { current: null },
- allowMouseUpTriggerRef: { current: false },
-};
+import { PATIENT_CLICK_THRESHOLD } from '../../utils/constants';
describe('', () => {
const { render } = createRenderer();
@@ -37,11 +12,7 @@ describe('', () => {
describeConformance(, () => ({
render: (node) => {
- return render(
-
- {node}
- ,
- );
+ return render({node});
},
refInstanceof: window.HTMLButtonElement,
}));
@@ -184,4 +155,88 @@ describe('', () => {
expect(trigger).to.have.attribute('data-pressed');
});
});
+
+ describe('impatient clicks with `openOnHover=true`', () => {
+ const { clock, render: renderFakeTimers } = createRenderer();
+
+ clock.withFakeTimers();
+
+ it('does not close the menu if the user clicks too quickly', async () => {
+ await renderFakeTimers(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+
+ fireEvent.mouseMove(trigger);
+
+ clock.tick(PATIENT_CLICK_THRESHOLD - 1);
+
+ fireEvent.click(trigger);
+
+ expect(trigger).to.have.attribute('data-popup-open');
+ });
+
+ it('closes the menu if the user clicks patiently', async () => {
+ await renderFakeTimers(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+
+ fireEvent.mouseEnter(trigger);
+
+ clock.tick(PATIENT_CLICK_THRESHOLD);
+
+ fireEvent.click(trigger);
+
+ expect(trigger).not.to.have.attribute('data-popup-open');
+ });
+
+ it('sticks if the user clicks impatiently', async () => {
+ await renderFakeTimers(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+
+ fireEvent.mouseEnter(trigger);
+
+ clock.tick(PATIENT_CLICK_THRESHOLD - 1);
+
+ fireEvent.click(trigger);
+ fireEvent.mouseLeave(trigger);
+
+ expect(trigger).to.have.attribute('data-popup-open');
+
+ clock.tick(1);
+
+ expect(trigger).to.have.attribute('data-popup-open');
+ });
+
+ it('does not stick if the user clicks patiently', async () => {
+ await renderFakeTimers(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+
+ fireEvent.mouseEnter(trigger);
+
+ clock.tick(PATIENT_CLICK_THRESHOLD);
+
+ fireEvent.click(trigger);
+ fireEvent.mouseLeave(trigger);
+
+ expect(trigger).not.to.have.attribute('data-popup-open');
+ });
+ });
});
diff --git a/packages/react/src/popover/popup/PopoverPopupDataAttributes.ts b/packages/react/src/popover/popup/PopoverPopupDataAttributes.ts
index 14a4ae6444..e705ace7c9 100644
--- a/packages/react/src/popover/popup/PopoverPopupDataAttributes.ts
+++ b/packages/react/src/popover/popup/PopoverPopupDataAttributes.ts
@@ -20,4 +20,9 @@ export enum PopoverPopupDataAttributes {
* @type {'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start'}
*/
side = 'data-side',
+ /**
+ * Indicates the instant type of the popover popup.
+ * @type {'click' | 'dismiss'}
+ */
+ instant = 'data-instant',
}
diff --git a/packages/react/src/popover/root/usePopoverRoot.ts b/packages/react/src/popover/root/usePopoverRoot.ts
index 02ecfe22f5..a7a0950ba8 100644
--- a/packages/react/src/popover/root/usePopoverRoot.ts
+++ b/packages/react/src/popover/root/usePopoverRoot.ts
@@ -13,7 +13,7 @@ import {
import { useControlled } from '../../utils/useControlled';
import { useEventCallback } from '../../utils/useEventCallback';
import { useTransitionStatus } from '../../utils/useTransitionStatus';
-import { PATIENT_CLICK_THRESHOLD, OPEN_DELAY } from '../utils/constants';
+import { OPEN_DELAY } from '../utils/constants';
import type { GenericHTMLProps } from '../../utils/types';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
import { type InteractionType } from '../../utils/useEnhancedClickHandler';
@@ -24,6 +24,7 @@ import {
type OpenChangeReason,
} from '../../utils/translateOpenChangeReason';
import { useAfterExitAnimation } from '../../utils/useAfterExitAnimation';
+import { PATIENT_CLICK_THRESHOLD } from '../../utils/constants';
export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoot.ReturnValue {
const {
@@ -78,7 +79,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
useAfterExitAnimation({
open,
animatedElementRef: popupRef,
- onFinished: () => {
+ onFinished() {
setMounted(false);
setOpenReason(null);
},
diff --git a/packages/react/src/popover/trigger/PopoverTrigger.test.tsx b/packages/react/src/popover/trigger/PopoverTrigger.test.tsx
index 645a1ef7a0..7b5d0e4bfd 100644
--- a/packages/react/src/popover/trigger/PopoverTrigger.test.tsx
+++ b/packages/react/src/popover/trigger/PopoverTrigger.test.tsx
@@ -3,7 +3,7 @@ import { Popover } from '@base-ui-components/react/popover';
import { createRenderer, describeConformance } from '#test-utils';
import { expect } from 'chai';
import { act, fireEvent, screen } from '@mui/internal-test-utils';
-import { PATIENT_CLICK_THRESHOLD } from '../utils/constants';
+import { PATIENT_CLICK_THRESHOLD } from '../../utils/constants';
describe('', () => {
const { render } = createRenderer();
@@ -99,13 +99,11 @@ describe('', () => {
const trigger = screen.getByRole('button');
- fireEvent.mouseEnter(trigger);
+ fireEvent.mouseMove(trigger);
clock.tick(PATIENT_CLICK_THRESHOLD - 1);
- await act(async () => {
- trigger.click();
- });
+ fireEvent.click(trigger);
expect(trigger).to.have.attribute('data-popup-open');
});
@@ -123,9 +121,26 @@ describe('', () => {
clock.tick(PATIENT_CLICK_THRESHOLD);
- await act(async () => {
- trigger.click();
- });
+ fireEvent.click(trigger);
+
+ expect(trigger).not.to.have.attribute('data-popup-open');
+ });
+
+ it('does not stick if the user clicks patiently', async () => {
+ await renderFakeTimers(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+
+ fireEvent.mouseEnter(trigger);
+
+ clock.tick(PATIENT_CLICK_THRESHOLD);
+
+ fireEvent.click(trigger);
+ fireEvent.mouseLeave(trigger);
expect(trigger).not.to.have.attribute('data-popup-open');
});
diff --git a/packages/react/src/popover/utils/constants.ts b/packages/react/src/popover/utils/constants.ts
index 671cbacd6a..8a71581619 100644
--- a/packages/react/src/popover/utils/constants.ts
+++ b/packages/react/src/popover/utils/constants.ts
@@ -1,2 +1 @@
export const OPEN_DELAY = 300;
-export const PATIENT_CLICK_THRESHOLD = 500;
diff --git a/packages/react/src/utils/constants.ts b/packages/react/src/utils/constants.ts
index c2d6958b2f..35e137f5d8 100644
--- a/packages/react/src/utils/constants.ts
+++ b/packages/react/src/utils/constants.ts
@@ -1 +1,2 @@
export const TYPEAHEAD_RESET_MS = 500;
+export const PATIENT_CLICK_THRESHOLD = 500;