Skip to content

Commit

Permalink
[AlertDialog, Dialog, Popover] Configure initial focus (#732)
Browse files Browse the repository at this point in the history
Signed-off-by: Michał Dudak <[email protected]>
Co-authored-by: atomiks <[email protected]>
  • Loading branch information
michaldudak and atomiks authored Nov 1, 2024
1 parent b7c8e14 commit 6abbb3b
Show file tree
Hide file tree
Showing 25 changed files with 629 additions and 70 deletions.
1 change: 1 addition & 0 deletions docs/data/api/alert-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
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
1 change: 1 addition & 0 deletions docs/data/api/popover-positioner.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"container": { "type": { "name": "union", "description": "HTML element<br>&#124;&nbsp;func" } },
"hideWhenDetached": { "type": { "name": "bool" }, "default": "false" },
"initialFocus": { "type": { "name": "union", "description": "func<br>&#124;&nbsp;ref" } },
"keepMounted": { "type": { "name": "bool" }, "default": "false" },
"positionMethod": {
"type": { "name": "enum", "description": "'absolute'<br>&#124;&nbsp;'fixed'" },
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
101 changes: 101 additions & 0 deletions packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -29,4 +30,104 @@ describe('<AlertDialog.Popup />', () => {
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(
<div>
<input />
<AlertDialog.Root animated={false}>
<AlertDialog.Backdrop />
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.Popup data-testid="dialog">
<input data-testid="dialog-input" />
<button>Close</button>
</AlertDialog.Popup>
</AlertDialog.Root>
<input />
</div>,
);

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<HTMLInputElement>(null);
return (
<div>
<input />
<AlertDialog.Root animated={false}>
<AlertDialog.Backdrop />
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.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>
</AlertDialog.Popup>
</AlertDialog.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 />
<AlertDialog.Root animated={false}>
<AlertDialog.Backdrop />
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.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>
</AlertDialog.Popup>
</AlertDialog.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();
});
});
});
});
47 changes: 38 additions & 9 deletions packages/mui-base/src/AlertDialog/Popup/AlertDialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlertDialogPopup.OwnerState> = {
...baseMapping,
Expand Down Expand Up @@ -39,18 +41,23 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
props: AlertDialogPopup.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 = useAlertDialogRootContext();
const { open, nestedOpenDialogCount } = rootContext;

const { getRootProps, floatingContext, mounted, transitionStatus } = useDialogPopup({
id,
ref: forwardedRef,
dismissible: false,
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,
dismissible: false,
initialFocus,
...rootContext,
});

const ownerState: AlertDialogPopup.OwnerState = React.useMemo(
() => ({
Expand Down Expand Up @@ -80,7 +87,12 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(

return (
<FloatingPortal root={container}>
<FloatingFocusManager context={floatingContext} modal disabled={!mounted}>
<FloatingFocusManager
context={floatingContext}
modal
disabled={!mounted}
initialFocus={resolvedInitialFocus}
>
{renderElement()}
</FloatingFocusManager>
</FloatingPortal>
Expand All @@ -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<HTMLElement | null>
| ((interactionType: InteractionType) => React.RefObject<HTMLElement | null>);
}

export interface OwnerState {
Expand Down Expand Up @@ -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.
*
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// This file is required by the API doc generator

describe('<AlertDialog.Root />', () => {
it('no-op', () => {});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ const AlertDialogTrigger = React.forwardRef(function AlertDialogTrigger(
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
const { render, className, ...other } = props;
const { open, onOpenChange, popupElementId } = useAlertDialogRootContext();
const { open, onOpenChange, onTriggerClick, popupElementId } = useAlertDialogRootContext();

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

Expand Down
98 changes: 98 additions & 0 deletions packages/mui-base/src/Dialog/Popup/DialogPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<Dialog.Popup />', () => {
Expand Down Expand Up @@ -40,4 +41,101 @@ describe('<Dialog.Popup />', () => {
});
});
});

describe('prop: initial focus', () => {
it('should focus the first focusable element within the popup', async () => {
const { getByText, getByTestId } = await render(
<div>
<input />
<Dialog.Root modal={false} animated={false}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup data-testid="dialog">
<input data-testid="dialog-input" />
<button>Close</button>
</Dialog.Popup>
</Dialog.Root>
<input />
</div>,
);

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<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

0 comments on commit 6abbb3b

Please sign in to comment.