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 3cf1d49
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 20 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
49 changes: 46 additions & 3 deletions packages/mui-base/src/Dialog/Popup/DialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import PropTypes from 'prop-types';
import { FloatingFocusManager, FloatingPortal } from '@floating-ui/react';
import { useDialogPopup } from './useDialogPopup';
import { useDialogRootContext } from '../Root/DialogRootContext';
import { type OpenChangeMethod } from '../Root/useDialogRoot';
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';

/**
*
Expand All @@ -23,13 +25,24 @@ 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 { open, modal, nestedOpenDialogCount, dismissible, openMethod } = rootContext;

const popupRef = React.useRef<HTMLElement | null>(null);
const mergedRef = useForkRef(forwardedRef, popupRef);

const defaultInitialFocus = React.useCallback((pointerType: OpenChangeMethod) => {
if (pointerType === 'touch') {
return popupRef;
}

return 0;
}, []);

const { getRootProps, floatingContext, mounted, transitionStatus } = useDialogPopup({
id,
ref: forwardedRef,
ref: mergedRef,
isTopmost: nestedOpenDialogCount === 0,
...rootContext,
});
Expand All @@ -41,6 +54,18 @@ const DialogPopup = React.forwardRef(function DialogPopup(
transitionStatus,
};

const resolvedInitialFocus = React.useMemo(() => {
if (initialFocus == null) {
return defaultInitialFocus(openMethod ?? 'unknown');
}

if (typeof initialFocus === 'function') {
return initialFocus(openMethod ?? 'unknown');
}

return initialFocus;
}, [defaultInitialFocus, initialFocus, openMethod]);

const { renderElement } = useComponentRenderer({
render: render ?? 'div',
className,
Expand Down Expand Up @@ -76,6 +101,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
modal={modal}
disabled={!mounted}
closeOnFocusOut={dismissible}
initialFocus={resolvedInitialFocus}
>
{renderElement()}
</FloatingFocusManager>
Expand All @@ -95,6 +121,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: OpenChangeMethod) => React.RefObject<HTMLElement>);
}

export interface OwnerState {
Expand Down Expand Up @@ -126,6 +160,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
11 changes: 9 additions & 2 deletions packages/mui-base/src/Dialog/Popup/useDialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
isTopmost,
} = parameters;

const handleOpenChange = React.useCallback(
(newOpen: boolean, event?: Event) => {
onOpenChange?.(newOpen, event);
},
[onOpenChange],
);

const { refs, context, elements } = useFloating({
open,
onOpenChange,
onOpenChange: handleOpenChange,
});

const popupRef = React.useRef<HTMLElement>(null);
Expand Down Expand Up @@ -103,7 +110,7 @@ export namespace useDialogPopup {
/**
* 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 Down
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();
});
});
});
33 changes: 29 additions & 4 deletions packages/mui-base/src/Dialog/Root/useDialogRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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<OpenChangeMethod | null>(null);

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
Expand All @@ -37,9 +38,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 +67,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 +85,13 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
hasBackdrop.current = present;
}, []);

const fireTriggerClick = React.useCallback(
(event: React.MouseEvent | React.PointerEvent, pointerType: string) => {
setOpenMethod(pointerType as OpenChangeMethod);
},
[],
);

return React.useMemo(() => {
return {
modal,
Expand All @@ -93,6 +107,8 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
onNestedDialogClose: handleNestedDialogClose,
nestedOpenDialogCount: ownNestedOpenDialogs,
setBackdropPresent,
fireTriggerClick,
openMethod,
};
}, [
modal,
Expand All @@ -105,9 +121,13 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo
handleNestedDialogOpen,
ownNestedOpenDialogs,
setBackdropPresent,
fireTriggerClick,
openMethod,
]);
}

export type OpenChangeMethod = 'keyboard' | 'mouse' | 'pen' | 'touch' | 'unknown';

export interface CommonParameters {
/**
* If `true`, the dialog supports CSS-based animations and transitions.
Expand All @@ -133,7 +153,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 +197,16 @@ 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: OpenChangeMethod | null;
fireTriggerClick: (event: React.MouseEvent | React.PointerEvent, pointerType: string) => void;
/**
* The id of the popup element.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/mui-base/src/Dialog/Trigger/DialogTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ const DialogTrigger = React.forwardRef(function DialogTrigger(
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
const { render, className, ...other } = props;
const { open, onOpenChange, modal, popupElementId } = useDialogRootContext();
const { open, onOpenChange, fireTriggerClick, modal, popupElementId } = useDialogRootContext();

const { getRootProps } = useDialogTrigger({
open,
onOpenChange,
fireTriggerClick,
popupElementId,
});

Expand Down
27 changes: 19 additions & 8 deletions packages/mui-base/src/Dialog/Trigger/useDialogTrigger.ts
Original file line number Diff line number Diff line change
@@ -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, fireTriggerClick } = params;

const handleClick = React.useCallback(
(event: React.MouseEvent, pointerType: PointerType) => {
if (!open) {
fireTriggerClick?.(event, pointerType);
onOpenChange?.(true, event.nativeEvent);
}
},
[open, onOpenChange, fireTriggerClick],
);

const { onClick, onPointerDown } = useEnhancedClickHandler(handleClick);

const getRootProps = React.useCallback(
(externalProps: React.HTMLAttributes<any> = {}) =>
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(
Expand All @@ -38,7 +48,8 @@ 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;
fireTriggerClick?: (event: React.MouseEvent | React.PointerEvent, pointerType: string) => void;
/**
* The id of the popup element.
*/
Expand Down
Loading

0 comments on commit 3cf1d49

Please sign in to comment.