Skip to content

Commit

Permalink
[Dialog] Configure initial focus
Browse files Browse the repository at this point in the history
  • Loading branch information
michaldudak committed Oct 10, 2024
1 parent cab77be commit 8ba8514
Show file tree
Hide file tree
Showing 9 changed files with 254 additions and 23 deletions.
1 change: 1 addition & 0 deletions docs/data/api/dialog-popup.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"props": {
"className": { "type": { "name": "union", "description": "func<br>&#124;&nbsp;string" } },
"container": { "type": { "name": "union", "description": "HTML element<br>&#124;&nbsp;ref" } },
"initialFocus": { "type": { "name": "union", "description": "func<br>&#124;&nbsp;ref" } },
"keepMounted": { "type": { "name": "bool" }, "default": "false" },
"render": { "type": { "name": "union", "description": "element<br>&#124;&nbsp;func" } }
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"description": "Class names applied to the element or a function that returns them based on the component&#39;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 <code>true</code>, the dialog element is kept in the DOM when closed."
},
Expand Down
39 changes: 32 additions & 7 deletions packages/mui-base/src/Dialog/Popup/DialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer';
import { refType, HTMLElementType } from '../../utils/proptypes';
import { type BaseUIComponentProps } from '../../utils/types';
import { type TransitionStatus } from '../../utils/useTransitionStatus';
import { useForkRef } from '../../utils/useForkRef';
import { PointerType } from '../../utils/useEnhancedClickHandler';

/**
*
Expand All @@ -23,16 +25,21 @@ const DialogPopup = React.forwardRef(function DialogPopup(
props: DialogPopup.Props,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
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<HTMLElement | null>(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,
Expand Down Expand Up @@ -76,6 +83,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
modal={modal}
disabled={!mounted}
closeOnFocusOut={dismissible}
initialFocus={resolvedInitialFocus}
>
{renderElement()}
</FloatingFocusManager>
Expand All @@ -95,6 +103,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<HTMLElement>
| ((pointerType: PointerType) => React.RefObject<HTMLElement>);
}

export interface OwnerState {
Expand Down Expand Up @@ -126,6 +142,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.
*
Expand Down
39 changes: 38 additions & 1 deletion packages/mui-base/src/Dialog/Popup/useDialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,6 +23,8 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
dismissible,
titleElementId,
isTopmost,
initialFocus,
openMethod,
} = parameters;

const { refs, context, elements } = useFloating({
Expand Down Expand Up @@ -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 () => {
Expand All @@ -74,6 +100,7 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
getRootProps,
mounted,
transitionStatus,
resolvedInitialFocus,
};
}

Expand All @@ -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.
*/
Expand All @@ -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<HTMLElement>
| ((pointerType: PointerType) => React.RefObject<HTMLElement>);
}

export interface ReturnValue {
Expand All @@ -146,5 +182,6 @@ export namespace useDialogPopup {
* The current transition status of the dialog.
*/
transitionStatus: TransitionStatus;
resolvedInitialFocus: React.RefObject<HTMLElement> | number;
}
}
73 changes: 71 additions & 2 deletions packages/mui-base/src/Dialog/Root/DialogRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,14 @@ describe('<Dialog.Root />', () => {
});
});

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(
<div>
<input />
Expand All @@ -201,4 +201,73 @@ describe('<Dialog.Root />', () => {
});
});
});

it('should focus the element provided to `initialFocus` as a ref when open', async () => {
function TestComponent() {
const input2Ref = React.useRef<HTMLInputElement>(null);
return (
<div>
<input />
<Dialog.Root modal={false} animated={false}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup data-testid="dialog" initialFocus={input2Ref}>
<input data-testid="input-1" />
<input data-testid="input-2" ref={input2Ref} />
<input data-testid="input-3" />
<button>Close</button>
</Dialog.Popup>
</Dialog.Root>
<input />
</div>
);
}

const { getByText, getByTestId } = await render(<TestComponent />);

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<HTMLInputElement>(null);

const getRef = React.useCallback(() => input2Ref, []);

return (
<div>
<input />
<Dialog.Root modal={false} animated={false}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup data-testid="dialog" initialFocus={getRef}>
<input data-testid="input-1" />
<input data-testid="input-2" ref={input2Ref} />
<input data-testid="input-3" />
<button>Close</button>
</Dialog.Popup>
</Dialog.Root>
<input />
</div>
);
}

const { getByText, getByTestId } = await render(<TestComponent />);

const trigger = getByText('Open');
await act(async () => {
trigger.click();
});

await waitFor(() => {
const input2 = getByTestId('input-2');
expect(input2).to.toHaveFocus();
});
});
});
38 changes: 34 additions & 4 deletions packages/mui-base/src/Dialog/Root/useDialogRoot.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,6 +25,7 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
);
const [popupElementId, setPopupElementId] = React.useState<string | undefined>(undefined);
const hasBackdrop = React.useRef(false);
const [openMethod, setOpenMethod] = React.useState<PointerType | null>(null);

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
Expand All @@ -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],
);
Expand All @@ -66,6 +68,12 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
};
}, [open, onNestedDialogClose, onNestedDialogOpen, ownNestedOpenDialogs]);

React.useEffect(() => {
if (!open) {
setOpenMethod(null);
}
}, [open]);

const handleNestedDialogOpen = React.useCallback((ownChildrenCount: number) => {
setOwnNestedOpenDialogs(ownChildrenCount + 1);
}, []);
Expand All @@ -78,6 +86,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,
Expand All @@ -93,6 +108,8 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
onNestedDialogClose: handleNestedDialogClose,
nestedOpenDialogCount: ownNestedOpenDialogs,
setBackdropPresent,
onTriggerClick: handleTriggerClick,
openMethod,
};
}, [
modal,
Expand All @@ -105,6 +122,8 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
handleNestedDialogOpen,
ownNestedOpenDialogs,
setBackdropPresent,
handleTriggerClick,
openMethod,
]);
}

Expand Down Expand Up @@ -133,7 +152,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
Expand Down Expand Up @@ -177,11 +196,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.
*/
Expand Down
Loading

0 comments on commit 8ba8514

Please sign in to comment.