diff --git a/.changeset/nervous-plums-end.md b/.changeset/nervous-plums-end.md
new file mode 100644
index 00000000000..da226357525
--- /dev/null
+++ b/.changeset/nervous-plums-end.md
@@ -0,0 +1,5 @@
+---
+'@shopify/polaris': minor
+---
+
+Exposed a `close` function on popovers imperative handle
diff --git a/polaris-react/src/components/Combobox/tests/Combobox.test.tsx b/polaris-react/src/components/Combobox/tests/Combobox.test.tsx
index 7b43e55154a..f6884425667 100644
--- a/polaris-react/src/components/Combobox/tests/Combobox.test.tsx
+++ b/polaris-react/src/components/Combobox/tests/Combobox.test.tsx
@@ -189,7 +189,7 @@ describe('', () => {
it('calls Popover.forceUpdatePosition() when onOptionSelected is triggered and allowMultiple is true and there are children', () => {
const mockForceUpdatePosition = jest.fn();
mockUseImperativeHandle.mockImplementation(
- (ref: {current: PopoverPublicAPI}) => {
+ (ref: {current: Partial}) => {
ref.current = {
forceUpdatePosition: mockForceUpdatePosition,
};
diff --git a/polaris-react/src/components/Popover/Popover.tsx b/polaris-react/src/components/Popover/Popover.tsx
index b0eac4cf1f0..58aeb3c68d2 100644
--- a/polaris-react/src/components/Popover/Popover.tsx
+++ b/polaris-react/src/components/Popover/Popover.tsx
@@ -82,8 +82,10 @@ export interface PopoverProps {
captureOverscroll?: boolean;
}
+type CloseTarget = 'activator' | 'next-node';
export interface PopoverPublicAPI {
forceUpdatePosition(): void;
+ close(target?: CloseTarget): void;
}
// TypeScript can't generate types that correctly infer the typing of
@@ -120,36 +122,6 @@ const PopoverComponent = forwardRef(
overlayRef.current?.forceUpdatePosition();
}
- useImperativeHandle(ref, () => {
- return {
- forceUpdatePosition,
- };
- });
-
- const setAccessibilityAttributes = useCallback(() => {
- if (activatorContainer.current == null) {
- return;
- }
-
- const firstFocusable = findFirstFocusableNodeIncludingDisabled(
- activatorContainer.current,
- );
- const focusableActivator: HTMLElement & {
- disabled?: boolean;
- } = firstFocusable || activatorContainer.current;
-
- const activatorDisabled =
- 'disabled' in focusableActivator &&
- Boolean(focusableActivator.disabled);
-
- setActivatorAttributes(focusableActivator, {
- id,
- active,
- ariaHaspopup,
- activatorDisabled,
- });
- }, [id, active, ariaHaspopup]);
-
const handleClose = (source: PopoverCloseSource) => {
onClose(source);
if (activatorContainer.current == null || preventFocusOnClose) {
@@ -181,6 +153,44 @@ const PopoverComponent = forwardRef(
}
};
+ useImperativeHandle(ref, () => {
+ return {
+ forceUpdatePosition,
+ close: (target = 'activator') => {
+ const source =
+ target === 'activator'
+ ? PopoverCloseSource.EscapeKeypress
+ : PopoverCloseSource.FocusOut;
+
+ handleClose(source);
+ },
+ };
+ });
+
+ const setAccessibilityAttributes = useCallback(() => {
+ if (activatorContainer.current == null) {
+ return;
+ }
+
+ const firstFocusable = findFirstFocusableNodeIncludingDisabled(
+ activatorContainer.current,
+ );
+ const focusableActivator: HTMLElement & {
+ disabled?: boolean;
+ } = firstFocusable || activatorContainer.current;
+
+ const activatorDisabled =
+ 'disabled' in focusableActivator &&
+ Boolean(focusableActivator.disabled);
+
+ setActivatorAttributes(focusableActivator, {
+ id,
+ active,
+ ariaHaspopup,
+ activatorDisabled,
+ });
+ }, [id, active, ariaHaspopup]);
+
useEffect(() => {
if (!activatorNode && activatorContainer.current) {
setActivatorNode(
diff --git a/polaris-react/src/components/Popover/tests/Popover.test.tsx b/polaris-react/src/components/Popover/tests/Popover.test.tsx
index 941ebc689ed..8c0ed16402c 100644
--- a/polaris-react/src/components/Popover/tests/Popover.test.tsx
+++ b/polaris-react/src/components/Popover/tests/Popover.test.tsx
@@ -1,5 +1,6 @@
import React, {useCallback, useRef, useState} from 'react';
import {mountWithApp} from 'tests/utilities';
+import {act} from 'react-dom/test-utils';
import {Portal} from '../../Portal';
import {PositionedOverlay} from '../../PositionedOverlay';
@@ -396,7 +397,7 @@ describe('', () => {
mountWithApp();
- expect(popoverRef).toStrictEqual({
+ expect(popoverRef).toMatchObject({
current: {
forceUpdatePosition: expect.anything(),
},
@@ -404,6 +405,67 @@ describe('', () => {
});
});
+ describe('close', () => {
+ it('exposes a function that closes the popover & focuses the activator by default', () => {
+ const activatorId = 'focus-target';
+ let popoverRef: React.RefObject | null = null;
+
+ function Test() {
+ popoverRef = useRef(null);
+
+ return (
+ }
+ onClose={noop}
+ />
+ );
+ }
+
+ const popover = mountWithApp();
+
+ act(() => {
+ popoverRef?.current?.close();
+ });
+
+ const focusTarget = popover.find('button', {id: activatorId})!.domNode;
+
+ expect(document.activeElement).toBe(focusTarget);
+ });
+
+ it('exposes a function that closes the popover & focuses the next node when the next-node option is used', () => {
+ const nextFocusedId = 'focus-target2';
+ let popoverRef: React.RefObject | null = null;
+
+ function Test() {
+ popoverRef = useRef(null);
+
+ return (
+ <>
+ }
+ onClose={noop}
+ />
+
+ >
+ );
+ }
+
+ const popover = mountWithApp();
+
+ act(() => {
+ popoverRef?.current?.close('next-node');
+ });
+
+ const focusTarget = popover.find('button', {id: nextFocusedId})!.domNode;
+
+ expect(document.activeElement).toBe(focusTarget);
+ });
+ });
+
describe('captureOverscroll', () => {
const TestActivator = ;