Skip to content

[popups] Refine OpenChangeReason #1782

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
2 changes: 1 addition & 1 deletion docs/reference/generated/alert-dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"description": "Whether the dialog is currently open."
},
"onOpenChange": {
"type": "((open: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => void)",
"type": "(open: boolean, event?: Event, reason?: AlertDialog.Root.OpenChangeReason) => void",
"description": "Event handler called when the dialog is opened or closed."
},
"actionsRef": {
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"description": "Whether the dialog is currently open."
},
"onOpenChange": {
"type": "((open: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => void)",
"type": "(open: boolean, event?: Event, reason?: Dialog.Root.OpenChangeReason) => void",
"description": "Event handler called when the dialog is opened or closed."
},
"actionsRef": {
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/generated/menu-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
"description": "Whether the menu is currently open."
},
"onOpenChange": {
"type": "((open: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => void)",
"type": "(open: boolean, event?: Event, reason?: Menu.Root.OpenChangeReason) => void",
"description": "Event handler called when the menu is opened or closed."
},
"actionsRef": {
"type": "RefObject<{ unmount: () => void; }>",
"type": "RefObject<Actions>",
"description": "A ref to imperative actions.\n- `unmount`: When specified, the menu will not be unmounted when closed.\nInstead, the `unmount` function must be called to unmount the menu manually.\nUseful when the menu's animation is controlled by an external library."
},
"closeParentOnEsc": {
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/popover-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"description": "Whether the popover is currently open."
},
"onOpenChange": {
"type": "((open: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => void)",
"type": "(open: boolean, event?: Event, reason?: Popover.Root.OpenChangeReason) => void",
"description": "Event handler called when the popover is opened or closed."
},
"actionsRef": {
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/preview-card-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"description": "Whether the preview card is currently open."
},
"onOpenChange": {
"type": "((open: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => void)",
"type": "(open: boolean, event?: Event, reason?: PreviewCard.Root.OpenChangeReason) => void",
"description": "Event handler called when the preview card is opened or closed."
},
"actionsRef": {
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/select-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"description": "Whether the select menu is currently open."
},
"onOpenChange": {
"type": "((open: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => void)",
"type": "(open: boolean, event?: Event, reason?: Select.Root.OpenChangeReason) => void",
"description": "Event handler called when the select menu is opened or closed."
},
"actionsRef": {
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/tooltip-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"description": "Whether the tooltip is currently open."
},
"onOpenChange": {
"type": "((open: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => void)",
"type": "(open: boolean, event?: Event, reason?: Tooltip.Root.OpenChangeReason) => void",
"description": "Event handler called when the tooltip is opened or closed."
},
"actionsRef": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@ describe('<AlertDialog.Root />', () => {
await user.click(openButton);

expect(handleOpenChange.callCount).to.equal(1);
expect(handleOpenChange.firstCall.args[2]).to.equal('click');
expect(handleOpenChange.firstCall.args[2]).to.equal('trigger-press');

const closeButton = screen.getByText('Close');
await user.click(closeButton);

expect(handleOpenChange.callCount).to.equal(2);
expect(handleOpenChange.secondCall.args[2]).to.equal('click');
expect(handleOpenChange.secondCall.args[2]).to.equal('close-press');
});

it('calls onOpenChange with the reason for change when pressed Esc while the dialog is open', async () => {
Expand Down
16 changes: 14 additions & 2 deletions packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as React from 'react';
import type { DialogRoot } from '../../dialog/root/DialogRoot';
import { AlertDialogRootContext } from './AlertDialogRootContext';
import { useDialogRoot } from '../../dialog/root/useDialogRoot';
import { type DialogOpenChangeReason, useDialogRoot } from '../../dialog/root/useDialogRoot';

/**
* Groups all parts of the alert dialog.
Expand Down Expand Up @@ -53,7 +53,19 @@ export const AlertDialogRoot: React.FC<AlertDialogRoot.Props> = function AlertDi
};

export namespace AlertDialogRoot {
export interface Props extends Omit<DialogRoot.Props, 'modal' | 'dismissible'> {}
export interface Props extends Omit<DialogRoot.Props, 'modal' | 'dismissible' | 'onOpenChange'> {
/**
* Event handler called when the dialog is opened or closed.
* @type (open: boolean, event?: Event, reason?: AlertDialog.Root.OpenChangeReason) => void
*/
onOpenChange?: (
open: boolean,
event: Event | undefined,
reason: OpenChangeReason | undefined,
) => void;
}

export type Actions = DialogRoot.Actions;

export type OpenChangeReason = DialogOpenChangeReason;
}
6 changes: 3 additions & 3 deletions packages/react/src/dialog/close/useDialogClose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
import * as React from 'react';
import { useButton } from '../../use-button/useButton';
import { mergeProps } from '../../merge-props';
import { OpenChangeReason } from '../../utils/translateOpenChangeReason';
import type { HTMLProps } from '../../utils/types';
import { useEventCallback } from '../../utils/useEventCallback';
import { DialogOpenChangeReason } from '../root/useDialogRoot';

export function useDialogClose(params: useDialogClose.Parameters): useDialogClose.ReturnValue {
const { open, setOpen, rootRef: externalRef, disabled } = params;

const handleClick = useEventCallback((event: React.MouseEvent) => {
if (open) {
setOpen(false, event.nativeEvent, 'click');
setOpen(false, event.nativeEvent, 'close-press');
}
});

Expand Down Expand Up @@ -44,7 +44,7 @@ export namespace useDialogClose {
setOpen: (
open: boolean,
event: Event | undefined,
reason: OpenChangeReason | undefined,
reason: DialogOpenChangeReason | undefined,
) => void;
rootRef: React.Ref<HTMLElement>;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/dialog/popup/useDialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import * as React from 'react';
import { useForkRef } from '../../utils/useForkRef';
import { mergeProps } from '../../merge-props';
import type { DialogOpenChangeReason } from '../root/useDialogRoot';
import { type InteractionType } from '../../utils/useEnhancedClickHandler';
import { HTMLProps } from '../../utils/types';
import { type OpenChangeReason } from '../../utils/translateOpenChangeReason';
import { COMPOSITE_KEYS } from '../../composite/composite';

export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialogPopup.ReturnValue {
Expand Down Expand Up @@ -87,7 +87,7 @@ export namespace useDialogPopup {
setOpen: (
open: boolean,
event: Event | undefined,
reason: OpenChangeReason | undefined,
reason: DialogOpenChangeReason | undefined,
) => void;
/**
* The id of the title element associated with the dialog.
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/dialog/root/DialogRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,13 @@ describe('<Dialog.Root />', () => {
await user.click(openButton);

expect(handleOpenChange.callCount).to.equal(1);
expect(handleOpenChange.firstCall.args[2]).to.equal('click');
expect(handleOpenChange.firstCall.args[2]).to.equal('trigger-press');

const closeButton = screen.getByText('Close');
await user.click(closeButton);

expect(handleOpenChange.callCount).to.equal(2);
expect(handleOpenChange.secondCall.args[2]).to.equal('click');
expect(handleOpenChange.secondCall.args[2]).to.equal('close-press');
});

it('calls onOpenChange with the reason for change when pressed Esc while the dialog is open', async () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/dialog/root/DialogRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as React from 'react';
import { DialogRootContext, useOptionalDialogRootContext } from './DialogRootContext';
import { DialogContext } from '../utils/DialogContext';
import { useDialogRoot } from './useDialogRoot';
import { type DialogOpenChangeReason, useDialogRoot } from './useDialogRoot';

/**
* Groups all parts of the dialog.
Expand Down Expand Up @@ -63,4 +63,6 @@ export namespace DialogRoot {
}

export type Actions = useDialogRoot.Actions;

export type OpenChangeReason = DialogOpenChangeReason;
}
11 changes: 7 additions & 4 deletions packages/react/src/dialog/root/useDialogRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import { useOpenInteractionType } from '../../utils/useOpenInteractionType';
import { mergeProps } from '../../merge-props';
import { useOpenChangeComplete } from '../../utils/useOpenChangeComplete';
import {
type OpenChangeReason,
type BaseOpenChangeReason,
translateOpenChangeReason,
} from '../../utils/translateOpenChangeReason';

export type DialogOpenChangeReason = BaseOpenChangeReason | 'close-press';

export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.ReturnValue {
const {
defaultOpen,
Expand Down Expand Up @@ -57,7 +59,7 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R
const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);

const setOpen = useEventCallback(
(nextOpen: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => {
(nextOpen: boolean, event: Event | undefined, reason: DialogOpenChangeReason | undefined) => {
onOpenChangeParameter?.(nextOpen, event, reason);
setOpenUnwrapped(nextOpen);
},
Expand Down Expand Up @@ -228,11 +230,12 @@ export namespace useDialogRoot {
modal?: boolean | 'trap-focus';
/**
* Event handler called when the dialog is opened or closed.
* @type (open: boolean, event?: Event, reason?: Dialog.Root.OpenChangeReason) => void
*/
onOpenChange?: (
open: boolean,
event: Event | undefined,
reason: OpenChangeReason | undefined,
reason: DialogOpenChangeReason | undefined,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the publicly exported type here (AlertDialog.Root.OpenChangeReason). If DialogOpenChangeReason is used, devs won't know where to import it from.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose type only imports don't lead to circular dependency issues?

Copy link
Contributor Author

@atomiks atomiks May 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator can't handle the public type of Select.Root.OpenChangeReason. To be fair, the Actions type is also not clear where it exists from at the moment, so I could just do OpenChangeReason, and maybe the generator (or some manual code) can automatically prepend the component name + Root. Would be useful to still have overrides in this case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once I update the API extractor to handle types in namespaces, we shouldn't need to use @type annotations manually. Still, an ability to override could be useful sometimes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does seem like they're still needed here

) => void;
/**
* Event handler called after any animations complete when the dialog is opened or closed.
Expand Down Expand Up @@ -298,7 +301,7 @@ export namespace useDialogRoot {
setOpen: (
open: boolean,
event: Event | undefined,
reason: OpenChangeReason | undefined,
reason: DialogOpenChangeReason | undefined,
) => void;
/**
* Whether the dialog is currently open.
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/menu/backdrop/MenuBackdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const MenuBackdrop = React.forwardRef(function MenuBackdrop(
role: 'presentation',
hidden: !mounted,
style: {
pointerEvents: lastOpenChangeReason === 'hover' ? 'none' : undefined,
pointerEvents: lastOpenChangeReason === 'trigger-hover' ? 'none' : undefined,
userSelect: 'none',
WebkitUserSelect: 'none',
},
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/menu/popup/useMenuPopup.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
'use client';
import * as React from 'react';
import type { FloatingEvents } from '@floating-ui/react';
import type { OpenChangeReason } from '../../utils/translateOpenChangeReason';
import type { MenuOpenChangeReason } from '../root/useMenuRoot';

export function useMenuPopup(parameters: useMenuPopup.Parameters): useMenuPopup.ReturnValue {
const { menuEvents, setOpen } = parameters;

React.useEffect(() => {
function handleClose(event: {
domEvent: Event | undefined;
reason: OpenChangeReason | undefined;
reason: MenuOpenChangeReason | undefined;
}) {
setOpen(false, event.domEvent, event.reason);
}
Expand All @@ -34,7 +34,7 @@ export namespace useMenuPopup {
setOpen: (
open: boolean,
event: Event | undefined,
reason: OpenChangeReason | undefined,
reason: MenuOpenChangeReason | undefined,
) => void;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/menu/positioner/MenuPositioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export const MenuPositioner = React.forwardRef(function MenuPositioner(
const shouldRenderBackdrop =
mounted &&
parent.type !== 'menu' &&
((parent.type !== 'menubar' && modal && lastOpenChangeReason !== 'hover') ||
((parent.type !== 'menubar' && modal && lastOpenChangeReason !== 'trigger-hover') ||
(parent.type === 'menubar' && parent.context.modal));

const backdropCutout = parent.type === 'menubar' ? parent.context.contentElement : undefined;
Expand Down
10 changes: 6 additions & 4 deletions packages/react/src/menu/root/MenuRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import * as React from 'react';
import { FloatingTree } from '@floating-ui/react';
import { MenuRootContext } from './MenuRootContext';
import { MenuOrientation, useMenuRoot } from './useMenuRoot';
import type { OpenChangeReason } from '../../utils/translateOpenChangeReason';
import { MenuOrientation, MenuOpenChangeReason, useMenuRoot } from './useMenuRoot';

/**
* Groups all parts of the menu.
Expand Down Expand Up @@ -78,11 +77,12 @@ export namespace MenuRoot {
modal?: boolean;
/**
* Event handler called when the menu is opened or closed.
* @type (open: boolean, event?: Event, reason?: Menu.Root.OpenChangeReason) => void
*/
onOpenChange?: (
open: boolean,
event: Event | undefined,
reason: OpenChangeReason | undefined,
reason: MenuOpenChangeReason | undefined,
) => void;
/**
* Event handler called after any animations complete when the menu is closed.
Expand Down Expand Up @@ -128,10 +128,12 @@ export namespace MenuRoot {
* Instead, the `unmount` function must be called to unmount the menu manually.
* Useful when the menu's animation is controlled by an external library.
*/
actionsRef?: React.RefObject<{ unmount: () => void }>;
actionsRef?: React.RefObject<Actions>;
}

export interface Actions {
unmount: () => void;
}

export type OpenChangeReason = MenuOpenChangeReason;
}
8 changes: 7 additions & 1 deletion packages/react/src/menu/root/MenuRootContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
import * as React from 'react';
import type { useMenuRoot } from './useMenuRoot';

export interface MenuRootContext extends useMenuRoot.ReturnValue {}
export interface MenuRootContext extends useMenuRoot.ReturnValue {
disabled: boolean;
typingRef: React.RefObject<boolean>;
modal: boolean;
onOpenChangeComplete: ((open: boolean) => void) | undefined;
setHoverEnabled: React.Dispatch<React.SetStateAction<boolean>>;
}

export const MenuRootContext = React.createContext<MenuRootContext | undefined>(undefined);

Expand Down
Loading