diff --git a/docs/reference/generated/menu-popup.json b/docs/reference/generated/menu-popup.json
index 198aed3a64..589987eac0 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": "Present if animations should be instant.",
+ "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..ae2fce2413 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": "Present if animations should be instant.",
+ "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/docs/reference/generated/tooltip-popup.json b/docs/reference/generated/tooltip-popup.json
index 56325e4847..c8188486a8 100644
--- a/docs/reference/generated/tooltip-popup.json
+++ b/docs/reference/generated/tooltip-popup.json
@@ -19,7 +19,7 @@
"description": "Present when the tooltip is closed."
},
"data-instant": {
- "description": "Indicates the instant type of the tooltip popup.",
+ "description": "Present if animations should be instant.",
"type": "'delay' | 'dismiss' | 'focus'"
},
"data-side": {
diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx
index 28c2cc79ac..2e2ab8aee4 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 91865587aa..e910ce8441 100644
--- a/packages/react/src/menu/item/MenuItem.test.tsx
+++ b/packages/react/src/menu/item/MenuItem.test.tsx
@@ -3,34 +3,8 @@ import { expect } from 'chai';
import { spy } from 'sinon';
import { MemoryRouter, Route, Routes, Link, useLocation } from 'react-router-dom';
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({
@@ -43,11 +17,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..d3ff83aa90 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',
+ /**
+ * Present if animations should be instant.
+ * @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 73a97bd4fd..f4aecebbc3 100644
--- a/packages/react/src/menu/positioner/MenuPositioner.test.tsx
+++ b/packages/react/src/menu/positioner/MenuPositioner.test.tsx
@@ -1,35 +1,9 @@
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();
@@ -37,11 +11,9 @@ describe('', () => {
describeConformance(, () => ({
render: (node) => {
return render(
-
-
- {node}
-
- ,
+
+ {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 5c6aa09b17..c3eefad755 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 c5f0daed1c..1c41629bfa 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -5,6 +5,7 @@ import { FloatingTree } from '@floating-ui/react';
import { useDirection } from '../../direction-provider/DirectionContext';
import { MenuRootContext, useMenuRootContext } from './MenuRootContext';
import { MenuOrientation, useMenuRoot } from './useMenuRoot';
+import type { OpenChangeReason } from '../../utils/translateOpenChangeReason';
/**
* Groups all parts of the menu.
@@ -107,7 +108,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;
/**
* Event handler called after any exit animations finish when the menu is closed.
*/
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 18b93968c6..27578c9688 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[] = [];
@@ -46,10 +51,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,
@@ -69,33 +79,86 @@ 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);
+ setOpenReason(null);
+ setHoverEnabled(true);
+ setStickIfOpen(true);
onCloseComplete?.();
},
});
+ 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,
},
@@ -106,6 +169,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
event: 'mousedown',
toggle: !nested,
ignoreMouse: nested,
+ stickIfOpen,
});
const dismiss = useDismiss(floatingRootContext, {
@@ -157,7 +221,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
(externalProps?: GenericHTMLProps) =>
getReferenceProps(
mergeReactProps(externalProps, {
- onMouseEnter: () => {
+ onMouseEnter() {
setHoverEnabled(true);
},
}),
@@ -169,12 +233,19 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
(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(
@@ -195,6 +266,8 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
setPositionerElement,
setTriggerElement,
transitionStatus,
+ openReason,
+ instantType,
}),
[
activeIndex,
@@ -210,6 +283,8 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
setOpen,
transitionStatus,
setPositionerElement,
+ openReason,
+ instantType,
],
);
}
@@ -225,7 +300,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;
/**
* Event handler called after any exit animations finish when the menu is closed.
*/
@@ -297,5 +372,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 4ffb900856..68040f68b7 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,
}));
@@ -190,4 +161,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..24ff5fd557 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',
+ /**
+ * Present if animations should be instant.
+ * @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 ff9c0cb2cd..3a1e3af1bf 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 {
@@ -79,7 +80,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
useAfterExitAnimation({
open,
animatedElementRef: popupRef,
- onFinished: () => {
+ onFinished() {
setMounted(false);
setOpenReason(null);
onCloseComplete?.();
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/tooltip/popup/TooltipPopupDataAttributes.ts b/packages/react/src/tooltip/popup/TooltipPopupDataAttributes.ts
index 9bf2420a26..df425103ac 100644
--- a/packages/react/src/tooltip/popup/TooltipPopupDataAttributes.ts
+++ b/packages/react/src/tooltip/popup/TooltipPopupDataAttributes.ts
@@ -21,7 +21,7 @@ export enum TooltipPopupDataAttributes {
*/
side = 'data-side',
/**
- * Indicates the instant type of the tooltip popup.
+ * Present if animations should be instant.
* @type {'delay' | 'dismiss' | 'focus'}
*/
instant = 'data-instant',
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;