Skip to content

Commit c466ab9

Browse files
authored
fix: restore modal launcher focus for MenuButton refs (#22161)
1 parent 45a04e4 commit c466ab9

2 files changed

Lines changed: 86 additions & 13 deletions

File tree

packages/react/src/components/Modal/Modal-test.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { FeatureFlags } from '../FeatureFlags';
1515
import { ModalPresence, withModalPresence } from './ModalPresence';
1616
import OverflowMenu from '../OverflowMenu';
1717
import OverflowMenuItem from '../OverflowMenuItem';
18+
import { MenuButton } from '../MenuButton';
19+
import { MenuItem } from '../Menu';
1820

1921
const prefix = 'cds';
2022

@@ -49,7 +51,7 @@ describe.each([
4951
Component: ModalWithPresenceContext,
5052
isPresence: true,
5153
},
52-
])('$title', ({ Component, isPresence }) => {
54+
])('$title', ({ Component, isPresence, title }) => {
5355
it('should add extra classes that are passed via className', () => {
5456
render(
5557
<Component data-testid="modal-1" className="custom-class">
@@ -582,6 +584,58 @@ describe.each([
582584
expect(button).toHaveFocus();
583585
});
584586

587+
it('should focus `MenuButton` trigger on close when `launcherButtonRef` is `MenuButton`', async () => {
588+
const ModalExample = () => {
589+
const launcherButtonRef = useRef(null);
590+
const [isOpen, setIsOpen] = useState(false);
591+
592+
return (
593+
<>
594+
<MenuButton label="Actions" ref={launcherButtonRef}>
595+
<MenuItem label="Open modal" onClick={() => setIsOpen(true)} />
596+
</MenuButton>
597+
<Component
598+
data-testid="modal"
599+
launcherButtonRef={launcherButtonRef}
600+
open={isOpen}
601+
onRequestClose={() => setIsOpen(false)}
602+
/>
603+
</>
604+
);
605+
};
606+
607+
render(<ModalExample />);
608+
609+
await userEvent.click(screen.getByRole('button', { name: 'Actions' }));
610+
await userEvent.click(screen.getByRole('menuitem', { name: 'Open modal' }));
611+
612+
expect(screen.getByTestId('modal')).toHaveClass(
613+
'cds--layer-one',
614+
'cds--modal',
615+
'cds--modal-tall',
616+
'is-visible',
617+
...(title === 'Modal with presence context'
618+
? ['cds--modal--enable-presence']
619+
: []),
620+
{ exact: true }
621+
);
622+
623+
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
624+
625+
if (isPresence) {
626+
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
627+
} else {
628+
expect(screen.getByTestId('modal')).toHaveClass(
629+
'cds--layer-one',
630+
'cds--modal',
631+
'cds--modal-tall',
632+
{ exact: true }
633+
);
634+
}
635+
636+
expect(screen.getByRole('button', { name: 'Actions' })).toHaveFocus();
637+
});
638+
585639
describe('enable-dialog-element feature flag', () => {
586640
it('should bring launcherButtonRef element into focus on close when the ref is defined', async () => {
587641
const ModalExample = () => {

packages/react/src/components/Modal/Modal.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import PropTypes, { type Validator } from 'prop-types';
99
import React, {
1010
cloneElement,
11+
useCallback,
1112
useContext,
1213
useEffect,
1314
useRef,
@@ -34,6 +35,7 @@ import { useMergedRefs } from '../../internal/useMergedRefs';
3435
import { usePrefix } from '../../internal/usePrefix';
3536
import { usePreviousValue } from '../../internal/usePreviousValue';
3637
import { keys, match } from '../../internal/keyboard';
38+
import { selectorTabbable } from '../../internal/keyboard/navigation';
3739
import { IconButton } from '../IconButton';
3840
import { noopFn } from '../../internal/noopFn';
3941
import { Text } from '../Text';
@@ -122,7 +124,7 @@ export interface ModalProps extends HTMLAttributes<HTMLDivElement> {
122124
/**
123125
* Provide a ref to return focus to once the modal is closed.
124126
*/
125-
launcherButtonRef?: RefObject<HTMLButtonElement | null>;
127+
launcherButtonRef?: RefObject<HTMLElement | null>;
126128

127129
/**
128130
* Specify the description for the loading text
@@ -585,6 +587,17 @@ const ModalDialog = React.forwardRef(function ModalDialog(
585587
}
586588
}, [open, prefix, enableDialogElement]);
587589

590+
const focusLauncherButton = useCallback(() => {
591+
if (!launcherButtonRef || !launcherButtonRef.current) return;
592+
593+
const { current: launcherButton } = launcherButtonRef;
594+
const focusTarget = launcherButton.matches(selectorTabbable)
595+
? launcherButton
596+
: launcherButton.querySelector<HTMLElement>(selectorTabbable);
597+
598+
focusTarget?.focus();
599+
}, [launcherButtonRef]);
600+
588601
useEffect(() => {
589602
if (
590603
!enableDialogElement &&
@@ -594,23 +607,29 @@ const ModalDialog = React.forwardRef(function ModalDialog(
594607
launcherButtonRef
595608
) {
596609
setTimeout(() => {
597-
if ('current' in launcherButtonRef) {
598-
launcherButtonRef.current?.focus();
599-
}
610+
focusLauncherButton();
600611
});
601612
}
602-
}, [open, prevOpen, launcherButtonRef, enableDialogElement, enablePresence]);
613+
}, [
614+
open,
615+
prevOpen,
616+
launcherButtonRef,
617+
enableDialogElement,
618+
enablePresence,
619+
focusLauncherButton,
620+
]);
621+
603622
// Focus launcherButtonRef on unmount
604623
useEffect(() => {
605624
const launcherButton = launcherButtonRef?.current;
606625
return () => {
607626
if (enablePresence && launcherButton) {
608627
setTimeout(() => {
609-
launcherButton.focus();
628+
focusLauncherButton();
610629
});
611630
}
612631
};
613-
}, [enablePresence, launcherButtonRef]);
632+
}, [enablePresence, launcherButtonRef, focusLauncherButton]);
614633

615634
useEffect(() => {
616635
if (!enableDialogElement) {
@@ -964,16 +983,16 @@ Modal.propTypes = {
964983
PropTypes.func,
965984
PropTypes.shape({
966985
current: PropTypes.oneOfType([
967-
// `PropTypes.instanceOf(HTMLButtonElement)` alone won't work because
968-
// `HTMLButtonElement` is not defined in the test environment even
986+
// `PropTypes.instanceOf(HTMLElement)` alone won't work because
987+
// `HTMLElement` is not defined in the test environment even
969988
// though `testEnvironment` is set to `jsdom`.
970-
typeof HTMLButtonElement !== 'undefined'
971-
? PropTypes.instanceOf(HTMLButtonElement)
989+
typeof HTMLElement !== 'undefined'
990+
? PropTypes.instanceOf(HTMLElement)
972991
: PropTypes.any,
973992
PropTypes.oneOf([null]),
974993
]).isRequired,
975994
}),
976-
]) as Validator<RefObject<HTMLButtonElement | null>>,
995+
]) as Validator<RefObject<HTMLElement | null>>,
977996

978997
/**
979998
* Specify the description for the loading text

0 commit comments

Comments
 (0)