diff --git a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts index d1a0e33a10..e7600b1bc1 100644 --- a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts +++ b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts @@ -15,14 +15,20 @@ type TransitionLifecycleCallbacks = Pick< 'onEnter' | 'onEntering' | 'onEntered' | 'onExit' | 'onExiting' | 'onExited' >; -export const DismissMode = { +/** + * Options to control how the popover element is dismissed. This should not be altered + * because it is intended to have parity with the web-native {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover popover attribute} + * @param Auto will automatically handle dismissal on backdrop click or esc key press, ensuring only one popover is visible at a time + * @param Manual will require that the consumer handle dismissal manually + */ +const DismissMode = { Auto: 'auto', Manual: 'manual', } as const; -export type DismissMode = (typeof DismissMode)[keyof typeof DismissMode]; +type DismissMode = (typeof DismissMode)[keyof typeof DismissMode]; /** Local implementation of web-native `ToggleEvent` until we use typescript v5 */ -export interface ToggleEvent extends Event { +interface ToggleEvent extends Event { type: 'toggle'; newState: 'open' | 'closed'; oldState: 'open' | 'closed'; @@ -119,7 +125,7 @@ interface RenderTopLayerProps { scrollContainer?: never; } -export type PopoverRenderModeProps = +type PopoverRenderModeProps = | RenderInlineProps | RenderPortalProps | RenderTopLayerProps; diff --git a/packages/leafygreen-provider/src/PopoverContext/index.ts b/packages/leafygreen-provider/src/PopoverContext/index.ts index ffac321661..69acd09128 100644 --- a/packages/leafygreen-provider/src/PopoverContext/index.ts +++ b/packages/leafygreen-provider/src/PopoverContext/index.ts @@ -4,8 +4,6 @@ export { usePopoverContext, } from './PopoverContext'; export { - DismissMode, type PopoverContextType, type PopoverProviderProps, - type ToggleEvent, } from './PopoverContext.types'; diff --git a/packages/leafygreen-provider/src/index.ts b/packages/leafygreen-provider/src/index.ts index f9c55c03ef..25ba508d85 100644 --- a/packages/leafygreen-provider/src/index.ts +++ b/packages/leafygreen-provider/src/index.ts @@ -8,12 +8,10 @@ export { useOverlayContext, } from './OverlayContext'; export { - DismissMode, PopoverContext, type PopoverContextType, PopoverProvider, type PopoverProviderProps, - type ToggleEvent, usePopoverContext, } from './PopoverContext'; export { useBaseFontSize } from './TypographyContext'; diff --git a/packages/popover/src/Popover.stories.tsx b/packages/popover/src/Popover.stories.tsx index 7b37e9d9c8..49e24c6ece 100644 --- a/packages/popover/src/Popover.stories.tsx +++ b/packages/popover/src/Popover.stories.tsx @@ -3,20 +3,22 @@ import { storybookExcludedControlParams, StoryMetaType, } from '@lg-tools/storybook-utils'; -import { StoryFn } from '@storybook/react'; +import { StoryFn, StoryObj } from '@storybook/react'; import Button from '@leafygreen-ui/button'; import { css, cx } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; import { color } from '@leafygreen-ui/tokens'; -import Popover, { +import { Align, + DismissMode, Justify, + Popover, PopoverProps, RenderMode, ToggleEvent, -} from '.'; +} from './Popover'; const popoverStyle = css` border: 1px solid ${palette.gray.light1}; @@ -84,21 +86,22 @@ const referenceElPositions: { [key: string]: string } = { `, }; +const defaultExcludedControls = [ + ...storybookExcludedControlParams, + 'active', + 'children', + 'portalClassName', + 'refButtonPosition', + 'refEl', +]; + const meta: StoryMetaType = { title: 'Components/Popover', component: Popover, parameters: { default: 'LiveExample', controls: { - exclude: [ - ...storybookExcludedControlParams, - 'active', - 'children', - 'portalClassName', - 'refButtonPosition', - 'refEl', - 'renderMode', - ], + exclude: defaultExcludedControls, }, generate: { storyNames: [ @@ -118,9 +121,6 @@ const meta: StoryMetaType = { }, // eslint-disable-next-line react/display-name decorator: Instance => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const [active, setActive] = useState(false); - return (
= { justify-content: center; `} > -
); }; -RenderModePortalInScrollableContainer.parameters = { - chromatic: { - disableSnapshot: true, +export const RenderModePortalInScrollableContainer = { + render: PortalPopoverInScrollableContainer, + parameters: { + chromatic: { + disableSnapshot: true, + }, + controls: { + exclude: [...defaultExcludedControls, 'dismissMode', 'renderMode'], + }, + }, + argTypes: { + renderMode: { control: 'none' }, + portalClassName: { control: 'none' }, + refEl: { control: 'none' }, + className: { control: 'none' }, + active: { control: 'none' }, }, -}; -RenderModePortalInScrollableContainer.argTypes = { - renderMode: { control: 'none' }, - portalClassName: { control: 'none' }, - refEl: { control: 'none' }, - className: { control: 'none' }, - active: { control: 'none' }, }; -export const RenderModeInline: StoryFn = ({ +const InlinePopover = ({ refButtonPosition, buttonText, ...args @@ -315,57 +331,110 @@ export const RenderModeInline: StoryFn = ({ ); }; -RenderModeInline.parameters = { - chromatic: { - disableSnapshot: true, +export const RenderModeInline = { + render: InlinePopover, + parameters: { + chromatic: { + disableSnapshot: true, + }, + controls: { + exclude: [...defaultExcludedControls, 'dismissMode', 'renderMode'], + }, + }, + argTypes: { + renderMode: { control: 'none' }, + portalClassName: { control: 'none' }, + refEl: { control: 'none' }, + className: { control: 'none' }, + active: { control: 'none' }, }, }; -RenderModeInline.argTypes = { - renderMode: { control: 'none' }, - portalClassName: { control: 'none' }, - refEl: { control: 'none' }, - className: { control: 'none' }, - active: { control: 'none' }, -}; + +const generatedStoryExcludedControlParams = [ + ...storybookExcludedControlParams, + 'active', + 'adjustOnMutation', + 'align', + 'buttonText', + 'children', + 'dismissMode', + 'justify', + 'portalClassName', + 'refButtonPosition', + 'refEl', + 'renderMode', + 'spacing', + 'usePortal', +]; export const Top = { - render: () => {}, + render: LiveExample.bind({}), args: { align: Align.Top, }, + parameters: { + controls: { + exclude: generatedStoryExcludedControlParams, + }, + }, }; export const Bottom = { - render: () => {}, + render: LiveExample.bind({}), args: { align: Align.Bottom, }, + parameters: { + controls: { + exclude: generatedStoryExcludedControlParams, + }, + }, }; export const Left = { - render: () => {}, + render: LiveExample.bind({}), args: { align: Align.Left, }, + parameters: { + controls: { + exclude: generatedStoryExcludedControlParams, + }, + }, }; export const Right = { - render: () => {}, + render: LiveExample.bind({}), args: { align: Align.Right, }, + parameters: { + controls: { + exclude: generatedStoryExcludedControlParams, + }, + }, }; export const CenterHorizontal = { - render: () => {}, + render: LiveExample.bind({}), args: { align: Align.CenterHorizontal, }, + parameters: { + controls: { + exclude: generatedStoryExcludedControlParams, + }, + }, }; -export const CenterVertical = { - render: () => {}, +export const CenterVertical: StoryObj = { + render: LiveExample.bind({}), args: { align: Align.CenterVertical, }, + parameters: { + controls: { + exclude: generatedStoryExcludedControlParams, + }, + }, }; diff --git a/packages/popover/src/Popover.hooks.tsx b/packages/popover/src/Popover/Popover.hooks.tsx similarity index 87% rename from packages/popover/src/Popover.hooks.tsx rename to packages/popover/src/Popover/Popover.hooks.tsx index 49bd24a926..4835825d8e 100644 --- a/packages/popover/src/Popover.hooks.tsx +++ b/packages/popover/src/Popover/Popover.hooks.tsx @@ -6,8 +6,9 @@ import { } from '@leafygreen-ui/hooks'; import { PopoverContextType } from '@leafygreen-ui/leafygreen-provider'; -import { getRenderMode } from './utils/getRenderMode'; -import { getElementDocumentPosition } from './utils/positionUtils'; +import { getRenderMode } from '../utils/getRenderMode'; +import { getElementDocumentPosition } from '../utils/positionUtils'; + import { PopoverProps, RenderMode, @@ -15,22 +16,12 @@ import { UseReferenceElementReturnObj, } from './Popover.types'; +/** + * This hook handles logic for determining what prop values are used for the `Popover` + * component. If a prop is not provided, the value from the `PopoverContext` will be used. + */ export function usePopoverContextProps( - props: Partial< - Omit< - PopoverProps, - | 'active' - | 'adjustOnMutation' - | 'align' - | 'children' - | 'className' - | 'justify' - | 'refEl' - > - >, - context: PopoverContextType, -) { - const { + { renderMode: renderModeProp, dismissMode, onToggle, @@ -45,15 +36,32 @@ export function usePopoverContextProps( onExit, onExiting, onExited, - popoverZIndex, + popoverZIndex: popoverZIndexProp, spacing, ...rest - } = props; + }: Partial< + Omit< + PopoverProps, + | 'active' + | 'adjustOnMutation' + | 'align' + | 'children' + | 'className' + | 'justify' + | 'refEl' + > + >, + context: PopoverContextType, +) { const renderMode = getRenderMode( renderModeProp || context.renderMode, usePortalProp, ); const usePortal = renderMode === RenderMode.Portal; + const popoverZIndex = + renderMode === RenderMode.TopLayer + ? undefined + : popoverZIndexProp || context.popoverZIndex; return { renderMode, @@ -70,7 +78,7 @@ export function usePopoverContextProps( onExit: onExit || context.onExit, onExiting: onExiting || context.onExiting, onExited: onExited || context.onExited, - popoverZIndex: popoverZIndex || context.popoverZIndex, + popoverZIndex, spacing: spacing || context.spacing, isPopoverOpen: context.isPopoverOpen, setIsPopoverOpen: context.setIsPopoverOpen, diff --git a/packages/popover/src/Popover/Popover.spec.tsx b/packages/popover/src/Popover/Popover.spec.tsx index 11b1bfab6d..c0bf73e61b 100644 --- a/packages/popover/src/Popover/Popover.spec.tsx +++ b/packages/popover/src/Popover/Popover.spec.tsx @@ -1,12 +1,18 @@ -import React, { createRef, PropsWithChildren } from 'react'; -import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import React, { createRef, PropsWithChildren, useRef, useState } from 'react'; +import { + act, + fireEvent, + render, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; +import Button from '@leafygreen-ui/button'; import { PopoverContext } from '@leafygreen-ui/leafygreen-provider'; -import { PopoverProps, RenderMode } from '../Popover.types'; - import { Popover } from './Popover'; +import { DismissMode, PopoverProps, RenderMode } from './Popover.types'; type RTLInlinePopoverProps = Partial< Omit< @@ -38,17 +44,41 @@ type RTLTopLayerPopoverProps = Partial< > >; +function TopLayerPopoverWithReference(props?: RTLTopLayerPopoverProps) { + const buttonRef = useRef(null); + const [active, setActive] = useState(props?.active ?? false); + + return ( + <> + + + Popover Content + + + ); +} + function renderTopLayerPopover(props?: RTLTopLayerPopoverProps) { const result = render( - - Popover Content - , + <> + + , ); + const button = result.getByTestId('popover-reference-element'); + const rerenderPopover = (newProps?: RTLTopLayerPopoverProps) => { const allProps = { ...props, ...newProps }; result.rerender( @@ -62,7 +92,7 @@ function renderTopLayerPopover(props?: RTLTopLayerPopoverProps) { ); }; - return { ...result, rerenderPopover }; + return { button, ...result, rerenderPopover }; } describe('packages/popover', () => { @@ -171,7 +201,7 @@ describe('packages/popover', () => { }); test('displays popover when the `active` prop is `true`', () => { - const { container, getByTestId } = renderPortalPopover({ active: true }); + const { getByTestId } = renderPortalPopover({ active: true }); expect(getByTestId('popover-test-id')).toBeInTheDocument(); }); @@ -187,9 +217,9 @@ describe('packages/popover', () => { test('accepts a `portalRef`', async () => { const portalRef = createRef(); - waitFor(() => { - renderPortalPopover({ portalRef }); + renderPortalPopover({ active: true, portalRef }); + waitFor(() => { expect(portalRef.current).toBeDefined(); expect(portalRef.current).toBeInTheDocument(); }); @@ -223,17 +253,123 @@ describe('packages/popover', () => { }); }); - test('displays popover in top layer when the `active` prop is `true`', () => { - const { container, getByTestId } = renderTopLayerPopover({ + describe(`when dismissMode=${DismissMode.Auto}`, () => { + // skip until JSDOM supports Popover API + // eslint-disable-next-line jest/no-disabled-tests + test.skip('dismisses popover when outside of popover is clicked', async () => { + const { getByTestId } = renderTopLayerPopover({ + active: true, + dismissMode: DismissMode.Auto, + }); + const popover = getByTestId('popover-test-id'); + + await waitFor(() => expect(popover).toBeVisible()); + + userEvent.click(document.body); + + await waitFor(() => expect(popover).not.toBeVisible()); + }); + + // skip until JSDOM supports Popover API + // eslint-disable-next-line jest/no-disabled-tests + test.skip('dismisses popover when `Escape` key is pressed', async () => { + const { getByTestId } = renderTopLayerPopover({ + active: true, + dismissMode: DismissMode.Auto, + }); + const popover = getByTestId('popover-test-id'); + + await waitFor(() => expect(popover).toBeVisible()); + + userEvent.keyboard('{escape}'); + + await waitFor(() => expect(popover).not.toBeVisible()); + }); + }); + + describe(`when dismissMode=${DismissMode.Manual}`, () => { + // skip until JSDOM supports Popover API + // eslint-disable-next-line jest/no-disabled-tests + test.skip('does not dismiss popover when outside of popover is clicked', async () => { + const { getByTestId } = renderTopLayerPopover({ + active: true, + dismissMode: DismissMode.Manual, + }); + const popover = getByTestId('popover-test-id'); + + await waitFor(() => expect(popover).toBeVisible()); + + userEvent.click(document.body); + + await waitFor(() => expect(popover).toBeVisible()); + }); + + // skip until JSDOM supports Popover API + // eslint-disable-next-line jest/no-disabled-tests + test.skip('does not dismiss popover when `Escape` key is pressed', async () => { + const { getByTestId } = renderTopLayerPopover({ + active: true, + dismissMode: DismissMode.Manual, + }); + const popover = getByTestId('popover-test-id'); + + await waitFor(() => expect(popover).toBeVisible()); + + userEvent.keyboard('{escape}'); + + await waitFor(() => expect(popover).toBeVisible()); + }); + }); + + test('displays popover in top layer when the `active` prop is `true`', async () => { + const { getByTestId } = renderTopLayerPopover({ active: true, }); - expect(getByTestId('popover-test-id')).toBeInTheDocument(); + const popover = getByTestId('popover-test-id'); + + expect(popover).toBeInTheDocument(); + await waitFor(() => expect(popover).toBeVisible()); }); test('does NOT display popover when the `active` prop is `false`', () => { const { queryByTestId } = renderTopLayerPopover({ active: false }); expect(queryByTestId('popover-test-id')).toBeNull(); }); + + describe('onToggle', () => { + const toggleEvent = new Event('toggle'); + test('is called when popover is opened', () => { + const onToggleSpy = jest.fn(); + const { button, getByTestId } = renderTopLayerPopover({ + active: false, + dismissMode: DismissMode.Auto, + onToggle: onToggleSpy, + }); + + userEvent.click(button); + + const popover = getByTestId('popover-test-id'); + popover.dispatchEvent(toggleEvent); + + expect(onToggleSpy).toHaveBeenCalledTimes(1); + }); + + test('is called when popover is closed', () => { + const onToggleSpy = jest.fn(); + const { button, getByTestId } = renderTopLayerPopover({ + active: true, + onToggle: onToggleSpy, + }); + + expect(onToggleSpy).not.toHaveBeenCalled(); + + const popover = getByTestId('popover-test-id'); + popover.dispatchEvent(toggleEvent); + userEvent.click(button); + + expect(onToggleSpy).toHaveBeenCalledTimes(1); + }); + }); }); test('accepts a ref', () => { @@ -274,7 +410,7 @@ describe('packages/popover', () => { onExiting: jest.fn(), onExited: jest.fn(), }; - const { rerenderPopover } = renderTopLayerPopover({ + const { button } = renderTopLayerPopover({ ...callbacks, }); @@ -284,7 +420,7 @@ describe('packages/popover', () => { } // Calls enter callbacks when active is toggled to true - rerenderPopover({ active: true }); + userEvent.click(button); expect(callbacks.onEnter).toHaveBeenCalledTimes(1); expect(callbacks.onEntering).toHaveBeenCalledTimes(1); @@ -295,7 +431,7 @@ describe('packages/popover', () => { expect(callbacks.onExited).not.toHaveBeenCalled(); // Calls exit callbacks when active is toggled to false - rerenderPopover({ active: false }); + userEvent.click(button); // Expect the `onEnter*` callbacks to _only_ have been called once (from the previous render) expect(callbacks.onEnter).toHaveBeenCalledTimes(1); @@ -326,32 +462,13 @@ describe('packages/popover', () => { const result = render( - - Popover Content - + , ); - const rerenderPopover = (newProps?: RTLTopLayerPopoverProps) => { - const allProps = { ...props, ...newProps }; - result.rerender( - - - Popover Content - - , - ); - }; + const button = result.getByTestId('popover-reference-element'); - return { ...result, rerenderPopover }; + return { button, ...result }; } afterEach(() => { @@ -359,19 +476,20 @@ describe('packages/popover', () => { }); test('toggling `active` calls setIsPopoverOpen', async () => { - const { rerenderPopover } = renderPopoverInContext(); + const { button } = renderPopoverInContext(); expect(setIsPopoverOpenMock).not.toHaveBeenCalled(); - rerenderPopover({ active: true }); - await waitFor(() => - expect(setIsPopoverOpenMock).toHaveBeenCalledWith(true), - ); + userEvent.click(button); + await waitFor(() => { + expect(setIsPopoverOpenMock).toHaveBeenCalledWith(true); + expect(setIsPopoverOpenMock).toHaveBeenCalledTimes(1); + }); - rerenderPopover({ active: false }); - expect(setIsPopoverOpenMock).not.toHaveBeenCalledWith(false); - await waitFor(() => - expect(setIsPopoverOpenMock).toHaveBeenCalledWith(false), - ); + userEvent.click(button); + await waitFor(() => { + expect(setIsPopoverOpenMock).toHaveBeenCalledWith(false); + expect(setIsPopoverOpenMock).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/packages/popover/src/Popover.styles.ts b/packages/popover/src/Popover/Popover.styles.ts similarity index 55% rename from packages/popover/src/Popover.styles.ts rename to packages/popover/src/Popover/Popover.styles.ts index 0e5781d495..6bbcd97d4b 100644 --- a/packages/popover/src/Popover.styles.ts +++ b/packages/popover/src/Popover/Popover.styles.ts @@ -4,7 +4,7 @@ import { css, cx } from '@leafygreen-ui/emotion'; import { createUniqueClassName } from '@leafygreen-ui/lib'; import { transitionDuration } from '@leafygreen-ui/tokens'; -import { ExtendedPlacement } from './Popover.types'; +import { ExtendedPlacement, TransformAlign } from './Popover.types'; export const TRANSITION_DURATION = transitionDuration.default; @@ -40,7 +40,7 @@ const getBasePopoverStyles = (floatingStyles: React.CSSProperties) => css` } @starting-style { - &:popover-open { + :popover-open { opacity: 0; transform: scale(0); } @@ -95,92 +95,102 @@ const transformOriginStyles: Record = { `, }; -const getClosedStyles = (placement: ExtendedPlacement, spacing: number) => { - if (placement.startsWith('top')) { - return css` - opacity: 0; - transform: translateY(${spacing}px) scale(0); - `; - } - - if (placement.startsWith('bottom')) { - return css` - opacity: 0; - transform: translateY(-${spacing}px) scale(0); - `; - } - - if (placement.startsWith('left')) { - return css` - opacity: 0; - transform: translateX(${spacing}px) scale(0); - `; - } +const baseClosedStyles = css` + opacity: 0; +`; - if (placement.startsWith('right')) { - return css` - opacity: 0; - transform: translateX(-${spacing}px) scale(0); - `; +const getClosedStyles = (spacing: number, transformAlign: TransformAlign) => { + switch (transformAlign) { + case TransformAlign.Top: + return cx( + baseClosedStyles, + css` + transform: translateY(${spacing}px) scale(0); + `, + ); + case TransformAlign.Bottom: + return cx( + baseClosedStyles, + css` + transform: translateY(-${spacing}px) scale(0); + `, + ); + case TransformAlign.Left: + return cx( + baseClosedStyles, + css` + transform: translateX(${spacing}px) scale(0); + `, + ); + case TransformAlign.Right: + return cx( + baseClosedStyles, + css` + transform: translateX(-${spacing}px) scale(0); + `, + ); + case TransformAlign.Center: + default: + return cx( + baseClosedStyles, + css` + transform: scale(0); + `, + ); } - - return css` - opacity: 0; - transform: scale(0); - `; }; -const getOpenStyles = (placement: ExtendedPlacement) => { - if (placement.startsWith('top')) { - return css` - opacity: 1; - pointer-events: initial; - transform: translateY(0) scale(1); - `; - } - - if (placement.startsWith('bottom')) { - return css` - opacity: 1; - pointer-events: initial; - transform: translateY(0) scale(1); - `; - } +const baseOpenStyles = css` + opacity: 1; + pointer-events: initial; - if (placement.startsWith('left')) { - return css` - opacity: 1; - pointer-events: initial; - transform: translateX(0) scale(1); - `; + &:popover-open { + opacity: 1; + pointer-events: initial; } +`; - if (placement.startsWith('right')) { - return css` - opacity: 1; - pointer-events: initial; - transform: translateX(0) scale(1); - `; +const getOpenStyles = (transformAlign: TransformAlign) => { + switch (transformAlign) { + case TransformAlign.Top: + case TransformAlign.Bottom: + return cx( + baseOpenStyles, + css` + transform: translateY(0) scale(1); + + &:popover-open { + transform: translateY(0) scale(1); + } + `, + ); + case TransformAlign.Left: + case TransformAlign.Right: + return cx( + baseOpenStyles, + css` + transform: translateX(0) scale(1); + + &:popover-open { + transform: translateX(0) scale(1); + } + `, + ); + case TransformAlign.Center: + default: + return cx( + baseOpenStyles, + css` + transform: scale(1); + + &:popover-open { + transform: scale(1); + } + `, + ); } - - return css` - opacity: 1; - pointer-events: initial; - transform: scale(1); - `; }; -const getTransitionStyles = ( - placement: ExtendedPlacement, - spacing: number, -) => ({ - exited: getClosedStyles(placement, spacing), - entering: getClosedStyles(placement, spacing), - entered: getOpenStyles(placement), - exiting: getClosedStyles(placement, spacing), - unmounted: getClosedStyles(placement, spacing), -}); - export const getPopoverStyles = ({ className, floatingStyles, @@ -188,6 +198,7 @@ export const getPopoverStyles = ({ popoverZIndex, spacing, state, + transformAlign, }: { className?: string; floatingStyles: React.CSSProperties; @@ -195,13 +206,14 @@ export const getPopoverStyles = ({ popoverZIndex?: number; spacing: number; state: TransitionStatus; + transformAlign: TransformAlign; }) => cx( getBasePopoverStyles(floatingStyles), transformOriginStyles[placement], - getTransitionStyles(placement, spacing)[state], - { + [getClosedStyles(spacing, transformAlign)]: state !== 'entered', + [getOpenStyles(transformAlign)]: state === 'entered', [css` z-index: ${popoverZIndex}; `]: typeof popoverZIndex === 'number', diff --git a/packages/popover/src/Popover.testutils.tsx b/packages/popover/src/Popover/Popover.testutils.tsx similarity index 100% rename from packages/popover/src/Popover.testutils.tsx rename to packages/popover/src/Popover/Popover.testutils.tsx diff --git a/packages/popover/src/Popover/Popover.tsx b/packages/popover/src/Popover/Popover.tsx index 603b97a727..0cf9239e99 100644 --- a/packages/popover/src/Popover/Popover.tsx +++ b/packages/popover/src/Popover/Popover.tsx @@ -9,17 +9,24 @@ import { consoleOnce } from '@leafygreen-ui/lib'; import Portal from '@leafygreen-ui/portal'; import { spacing as spacingToken } from '@leafygreen-ui/tokens'; +import { + getExtendedPlacementValues, + getFloatingPlacement, + getOffsetValue, + getWindowSafePlacementValues, +} from '../utils/positionUtils'; + import { useContentNode, usePopoverContextProps, useReferenceElement, -} from '../Popover.hooks'; +} from './Popover.hooks'; import { contentClassName, getPopoverStyles, hiddenPlaceholderStyle, TRANSITION_DURATION, -} from '../Popover.styles'; +} from './Popover.styles'; import { Align, DismissMode, @@ -27,13 +34,7 @@ import { PopoverComponentProps, PopoverProps, RenderMode, -} from '../Popover.types'; -import { - getExtendedPlacementValue, - getFloatingPlacement, - getOffsetValue, - getWindowSafePlacementValues, -} from '../utils/positionUtils'; +} from './Popover.types'; /** * @@ -102,11 +103,11 @@ export const Popover = forwardRef( } = usePopoverContextProps(rest, popoverContext); /** - * When usePortal is true and a scrollContainer is defined, log a warning if the - * portalContainer is not inside of the scrollContainer. - * Note: If no portalContainer is passed the portalContainer will be undefined, and - * this warning will show up. By default if no portalContainer is passed the - * component will create a div and append it to the body. + * When `usePortal` is true and a `scrollContainer` is defined, + * log a warning if the `portalContainer` is not inside of the `scrollContainer`. + * + * Note: If no `portalContainer` is provided, + * the `Portal` component will create a `div` and append it to the body. */ if (usePortal && scrollContainer) { if (!scrollContainer.contains(portalContainer as HTMLElement)) { @@ -154,10 +155,11 @@ export const Popover = forwardRef( const { align: windowSafeAlign, justify: windowSafeJustify } = getWindowSafePlacementValues(placement); - const extendedPlacement = getExtendedPlacementValue({ - placement, - align, - }); + const { placement: extendedPlacement, transformAlign } = + getExtendedPlacementValues({ + placement, + align, + }); const renderChildren = () => { if (children === null) { @@ -175,20 +177,36 @@ export const Popover = forwardRef( return children; }; - useEffect(() => { - if (renderMode !== RenderMode.TopLayer || !onToggle) { - return; + const handleEntering = (isAppearing: boolean) => { + if (renderMode === RenderMode.TopLayer) { + // @ts-expect-error - `toggle` event not supported pre-typescript v5 + refs.floating.current?.addEventListener('toggle', onToggle); } - // @ts-expect-error - `toggle` event not supported pre-typescript v5 - refs.floating.current?.addEventListener('toggle', onToggle); - return () => + onEntering?.(isAppearing); + }; + + const handleEntered = (isAppearing: boolean) => { + setIsPopoverOpen(true); + onEntered?.(isAppearing); + }; + + const handleExiting = () => { + if (renderMode === RenderMode.TopLayer) { // @ts-expect-error - `toggle` event not supported pre-typescript v5 refs.floating.current?.removeEventListener('toggle', onToggle); - }, [onToggle, renderMode]); + } + + onExiting?.(); + }; + + const handleExited = () => { + setIsPopoverOpen(false); + onExited?.(); + }; useEffect(() => { - if (renderMode !== RenderMode.TopLayer) { + if (!refs.floating.current || renderMode !== RenderMode.TopLayer) { return; } @@ -205,19 +223,16 @@ export const Popover = forwardRef( { - setIsPopoverOpen(true); - onEntered?.(...args); + timeout={{ + enter: TRANSITION_DURATION / 2, + exit: TRANSITION_DURATION, }} - onExiting={onExiting} + onEnter={onEnter} + onEntering={handleEntering} + onEntered={handleEntered} onExit={onExit} - onExited={(...args) => { - setIsPopoverOpen(false); - onExited?.(...args); - }} + onExiting={handleExiting} + onExited={handleExited} mountOnEnter unmountOnExit appear @@ -236,12 +251,10 @@ export const Popover = forwardRef( className, floatingStyles, placement: extendedPlacement, - popoverZIndex: - renderMode === RenderMode.TopLayer - ? undefined - : popoverZIndex, + popoverZIndex, spacing, state, + transformAlign, })} // @ts-expect-error - `popover` attribute is not typed in current version of `@types/react` https://github.com/DefinitelyTyped/DefinitelyTyped/pull/69670 // eslint-disable-next-line react/no-unknown-property diff --git a/packages/popover/src/Popover.types.ts b/packages/popover/src/Popover/Popover.types.ts similarity index 59% rename from packages/popover/src/Popover.types.ts rename to packages/popover/src/Popover/Popover.types.ts index 6ff841b2af..4bbb72a323 100644 --- a/packages/popover/src/Popover.types.ts +++ b/packages/popover/src/Popover/Popover.types.ts @@ -13,9 +13,8 @@ type TransitionLifecycleCallbacks = Pick< /** * Options to render the popover element - * @param Inline will render the popover element inline with the reference element - * @param Portal will render the popover element in a provided `portalContainer` or - * in a new div appended to the body + * @param Inline will render the popover element inline in the DOM where it's written + * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` * @param TopLayer will render the popover element in the top layer */ export const RenderMode = { @@ -26,7 +25,7 @@ export const RenderMode = { export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; /** - * Options to control how the popover element is dismissed. This should not be extended + * Options to control how the popover element is dismissed. This should not be altered * because it is intended to have parity with the web-native {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover popover attribute} * @param Auto will automatically handle dismissal on backdrop click or esc key press, ensuring only one popover is visible at a time * @param Manual will require that the consumer handle dismissal manually @@ -82,6 +81,20 @@ type Justify = (typeof Justify)[keyof typeof Justify]; export { Justify }; +/** + * This value is derived from the placement value returned by the `useFloating` hook and + * used to determine the `transform` styling of the popover element + */ +export const TransformAlign = { + Top: 'top', + Bottom: 'bottom', + Left: 'left', + Right: 'right', + Center: 'center', +} as const; +export type TransformAlign = + (typeof TransformAlign)[keyof typeof TransformAlign]; + export type ExtendedPlacement = | Placement | 'center' @@ -103,67 +116,100 @@ export interface ChildrenFunctionParameters { referenceElPos: ElementPosition; } -/** @deprecated - use {@link RenderTopLayerProps} */ export interface RenderInlineProps { /** - * Popover element will render inline with the reference element - * @deprecated use 'top-layer' + * Options to render the popover element + * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future. + * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future. + * @param TopLayer will render the popover element in the top layer */ renderMode?: 'inline'; - /** Not used in this `renderMode` */ + /** + * When `renderMode="top-layer"`, these options can control how a popover element is dismissed + * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time + * - `'manual'` will require that the consumer handle dismissal manually + */ dismissMode?: never; - /** Not used in this `renderMode` */ + /** + * A callback function that is called when the visibility of a popover element rendered in the top layer is toggled + */ onToggle?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it specifies a class name to apply to the portal element + * @deprecated + */ portalClassName?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body + * @deprecated + */ portalContainer?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it passes a ref to forward to the portal element + * @deprecated + */ portalRef?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it specifies the scrollable element to position relative to + * @deprecated + */ scrollContainer?: never; - /** Not used in this `renderMode` */ + /** + * Duplicated by `renderMode='portal'` + * @deprecated TODO: https://jira.mongodb.org/browse/LG-4526 + */ usePortal: false; } -/** @deprecated - use {@link RenderTopLayerProps} */ export interface RenderPortalProps { /** - * Popover element will render in a provided `portalContainer` or in a new div appended to the body - * @deprecated + * Options to render the popover element + * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future. + * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future. + * @param TopLayer will render the popover element in the top layer */ renderMode?: 'portal'; - /** Not used in this `renderMode` */ + /** + * When `renderMode="top-layer"`, these options can control how a popover element is dismissed + * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time + * - `'manual'` will require that the consumer handle dismissal manually + */ dismissMode?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="top-layer"`, this callback function is called when the visibility of a popover element is toggled + */ onToggle?: never; /** - * Specifies a class name to apply to the portal element + * When `renderMode="portal"`, it specifies a class name to apply to the portal element + * @deprecated */ portalClassName?: string; /** - * Specifies an element to portal within. If not provided, a div is generated at the end of the body + * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body + * @deprecated */ portalContainer?: HTMLElement | null; /** - * Passes a ref to forward to the portal element + * When `renderMode="portal"`, it passes a ref to forward to the portal element + * @deprecated */ portalRef?: React.MutableRefObject; /** - * Specifies the scrollable element to position relative to + * When `renderMode="portal"`, it specifies the scrollable element to position relative to + * @deprecated */ scrollContainer?: HTMLElement | null; @@ -176,35 +222,53 @@ export interface RenderPortalProps { export interface RenderTopLayerProps { /** - * Popover element will render in the top layer + * Options to render the popover element + * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future. + * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future. + * @param TopLayer will render the popover element in the top layer */ renderMode?: 'top-layer'; /** - * Options to control how the popover element is dismissed + * When `renderMode="top-layer"`, these options can control how a popover element is dismissed * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time * - `'manual'` will require that the consumer handle dismissal manually */ dismissMode?: DismissMode; /** - * A callback function that is called when the popover is toggled + * A callback function that is called when the visibility of a popover element rendered in the top layer is toggled */ onToggle?: (e: ToggleEvent) => void; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it specifies a class name to apply to the portal element + * @deprecated + */ portalClassName?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body + * @deprecated + */ portalContainer?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it passes a ref to forward to the portal element + * @deprecated + */ portalRef?: never; - /** Not used in this `renderMode` */ + /** + * When `renderMode="portal"`, it specifies the scrollable element to position relative to + * @deprecated + */ scrollContainer?: never; - /** Not used in this `renderMode` */ + /** + * Duplicated by `renderMode='portal'` + * @deprecated TODO: https://jira.mongodb.org/browse/LG-4526 + */ usePortal?: never; } @@ -233,9 +297,11 @@ export type PopoverProps = { active?: boolean; /** - * Class name applied to popover container. + * Should the Popover auto adjust its content when the DOM changes (using MutationObserver). + * + * default: false */ - className?: string; + adjustOnMutation?: boolean; /** * Determines the alignment of the popover content relative to the trigger element @@ -244,6 +310,11 @@ export type PopoverProps = { */ align?: Align; + /** + * Class name applied to popover container. + */ + className?: string; + /** * Determines the justification of the popover content relative to the trigger element * @@ -252,33 +323,26 @@ export type PopoverProps = { justify?: Justify; /** - * A reference to the element against which the popover component will be positioned. - */ - refEl?: React.RefObject; - - /** - * Specifies the amount of spacing (in pixels) between the trigger element and the Popover content. - * - * default: `4` + * Click event handler passed to the root div element within the portal container. */ - spacing?: number; + onClick?: React.MouseEventHandler; /** - * Should the Popover auto adjust its content when the DOM changes (using MutationObserver). - * - * default: false + * Number that controls the z-index of the popover element directly. */ - adjustOnMutation?: boolean; + popoverZIndex?: number; /** - * Click event handler passed to the root div element within the portal container. + * A reference to the element against which the popover component will be positioned. */ - onClick?: React.MouseEventHandler; + refEl?: React.RefObject; /** - * Number that controls the z-index of the popover element directly. + * Specifies the amount of spacing (in pixels) between the trigger element and the Popover content. + * + * default: `4` */ - popoverZIndex?: number; + spacing?: number; } & PopoverRenderModeProps & TransitionLifecycleCallbacks; diff --git a/packages/popover/src/Popover/index.ts b/packages/popover/src/Popover/index.ts index 6e5a636481..ada8fdc272 100644 --- a/packages/popover/src/Popover/index.ts +++ b/packages/popover/src/Popover/index.ts @@ -1 +1,14 @@ export { Popover } from './Popover'; +export { contentClassName } from './Popover.styles'; +export { getAlign, getJustify } from './Popover.testutils'; +export { + Align, + type ChildrenFunctionParameters, + DismissMode, + type ElementPosition, + Justify, + type PopoverProps, + type PopoverRenderModeProps, + RenderMode, + type ToggleEvent, +} from './Popover.types'; diff --git a/packages/popover/src/index.ts b/packages/popover/src/index.ts index d1abf5f74a..751c80a239 100644 --- a/packages/popover/src/index.ts +++ b/packages/popover/src/index.ts @@ -1,22 +1,21 @@ -import { Popover } from './Popover'; -import { contentClassName } from './Popover.styles'; -import { getAlign, getJustify } from './Popover.testutils'; +import { getAlign, getJustify, Popover } from './Popover'; + +export const TestUtils = { + getAlign, + getJustify, +}; export { Align, type ChildrenFunctionParameters, + contentClassName, DismissMode, type ElementPosition, Justify, + Popover, type PopoverProps, type PopoverRenderModeProps, RenderMode, type ToggleEvent, -} from './Popover.types'; - -export { contentClassName, Popover }; -export const TestUtils = { - getAlign, - getJustify, -}; +} from './Popover'; export default Popover; diff --git a/packages/popover/src/utils/getRenderMode.test.ts b/packages/popover/src/utils/getRenderMode.test.ts index 2d944fb447..6adfb4e085 100644 --- a/packages/popover/src/utils/getRenderMode.test.ts +++ b/packages/popover/src/utils/getRenderMode.test.ts @@ -1,4 +1,4 @@ -import { RenderMode } from '../Popover.types'; +import { RenderMode } from '../Popover/Popover.types'; import { getRenderMode } from './getRenderMode'; diff --git a/packages/popover/src/utils/getRenderMode.ts b/packages/popover/src/utils/getRenderMode.ts index 679551622b..5f805add2e 100644 --- a/packages/popover/src/utils/getRenderMode.ts +++ b/packages/popover/src/utils/getRenderMode.ts @@ -1,4 +1,4 @@ -import { RenderMode } from '../Popover.types'; +import { RenderMode } from '../Popover/Popover.types'; export function getRenderMode( renderMode?: RenderMode, diff --git a/packages/popover/src/utils/positionUtils.spec.ts b/packages/popover/src/utils/positionUtils.spec.ts index b00aff47d8..3e11e55220 100644 --- a/packages/popover/src/utils/positionUtils.spec.ts +++ b/packages/popover/src/utils/positionUtils.spec.ts @@ -1,10 +1,10 @@ import { Placement } from '@floating-ui/react'; -import { Align, Justify } from '../Popover.types'; +import { Align, Justify } from '../Popover/Popover.types'; import { getElementDocumentPosition, - getExtendedPlacementValue, + getExtendedPlacementValues, getFloatingPlacement, getOffsetValue, getWindowSafePlacementValues, @@ -124,65 +124,65 @@ describe('positionUtils', () => { ); }); - describe('getExtendedPlacementValue', () => { + describe('getExtendedPlacementValues', () => { test(`returns standard placement values if align prop is not ${Align.CenterHorizontal} or ${Align.CenterVertical}`, () => { expect( - getExtendedPlacementValue({ placement: 'top-start', align: 'top' }), - ).toBe('top-start'); + getExtendedPlacementValues({ placement: 'top-start', align: 'top' }), + ).toEqual({ placement: 'top-start', transformAlign: 'top' }); expect( - getExtendedPlacementValue({ placement: 'bottom', align: 'bottom' }), - ).toBe('bottom'); + getExtendedPlacementValues({ placement: 'bottom', align: 'bottom' }), + ).toEqual({ placement: 'bottom', transformAlign: 'bottom' }); expect( - getExtendedPlacementValue({ placement: 'left-end', align: 'left' }), - ).toBe('left-end'); + getExtendedPlacementValues({ placement: 'left-end', align: 'left' }), + ).toEqual({ placement: 'left-end', transformAlign: 'left' }); expect( - getExtendedPlacementValue({ placement: 'right', align: 'right' }), - ).toBe('right'); + getExtendedPlacementValues({ placement: 'right', align: 'right' }), + ).toEqual({ placement: 'right', transformAlign: 'right' }); }); describe(`when align prop is ${Align.CenterHorizontal}`, () => { test('returns right* placement values for right, right-start, and right-end placements', () => { expect( - getExtendedPlacementValue({ + getExtendedPlacementValues({ placement: 'right', align: Align.CenterHorizontal, }), - ).toBe('center'); + ).toEqual({ placement: 'center', transformAlign: 'center' }); expect( - getExtendedPlacementValue({ + getExtendedPlacementValues({ placement: 'right-start', align: Align.CenterHorizontal, }), - ).toBe('center-start'); + ).toEqual({ placement: 'center-start', transformAlign: 'center' }); expect( - getExtendedPlacementValue({ + getExtendedPlacementValues({ placement: 'right-end', align: Align.CenterHorizontal, }), - ).toBe('center-end'); + ).toEqual({ placement: 'center-end', transformAlign: 'center' }); }); }); describe(`when align prop is ${Align.CenterVertical}`, () => { test('returns bottom* placement values for bottom, bottom-start, and bottom-end placements', () => { expect( - getExtendedPlacementValue({ + getExtendedPlacementValues({ placement: 'bottom', align: Align.CenterVertical, }), - ).toBe('center'); + ).toEqual({ placement: 'center', transformAlign: 'center' }); expect( - getExtendedPlacementValue({ + getExtendedPlacementValues({ placement: 'bottom-start', align: Align.CenterVertical, }), - ).toBe('right'); + ).toEqual({ placement: 'right', transformAlign: 'right' }); expect( - getExtendedPlacementValue({ + getExtendedPlacementValues({ placement: 'bottom-end', align: Align.CenterVertical, }), - ).toBe('left'); + ).toEqual({ placement: 'left', transformAlign: 'left' }); }); }); }); diff --git a/packages/popover/src/utils/positionUtils.ts b/packages/popover/src/utils/positionUtils.ts index 7aaa4b6efd..10323847aa 100644 --- a/packages/popover/src/utils/positionUtils.ts +++ b/packages/popover/src/utils/positionUtils.ts @@ -5,7 +5,8 @@ import { ElementPosition, ExtendedPlacement, Justify, -} from '../Popover.types'; + TransformAlign, +} from '../Popover/Popover.types'; const defaultElementPosition = { top: 0, @@ -139,38 +140,53 @@ export const getWindowSafePlacementValues = (placement: Placement) => { /** * Function to extend the {@link https://floating-ui.com/docs/usefloating#placement-1 final placement} - * calculated by the `useFloating` hook. Floating UI supports 12 placements out-of-the-box. We - * extend these placements when the `align` prop is set to 'center-horizontal' or 'center-vertical' + * calculated by the `useFloating` hook and provide the align value used for transform styling. + * + * Floating UI supports 12 placements out-of-the-box. We extend these placements when the `align` prop is + * set to 'center-horizontal' or 'center-vertical' */ -export const getExtendedPlacementValue = ({ +export const getExtendedPlacementValues = ({ placement, align: alignProp, }: { placement: Placement; align: Align; -}): ExtendedPlacement => { - // Use the default placements if the `align` prop is not 'center-horizontal' or 'center-vertical' - if ( - alignProp !== Align.CenterHorizontal && - alignProp !== Align.CenterVertical - ) { - return placement; - } +}): { + placement: ExtendedPlacement; + transformAlign: TransformAlign; +} => { + // The `floatingAlign` value is 'top', 'right', 'bottom', or 'left'. + // The `floatingJustify` value is 'start', 'end', or undefined. + const [floatingAlign, floatingJustify] = placement.split('-'); - // Otherwise, we need to adjust the placement based on the `align` prop - // The `floatingJustify` value should be 'start', 'end', or undefined. - const [_, floatingJustify] = placement.split('-'); + const isAlignCenterHorizontal = alignProp === Align.CenterHorizontal; + const isAlignCenterVertical = alignProp === Align.CenterVertical; + + // If the `align` prop is not 'center-horizontal' or 'center-vertical', use the placement and + // align values calculated by the `useFloating` hook + if (!isAlignCenterHorizontal && !isAlignCenterVertical) { + return { + placement, + transformAlign: floatingAlign as TransformAlign, + }; + } // If the calculated justify value is 'start' if (floatingJustify === Justify.Start) { // and the `align` prop is 'center-horizontal', if (alignProp === Align.CenterHorizontal) { // we center the floating element horizontally and place it aligned to the start of the reference point - return 'center-start'; + return { + placement: 'center-start', + transformAlign: TransformAlign.Center, + }; // and the `align` prop is 'center-vertical', } else if (alignProp === Align.CenterVertical) { // we center the floating element vertically and place it to the right of the reference point - return 'right'; + return { + placement: 'right', + transformAlign: TransformAlign.Right, + }; } } @@ -179,16 +195,25 @@ export const getExtendedPlacementValue = ({ // and the `align` prop is 'center-horizontal', if (alignProp === Align.CenterHorizontal) { // we center the floating element horizontally and place it aligned to the end of the reference point - return 'center-end'; + return { + placement: 'center-end', + transformAlign: TransformAlign.Center, + }; // and the `align` prop is 'center-vertical', } else if (alignProp === Align.CenterVertical) { // we center the floating element vertically and place it to the left of the reference point - return 'left'; + return { + placement: 'left', + transformAlign: TransformAlign.Left, + }; } } // If the calculated justify value calculated is not specified, we center the floating element - return 'center'; + return { + placement: 'center', + transformAlign: TransformAlign.Center, + }; }; /**