Skip to content
This repository was archived by the owner on Sep 30, 2025. It is now read-only.
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
6 changes: 6 additions & 0 deletions .changeset/empty-poets-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/polaris': minor
---

- Fixed `Modal` `activator` not regaining focus on close
- Added an `activatorWrapper` prop to `Modal` to support setting the element that wraps the `activator`
2 changes: 1 addition & 1 deletion polaris-react/src/components/Box/Box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {ResponsiveProp} from '../../utilities/css';

import styles from './Box.scss';

type Element = 'div' | 'span' | 'section' | 'legend' | 'ul' | 'li';
export type Element = 'div' | 'span' | 'section' | 'legend' | 'ul' | 'li';

type LineStyles = 'solid' | 'dashed';
type Overflow = 'hidden' | 'scroll';
Expand Down
18 changes: 14 additions & 4 deletions polaris-react/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useState, useCallback, useRef, useId, cloneElement} from 'react';
import React, {useState, useCallback, useRef, useId} from 'react';
import {TransitionGroup} from 'react-transition-group';

import {focusFirstFocusableNode} from '../../utilities/focus';
Expand All @@ -7,6 +7,7 @@ import {WithinContentContext} from '../../utilities/within-content-context';
import {wrapWithComponent} from '../../utilities/components';
import {Backdrop} from '../Backdrop';
import {Box} from '../Box';
import type {Element} from '../Box';
import {InlineStack} from '../InlineStack';
import {Scrollable} from '../Scrollable';
import {Spinner} from '../Spinner';
Expand Down Expand Up @@ -59,6 +60,11 @@ export interface ModalProps extends FooterProps {
onScrolledToBottom?(): void;
/** The element or the RefObject that activates the Modal */
activator?: React.RefObject<HTMLElement> | React.ReactElement;
/**
* The element type to wrap the activator in
* @default 'div'
*/
activatorWrapper?: Element;
/** Removes Scrollable container from the modal content */
noScroll?: boolean;
}
Expand All @@ -82,6 +88,7 @@ export const Modal: React.FunctionComponent<ModalProps> & {
secondaryActions,
onScrolledToBottom,
activator,
activatorWrapper = 'div',
onClose,
onIFrameLoad,
onTransitionEnd,
Expand Down Expand Up @@ -112,6 +119,7 @@ export const Modal: React.FunctionComponent<ModalProps> & {
activator && isRef(activator)
? activator && activator.current
: activatorRef.current;

if (activatorElement) {
requestAnimationFrame(() => focusFirstFocusableNode(activatorElement));
}
Expand Down Expand Up @@ -217,9 +225,11 @@ export const Modal: React.FunctionComponent<ModalProps> & {
const animated = !instant;

const activatorMarkup =
activator && !isRef(activator)
? cloneElement(activator, {ref: activatorRef})
: null;
activator && !isRef(activator) ? (
<Box ref={activatorRef} as={activatorWrapper}>
{activator}
</Box>
) : null;

return (
<WithinContentContext.Provider value>
Expand Down
75 changes: 61 additions & 14 deletions polaris-react/src/components/Modal/tests/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {ReactNode} from 'react';
import React, {useRef} from 'react';
import {animationFrame} from '@shopify/jest-dom-mocks';
import {mountWithApp} from 'tests/utilities';
Expand Down Expand Up @@ -27,17 +28,31 @@ jest.mock('react-transition-group', () => {
};
});

jest.mock('../../TrapFocus', () => ({
...jest.requireActual('../../TrapFocus'),
TrapFocus({children}: {children: ReactNode}) {
return <div>{children}</div>;
},
}));

describe('<Modal>', () => {
let scrollSpy: jest.SpyInstance;
let requestAnimationFrameSpy: jest.SpyInstance;

beforeEach(() => {
scrollSpy = jest.spyOn(window, 'scroll');
animationFrame.mock();
requestAnimationFrameSpy = jest.spyOn(window, 'requestAnimationFrame');
requestAnimationFrameSpy.mockImplementation((cb: () => number) => {
cb();
return 1;
});
});

afterEach(() => {
animationFrame.restore();
scrollSpy.mockRestore();
requestAnimationFrameSpy.mockRestore();
});

it('has a child with contentContext', () => {
Expand Down Expand Up @@ -513,10 +528,41 @@ describe('<Modal>', () => {
}).not.toThrow();
});

// Causes a circular dependency that causes the whole test file to be unrunnable
// eslint-disable-next-line jest/no-disabled-tests
it.skip('focuses the activator when the activator is an element on close', () => {
it('wraps the activator in a div by default', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests can now be un-skipped and have been fixed!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unfortunate these tests were skipped (and I didn't notice) at the time I shipped my PR. It would have caught that!

const activator = <Button />;

const modal = mountWithApp(
<Modal title="foo" onClose={noop} open={false} activator={activator} />,
);

expect(modal).toContainReactComponent(Box, {
as: 'div',
children: activator,
});
});

it('wraps the activator in a span if activatorWrapper is provided', () => {
const activator = <Button />;

const modal = mountWithApp(
<Modal
title="foo"
onClose={noop}
open={false}
activator={activator}
activatorWrapper="span"
/>,
);

expect(modal).toContainReactComponent(Box, {
as: 'span',
children: activator,
});
});

it('focuses the activator on close when the activator is an element', () => {
const id = 'activator-id';

const modal = mountWithApp(
<Modal
title="foo"
Expand All @@ -527,21 +573,22 @@ describe('<Modal>', () => {
);

modal.find(Dialog)!.trigger('onExited');
const activator = modal.find('button', {id})!.domNode;

const activator = modal.find('button', {
id,
})!.domNode;

expect(document.activeElement).toBe(activator);
});

// Causes a circular dependency that causes the whole test file to be unrunnable
// eslint-disable-next-line jest/no-disabled-tests
it.skip('focuses the activator when the activator a ref on close', () => {
const buttonId = 'buttonId';
it('focuses the activator on close when the activator is a ref', () => {
const id = 'buttonId';
const TestHarness = () => {
const buttonRef = useRef<HTMLDivElement>(null);

const button = (
<div ref={buttonRef}>
<Button id={buttonId} />
<Button id={id} />
</div>
);

Expand All @@ -557,11 +604,11 @@ describe('<Modal>', () => {

testHarness.find(Modal)!.find(Dialog)!.trigger('onExited');

expect(document.activeElement).toBe(
testHarness.findWhere(
(wrap) => wrap.is('button') && wrap.prop('id') === buttonId,
)!.domNode,
);
const activator = testHarness.find('button', {
id,
})!.domNode;

expect(document.activeElement).toBe(activator);
});
});
});
Expand Down