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;