diff --git a/UNRELEASED.md b/UNRELEASED.md index 98fd56384c5..fe7d5c52392 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -15,6 +15,10 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Allow promoted actions to be rendered as a menu on the `BulkAction` component ([#4266](https://github.com/Shopify/polaris-react/pull/4266)) - Add `extraSmall` prop to `Avatar` ([#4371](https://github.com/Shopify/polaris-react/pull/4371)) - Add `critical` color option to `ProgressBar` component ([#4408](https://github.com/Shopify/polaris-react/pull/4408)) +- `Popover` now exposes an imperative `forceReLayout()` API for programmatically triggering a re-render of the underlying overlay component ([#4385](https://github.com/Shopify/polaris-react/pull/4385)) +- `PositionedOverlay` now exposes an imperative `forceReLayout()` API for programmatically triggering a re-render of the component ([#4385](https://github.com/Shopify/polaris-react/pull/4385)) +- `Popover` now exposes an imperative `forceUpdatePosition()` API for programmatically triggering a re-render of the underlying overlay component ([#4385](https://github.com/Shopify/polaris-react/pull/4385)) +- `PositionedOverlay` now exposes an imperative `forceUpdatePosition()` API for programmatically triggering a re-render of the component ([#4385](https://github.com/Shopify/polaris-react/pull/4385)) ### Bug fixes diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx index c3106304ccc..05f9838c792 100644 --- a/src/components/Popover/Popover.tsx +++ b/src/components/Popover/Popover.tsx @@ -1,11 +1,13 @@ import React, { Children, - useRef, + forwardRef, useEffect, useCallback, + useImperativeHandle, + useRef, useState, - AriaAttributes, } from 'react'; +import type {AriaAttributes} from 'react'; import { findFirstFocusableNodeIncludingDisabled, @@ -78,128 +80,149 @@ export interface PopoverProps { autofocusTarget?: PopoverAutofocusTarget; } +export interface PopoverPublicAPI { + forceUpdatePosition(): void; +} + // TypeScript can't generate types that correctly infer the typing of // subcomponents so explicitly state the subcomponents in the type definition. // Letting this be implicit works in this project but fails in projects that use // generated *.d.ts files. -export const Popover: React.FunctionComponent & { - Pane: typeof Pane; - Section: typeof Section; -} = function Popover({ - activatorWrapper = 'div', - children, - onClose, - activator, - preventFocusOnClose, - active, - fixed, - ariaHaspopup, - preferInputActivator = true, - colorScheme, - zIndexOverride, - ...rest -}: PopoverProps) { - const [activatorNode, setActivatorNode] = useState(); - const activatorContainer = useRef(null); - const WrapperComponent: any = activatorWrapper; - const id = useUniqueId('popover'); - - 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, +const PopoverComponent = forwardRef( + function Popover( + { + activatorWrapper = 'div', + children, + onClose, + activator, + preventFocusOnClose, active, + fixed, ariaHaspopup, - activatorDisabled, - }); - }, [id, active, ariaHaspopup]); - - const handleClose = (source: PopoverCloseSource) => { - onClose(source); - if (activatorContainer.current == null || preventFocusOnClose) { - return; + preferInputActivator = true, + colorScheme, + zIndexOverride, + ...rest + }, + ref, + ) { + const [activatorNode, setActivatorNode] = useState(); + + const overlayRef = useRef(null); + const activatorContainer = useRef(null); + + const WrapperComponent: any = activatorWrapper; + const id = useUniqueId('popover'); + + function forceUpdatePosition() { + overlayRef.current?.forceUpdatePosition(); } - if ( - (source === PopoverCloseSource.FocusOut || - source === PopoverCloseSource.EscapeKeypress) && - activatorNode - ) { - const focusableActivator = - findFirstFocusableNodeIncludingDisabled(activatorNode) || - findFirstFocusableNodeIncludingDisabled(activatorContainer.current) || - activatorContainer.current; - if (!focusNextFocusableNode(focusableActivator, isInPortal)) { - focusableActivator.focus(); + useImperativeHandle(ref, () => { + return { + forceUpdatePosition, + }; + }); + + const setAccessibilityAttributes = useCallback(() => { + if (activatorContainer.current == null) { + return; } - } - }; - useEffect(() => { - if (!activatorNode && activatorContainer.current) { - setActivatorNode( - activatorContainer.current.firstElementChild as HTMLElement, - ); - } else if ( - activatorNode && - activatorContainer.current && - !activatorContainer.current.contains(activatorNode) - ) { - setActivatorNode( - activatorContainer.current.firstElementChild as HTMLElement, + const firstFocusable = findFirstFocusableNodeIncludingDisabled( + activatorContainer.current, ); - } - setAccessibilityAttributes(); - }, [activatorNode, setAccessibilityAttributes]); + 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) { + return; + } - useEffect(() => { - if (activatorNode && activatorContainer.current) { - setActivatorNode( - activatorContainer.current.firstElementChild as HTMLElement, - ); - } - setAccessibilityAttributes(); - }, [activatorNode, setAccessibilityAttributes]); - - const portal = activatorNode ? ( - - - {children} - - - ) : null; - - return ( - - {Children.only(activator)} - {portal} - - ); -}; + if ( + (source === PopoverCloseSource.FocusOut || + source === PopoverCloseSource.EscapeKeypress) && + activatorNode + ) { + const focusableActivator = + findFirstFocusableNodeIncludingDisabled(activatorNode) || + findFirstFocusableNodeIncludingDisabled(activatorContainer.current) || + activatorContainer.current; + if (!focusNextFocusableNode(focusableActivator, isInPortal)) { + focusableActivator.focus(); + } + } + }; + + useEffect(() => { + if (!activatorNode && activatorContainer.current) { + setActivatorNode( + activatorContainer.current.firstElementChild as HTMLElement, + ); + } else if ( + activatorNode && + activatorContainer.current && + !activatorContainer.current.contains(activatorNode) + ) { + setActivatorNode( + activatorContainer.current.firstElementChild as HTMLElement, + ); + } + setAccessibilityAttributes(); + }, [activatorNode, setAccessibilityAttributes]); + + useEffect(() => { + if (activatorNode && activatorContainer.current) { + setActivatorNode( + activatorContainer.current.firstElementChild as HTMLElement, + ); + } + setAccessibilityAttributes(); + }, [activatorNode, setAccessibilityAttributes]); + + const portal = activatorNode ? ( + + + {children} + + + ) : null; + + return ( + + {Children.only(activator)} + {portal} + + ); + }, +); function isInPortal(element: Element) { let parentElement = element.parentElement; @@ -212,5 +235,4 @@ function isInPortal(element: Element) { return true; } -Popover.Pane = Pane; -Popover.Section = Section; +export const Popover = Object.assign(PopoverComponent, {Pane, Section}); diff --git a/src/components/Popover/components/PopoverOverlay/PopoverOverlay.tsx b/src/components/Popover/components/PopoverOverlay/PopoverOverlay.tsx index f97e9539476..4ea3fb77215 100644 --- a/src/components/Popover/components/PopoverOverlay/PopoverOverlay.tsx +++ b/src/components/Popover/components/PopoverOverlay/PopoverOverlay.tsx @@ -69,6 +69,16 @@ export class PopoverOverlay extends PureComponent { private contentNode = createRef(); private enteringTimer?: number; private exitingTimer?: number; + private overlayRef: React.RefObject; + + constructor(props: PopoverOverlayProps) { + super(props); + this.overlayRef = createRef(); + } + + forceUpdatePosition() { + this.overlayRef.current?.forceUpdatePosition(); + } changeTransitionStatus(transitionStatus: TransitionStatus, cb?: () => void) { this.setState({transitionStatus}, cb); @@ -136,6 +146,7 @@ export class PopoverOverlay extends PureComponent { return ( ', () => { expect(document.activeElement).not.toBe(focusTargetFirstNode); }); }); + + describe('forceUpdatePosition', () => { + it('exposes a function that allows the Overlay to be programmatically re-rendered', () => { + let overlayRef = null; + + function Test() { + overlayRef = useRef(null); + + return ( + + + + ); + } + + mountWithApp(); + + expect(overlayRef).toHaveProperty('current.forceUpdatePosition'); + }); + }); }); function noop() {} diff --git a/src/components/Popover/tests/Popover.test.tsx b/src/components/Popover/tests/Popover.test.tsx index 3787bb1b1eb..8295e13605b 100644 --- a/src/components/Popover/tests/Popover.test.tsx +++ b/src/components/Popover/tests/Popover.test.tsx @@ -1,9 +1,10 @@ -import React, {useState, useCallback} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {mountWithApp} from 'test-utilities'; import {PositionedOverlay} from 'components/PositionedOverlay'; import {Portal} from 'components'; import {Popover} from '../Popover'; +import type {PopoverPublicAPI} from '../Popover'; import {PopoverOverlay} from '../components'; import * as setActivatorAttributes from '../set-activator-attributes'; @@ -343,6 +344,33 @@ describe('', () => { expect(document.activeElement).not.toBe(activatorTarget); expect(document.activeElement).not.toBe(nextElementTarget); }); + + describe('forceUpdatePosition', () => { + it('exposes a function that allows the Overlay to be programmatically re-rendered', () => { + let popoverRef: React.RefObject | null = null; + + function Test() { + popoverRef = useRef(null); + + return ( + Activator} + onClose={spy} + /> + ); + } + + mountWithApp(); + + expect(popoverRef).toStrictEqual({ + current: { + forceUpdatePosition: expect.anything(), + }, + }); + }); + }); }); function noop() {} diff --git a/src/components/PositionedOverlay/PositionedOverlay.tsx b/src/components/PositionedOverlay/PositionedOverlay.tsx index a4649666c25..12cf5989e32 100644 --- a/src/components/PositionedOverlay/PositionedOverlay.tsx +++ b/src/components/PositionedOverlay/PositionedOverlay.tsx @@ -158,6 +158,14 @@ export class PositionedOverlay extends PureComponent< ); } + forceUpdatePosition() { + // Wait a single animation frame before re-measuring. + // Consumer's may also need to setup their own timers for + // triggering forceUpdatePosition() `children` use animation. + // Ideally, forceUpdatePosition() is fired at the end of a transition event. + requestAnimationFrame(this.handleMeasurement); + } + private overlayDetails = (): OverlayDetails => { const { measuring, diff --git a/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx b/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx index 1bd293a64b3..9795e34c48a 100644 --- a/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx +++ b/src/components/PositionedOverlay/tests/PositionedOverlay.test.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, {useRef} from 'react'; // eslint-disable-next-line no-restricted-imports import {mountWithAppProvider} from 'test-utilities/legacy'; +import {mountWithApp} from 'test-utilities/react-testing'; import {EventListener} from '../../EventListener'; import {PositionedOverlay} from '../PositionedOverlay'; @@ -275,6 +276,22 @@ describe('', () => { ).toBe(false); }); }); + + describe('forceUpdatePosition', () => { + it('exposes a function that allows the Overlay to be programmatically re-rendered', () => { + let overlayRef = null; + + function Test() { + overlayRef = useRef(null); + + return ; + } + + mountWithApp(); + + expect(overlayRef).toHaveProperty('current.forceUpdatePosition'); + }); + }); }); function mockRender() { diff --git a/src/components/index.ts b/src/components/index.ts index 5d14b8e5200..f86b89e5716 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -205,7 +205,11 @@ export {PolarisTestProvider} from './PolarisTestProvider'; export type {WithPolarisTestProviderOptions} from './PolarisTestProvider'; export {Popover, PopoverCloseSource} from './Popover'; -export type {PopoverProps, PopoverAutofocusTarget} from './Popover'; +export type { + PopoverProps, + PopoverAutofocusTarget, + PopoverPublicAPI, +} from './Popover'; export {Portal} from './Portal'; export type {PortalProps} from './Portal';