diff --git a/docs/data/api/alert-dialog-popup.json b/docs/data/api/alert-dialog-popup.json
index 902e31c784..472c6acc34 100644
--- a/docs/data/api/alert-dialog-popup.json
+++ b/docs/data/api/alert-dialog-popup.json
@@ -2,6 +2,7 @@
"props": {
"className": { "type": { "name": "union", "description": "func | string" } },
"container": { "type": { "name": "union", "description": "HTML element | ref" } },
+ "initialFocus": { "type": { "name": "union", "description": "func | ref" } },
"keepMounted": { "type": { "name": "bool" }, "default": "false" },
"render": { "type": { "name": "union", "description": "element | func" } }
},
diff --git a/docs/data/api/dialog-popup.json b/docs/data/api/dialog-popup.json
index 4bb1be663a..d2ae6b679f 100644
--- a/docs/data/api/dialog-popup.json
+++ b/docs/data/api/dialog-popup.json
@@ -2,6 +2,7 @@
"props": {
"className": { "type": { "name": "union", "description": "func | string" } },
"container": { "type": { "name": "union", "description": "HTML element | ref" } },
+ "initialFocus": { "type": { "name": "union", "description": "func | ref" } },
"keepMounted": { "type": { "name": "bool" }, "default": "false" },
"render": { "type": { "name": "union", "description": "element | func" } }
},
diff --git a/docs/data/api/popover-positioner.json b/docs/data/api/popover-positioner.json
index dc1d42a20d..798223f685 100644
--- a/docs/data/api/popover-positioner.json
+++ b/docs/data/api/popover-positioner.json
@@ -32,6 +32,7 @@
},
"container": { "type": { "name": "union", "description": "HTML element | func" } },
"hideWhenDetached": { "type": { "name": "bool" }, "default": "false" },
+ "initialFocus": { "type": { "name": "union", "description": "func | ref" } },
"keepMounted": { "type": { "name": "bool" }, "default": "false" },
"positionMethod": {
"type": { "name": "enum", "description": "'absolute' | 'fixed'" },
diff --git a/docs/data/translations/api-docs/alert-dialog-popup/alert-dialog-popup.json b/docs/data/translations/api-docs/alert-dialog-popup/alert-dialog-popup.json
index e91f99d7cc..4f0c02e6af 100644
--- a/docs/data/translations/api-docs/alert-dialog-popup/alert-dialog-popup.json
+++ b/docs/data/translations/api-docs/alert-dialog-popup/alert-dialog-popup.json
@@ -5,6 +5,9 @@
"description": "Class names applied to the element or a function that returns them based on the component's state."
},
"container": { "description": "The container element to which the popup is appended to." },
+ "initialFocus": {
+ "description": "Determines an element to focus when the dialog is opened. It can be either a ref to the element or a function that returns such a ref. If not provided, the first focusable element is focused."
+ },
"keepMounted": {
"description": "If true
, the dialog element is kept in the DOM when closed."
},
diff --git a/docs/data/translations/api-docs/dialog-popup/dialog-popup.json b/docs/data/translations/api-docs/dialog-popup/dialog-popup.json
index e91f99d7cc..4f0c02e6af 100644
--- a/docs/data/translations/api-docs/dialog-popup/dialog-popup.json
+++ b/docs/data/translations/api-docs/dialog-popup/dialog-popup.json
@@ -5,6 +5,9 @@
"description": "Class names applied to the element or a function that returns them based on the component's state."
},
"container": { "description": "The container element to which the popup is appended to." },
+ "initialFocus": {
+ "description": "Determines an element to focus when the dialog is opened. It can be either a ref to the element or a function that returns such a ref. If not provided, the first focusable element is focused."
+ },
"keepMounted": {
"description": "If true
, the dialog element is kept in the DOM when closed."
},
diff --git a/docs/data/translations/api-docs/popover-positioner/popover-positioner.json b/docs/data/translations/api-docs/popover-positioner/popover-positioner.json
index a3e4cceb4d..e21cb08fe6 100644
--- a/docs/data/translations/api-docs/popover-positioner/popover-positioner.json
+++ b/docs/data/translations/api-docs/popover-positioner/popover-positioner.json
@@ -24,6 +24,9 @@
"hideWhenDetached": {
"description": "Whether the popover element is hidden if it appears detached from its anchor element due to the anchor element being clipped (or hidden) from view."
},
+ "initialFocus": {
+ "description": "Determines an element to focus when the popover is opened. It can be either a ref to the element or a function that returns such a ref. If not provided, the first focusable element is focused."
+ },
"keepMounted": {
"description": "Whether the popover remains mounted in the DOM while closed."
},
diff --git a/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.test.tsx b/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.test.tsx
index e11cd0b2aa..f493f99dbf 100644
--- a/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.test.tsx
+++ b/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.test.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { expect } from 'chai';
+import { act, waitFor } from '@mui/internal-test-utils';
import { AlertDialog } from '@base_ui/react/AlertDialog';
import { createRenderer, describeConformance } from '#test-utils';
@@ -29,4 +30,104 @@ describe(' ', () => {
const dialog = getByTestId('test-alert-dialog');
expect(dialog).to.have.attribute('role', 'alertdialog');
});
+
+ describe('prop: initial focus', () => {
+ it('should focus the first focusable element within the popup by default', async () => {
+ const { getByText, getByTestId } = await render(
+
,
+ );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const dialogInput = getByTestId('dialog-input');
+ expect(dialogInput).to.toHaveFocus();
+ });
+ });
+
+ it('should focus the element provided to `initialFocus` as a ref when open', async () => {
+ function TestComponent() {
+ const input2Ref = React.useRef(null);
+ return (
+
+ );
+ }
+
+ const { getByText, getByTestId } = await render( );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const input2 = getByTestId('input-2');
+ expect(input2).to.toHaveFocus();
+ });
+ });
+
+ it('should focus the element provided to `initialFocus` as a function when open', async () => {
+ function TestComponent() {
+ const input2Ref = React.useRef(null);
+
+ const getRef = React.useCallback(() => input2Ref, []);
+
+ return (
+
+ );
+ }
+
+ const { getByText, getByTestId } = await render( );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const input2 = getByTestId('input-2');
+ expect(input2).to.toHaveFocus();
+ });
+ });
+ });
});
diff --git a/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.tsx b/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.tsx
index 8f5d9f7296..4979c7c513 100644
--- a/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.tsx
+++ b/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.tsx
@@ -10,6 +10,8 @@ import type { BaseUIComponentProps } from '../../utils/types';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
import { popupOpenStateMapping as baseMapping } from '../../utils/popupOpenStateMapping';
+import { useForkRef } from '../../utils/useForkRef';
+import { InteractionType } from '../../utils/useEnhancedClickHandler';
const customStyleHookMapping: CustomStyleHookMapping = {
...baseMapping,
@@ -39,18 +41,23 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
props: AlertDialogPopup.Props,
forwardedRef: React.ForwardedRef,
) {
- const { className, container, id, keepMounted = false, render, ...other } = props;
+ const { className, container, id, keepMounted = false, render, initialFocus, ...other } = props;
const rootContext = useAlertDialogRootContext();
const { open, nestedOpenDialogCount } = rootContext;
- const { getRootProps, floatingContext, mounted, transitionStatus } = useDialogPopup({
- id,
- ref: forwardedRef,
- dismissible: false,
- isTopmost: nestedOpenDialogCount === 0,
- ...rootContext,
- });
+ const popupRef = React.useRef(null);
+ const mergedRef = useForkRef(forwardedRef, popupRef);
+
+ const { getRootProps, floatingContext, mounted, transitionStatus, resolvedInitialFocus } =
+ useDialogPopup({
+ id,
+ ref: mergedRef,
+ isTopmost: nestedOpenDialogCount === 0,
+ dismissible: false,
+ initialFocus,
+ ...rootContext,
+ });
const ownerState: AlertDialogPopup.OwnerState = React.useMemo(
() => ({
@@ -80,7 +87,12 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
return (
-
+
{renderElement()}
@@ -99,6 +111,14 @@ namespace AlertDialogPopup {
* @default false
*/
keepMounted?: boolean;
+ /**
+ * Determines an element to focus when the dialog is opened.
+ * It can be either a ref to the element or a function that returns such a ref.
+ * If not provided, the first focusable element is focused.
+ */
+ initialFocus?:
+ | React.RefObject
+ | ((interactionType: InteractionType) => React.RefObject);
}
export interface OwnerState {
@@ -129,6 +149,15 @@ AlertDialogPopup.propTypes /* remove-proptypes */ = {
* @ignore
*/
id: PropTypes.string,
+ /**
+ * Determines an element to focus when the dialog is opened.
+ * It can be either a ref to the element or a function that returns such a ref.
+ * If not provided, the first focusable element is focused.
+ */
+ initialFocus: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
+ PropTypes.func,
+ refType,
+ ]),
/**
* If `true`, the dialog element is kept in the DOM when closed.
*
diff --git a/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.ts b/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.ts
deleted file mode 100644
index 806101ee5d..0000000000
--- a/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// This file must be present for the doc gen to work
-
-describe(' ', () => {
- it('no-op', () => {});
-});
diff --git a/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.tsx b/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.tsx
new file mode 100644
index 0000000000..97c99404f7
--- /dev/null
+++ b/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.tsx
@@ -0,0 +1,5 @@
+// This file is required by the API doc generator
+
+describe(' ', () => {
+ it('no-op', () => {});
+});
diff --git a/packages/mui-base/src/AlertDialog/Trigger/AlertDialogTrigger.tsx b/packages/mui-base/src/AlertDialog/Trigger/AlertDialogTrigger.tsx
index 737c55888b..3f67230d20 100644
--- a/packages/mui-base/src/AlertDialog/Trigger/AlertDialogTrigger.tsx
+++ b/packages/mui-base/src/AlertDialog/Trigger/AlertDialogTrigger.tsx
@@ -22,11 +22,12 @@ const AlertDialogTrigger = React.forwardRef(function AlertDialogTrigger(
forwardedRef: React.ForwardedRef,
) {
const { render, className, ...other } = props;
- const { open, onOpenChange, popupElementId } = useAlertDialogRootContext();
+ const { open, onOpenChange, onTriggerClick, popupElementId } = useAlertDialogRootContext();
const { getRootProps } = useDialogTrigger({
open,
onOpenChange,
+ onTriggerClick,
popupElementId,
});
diff --git a/packages/mui-base/src/Dialog/Popup/DialogPopup.test.tsx b/packages/mui-base/src/Dialog/Popup/DialogPopup.test.tsx
index 7f1114b7ce..c28c50c098 100644
--- a/packages/mui-base/src/Dialog/Popup/DialogPopup.test.tsx
+++ b/packages/mui-base/src/Dialog/Popup/DialogPopup.test.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { expect } from 'chai';
import { Dialog } from '@base_ui/react/Dialog';
+import { act, waitFor } from '@mui/internal-test-utils';
import { describeConformance, createRenderer } from '#test-utils';
describe(' ', () => {
@@ -40,4 +41,101 @@ describe(' ', () => {
});
});
});
+
+ describe('prop: initial focus', () => {
+ it('should focus the first focusable element within the popup', async () => {
+ const { getByText, getByTestId } = await render(
+
+
+
+ Open
+
+
+ Close
+
+
+
+
,
+ );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const dialogInput = getByTestId('dialog-input');
+ expect(dialogInput).to.toHaveFocus();
+ });
+ });
+
+ it('should focus the element provided to `initialFocus` as a ref when open', async () => {
+ function TestComponent() {
+ const input2Ref = React.useRef(null);
+ return (
+
+
+
+ Open
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+
+ const { getByText, getByTestId } = await render( );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const input2 = getByTestId('input-2');
+ expect(input2).to.toHaveFocus();
+ });
+ });
+
+ it('should focus the element provided to `initialFocus` as a function when open', async () => {
+ function TestComponent() {
+ const input2Ref = React.useRef(null);
+
+ const getRef = React.useCallback(() => input2Ref, []);
+
+ return (
+
+
+
+ Open
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+
+ const { getByText, getByTestId } = await render( );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const input2 = getByTestId('input-2');
+ expect(input2).to.toHaveFocus();
+ });
+ });
+ });
});
diff --git a/packages/mui-base/src/Dialog/Popup/DialogPopup.tsx b/packages/mui-base/src/Dialog/Popup/DialogPopup.tsx
index b3d990e352..ca3b8505ee 100644
--- a/packages/mui-base/src/Dialog/Popup/DialogPopup.tsx
+++ b/packages/mui-base/src/Dialog/Popup/DialogPopup.tsx
@@ -10,6 +10,8 @@ import { type BaseUIComponentProps } from '../../utils/types';
import { type TransitionStatus } from '../../utils/useTransitionStatus';
import { type CustomStyleHookMapping } from '../../utils/getStyleHookProps';
import { popupOpenStateMapping as baseMapping } from '../../utils/popupOpenStateMapping';
+import { useForkRef } from '../../utils/useForkRef';
+import { InteractionType } from '../../utils/useEnhancedClickHandler';
const customStyleHookMapping: CustomStyleHookMapping = {
...baseMapping,
@@ -39,16 +41,21 @@ const DialogPopup = React.forwardRef(function DialogPopup(
props: DialogPopup.Props,
forwardedRef: React.ForwardedRef,
) {
- const { className, container, id, keepMounted = false, render, ...other } = props;
+ const { className, container, id, keepMounted = false, render, initialFocus, ...other } = props;
const rootContext = useDialogRootContext();
const { open, modal, nestedOpenDialogCount, dismissible } = rootContext;
- const { getRootProps, floatingContext, mounted, transitionStatus } = useDialogPopup({
- id,
- ref: forwardedRef,
- isTopmost: nestedOpenDialogCount === 0,
- ...rootContext,
- });
+ const popupRef = React.useRef(null);
+ const mergedRef = useForkRef(forwardedRef, popupRef);
+
+ const { getRootProps, floatingContext, mounted, transitionStatus, resolvedInitialFocus } =
+ useDialogPopup({
+ id,
+ ref: mergedRef,
+ isTopmost: nestedOpenDialogCount === 0,
+ initialFocus,
+ ...rootContext,
+ });
const ownerState: DialogPopup.OwnerState = {
open,
@@ -80,6 +87,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
modal={modal}
disabled={!mounted}
closeOnFocusOut={dismissible}
+ initialFocus={resolvedInitialFocus}
>
{renderElement()}
@@ -99,6 +107,14 @@ namespace DialogPopup {
* @default false
*/
keepMounted?: boolean;
+ /**
+ * Determines an element to focus when the dialog is opened.
+ * It can be either a ref to the element or a function that returns such a ref.
+ * If not provided, the first focusable element is focused.
+ */
+ initialFocus?:
+ | React.RefObject
+ | ((interactionType: InteractionType) => React.RefObject);
}
export interface OwnerState {
@@ -130,6 +146,15 @@ DialogPopup.propTypes /* remove-proptypes */ = {
* @ignore
*/
id: PropTypes.string,
+ /**
+ * Determines an element to focus when the dialog is opened.
+ * It can be either a ref to the element or a function that returns such a ref.
+ * If not provided, the first focusable element is focused.
+ */
+ initialFocus: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
+ PropTypes.func,
+ refType,
+ ]),
/**
* If `true`, the dialog element is kept in the DOM when closed.
*
diff --git a/packages/mui-base/src/Dialog/Popup/useDialogPopup.tsx b/packages/mui-base/src/Dialog/Popup/useDialogPopup.tsx
index 42a8ede3a0..b60f091dc8 100644
--- a/packages/mui-base/src/Dialog/Popup/useDialogPopup.tsx
+++ b/packages/mui-base/src/Dialog/Popup/useDialogPopup.tsx
@@ -8,6 +8,7 @@ import { useAnimatedElement } from '../../utils/useAnimatedElement';
import { useScrollLock } from '../../utils/useScrollLock';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
import { type TransitionStatus } from '../../utils/useTransitionStatus';
+import { type InteractionType } from '../../utils/useEnhancedClickHandler';
export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialogPopup.ReturnValue {
const {
@@ -22,6 +23,8 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
dismissible,
titleElementId,
isTopmost,
+ initialFocus,
+ openMethod,
} = parameters;
const { refs, context, elements } = useFloating({
@@ -49,6 +52,29 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
useScrollLock(modal && mounted, elements.floating);
+ // Default initial focus logic:
+ // If opened by touch, focus the popup element to prevent the virtual keyboard from opening
+ // (this is required for Android specifically as iOS handles this automatically).
+ const defaultInitialFocus = React.useCallback((interactionType: InteractionType) => {
+ if (interactionType === 'touch') {
+ return popupRef;
+ }
+
+ return 0;
+ }, []);
+
+ const resolvedInitialFocus = React.useMemo(() => {
+ if (initialFocus == null) {
+ return defaultInitialFocus(openMethod ?? '');
+ }
+
+ if (typeof initialFocus === 'function') {
+ return initialFocus(openMethod ?? '');
+ }
+
+ return initialFocus;
+ }, [defaultInitialFocus, initialFocus, openMethod]);
+
useEnhancedEffect(() => {
setPopupElementId(id);
return () => {
@@ -74,6 +100,7 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
getRootProps,
mounted,
transitionStatus,
+ resolvedInitialFocus,
};
}
@@ -100,10 +127,11 @@ export namespace useDialogPopup {
* Determines if the dialog is open.
*/
open: boolean;
+ openMethod: InteractionType | null;
/**
* Callback fired when the dialog is requested to be opened or closed.
*/
- onOpenChange: (open: boolean) => void;
+ onOpenChange: (open: boolean, event?: Event) => void;
/**
* The id of the title element associated with the dialog.
*/
@@ -125,6 +153,14 @@ export namespace useDialogPopup {
* Determines if the dialog is the top-most one.
*/
isTopmost: boolean;
+ /**
+ * Determines an element to focus when the dialog is opened.
+ * It can be either a ref to the element or a function that returns such a ref.
+ * If not provided, the first focusable element is focused.
+ */
+ initialFocus?:
+ | React.RefObject
+ | ((interactionType: InteractionType) => React.RefObject);
}
export interface ReturnValue {
@@ -146,5 +182,6 @@ export namespace useDialogPopup {
* The current transition status of the dialog.
*/
transitionStatus: TransitionStatus;
+ resolvedInitialFocus: React.RefObject | number;
}
}
diff --git a/packages/mui-base/src/Dialog/Root/DialogRoot.test.tsx b/packages/mui-base/src/Dialog/Root/DialogRoot.test.tsx
index 1804cbee3f..c4b9d8854a 100644
--- a/packages/mui-base/src/Dialog/Root/DialogRoot.test.tsx
+++ b/packages/mui-base/src/Dialog/Root/DialogRoot.test.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
-import { act, describeSkipIf, fireEvent, waitFor } from '@mui/internal-test-utils';
+import { act, describeSkipIf, fireEvent } from '@mui/internal-test-utils';
import { Dialog } from '@base_ui/react/Dialog';
import { createRenderer } from '#test-utils';
@@ -161,32 +161,4 @@ describe(' ', () => {
expect(queryByRole('dialog')).to.equal(null);
});
});
-
- describeSkipIf(/jsdom/.test(window.navigator.userAgent))('focus management', () => {
- it('should focus the first focusable element within the popup', async () => {
- const { getByText, getByTestId } = await render(
-
-
-
- Open
-
-
- Close
-
-
-
-
,
- );
-
- const trigger = getByText('Open');
- await act(async () => {
- trigger.click();
- });
-
- await waitFor(() => {
- const dialogInput = getByTestId('dialog-input');
- expect(dialogInput).to.toHaveFocus();
- });
- });
- });
});
diff --git a/packages/mui-base/src/Dialog/Root/useDialogRoot.ts b/packages/mui-base/src/Dialog/Root/useDialogRoot.ts
index 4a96b0af86..68d0f1e17c 100644
--- a/packages/mui-base/src/Dialog/Root/useDialogRoot.ts
+++ b/packages/mui-base/src/Dialog/Root/useDialogRoot.ts
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useControlled } from '../../utils/useControlled';
+import { InteractionType } from '../../utils/useEnhancedClickHandler';
export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRoot.ReturnValue {
const {
@@ -24,6 +25,7 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
);
const [popupElementId, setPopupElementId] = React.useState(undefined);
const hasBackdrop = React.useRef(false);
+ const [openMethod, setOpenMethod] = React.useState(null);
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -37,9 +39,9 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
}
const handleOpenChange = React.useCallback(
- (shouldOpen: boolean) => {
+ (shouldOpen: boolean, event?: Event) => {
setOpen(shouldOpen);
- onOpenChange?.(shouldOpen);
+ onOpenChange?.(shouldOpen, event);
},
[onOpenChange, setOpen],
);
@@ -66,6 +68,10 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
};
}, [open, onNestedDialogClose, onNestedDialogOpen, ownNestedOpenDialogs]);
+ if (!open && openMethod !== null) {
+ setOpenMethod(null);
+ }
+
const handleNestedDialogOpen = React.useCallback((ownChildrenCount: number) => {
setOwnNestedOpenDialogs(ownChildrenCount + 1);
}, []);
@@ -78,6 +84,13 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
hasBackdrop.current = present;
}, []);
+ const handleTriggerClick = React.useCallback(
+ (_: React.MouseEvent | React.PointerEvent, interactionType: InteractionType) => {
+ setOpenMethod(interactionType);
+ },
+ [],
+ );
+
return React.useMemo(() => {
return {
modal,
@@ -93,6 +106,8 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
onNestedDialogClose: handleNestedDialogClose,
nestedOpenDialogCount: ownNestedOpenDialogs,
setBackdropPresent,
+ onTriggerClick: handleTriggerClick,
+ openMethod,
};
}, [
modal,
@@ -105,6 +120,8 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
handleNestedDialogOpen,
ownNestedOpenDialogs,
setBackdropPresent,
+ handleTriggerClick,
+ openMethod,
]);
}
@@ -133,7 +150,7 @@ export interface CommonParameters {
/**
* Callback invoked when the dialog is being opened or closed.
*/
- onOpenChange?: (open: boolean) => void;
+ onOpenChange?: (open: boolean, event?: Event) => void;
/**
* Determines whether the dialog should close when clicking outside of it.
* @default true
@@ -177,11 +194,22 @@ export namespace useDialogRoot {
/**
* Callback to fire when the dialog is requested to be opened or closed.
*/
- onOpenChange: (open: boolean) => void;
+ onOpenChange: (open: boolean, event?: Event) => void;
/**
* Determines if the dialog is open.
*/
open: boolean;
+ /**
+ * Determines what triggered the dialog to open.
+ */
+ openMethod: InteractionType | null;
+ /**
+ * Callback to fire when the trigger is activated.
+ */
+ onTriggerClick: (
+ event: React.MouseEvent | React.PointerEvent,
+ interactionType: InteractionType,
+ ) => void;
/**
* The id of the popup element.
*/
diff --git a/packages/mui-base/src/Dialog/Trigger/DialogTrigger.tsx b/packages/mui-base/src/Dialog/Trigger/DialogTrigger.tsx
index 5da69b0e14..6bdfde06b1 100644
--- a/packages/mui-base/src/Dialog/Trigger/DialogTrigger.tsx
+++ b/packages/mui-base/src/Dialog/Trigger/DialogTrigger.tsx
@@ -22,11 +22,12 @@ const DialogTrigger = React.forwardRef(function DialogTrigger(
forwardedRef: React.ForwardedRef,
) {
const { render, className, ...other } = props;
- const { open, onOpenChange, modal, popupElementId } = useDialogRootContext();
+ const { open, onOpenChange, onTriggerClick, modal, popupElementId } = useDialogRootContext();
const { getRootProps } = useDialogTrigger({
open,
onOpenChange,
+ onTriggerClick,
popupElementId,
});
diff --git a/packages/mui-base/src/Dialog/Trigger/useDialogTrigger.ts b/packages/mui-base/src/Dialog/Trigger/useDialogTrigger.ts
index 7c54f88010..812a7fff48 100644
--- a/packages/mui-base/src/Dialog/Trigger/useDialogTrigger.ts
+++ b/packages/mui-base/src/Dialog/Trigger/useDialogTrigger.ts
@@ -1,24 +1,34 @@
'use client';
import * as React from 'react';
import { mergeReactProps } from '../../utils/mergeReactProps';
+import { InteractionType, useEnhancedClickHandler } from '../../utils/useEnhancedClickHandler';
export function useDialogTrigger(
params: useDialogTrigger.Parameters,
): useDialogTrigger.ReturnValue {
- const { open, onOpenChange, popupElementId } = params;
+ const { open, onOpenChange, popupElementId, onTriggerClick } = params;
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent, interactionType: InteractionType) => {
+ if (!open) {
+ onTriggerClick?.(event, interactionType);
+ onOpenChange?.(true, event.nativeEvent);
+ }
+ },
+ [open, onOpenChange, onTriggerClick],
+ );
+
+ const { onClick, onPointerDown } = useEnhancedClickHandler(handleClick);
const getRootProps = React.useCallback(
(externalProps: React.HTMLAttributes = {}) =>
mergeReactProps(externalProps, {
- onClick: () => {
- if (!open) {
- onOpenChange?.(true);
- }
- },
+ onPointerDown,
+ onClick,
'aria-haspopup': 'dialog',
'aria-controls': popupElementId ?? undefined,
}),
- [open, onOpenChange, popupElementId],
+ [popupElementId, onClick, onPointerDown],
);
return React.useMemo(
@@ -38,7 +48,11 @@ namespace useDialogTrigger {
/**
* Callback to fire when the dialog is requested to be opened or closed.
*/
- onOpenChange: (open: boolean) => void;
+ onOpenChange: (open: boolean, event: Event) => void;
+ onTriggerClick?: (
+ event: React.MouseEvent | React.PointerEvent,
+ interactionType: InteractionType,
+ ) => void;
/**
* The id of the popup element.
*/
diff --git a/packages/mui-base/src/Popover/Positioner/PopoverPositioner.test.tsx b/packages/mui-base/src/Popover/Positioner/PopoverPositioner.test.tsx
index f784a5109b..267d60816a 100644
--- a/packages/mui-base/src/Popover/Positioner/PopoverPositioner.test.tsx
+++ b/packages/mui-base/src/Popover/Positioner/PopoverPositioner.test.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Popover } from '@base_ui/react/Popover';
import { createRenderer, describeConformance } from '#test-utils';
-import { screen } from '@mui/internal-test-utils';
+import { screen, act, waitFor } from '@mui/internal-test-utils';
import { expect } from 'chai';
describe(' ', () => {
@@ -39,4 +39,107 @@ describe(' ', () => {
expect(screen.getByTestId('positioner')).not.to.have.attribute('inert');
});
});
+
+ describe('prop: initial focus', () => {
+ it('should focus the first focusable element within the popup by default', async () => {
+ const { getByText, getByTestId } = await render(
+ ,
+ );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const innerInput = getByTestId('popover-input');
+ expect(innerInput).to.toHaveFocus();
+ });
+ });
+
+ it('should focus the element provided to `initialFocus` as a ref when open', async () => {
+ function TestComponent() {
+ const input2Ref = React.useRef(null);
+ return (
+
+ );
+ }
+
+ const { getByText, getByTestId } = await render( );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const input2 = getByTestId('input-2');
+ expect(input2).to.toHaveFocus();
+ });
+ });
+
+ it('should focus the element provided to `initialFocus` as a function when open', async () => {
+ function TestComponent() {
+ const input2Ref = React.useRef(null);
+
+ const getRef = React.useCallback(() => input2Ref, []);
+
+ return (
+
+ );
+ }
+
+ const { getByText, getByTestId } = await render( );
+
+ const trigger = getByText('Open');
+ await act(async () => {
+ trigger.click();
+ });
+
+ await waitFor(() => {
+ const input2 = getByTestId('input-2');
+ expect(input2).to.toHaveFocus();
+ });
+ });
+ });
});
diff --git a/packages/mui-base/src/Popover/Positioner/PopoverPositioner.tsx b/packages/mui-base/src/Popover/Positioner/PopoverPositioner.tsx
index 87f8154bc7..65bb619018 100644
--- a/packages/mui-base/src/Popover/Positioner/PopoverPositioner.tsx
+++ b/packages/mui-base/src/Popover/Positioner/PopoverPositioner.tsx
@@ -7,7 +7,7 @@ import { useForkRef } from '../../utils/useForkRef';
import { usePopoverRootContext } from '../Root/PopoverRootContext';
import { usePopoverPositioner } from './usePopoverPositioner';
import { PopoverPositionerContext } from './PopoverPositionerContext';
-import { HTMLElementType } from '../../utils/proptypes';
+import { HTMLElementType, refType } from '../../utils/proptypes';
import type { BaseUIComponentProps } from '../../utils/types';
import type { Side, Alignment } from '../../utils/useAnchorPositioning';
import { popupOpenStateMapping } from '../../utils/popupOpenStateMapping';
@@ -43,10 +43,12 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
arrowPadding = 5,
hideWhenDetached = false,
sticky = false,
+ initialFocus,
...otherProps
} = props;
- const { floatingRootContext, open, mounted, setPositionerElement } = usePopoverRootContext();
+ const { floatingRootContext, open, mounted, setPositionerElement, popupRef, openMethod } =
+ usePopoverRootContext();
const positioner = usePopoverPositioner({
anchor,
@@ -63,6 +65,9 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
collisionPadding,
hideWhenDetached,
sticky,
+ popupRef,
+ openMethod,
+ initialFocus,
});
const ownerState: PopoverPositioner.OwnerState = React.useMemo(
@@ -108,6 +113,7 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
context={positioner.positionerContext}
modal={false}
disabled={!mounted}
+ initialFocus={positioner.resolvedInitialFocus}
>
{renderElement()}
@@ -212,6 +218,15 @@ PopoverPositioner.propTypes /* remove-proptypes */ = {
* @default false
*/
hideWhenDetached: PropTypes.bool,
+ /**
+ * Determines an element to focus when the popover is opened.
+ * It can be either a ref to the element or a function that returns such a ref.
+ * If not provided, the first focusable element is focused.
+ */
+ initialFocus: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
+ PropTypes.func,
+ refType,
+ ]),
/**
* Whether the popover remains mounted in the DOM while closed.
* @default false
diff --git a/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx b/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx
index 717c1b1c6a..278b607ac5 100644
--- a/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx
+++ b/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx
@@ -10,11 +10,12 @@ import { mergeReactProps } from '../../utils/mergeReactProps';
import { useAnchorPositioning } from '../../utils/useAnchorPositioning';
import type { GenericHTMLProps } from '../../utils/types';
import { getInertValue } from '../../utils/getInertValue';
+import { InteractionType } from '../../utils/useEnhancedClickHandler';
export function usePopoverPositioner(
params: usePopoverPositioner.Parameters,
): usePopoverPositioner.ReturnValue {
- const { open = false, keepMounted = false } = params;
+ const { open = false, keepMounted = false, initialFocus, openMethod, popupRef } = params;
const {
positionerStyles,
@@ -27,6 +28,32 @@ export function usePopoverPositioner(
positionerContext,
} = useAnchorPositioning(params);
+ // Default initial focus logic:
+ // If opened by touch, focus the popup element to prevent the virtual keyboard from opening
+ // (this is required for Android specifically as iOS handles this automatically).
+ const defaultInitialFocus = React.useCallback(
+ (interactionType: InteractionType) => {
+ if (interactionType === 'touch') {
+ return popupRef;
+ }
+
+ return 0;
+ },
+ [popupRef],
+ );
+
+ const resolvedInitialFocus = React.useMemo(() => {
+ if (initialFocus == null) {
+ return defaultInitialFocus(openMethod ?? '');
+ }
+
+ if (typeof initialFocus === 'function') {
+ return initialFocus(openMethod ?? '');
+ }
+
+ return initialFocus;
+ }, [defaultInitialFocus, initialFocus, openMethod]);
+
const getPositionerProps: usePopoverPositioner.ReturnValue['getPositionerProps'] =
React.useCallback(
(externalProps = {}) => {
@@ -59,6 +86,7 @@ export function usePopoverPositioner(
side: renderedSide,
alignment: renderedAlignment,
positionerContext,
+ resolvedInitialFocus,
}),
[
getPositionerProps,
@@ -68,6 +96,7 @@ export function usePopoverPositioner(
renderedSide,
renderedAlignment,
positionerContext,
+ resolvedInitialFocus,
],
);
}
@@ -147,6 +176,14 @@ export namespace usePopoverPositioner {
* @default true
*/
trackAnchor?: boolean;
+ /**
+ * Determines an element to focus when the popover is opened.
+ * It can be either a ref to the element or a function that returns such a ref.
+ * If not provided, the first focusable element is focused.
+ */
+ initialFocus?:
+ | React.RefObject
+ | ((interactionType: InteractionType) => React.RefObject);
}
export interface Parameters extends SharedParameters {
@@ -159,6 +196,14 @@ export namespace usePopoverPositioner {
* The floating root context.
*/
floatingRootContext?: FloatingRootContext;
+ /**
+ * Method used to open the popover.
+ */
+ openMethod: InteractionType | null;
+ /**
+ * The ref to the popup element.
+ */
+ popupRef: React.RefObject;
}
export interface ReturnValue {
@@ -190,5 +235,9 @@ export namespace usePopoverPositioner {
* The floating context.
*/
positionerContext: FloatingContext;
+ /**
+ * Ref to the element to focus when the popover is opened, or `0` to focus the first element within the popover.
+ */
+ resolvedInitialFocus: React.RefObject | 0;
}
}
diff --git a/packages/mui-base/src/Popover/Root/PopoverRoot.tsx b/packages/mui-base/src/Popover/Root/PopoverRoot.tsx
index a0a48af368..7a52ac417a 100644
--- a/packages/mui-base/src/Popover/Root/PopoverRoot.tsx
+++ b/packages/mui-base/src/Popover/Root/PopoverRoot.tsx
@@ -39,6 +39,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
setTitleId,
descriptionId,
setDescriptionId,
+ openMethod,
} = usePopoverRoot({
openOnHover,
delay: delayWithDefault,
@@ -71,6 +72,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
floatingRootContext,
getRootPopupProps,
getRootTriggerProps,
+ openMethod,
}),
[
openOnHover,
@@ -93,6 +95,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
floatingRootContext,
getRootPopupProps,
getRootTriggerProps,
+ openMethod,
],
);
diff --git a/packages/mui-base/src/Popover/Root/PopoverRootContext.ts b/packages/mui-base/src/Popover/Root/PopoverRootContext.ts
index 859a728890..9efaa21b9d 100644
--- a/packages/mui-base/src/Popover/Root/PopoverRootContext.ts
+++ b/packages/mui-base/src/Popover/Root/PopoverRootContext.ts
@@ -3,6 +3,7 @@ import * as React from 'react';
import type { OpenChangeReason, FloatingRootContext } from '@floating-ui/react';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
import type { GenericHTMLProps } from '../../utils/types';
+import type { InteractionType } from '../../utils/useEnhancedClickHandler';
export interface PopoverRootContext {
open: boolean;
@@ -25,6 +26,7 @@ export interface PopoverRootContext {
floatingRootContext: FloatingRootContext;
getRootTriggerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps;
getRootPopupProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps;
+ openMethod: InteractionType | null;
}
export const PopoverRootContext = React.createContext(undefined);
diff --git a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts
index e70e2c6934..b86cc5cad0 100644
--- a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts
+++ b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts
@@ -18,6 +18,8 @@ import { useAnimationsFinished } from '../../utils/useAnimationsFinished';
import { OPEN_DELAY } from '../utils/constants';
import type { GenericHTMLProps } from '../../utils/types';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
+import { useEnhancedClickHandler, type InteractionType } from '../../utils/useEnhancedClickHandler';
+import { mergeReactProps } from '../../utils/mergeReactProps';
export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoot.ReturnValue {
const {
@@ -39,6 +41,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
const [descriptionId, setDescriptionId] = React.useState();
const [triggerElement, setTriggerElement] = React.useState(null);
const [positionerElement, setPositionerElement] = React.useState(null);
+ const [openMethod, setOpenMethod] = React.useState(null);
const popupRef = React.useRef(null);
@@ -55,6 +58,10 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
const runOnceAnimationsFinish = useAnimationsFinished(popupRef);
+ if (!open && openMethod !== null) {
+ setOpenMethod(null);
+ }
+
const setOpen = useEventCallback(
(nextOpen: boolean, event?: Event, reason?: OpenChangeReason) => {
onOpenChange(nextOpen, event, reason);
@@ -113,6 +120,17 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
const { getReferenceProps, getFloatingProps } = useInteractions([hover, click, dismiss, role]);
+ const handleClick = React.useCallback(
+ (_: React.MouseEvent, interactionType: InteractionType) => {
+ if (!open) {
+ setOpenMethod(interactionType);
+ }
+ },
+ [open, setOpenMethod],
+ );
+
+ const { onClick, onPointerDown } = useEnhancedClickHandler(handleClick);
+
return React.useMemo(
() => ({
open,
@@ -128,10 +146,12 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
setTitleId,
descriptionId,
setDescriptionId,
- getRootTriggerProps: getReferenceProps,
+ getRootTriggerProps: (externalProps?: React.HTMLProps) =>
+ getReferenceProps(mergeReactProps(externalProps, { onClick, onPointerDown })),
getRootPopupProps: getFloatingProps,
floatingRootContext: context,
instantType,
+ openMethod,
}),
[
mounted,
@@ -146,6 +166,9 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
getFloatingProps,
context,
instantType,
+ openMethod,
+ onClick,
+ onPointerDown,
],
);
}
@@ -213,5 +236,6 @@ export namespace usePopoverRoot {
positionerElement: HTMLElement | null;
setPositionerElement: React.Dispatch>;
popupRef: React.RefObject;
+ openMethod: InteractionType | null;
}
}
diff --git a/packages/mui-base/src/utils/useEnhancedClickHandler.ts b/packages/mui-base/src/utils/useEnhancedClickHandler.ts
new file mode 100644
index 0000000000..900219b1d9
--- /dev/null
+++ b/packages/mui-base/src/utils/useEnhancedClickHandler.ts
@@ -0,0 +1,45 @@
+import * as React from 'react';
+
+export type InteractionType = 'mouse' | 'touch' | 'pen' | 'keyboard' | '';
+
+/**
+ * Provides a cross-browser way to determine the type of the pointer used to click.
+ * Safari and Firefox do not provide the PointerEvent to the click handler (they use MouseEvent) yet.
+ * Additionally, this implementation detects if the click was triggered by the keyboard.
+ *
+ * @param handler The function to be called when the button is clicked. The first parameter is the original event and the second parameter is the pointer type.
+ */
+export function useEnhancedClickHandler(
+ handler: (event: React.MouseEvent | React.PointerEvent, interactionType: InteractionType) => void,
+) {
+ const lastClickInteractionTypeRef = React.useRef('');
+
+ const handlePointerDown = React.useCallback((event: React.PointerEvent) => {
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ lastClickInteractionTypeRef.current = event.pointerType as InteractionType;
+ }, []);
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent | React.PointerEvent) => {
+ // event.detail has the number of clicks performed on the element. 0 means it was triggered by the keyboard.
+ if (event.detail === 0) {
+ handler(event, 'keyboard');
+ return;
+ }
+
+ if ('pointerType' in event) {
+ // Chrome and Edge correctly use PointerEvent
+ handler(event, event.pointerType);
+ }
+
+ handler(event, lastClickInteractionTypeRef.current);
+ lastClickInteractionTypeRef.current = '';
+ },
+ [handler],
+ );
+
+ return { onClick: handleClick, onPointerDown: handlePointerDown };
+}