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

Merged
merged 20 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { PointerType } 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>
| ((pointerType: PointerType) => React.RefObject<HTMLElement>);
}

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