Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 11 additions & 9 deletions packages/mui-material/src/Dialog/Dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { styled, useTheme } from '../zero-styled';
import memoTheme from '../utils/memoTheme';
import { useDefaultProps } from '../DefaultPropsProvider';
import useSlot from '../utils/useSlot';
import { FOCUSABLE_ATTRIBUTE } from '../utils/focusable';

const DialogBackdrop = styled(Backdrop, {
name: 'MuiDialog',
Expand Down Expand Up @@ -313,6 +314,15 @@ const Dialog = React.forwardRef(function Dialog(inProps, ref) {
externalForwardedProps,
ownerState,
className: classes.paper,
additionalProps: {
elevation: 24,
role,
'aria-describedby': ariaDescribedby,
'aria-labelledby': ariaLabelledby,
'aria-modal': ariaModal,
tabIndex: -1,
[FOCUSABLE_ATTRIBUTE]: '',
},
});

const [ContainerSlot, containerSlotProps] = useSlot('container', {
Expand Down Expand Up @@ -354,15 +364,7 @@ const Dialog = React.forwardRef(function Dialog(inProps, ref) {
{/* roles are applied via cloneElement from TransitionComponent */}
{/* roles needs to be applied on the immediate child of Modal or it'll inject one */}
<ContainerSlot onMouseDown={handleMouseDown} {...containerSlotProps}>
<PaperSlot
as={PaperComponent}
elevation={24}
role={role}
aria-describedby={ariaDescribedby}
aria-labelledby={ariaLabelledby}
aria-modal={ariaModal}
{...paperSlotProps}
>
<PaperSlot as={PaperComponent} {...paperSlotProps}>
<DialogContext.Provider value={dialogContextValue}>{children}</DialogContext.Provider>
</PaperSlot>
</ContainerSlot>
Expand Down
50 changes: 50 additions & 0 deletions packages/mui-material/src/Dialog/Dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,56 @@ describe('<Dialog />', () => {
expect(screen.queryByRole('dialog')).to.equal(null);
});

it('should focus the Paper element (role="dialog") on open', () => {
render(
<Dialog open transitionDuration={0}>
<p>Hello World</p>
</Dialog>,
);

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveFocus();
expect(dialog.tagName).to.equal('DIV');
expect(dialog).to.have.attribute('tabindex', '-1');
});

it('should focus the Paper element (role="alertdialog") on open', () => {
render(
<Dialog open transitionDuration={0} role="alertdialog">
<p>Hello World</p>
</Dialog>,
);

const dialog = screen.getByRole('alertdialog');
expect(dialog).toHaveFocus();
expect(dialog).to.have.attribute('tabindex', '-1');
});

it('should focus a custom PaperComponent on open', () => {
render(
<Dialog open transitionDuration={0} PaperComponent="span">
<p>Hello World</p>
</Dialog>,
);

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveFocus();
expect(dialog.tagName).to.equal('SPAN');
expect(dialog).to.have.attribute('tabindex', '-1');
});

it('should respect slotProps.paper.tabIndex while still focusing the Paper', () => {
render(
<Dialog open transitionDuration={0} slotProps={{ paper: { tabIndex: 0 } }}>
<p>Hello World</p>
</Dialog>,
);

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveFocus();
expect(dialog).to.have.attribute('tabindex', '0');
});

it('should not close until the IME is cancelled', () => {
const onClose = spy();

Expand Down
3 changes: 3 additions & 0 deletions packages/mui-material/src/Drawer/Drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import memoTheme from '../utils/memoTheme';
import { useDefaultProps } from '../DefaultPropsProvider';
import { getDrawerUtilityClass } from './drawerClasses';
import useSlot from '../utils/useSlot';
import { FOCUSABLE_ATTRIBUTE } from '../utils/focusable';
import { mergeSlotProps } from '../utils';

const overridesResolver = (props, styles) => {
Expand Down Expand Up @@ -266,6 +267,8 @@ const Drawer = React.forwardRef(function Drawer(inProps, ref) {
...(variant === 'temporary' && {
role: 'dialog',
'aria-modal': 'true',
[FOCUSABLE_ATTRIBUTE]: '',
tabIndex: -1,
}),
},
});
Expand Down
23 changes: 23 additions & 0 deletions packages/mui-material/src/Drawer/Drawer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,29 @@ describe('<Drawer />', () => {
expect(paper).to.have.attribute('aria-modal', 'true');
});

it('should focus the Paper element on open when variant is temporary', () => {
render(
<Drawer open variant="temporary">
<div data-testid="child" />
</Drawer>,
);

const paper = document.querySelector(`.${classes.paper}`);
expect(paper).to.have.attribute('tabindex', '-1');
expect(paper).toHaveFocus();
});

it('should not have tabIndex on Paper when variant is permanent', () => {
render(
<Drawer variant="permanent">
<div data-testid="child" />
</Drawer>,
);

const paper = document.querySelector(`.${classes.paper}`);
expect(paper).not.to.have.attribute('tabindex');
});

it('should not have role="dialog" and aria-modal="true" when variant is permanent', () => {
render(
<Drawer variant="permanent">
Expand Down
61 changes: 61 additions & 0 deletions packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expect } from 'chai';
import { act, createRenderer, reactMajor, screen } from '@mui/internal-test-utils';
import FocusTrap from '@mui/material/Unstable_TrapFocus';
import Portal from '@mui/material/Portal';
import { FOCUSABLE_ATTRIBUTE } from '../utils/focusable';

interface GenericProps {
[index: string]: any;
Expand Down Expand Up @@ -111,6 +112,66 @@ describe('<FocusTrap />', () => {
expect(screen.getByTestId('root')).toHaveFocus();
});

it('should focus a marked descendant instead of the root', () => {
render(
<FocusTrap open>
<div data-testid="root">
<div {...{ [FOCUSABLE_ATTRIBUTE]: '' }} tabIndex={-1} data-testid="focusable">
<button>Click me</button>
</div>
</div>
</FocusTrap>,
);
expect(screen.getByTestId('focusable')).toHaveFocus();
});

it('should prefer the marked descendant over unmarked descendants', () => {
render(
<FocusTrap open>
<div data-testid="root">
<div tabIndex={-1} data-testid="other">
<button>Other</button>
</div>
<div {...{ [FOCUSABLE_ATTRIBUTE]: '' }} tabIndex={-1} data-testid="focusable">
<button>Focusable</button>
</div>
</div>
</FocusTrap>,
);
expect(screen.getByTestId('focusable')).toHaveFocus();
});

it('should fall back to rootRef when no descendant is marked focusable', () => {
render(
<FocusTrap open>
<div tabIndex={-1} data-testid="root">
<button>Click me</button>
</div>
</FocusTrap>,
);
expect(screen.getByTestId('root')).toHaveFocus();
});

it('keeps focus trapped after the React 18 Strict Mode remount', async () => {
render(
<div>
<input data-testid="outside-input" />
<FocusTrap open>
<div tabIndex={-1} data-testid="root" />
</FocusTrap>
</div>,
{ strict: reactMajor <= 18 },
);

expect(screen.getByTestId('root')).toHaveFocus();

await act(async () => {
screen.getByTestId('outside-input').focus();
});

expect(screen.getByTestId('root')).toHaveFocus();
});

it('does not steal focus from a portaled element if any prop but open changes', async () => {
function Test(props: GenericProps) {
return (
Expand Down
15 changes: 12 additions & 3 deletions packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import exactProp from '@mui/utils/exactProp';
import elementAcceptingRef from '@mui/utils/elementAcceptingRef';
import contains from '../utils/contains';
import getActiveElement from '../utils/getActiveElement';
import { getFocusTarget } from '../utils/focusable';
import { FocusTrapProps } from './FocusTrap.types';

// Inspired by https://github.com/focus-trap/tabbable
Expand Down Expand Up @@ -158,6 +159,10 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
}, [disableAutoFocus, open]);

React.useEffect(() => {
// Reset on every mount — React 18 Strict Mode double-mounts leave this
// stuck at `true` after the cleanup of the previous mount set it.
ignoreNextEnforceFocus.current = false;

// We might render an empty child.
if (!open || !rootRef.current) {
return;
Expand All @@ -166,8 +171,12 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
const doc = ownerDocument(rootRef.current);
const activeElement = getActiveElement(doc);

// Prefer the explicitly marked focusable element. Fall back to the root
// element for generic FocusTrap usage.
const focusTarget = getFocusTarget(rootRef.current) ?? rootRef.current;

if (!contains(rootRef.current, activeElement)) {
if (!rootRef.current.hasAttribute('tabIndex')) {
if (!focusTarget.hasAttribute('tabIndex')) {
if (process.env.NODE_ENV !== 'production') {
console.error(
[
Expand All @@ -177,11 +186,11 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
].join('\n'),
);
}
rootRef.current.setAttribute('tabIndex', '-1');
focusTarget.setAttribute('tabIndex', '-1');
}

if (activated.current) {
rootRef.current.focus();
focusTarget.focus();
}
}

Expand Down
16 changes: 16 additions & 0 deletions packages/mui-material/src/utils/focusable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const FOCUSABLE_ATTRIBUTE = 'data-mui-focusable';

/**
* Returns the element marked as the initial focus target inside a focus trap.
* The root element takes precedence over marked descendants so components can
* opt into focusing their own root surface directly.
*/
export function getFocusTarget(rootElement: HTMLElement | null | undefined): HTMLElement | null {
if (!rootElement) {
return null;
}

return rootElement.hasAttribute(FOCUSABLE_ATTRIBUTE)
? rootElement
: rootElement.querySelector<HTMLElement>(`[${FOCUSABLE_ATTRIBUTE}]`);
}
Loading