Skip to content
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

[AlertDialog, Dialog, Popover] Configure initial focus #732

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
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
68 changes: 48 additions & 20 deletions packages/mui-base/src/Dialog/Popup/DialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ 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';
import { type CustomStyleHookMapping } from '../../utils/getStyleHookProps';

const customStyleHookMapping: CustomStyleHookMapping<DialogPopup.OwnerState> = {
open: (value) => ({ 'data-state': value ? 'open' : 'closed' }),
nestedOpenDialogCount: (value) => ({ 'data-nested-dialogs': value.toString() }),
transitionStatus: (value) => {
if (value === 'entering') {
return { 'data-entering': '' } as Record<string, string>;
}
if (value === 'exiting') {
return { 'data-exiting': '' };
}
return null;
},
};

/**
*
Expand All @@ -23,16 +40,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 All @@ -50,19 +72,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
...other,
style: { ...other.style, '--nested-dialogs': nestedOpenDialogCount },
},
customStyleHookMapping: {
open: (value) => ({ 'data-state': value ? 'open' : 'closed' }),
nestedOpenDialogCount: (value) => ({ 'data-nested-dialogs': value.toString() }),
transitionStatus: (value) => {
if (value === 'entering') {
return { 'data-entering': '' } as Record<string, string>;
}
if (value === 'exiting') {
return { 'data-exiting': '' };
}
return null;
},
},
customStyleHookMapping,
});

if (!keepMounted && !mounted) {
Expand All @@ -76,6 +86,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
modal={modal}
disabled={!mounted}
closeOnFocusOut={dismissible}
initialFocus={resolvedInitialFocus}
>
{renderElement()}
</FloatingFocusManager>
Expand All @@ -95,6 +106,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 +145,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();
});
});
});
Loading