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} + /> + ;