diff --git a/docs/data/api/alert-dialog-popup.json b/docs/data/api/alert-dialog-popup.json index 902e31c78..472c6acc3 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 4bb1be663..d2ae6b679 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 dc1d42a20..798223f68 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 e91f99d7c..4f0c02e6a 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 e91f99d7c..4f0c02e6a 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 a3e4cceb4..e21cb08fe 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.tsx b/packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.tsx index 8f5d9f729..4d70908f4 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 { PointerType } 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 + | ((pointerType: PointerType) => 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 7d85215e8..000000000 --- a/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.ts +++ /dev/null @@ -1 +0,0 @@ -// This file must be present for the doc gen to work 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 000000000..61ba8a08c --- /dev/null +++ b/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.test.tsx @@ -0,0 +1,115 @@ +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 } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describe('prop: initial focus', () => { + before(function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + }); + + it('should focus the first focusable element within the popup by default', async () => { + const { getByText, getByTestId } = await render( +
+ + + + Open + + + + + + +
, + ); + + 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 + + + + + + + + +
+ ); + } + + 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 + + + + + + + + +
+ ); + } + + 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/Trigger/AlertDialogTrigger.tsx b/packages/mui-base/src/AlertDialog/Trigger/AlertDialogTrigger.tsx index 737c55888..3f67230d2 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.tsx b/packages/mui-base/src/Dialog/Popup/DialogPopup.tsx index b3d990e35..07deb18d6 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 { PointerType } 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 + | ((pointerType: PointerType) => 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 42a8ede3a..24f1377f8 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 PointerType } 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((pointerType: PointerType) => { + if (pointerType === '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: PointerType | 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 + | ((pointerType: PointerType) => 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 6b7ec6174..55efe19a3 100644 --- a/packages/mui-base/src/Dialog/Root/DialogRoot.test.tsx +++ b/packages/mui-base/src/Dialog/Root/DialogRoot.test.tsx @@ -168,14 +168,14 @@ describe('', () => { }); }); - describe('focus management', () => { + describe('prop: initial focus', () => { before(function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); } }); - it('should focus the first focusable element within the popup', async () => { + it('should focus the first focusable element within the popup by default', async () => { const { getByText, getByTestId } = await render(
@@ -201,4 +201,73 @@ describe('', () => { }); }); }); + + it('should focus the element provided to `initialFocus` as a ref when open', async () => { + function TestComponent() { + const input2Ref = React.useRef(null); + return ( +
+ + + Open + + + + + + + + +
+ ); + } + + 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 + + + + + + + + +
+ ); + } + + 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/Root/useDialogRoot.ts b/packages/mui-base/src/Dialog/Root/useDialogRoot.ts index 4a96b0af8..ad3a4353b 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 { PointerType } 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, pointerType: PointerType) => { + setOpenMethod(pointerType); + }, + [], + ); + 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: PointerType | null; + /** + * Callback to fire when the trigger is activated. + */ + onTriggerClick: ( + event: React.MouseEvent | React.PointerEvent, + pointerType: PointerType, + ) => 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 5da69b0e1..6bdfde06b 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 7c54f8801..2f6518ebe 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 { PointerType, 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, pointerType: PointerType) => { + if (!open) { + onTriggerClick?.(event, pointerType); + 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, + pointerType: PointerType, + ) => void; /** * The id of the popup element. */ diff --git a/packages/mui-base/src/Popover/Positioner/PopoverPositioner.tsx b/packages/mui-base/src/Popover/Positioner/PopoverPositioner.tsx index e8ff43bac..05e99f6fa 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,11 +43,19 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner( arrowPadding = 5, hideWhenDetached = false, sticky = false, + initialFocus, ...otherProps } = props; - const { floatingRootContext, open, mounted, triggerElement, setPositionerElement } = - usePopoverRootContext(); + const { + floatingRootContext, + open, + mounted, + triggerElement, + setPositionerElement, + popupRef, + openMethod, + } = usePopoverRootContext(); const positioner = usePopoverPositioner({ anchor: anchor || triggerElement, @@ -64,6 +72,9 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner( collisionPadding, hideWhenDetached, sticky, + popupRef, + openMethod, + initialFocus, }); const ownerState: PopoverPositioner.OwnerState = React.useMemo( @@ -109,6 +120,7 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner( context={positioner.positionerContext} modal={false} disabled={!mounted} + initialFocus={positioner.resolvedInitialFocus} > {renderElement()} @@ -213,6 +225,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 717c1b1c6..b32243083 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 { PointerType } 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( + (pointerType: PointerType) => { + if (pointerType === '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 + | ((pointerType: PointerType) => 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: PointerType | 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 905deebde..358943b07 100644 --- a/packages/mui-base/src/Popover/Root/PopoverRoot.tsx +++ b/packages/mui-base/src/Popover/Root/PopoverRoot.tsx @@ -40,6 +40,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { setTitleId, descriptionId, setDescriptionId, + openMethod, } = usePopoverRoot({ openOnHover, delay: delayWithDefault, @@ -73,6 +74,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { floatingRootContext, getRootPopupProps, getRootTriggerProps, + openMethod, }), [ openOnHover, @@ -96,6 +98,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 666a129a9..d6b4470ce 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 { PointerType } from '../../utils/useEnhancedClickHandler'; export interface PopoverRootContext { open: boolean; @@ -26,6 +27,7 @@ export interface PopoverRootContext { floatingRootContext: FloatingRootContext; getRootTriggerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; getRootPopupProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + openMethod: PointerType | 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 b8b609abb..68ae513a4 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 PointerType } 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, pointerType: PointerType) => { + if (!open) { + setOpenMethod(pointerType); + } + }, + [open, setOpenMethod], + ); + + const { onClick, onPointerDown } = useEnhancedClickHandler(handleClick); + return React.useMemo( () => ({ open, @@ -129,10 +147,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, @@ -148,6 +168,9 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo getFloatingProps, context, instantType, + openMethod, + onClick, + onPointerDown, ], ); } @@ -216,5 +239,6 @@ export namespace usePopoverRoot { positionerElement: HTMLElement | null; setPositionerElement: React.Dispatch>; popupRef: React.RefObject; + openMethod: PointerType | null; } } diff --git a/packages/mui-base/src/utils/useEnhancedClickHandler.ts b/packages/mui-base/src/utils/useEnhancedClickHandler.ts new file mode 100644 index 000000000..7e1dcfee2 --- /dev/null +++ b/packages/mui-base/src/utils/useEnhancedClickHandler.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; + +export type PointerType = '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, pointerType: PointerType) => void, +) { + const lastClickPointerTypeRef = React.useRef(''); + + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { + if (event.defaultPrevented) { + return; + } + + lastClickPointerTypeRef.current = event.pointerType as PointerType; + }, []); + + 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, lastClickPointerTypeRef.current); + lastClickPointerTypeRef.current = ''; + }, + [handler], + ); + + return { onClick: handleClick, onPointerDown: handlePointerDown }; +}