diff --git a/chat/input-bar/src/InputBar/InputBar.types.ts b/chat/input-bar/src/InputBar/InputBar.types.ts index 9cd8ad3874..aaaa097d42 100644 --- a/chat/input-bar/src/InputBar/InputBar.types.ts +++ b/chat/input-bar/src/InputBar/InputBar.types.ts @@ -2,7 +2,7 @@ import { FormEvent, ReactElement } from 'react'; import { TextareaAutosizeProps } from 'react-textarea-autosize'; import { DarkModeProps, HTMLElementProps } from '@leafygreen-ui/lib'; -import { PortalControlProps } from '@leafygreen-ui/popover'; +import { PopoverRenderModeProps } from '@leafygreen-ui/popover'; export type InputBarProps = HTMLElementProps<'form'> & DarkModeProps & { @@ -48,7 +48,7 @@ export type InputBarProps = HTMLElementProps<'form'> & /** * Props passed to the Popover that renders the suggested promps. */ - dropdownProps?: PortalControlProps; + dropdownProps?: Omit; }; export type { TextareaAutosizeProps }; diff --git a/packages/combobox/src/Combobox/Combobox.types.ts b/packages/combobox/src/Combobox/Combobox.types.ts index 25b24f1370..af5b29bbc4 100644 --- a/packages/combobox/src/Combobox/Combobox.types.ts +++ b/packages/combobox/src/Combobox/Combobox.types.ts @@ -2,7 +2,7 @@ import React, { ReactNode } from 'react'; import { type ChipProps } from '@leafygreen-ui/chip'; import { Either, HTMLElementProps } from '@leafygreen-ui/lib'; -import { PortalControlProps } from '@leafygreen-ui/popover'; +import { PopoverRenderModeProps } from '@leafygreen-ui/popover'; import { ComboboxSize, @@ -60,7 +60,7 @@ type PartialChipProps = Pick< >; export type BaseComboboxProps = Omit, 'onChange'> & - PortalControlProps & + PopoverRenderModeProps & PartialChipProps & { /** * Defines the Combobox Options by passing children. Must be `ComboboxOption` or `ComboboxGroup` diff --git a/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx b/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx index 90c2791865..b5c25f8783 100644 --- a/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx +++ b/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx @@ -6,7 +6,7 @@ import { useAvailableSpace, useForwardedRef } from '@leafygreen-ui/hooks'; import Icon from '@leafygreen-ui/icon'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { palette } from '@leafygreen-ui/palette'; -import Popover, { PortalControlProps } from '@leafygreen-ui/popover'; +import Popover, { PopoverRenderModeProps } from '@leafygreen-ui/popover'; import { Error } from '@leafygreen-ui/typography'; import { ComboboxProps } from '../Combobox'; @@ -30,7 +30,7 @@ type ComboboxMenuProps = { id: string; labelId: string; menuWidth: number; -} & PortalControlProps & +} & PopoverRenderModeProps & Pick< ComboboxProps, | 'searchLoadingMessage' diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts index 1a21370038..29b4c8f80e 100644 --- a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts @@ -1,7 +1,8 @@ import { HTMLElementProps } from '@leafygreen-ui/lib'; import { PopoverProps } from '@leafygreen-ui/popover'; -import { PortalControlProps } from '@leafygreen-ui/popover'; -export type DatePickerMenuProps = PortalControlProps & - Omit & +export type DatePickerMenuProps = Omit< + PopoverProps, + 'children' | 'renderMode' | 'usePortal' +> & HTMLElementProps<'div'>; diff --git a/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx index 445e80fc79..1bf7e26a6b 100644 --- a/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx +++ b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx @@ -10,7 +10,11 @@ import Popover, { PopoverProps } from '@leafygreen-ui/popover'; import { menuStyles } from './MenuWrapper.styles'; -export type MenuWrapperProps = PopoverProps & HTMLElementProps<'div'>; +export type MenuWrapperProps = Omit< + PopoverProps, + 'dismissMode' | 'onToggle' | 'renderMode' +> & + HTMLElementProps<'div'>; /** * A simple styled popover component @@ -24,6 +28,7 @@ export const MenuWrapper = forwardRef( ref={fwdRef} className={cx(menuStyles[theme], className)} {...props} + usePortal > {/* * Prevents the opening and closing state of a select dropdown from propagating up diff --git a/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts b/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts index adf2a821fb..bef824b244 100644 --- a/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts +++ b/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts @@ -8,7 +8,13 @@ import { AutoComplete, DatePickerState } from './types'; export type ModifiedPopoverProps = Omit< PopoverProps, - 'usePortal' | 'refEl' | 'children' | 'className' | 'active' | 'onClick' + | 'usePortal' + | 'refEl' + | 'children' + | 'className' + | 'active' + | 'onClick' + | 'renderMode' >; export type BaseDatePickerProps = { diff --git a/packages/guide-cue/src/GuideCue.tsx b/packages/guide-cue/src/GuideCue.tsx index e564db4e46..978f2ffada 100644 --- a/packages/guide-cue/src/GuideCue.tsx +++ b/packages/guide-cue/src/GuideCue.tsx @@ -160,6 +160,7 @@ function GuideCue({ adjustOnMutation={true} popoverZIndex={popoverZIndex} {...sharedProps} + usePortal={true} > {/* The beacon is using the popover component to position itself */}
; interface StandaloneProps { diff --git a/packages/leafygreen-provider/src/LeafyGreenContext.tsx b/packages/leafygreen-provider/src/LeafyGreenContext.tsx index cf27769360..b756c1dcd3 100644 --- a/packages/leafygreen-provider/src/LeafyGreenContext.tsx +++ b/packages/leafygreen-provider/src/LeafyGreenContext.tsx @@ -63,7 +63,7 @@ function LeafyGreenProvider({ ['portalContainer', 'scrollContainer'].includes(key), ), ); - const popoverContextContainerValues = + const { portalContainer, scrollContainer } = popoverPortalContainerProp ?? inheritedPopoverContextContainers; return ( @@ -74,7 +74,10 @@ function LeafyGreenProvider({ setDarkMode={setDarkMode} > - + {children} diff --git a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts index 394d5d3f4a..e7600b1bc1 100644 --- a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts +++ b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts @@ -15,68 +15,123 @@ type TransitionLifecycleCallbacks = Pick< 'onEnter' | 'onEntering' | 'onEntered' | 'onExit' | 'onExiting' | 'onExited' >; -type PortalControlProps = - | { - /** - * Specifies that the popover content should be rendered at the end of the DOM, - * rather than in the DOM tree. - * - * default: `true` - */ - usePortal?: true; - - /** - * When usePortal is `true`, specifies a class name to apply to the root element of the portal. - */ - portalClassName?: string; - - /** - * When usePortal is `true`, specifies an element to portal within. The default behavior is to generate a div at the end of the document to render within. - */ - portalContainer?: HTMLElement | null; - - /** - * A ref for the portal element - */ - portalRef?: React.MutableRefObject; - - /** - * When usePortal is `true`, specifies the scrollable element to position relative to. - */ - scrollContainer?: HTMLElement | null; - } - | { - /** - * Specifies that the popover content should be rendered at the end of the DOM, - * rather than in the DOM tree. - * - * default: `true` - */ - usePortal: false; - - /** - * When usePortal is `true`, specifies a class name to apply to the root element of the portal. - */ - portalClassName?: undefined; - - /** - * When usePortal is `true`, specifies an element to portal within. The default behavior is to generate a div at the end of the document to render within. - */ - portalContainer?: null; - - /** - * A ref for the portal element - */ - portalRef?: undefined; - - /** - * When usePortal is `true`, specifies the scrollable element to position relative to. - */ - scrollContainer?: null; - }; +/** + * 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; +type DismissMode = (typeof DismissMode)[keyof typeof DismissMode]; + +/** Local implementation of web-native `ToggleEvent` until we use typescript v5 */ +interface ToggleEvent extends Event { + type: 'toggle'; + newState: 'open' | 'closed'; + oldState: 'open' | 'closed'; +} + +/** @deprecated - use {@link RenderTopLayerProps} */ +interface RenderInlineProps { + /** + * Popover element will render inline with the reference element + */ + renderMode: 'inline'; + + /** Not used in this `renderMode` */ + dismissMode?: never; + + /** Not used in this `renderMode` */ + onToggle?: never; + + /** Not used in this `renderMode` */ + portalClassName?: never; + + /** Not used in this `renderMode` */ + portalContainer?: never; + + /** Not used in this `renderMode` */ + portalRef?: never; + + /** Not used in this `renderMode` */ + scrollContainer?: never; +} + +/** @deprecated - use {@link RenderTopLayerProps} */ +interface RenderPortalProps { + /** + * Popover element will render in a provided `portalContainer` or in a new div appended to the body + */ + renderMode?: 'portal'; + + /** Not used in this `renderMode` */ + dismissMode?: never; + + /** Not used in this `renderMode` */ + onToggle?: never; + + /** + * Specifies a class name to apply to the portal element + */ + portalClassName?: string; + + /** + * Specifies an element to portal within. If not provided, a div is generated at the end of the body + */ + portalContainer?: HTMLElement | null; + + /** + * Passes a ref to forward to the portal element + */ + portalRef?: React.MutableRefObject; + + /** + * Specifies the scrollable element to position relative to + */ + scrollContainer?: HTMLElement | null; +} + +interface RenderTopLayerProps { + /** + * Popover element will render in the top layer + */ + renderMode?: 'top-layer'; + + /** + * Options to control how the 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 + */ + onToggle?: (e: ToggleEvent) => void; + + /** Not used in this `renderMode` */ + portalClassName?: never; + + /** Not used in this `renderMode` */ + portalContainer?: never; + + /** Not used in this `renderMode` */ + portalRef?: never; + + /** Not used in this `renderMode` */ + scrollContainer?: never; +} + +type PopoverRenderModeProps = + | RenderInlineProps + | RenderPortalProps + | RenderTopLayerProps; export type PopoverProviderProps = TransitionLifecycleCallbacks & - PortalControlProps & { + PopoverRenderModeProps & { /** * Specifies the amount of spacing (in pixels) between the trigger element and the Popover content. * diff --git a/packages/number-input/src/UnitSelect/UnitSelect.types.ts b/packages/number-input/src/UnitSelect/UnitSelect.types.ts index 439bf3362a..0fd84f12ca 100644 --- a/packages/number-input/src/UnitSelect/UnitSelect.types.ts +++ b/packages/number-input/src/UnitSelect/UnitSelect.types.ts @@ -1,11 +1,11 @@ import { PopoverProps as ImportedPopoverProps, - PortalControlProps, + PopoverRenderModeProps, } from '@leafygreen-ui/popover'; import { Size, UnitOption } from '../NumberInput/NumberInput.types'; -export type PopoverProps = PortalControlProps & +export type PopoverProps = PopoverRenderModeProps & Pick; export type UnitSelectProps = { diff --git a/packages/popover/README.md b/packages/popover/README.md index f6f93c2dda..4069eb4586 100644 --- a/packages/popover/README.md +++ b/packages/popover/README.md @@ -27,15 +27,15 @@ import Popover from '@leafygreen-ui/popover'; className={containerStyle} onClick={() => this.setState({ active: !this.state.active })} > - Popover + Open Popover -
Popover content
+
Popover content
; ``` @@ -43,41 +43,70 @@ import Popover from '@leafygreen-ui/popover'; ## Output HTML ```html - - -
-
Popover content
-
+ + +
+
+
Popover content
+ ::backdrop +
+ + + +#top-layer + > div + > ::backdrop ``` -## Simple Use Case +## Render mode -The popover component will be automatically positioned relative to its nearest parent. If `usePortal` is set to `false`, then it will be positioned relative to its nearest ancestor with the CSS property: `position: absolute | relative | fixed`. +### v12+ + +In v12+ versions, a popover should now render in the [top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer), which "appear[s] on top of all other content on the page." + +The `usePortal` prop is available as an escape hatch to override the `renderMode` prop. `usePortal` can be used to render a popover positioned `'inline'` relative to the nearest ancestor or in a `'portal'`. `RenderMode.Inline` and `RenderMode.Portal` are marked deprecated and will eventually lose support. All overlay elements should migrate to using the top layer. + +### Pre-v12 + +In pre-v12 versions, a popover can be rendered in 2 ways using the `usePortal` prop. By default, `usePortal={true}`, and it is rendered in a portal. If `usePortal={false}`, it is rendered inline in the DOM. ## Properties -| Prop | Type | Description | Default | -| ------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | -| `active` | `boolean` | Determines whether the Popover is active or inactive | `false` | -| `align` | `'top'` \| `'bottom'` \| `'left'` \| `'right'` \| `'center-horizontal'` \| `'center-vertical'` | A string that determines the alignment of the popover relative to the `refEl`. | `'bottom'` | -| `justify` | `'start'` \| `'middle'` \| `'end'` | A string that determines the justification of the popover relative to the `refEl`. Justification will be defined relative to the `align` prop | `'start'` | -| `children` | `node` | Content that will appear inside of the `` component | | -| `spacing` | `number` | Specifies the amount of spacing (in pixels) between the trigger element and the content element. | `4` | -| `className` | `string` | Classname to apply to popover-content container | | -| `adjustOnMutation` | `boolean` | Should the Popover auto adjust its content when the DOM changes (using MutationObserver). | `false` | -| `onClick` | `function` | Function that will be called when popover content is clicked. | | -| `usePortal` | `boolean` | Will position Popover's children relative to its parent without using a Portal, if `usePortal` is set to false. NOTE: The parent element should be CSS position `relative`, `fixed`, or `absolute` if using this option. | `true` | -| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | | -| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | | -| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | | -| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | | -| ... | native attributes of Portal or Fragment | Any other properties will be spread on the popover-content container | | - -## Advanced Use Case - -| Prop | Type | Description | Default | -| ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `refEl` | `HTMLElement` | You can supply a `refEl` prop, if you do not want the popover to be positioned relative to it's nearest parent. Ref to the element to which the popover component should be positioned relative to. | `null` | +| Prop | Type | Description | Default | +| ---------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `active` | `boolean` | Determines whether the Popover is active or inactive | `false` | +| `adjustOnMutation` | `boolean` | Should the Popover auto adjust its content when the DOM changes (using MutationObserver). | `false` | +| `align` | `'top'` \| `'bottom'` \| `'left'` \| `'right'` \| `'center-horizontal'` \| `'center-vertical'` | A string that determines the alignment of the popover relative to the `refEl`. | `'bottom'` | +| `children` | `node` | Content that will appear inside of the `` component | | +| `className` | `string` | Classname to apply to popover-content container | | +| `justify` | `'start'` \| `'middle'` \| `'end'` | A string that determines the justification of the popover relative to the `refEl`. Justification will be defined relative to the `align` prop | `'start'` | +| `onClick` | `function` | Function that will be called when popover content is clicked. | | +| `popoverZIndex` (deprecated) | `number` | Sets the z-index CSS property for the popover. This will only apply if `usePortal` is defined and `renderMode` is not `'top-layer'` | | +| `refEl` | `React.RefObject` | You can supply a `refEl` prop, if you do not want the popover to be positioned relative to it's nearest parent. Ref to the element to which the popover component should be positioned relative to. | `null` | +| `spacing` | `number` | Specifies the amount of spacing (in pixels) between the trigger element and the content element. | `4` | +| ... | native attributes of Portal or Fragment | Any other properties will be spread on the popover-content container | | + +### v12+ + +| Prop | Type | Description | Default | +| ------------------------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `dismissMode` | `'auto'` \| `'manual'` | Options to control how the popover element is dismissed. This will only apply when `usePortal` is undefined and `renderMode` is `'top-layer'`
\* `'auto'` will automatically handle dismissal on backdrop click or esc key press, ensuring only one popover is visible at a time
\* `'manual'` will require that the consumer handle dismissal manually | `'auto'` | +| `onToggle` | `(e: ToggleEvent) => void;` | Function that is called when the popover is toggled. This will only apply when `usePortal` is undefined and `renderMode` is `'top-layer'` | | +| `portalClassName` (deprecated) | `string` | Passes the given className to the popover's portal container if the default portal container is being used. This will only apply when `usePortal` is `true` | | +| `portalContainer` (deprecated) | `HTMLElement` \| `null` | Sets the container used for the popover's portal. This will only apply when `usePortal` is `true`.
NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | | +| `portalRef` (deprecated) | `string` | Passes a ref to forward to the portal element. This will only apply when `usePortal` is `true` | | +| `scrollContainer` (deprecated) | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. This will only apply when `usePortal` is `true` | | +| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element
\* [deprecated] `'inline'` will render the popover element inline with the reference element
\* [deprecated] `'portal'` will render the popover element in a provided `portalContainer` or in a new div appended to the body
\* `'top-layer'` will render the popover element in the top layer | `'auto'` | +| `usePortal` (deprecated) | `boolean` | Migration escape hatch that can be defined to override `renderMode`.
When `true`, it will set `renderMode` to `'portal'`
When `false`, it will set `renderMode` to `'inline'` | | + +### Pre-v12 + +| Prop | Type | Description | Default | +| ----------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. This will only apply when `usePortal` is `true` | | +| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. This will only apply when `usePortal` is `true`.
NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | | +| `portalRef` | `string` | Passes a ref to forward to the portal element. This will only apply when `usePortal` is `true` | | +| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. This will only apply when `usePortal` is `true` | | +| `usePortal` | `boolean` | Option to render popover element in a portal.
When `true`, the popover element will portal into the provided `portalContainer` or a new div appended to the end of the ``
When `false`, the popover element will render inline in the DOM | `true` | diff --git a/packages/popover/src/Popover.hooks.tsx b/packages/popover/src/Popover.hooks.tsx deleted file mode 100644 index d455c94d9b..0000000000 --- a/packages/popover/src/Popover.hooks.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useMemo, useRef, useState } from 'react'; - -import { - useIsomorphicLayoutEffect, - useObjectDependency, -} from '@leafygreen-ui/hooks'; - -import { getElementDocumentPosition } from './utils/positionUtils'; -import { - PopoverProps, - UseContentNodeReturnObj, - UseReferenceElementReturnObj, -} from './Popover.types'; - -/** - * This hook handles logic for determining the reference element for the popover element. - * 1. If a `refEl` is provided, the ref value will be used as the reference element. - * 2. If not, a hidden placeholder element will be rendered, and the parent element of the - * placeholder will used as the reference element. - * - * Additionally, this hook calculates the document position of the reference element. - */ -export function useReferenceElement( - refEl?: PopoverProps['refEl'], - scrollContainer?: PopoverProps['scrollContainer'], -): UseReferenceElementReturnObj { - const placeholderRef = useRef(null); - const [referenceElement, setReferenceElement] = useState( - null, - ); - - useIsomorphicLayoutEffect(() => { - if (refEl && refEl.current) { - setReferenceElement(refEl.current); - } - - const placeholderEl = placeholderRef?.current; - const maybeParentEl = placeholderEl !== null && placeholderEl?.parentNode; - - if (maybeParentEl && maybeParentEl instanceof HTMLElement) { - setReferenceElement(maybeParentEl); - } - }, [placeholderRef.current, refEl]); - - const referenceElDocumentPos = useObjectDependency( - useMemo( - () => getElementDocumentPosition(referenceElement, scrollContainer, true), - [referenceElement, scrollContainer], - ), - ); - - return { - placeholderRef, - referenceElement, - referenceElDocumentPos, - renderHiddenPlaceholder: !refEl, - }; -} - -export function useContentNode(): UseContentNodeReturnObj { - const [contentNode, setContentNode] = React.useState( - null, - ); - - const contentNodeRef = useRef(contentNode); - contentNodeRef.current = contentNode; - - return { - contentNode, - contentNodeRef, - setContentNode, - }; -} diff --git a/packages/popover/src/Popover.stories.tsx b/packages/popover/src/Popover.stories.tsx index d8e5e03c99..b96713b610 100644 --- a/packages/popover/src/Popover.stories.tsx +++ b/packages/popover/src/Popover.stories.tsx @@ -3,19 +3,28 @@ 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, { Align, Justify, PopoverProps } from '.'; +import { + Align, + DismissMode, + Justify, + Popover, + PopoverProps, + RenderMode, + ToggleEvent, +} from './Popover'; const popoverStyle = css` border: 1px solid ${palette.gray.light1}; text-align: center; padding: 12px; + max-width: 200px; max-height: 100%; overflow: hidden; // Reset these properties since they'll be inherited @@ -25,7 +34,7 @@ const popoverStyle = css` background-color: initial; `; -const regularStyles = css` +const containerStyles = css` position: relative; width: 100%; height: 100%; @@ -34,7 +43,7 @@ const regularStyles = css` justify-content: center; `; -const scrollableStyle = css` +const scrollableOuterStyles = css` width: 500px; height: 90vh; background-color: ${palette.gray.light2}; @@ -42,7 +51,7 @@ const scrollableStyle = css` position: relative; `; -const scrollableInnerStyle = css` +const scrollableInnerStyles = css` position: relative; height: 160vh; width: 80vw; @@ -77,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, - 'children', - 'active', - 'refEl', - 'portalClassName', - 'refButtonPosition', - 'usePortal', - ], + exclude: defaultExcludedControls, }, generate: { storyNames: [ @@ -115,7 +125,7 @@ const meta: StoryMetaType = {
= {
@@ -136,26 +148,31 @@ const meta: StoryMetaType = { }, }, args: { + adjustOnMutation: false, align: Align.Top, + buttonText: 'Button Text', + dismissMode: DismissMode.Auto, justify: Justify.Start, spacing: 4, - adjustOnMutation: false, - buttonText: 'Button Text', }, argTypes: { align: { options: Object.values(Align), control: { type: 'radio' }, }, - justify: { - options: Object.values(Justify), - control: { type: 'radio' }, - }, buttonText: { type: 'string', description: 'Storybook only prop. Used to change the reference button text', }, + dismissMode: { + options: Object.values(DismissMode), + control: { type: 'radio' }, + }, + justify: { + options: Object.values(Justify), + control: { type: 'radio' }, + }, refButtonPosition: { options: ['centered', 'top', 'right', 'bottom', 'left'], control: { type: 'select' }, @@ -171,27 +188,53 @@ type PopoverStoryProps = PopoverProps & { buttonText: string; refButtonPosition: string; }; - export const LiveExample: StoryFn = ({ refButtonPosition, buttonText, ...args }: PopoverStoryProps) => { + const { + portalClassName, + portalContainer, + portalRef, + scrollContainer, + usePortal, + onToggle, + ...rest + } = args; + const buttonRef = useRef(null); const [active, setActive] = useState(false); const position = referenceElPositions[refButtonPosition]; + const handleClick = () => { + setActive(active => !active); + }; + + const handleToggle = (e: ToggleEvent) => { + onToggle?.(e); + const newActive = e.newState === 'open'; + setActive(newActive); + }; + return ( -
+
+ +
Popover content
+
); }; @@ -201,11 +244,12 @@ LiveExample.parameters = { }, }; -export const ScrollableContainer: StoryFn = ({ +const PortalPopoverInScrollableContainer = ({ refButtonPosition, buttonText, ...args }: PopoverStoryProps) => { + const { dismissMode, onToggle, renderMode, ...rest } = args; const [active, setActive] = useState(false); const portalRef = useRef(null); const scrollContainer = useRef(null); @@ -213,15 +257,15 @@ export const ScrollableContainer: StoryFn = ({ const position = referenceElPositions[refButtonPosition]; return ( -
-
+
+
); }; -ScrollableContainer.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' }, }, }; -ScrollableContainer.args = { - usePortal: true, + +const InlinePopover = ({ + refButtonPosition, + buttonText, + ...args +}: PopoverStoryProps) => { + const { + dismissMode, + onToggle, + renderMode, + portalClassName, + portalContainer, + portalRef, + scrollContainer, + ...rest + } = args; + const buttonRef = useRef(null); + const [active, setActive] = useState(false); + + const position = referenceElPositions[refButtonPosition]; + + return ( +
+ + +
Popover content
+
+
+ ); }; -ScrollableContainer.argTypes = { - usePortal: { control: 'none' }, - portalClassName: { control: 'none' }, - refEl: { control: 'none' }, - className: { control: 'none' }, - active: { control: 'none' }, +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' }, + }, }; +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.styles.ts b/packages/popover/src/Popover.styles.ts deleted file mode 100644 index 74fb7df8f1..0000000000 --- a/packages/popover/src/Popover.styles.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { TransitionStatus } from 'react-transition-group'; - -import { css, cx } from '@leafygreen-ui/emotion'; -import { createUniqueClassName } from '@leafygreen-ui/lib'; -import { transitionDuration } from '@leafygreen-ui/tokens'; - -import { ExtendedPlacement } from './Popover.types'; - -export const TRANSITION_DURATION = transitionDuration.default; - -export const contentClassName = createUniqueClassName('popover-content'); - -export const hiddenPlaceholderStyle = css` - display: none; -`; - -const getBasePopoverStyles = (floatingStyles: React.CSSProperties) => css` - margin: 0; - border: none; - padding: 0; - overflow: visible; - - position: ${floatingStyles.position}; - top: ${floatingStyles.top}px; - left: ${floatingStyles.left}px; - - transition-property: opacity, transform; - transition-duration: ${TRANSITION_DURATION}ms; - transition-timing-function: ease-in-out; - transition-behavior: allow-discrete; -`; - -const transformOriginStyles: Record = { - top: css` - transform-origin: bottom; - `, - 'top-start': css` - transform-origin: bottom left; - `, - 'top-end': css` - transform-origin: bottom right; - `, - bottom: css` - transform-origin: top; - `, - 'bottom-start': css` - transform-origin: top left; - `, - 'bottom-end': css` - transform-origin: top right; - `, - left: css` - transform-origin: right; - `, - 'left-start': css` - transform-origin: right top; - `, - 'left-end': css` - transform-origin: right bottom; - `, - right: css` - transform-origin: left; - `, - 'right-start': css` - transform-origin: left top; - `, - 'right-end': css` - transform-origin: left bottom; - `, - center: css` - transform-origin: center; - `, - 'center-start': css` - transform-origin: top; - `, - 'center-end': css` - transform-origin: bottom; - `, -}; - -const getTransformStyles = (placement: ExtendedPlacement, spacing: number) => - cx({ - [css` - transform: translateY(${spacing}px) scale(0); - `]: placement.startsWith('top'), - [css` - transform: translateY(-${spacing}px) scale(0); - `]: placement.startsWith('bottom'), - [css` - transform: translateX(${spacing}px) scale(0); - `]: placement.startsWith('left'), - [css` - transform: translateX(-${spacing}px) scale(0); - `]: placement.startsWith('right'), - [css` - transform: scale(0); - `]: placement.startsWith('center'), - }); - -const closedStyles = css` - opacity: 0; -`; - -const openStyles = css` - opacity: 1; - pointer-events: initial; -`; - -export const getPopoverStyles = ({ - className, - floatingStyles, - placement, - popoverZIndex, - spacing, - state, -}: { - className?: string; - floatingStyles: React.CSSProperties; - placement: ExtendedPlacement; - popoverZIndex?: number; - spacing: number; - state: TransitionStatus; -}) => - cx( - className, - getBasePopoverStyles(floatingStyles), - transformOriginStyles[placement], - { - [getTransformStyles(placement, spacing)]: - state === 'exiting' || state === 'entering', - [closedStyles]: state === 'exiting' || state === 'exited', - [openStyles]: state === 'entering' || state === 'entered', - [css` - z-index: ${popoverZIndex}; - `]: typeof popoverZIndex === 'number', - }, - ); diff --git a/packages/popover/src/Popover.types.ts b/packages/popover/src/Popover.types.ts deleted file mode 100644 index 3db17dd9a8..0000000000 --- a/packages/popover/src/Popover.types.ts +++ /dev/null @@ -1,247 +0,0 @@ -import React from 'react'; -import { Transition } from 'react-transition-group'; -import { Placement } from '@floating-ui/react'; - -import { HTMLElementProps } from '@leafygreen-ui/lib'; - -type TransitionProps = React.ComponentProps>; - -type TransitionLifecycleCallbacks = Pick< - TransitionProps, - 'onEnter' | 'onEntering' | 'onEntered' | 'onExit' | 'onExiting' | 'onExited' ->; - -/** - * Options to determine the alignment of the popover relative to - * the other component - * @param Top will align content above other element - * @param Bottom will align content below other element - * @param Left will align content to the left of other element - * @param Right will align content to the right of other element - */ -const Align = { - Top: 'top', - Bottom: 'bottom', - Left: 'left', - Right: 'right', - CenterVertical: 'center-vertical', - CenterHorizontal: 'center-horizontal', -} as const; - -type Align = (typeof Align)[keyof typeof Align]; - -export { Align }; - -/** - * Options to determine the justification of the popover relative to - * the other component - * @param Start will justify content against the start of other element - * @param Middle will justify content against the middle of other element - * @param End will justify content against the end of other element - */ -const Justify = { - Start: 'start', - Middle: 'middle', - End: 'end', -} as const; - -type Justify = (typeof Justify)[keyof typeof Justify]; - -export { Justify }; - -export type ExtendedPlacement = - | Placement - | 'center' - | 'center-start' - | 'center-end'; - -export interface ElementPosition { - top: number; - bottom: number; - left: number; - right: number; - height: number; - width: number; -} - -export interface ChildrenFunctionParameters { - align: Align; - justify: Justify; - referenceElPos: ElementPosition; -} - -export type PortalControlProps = - | { - /** - * Specifies that the popover content should be rendered at the end of the DOM, - * rather than in the DOM tree. - * - * default: `true` - */ - usePortal?: true; - - /** - * When usePortal is `true`, specifies a class name to apply to the root element of the portal. - */ - portalClassName?: string; - - /** - * When usePortal is `true`, specifies an element to portal within. The default behavior is to generate a div at the end of the document to render within. - */ - portalContainer?: HTMLElement | null; - - /** - * A ref for the portal element - */ - portalRef?: React.MutableRefObject; - - /** - * When usePortal is `true`, specifies the scrollable element to position relative to. - */ - scrollContainer?: HTMLElement | null; - } - | { - /** - * Specifies that the popover content should be rendered at the end of the DOM, - * rather than in the DOM tree. - * - * default: `true` - */ - usePortal: false; - - /** - * When usePortal is `true`, specifies a class name to apply to the root element of the portal. - */ - portalClassName?: undefined; - - /** - * When usePortal is `true`, specifies an element to portal within. The default behavior is to generate a div at the end of the document to render within. - */ - portalContainer?: null; - - /** - * A ref for the portal element - */ - portalRef?: undefined; - - /** - * When usePortal is `true`, specifies the scrollable element to position relative to. - */ - scrollContainer?: null; - }; - -/** - * Base popover props. - * Use these props to extend popover behavior - */ -export type PopoverProps = { - /** - * Content that will appear inside of the popover component. - */ - children: - | React.ReactNode - | ((Options: ChildrenFunctionParameters) => React.ReactNode); - - /** - * Determines the active state of the popover component - * - * default: `false` - */ - active?: boolean; - - /** - * Class name applied to popover container. - */ - className?: string; - - /** - * Determines the alignment of the popover content relative to the trigger element - * - * default: `bottom` - */ - align?: Align; - - /** - * Determines the justification of the popover content relative to the trigger element - * - * default: `start` - */ - 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` - */ - spacing?: number; - - /** - * Should the Popover auto adjust its content when the DOM changes (using MutationObserver). - * - * default: false - */ - adjustOnMutation?: boolean; - - /** - * Click event handler passed to the root div element within the portal container. - */ - onClick?: React.MouseEventHandler; - - /** - * Number that controls the z-index of the popover element directly. - */ - popoverZIndex?: number; -} & PortalControlProps & - TransitionLifecycleCallbacks; - -/** Props used by the popover component */ -export type PopoverComponentProps = Omit, 'children'> & - PopoverProps; - -export interface UseReferenceElementReturnObj { - /** - * Ref to access hidden placeholder element - */ - placeholderRef: React.MutableRefObject; - - /** - * Element against which the popover component will be positioned - */ - referenceElement: HTMLElement | null; - - /** - * Document position details of the reference element - */ - referenceElDocumentPos: ElementPosition; - - /** - * Boolean to determine if a hidden placeholder should be rendered - */ - renderHiddenPlaceholder: boolean; -} - -export interface UseContentNodeReturnObj { - /** - * `contentNode` is the direct child of the popover element and wraps the children. It - * is used to calculate the position of the popover because its parent has a transition. - * This prevents getting the width of the popover until the transition completes - */ - contentNode: HTMLDivElement | null; - - /** - * We shadow the `contentNode` onto this `contentNodeRef` as `` from - * react-transition-group only accepts a `MutableRefObject` type. Without this, StrictMode - * warnings are produced by react-transition-group. - */ - contentNodeRef: React.MutableRefObject; - - /** - * Dispatch method to attach `contentNode` to the `ContentWrapper` - */ - setContentNode: React.Dispatch>; -} diff --git a/packages/popover/src/Popover/Popover.hooks.tsx b/packages/popover/src/Popover/Popover.hooks.tsx new file mode 100644 index 0000000000..ce7d98228f --- /dev/null +++ b/packages/popover/src/Popover/Popover.hooks.tsx @@ -0,0 +1,146 @@ +import React, { useMemo, useRef, useState } from 'react'; + +import { + useIsomorphicLayoutEffect, + useObjectDependency, +} from '@leafygreen-ui/hooks'; +import { usePopoverContext } from '@leafygreen-ui/leafygreen-provider'; + +import { getRenderMode } from '../utils/getRenderMode'; +import { getElementDocumentPosition } from '../utils/positionUtils'; + +import { + PopoverProps, + RenderMode, + UseContentNodeReturnObj, + 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({ + renderMode: renderModeProp, + dismissMode, + onToggle, + portalClassName, + portalContainer, + portalRef, + scrollContainer, + usePortal: usePortalProp, + onEnter, + onEntering, + onEntered, + onExit, + onExiting, + onExited, + popoverZIndex: popoverZIndexProp, + spacing, + ...rest +}: Partial< + Omit< + PopoverProps, + | 'active' + | 'adjustOnMutation' + | 'align' + | 'children' + | 'className' + | 'justify' + | 'refEl' + > +>) { + const context = usePopoverContext(); + const renderMode = getRenderMode( + renderModeProp || context.renderMode, + usePortalProp, + ); + const usePortal = renderMode === RenderMode.Portal; + const popoverZIndex = + renderMode === RenderMode.TopLayer + ? undefined + : popoverZIndexProp || context.popoverZIndex; + + return { + renderMode, + usePortal, + dismissMode: dismissMode || context.dismissMode, + onToggle: onToggle || context.onToggle, + portalClassName: portalClassName || context.portalClassName, + portalContainer: portalContainer || context.portalContainer, + portalRef: portalRef || context.portalRef, + scrollContainer: scrollContainer || context.scrollContainer, + onEnter: onEnter || context.onEnter, + onEntering: onEntering || context.onEntering, + onEntered: onEntered || context.onEntered, + onExit: onExit || context.onExit, + onExiting: onExiting || context.onExiting, + onExited: onExited || context.onExited, + popoverZIndex, + spacing: spacing || context.spacing, + isPopoverOpen: context.isPopoverOpen, + setIsPopoverOpen: context.setIsPopoverOpen, + ...rest, + }; +} + +/** + * This hook handles logic for determining the reference element for the popover element. + * 1. If a `refEl` is provided, the ref value is used as the reference element. + * 2. As a fallback, a hidden placeholder element is rendered, and the parent element of the + * placeholder is used as the reference element. + * + * Additionally, this hook calculates the document position of the reference element. + */ +export function useReferenceElement( + refEl?: PopoverProps['refEl'], + scrollContainer?: PopoverProps['scrollContainer'], +): UseReferenceElementReturnObj { + const placeholderRef = useRef(null); + const [referenceElement, setReferenceElement] = useState( + null, + ); + + useIsomorphicLayoutEffect(() => { + if (refEl && refEl.current) { + setReferenceElement(refEl.current); + return; + } + + const placeholderEl = placeholderRef?.current; + const maybeParentEl = placeholderEl !== null && placeholderEl?.parentNode; + + if (maybeParentEl && maybeParentEl instanceof HTMLElement) { + setReferenceElement(maybeParentEl); + return; + } + }, [placeholderRef.current, refEl]); + + const referenceElDocumentPos = useObjectDependency( + useMemo( + () => getElementDocumentPosition(referenceElement, scrollContainer, true), + [referenceElement, scrollContainer], + ), + ); + + return { + placeholderRef, + referenceElement, + referenceElDocumentPos, + }; +} + +export function useContentNode(): UseContentNodeReturnObj { + const [contentNode, setContentNode] = React.useState( + null, + ); + + const contentNodeRef = useRef(contentNode); + contentNodeRef.current = contentNode; + + return { + contentNode, + contentNodeRef, + setContentNode, + }; +} diff --git a/packages/popover/src/Popover/Popover.spec.tsx b/packages/popover/src/Popover/Popover.spec.tsx index aedd5e9441..20133c1695 100644 --- a/packages/popover/src/Popover/Popover.spec.tsx +++ b/packages/popover/src/Popover/Popover.spec.tsx @@ -1,120 +1,397 @@ -import React, { createRef, PropsWithChildren } from '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 } from '../Popover.types'; - import { Popover } from './Popover'; +import { DismissMode, PopoverProps, RenderMode } from './Popover.types'; + +type RTLInlinePopoverProps = Partial< + Omit< + PopoverProps, + | 'dismissMode' + | 'onToggle' + | 'portalClassName' + | 'portalContainer' + | 'portalRef' + | 'renderMode' + | 'scrollContainer' + | 'usePortal' + > +>; + +type RTLPortalPopoverProps = Partial< + Omit +>; + +type RTLTopLayerPopoverProps = Partial< + Omit< + PopoverProps, + | 'portalClassName' + | 'portalContainer' + | 'portalRef' + | 'renderMode' + | 'scrollContainer' + | 'usePortal' + > +>; + +function TopLayerPopoverWithReference(props?: RTLTopLayerPopoverProps) { + const buttonRef = useRef(null); + const [active, setActive] = useState(props?.active ?? false); + + return ( + <> + + + Popover Content + + + ); +} -function renderPopover(props?: Partial) { +function renderTopLayerPopover(props?: RTLTopLayerPopoverProps) { const result = render( - - Popover Content - , + <> + + , ); - const rerenderPopover = (newProps?: Partial) => { + const button = result.getByTestId('popover-reference-element'); + + const rerenderPopover = (newProps?: RTLTopLayerPopoverProps) => { const allProps = { ...props, ...newProps }; result.rerender( - + Popover Content , ); }; - return { ...result, rerenderPopover }; + return { button, ...result, rerenderPopover }; } describe('packages/popover', () => { - describe('a11y', () => { - test('does not have basic accessibility issues', async () => { - const { container, rerenderPopover } = renderPopover(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - - type AxeResult = Awaited>; - let newResults: AxeResult = {} as AxeResult; - rerenderPopover({ active: true }); - await act(async () => { - newResults = await axe(container); + describe(`renderMode=${RenderMode.Inline}`, () => { + function renderInlinePopover(props?: RTLInlinePopoverProps) { + const result = render( + + Popover Content + , + ); + + const rerenderPopover = (newProps?: RTLInlinePopoverProps) => { + const allProps = { ...props, ...newProps }; + result.rerender( + + Popover Content + , + ); + }; + + return { ...result, rerenderPopover }; + } + + describe('a11y', () => { + test('does not have basic accessibility issues', async () => { + const { container, rerenderPopover } = renderInlinePopover(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + + type AxeResult = Awaited>; + let newResults: AxeResult = {} as AxeResult; + rerenderPopover({ active: true }); + await act(async () => { + newResults = await axe(container); + }); + expect(newResults).toHaveNoViolations(); }); - expect(newResults).toHaveNoViolations(); }); - }); - test('accepts a ref', () => { - const ref = createRef(); - render( - - Popover Content - , - ); + test('displays popover inline when the `active` prop is `true`', () => { + const { container, getByTestId } = renderInlinePopover({ active: true }); + expect(getByTestId('popover-test-id')).toBeInTheDocument(); + expect(container.innerHTML.includes('popover-test-id')).toBeTruthy(); + }); - expect(ref.current).toBeDefined(); + test('does NOT display popover when the `active` prop is `false`', () => { + const { queryByTestId } = renderInlinePopover({ active: false }); + expect(queryByTestId('popover-test-id')).toBeNull(); + }); }); - test('accepts a portalRef', async () => { - const portalRef = createRef(); - waitFor(() => { - render( - + describe(`renderMode=${RenderMode.Portal}`, () => { + function renderPortalPopover(props?: RTLPortalPopoverProps) { + const result = render( + Popover Content , ); - expect(portalRef.current).toBeDefined(); - expect(portalRef.current).toBeInTheDocument(); + const rerenderPopover = (newProps?: RTLPortalPopoverProps) => { + const allProps = { ...props, ...newProps }; + result.rerender( + + Popover Content + , + ); + }; + + return { ...result, rerenderPopover }; + } + + describe('a11y', () => { + test('does not have basic accessibility issues', async () => { + const { container, rerenderPopover } = renderPortalPopover(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + + type AxeResult = Awaited>; + let newResults: AxeResult = {} as AxeResult; + rerenderPopover({ active: true }); + await act(async () => { + newResults = await axe(container); + }); + expect(newResults).toHaveNoViolations(); + }); }); - }); - test('displays popover when the "active" prop is set', () => { - const { getByTestId } = renderPopover({ active: true }); - expect(getByTestId('popover-test-id')).toBeInTheDocument(); - }); + test('displays popover when the `active` prop is `true`', () => { + const { getByTestId } = renderPortalPopover({ active: true }); + expect(getByTestId('popover-test-id')).toBeInTheDocument(); + }); - test('does not display popover when "active" prop is not set', () => { - const { container } = renderPopover(); - expect(container.innerHTML.includes('popover-test-id')).toBe(false); - }); + test('portals popover content to end of DOM by default', () => { + const { container, getByTestId } = renderPortalPopover({ active: true }); + expect(container).not.toContain(getByTestId('popover-test-id')); + }); - test('onClick handler is called when popover contents is clicked', () => { - const clickSpy = jest.fn(); - const { getByText } = renderPopover({ active: true, onClick: clickSpy }); + test('does NOT display popover when the `active` prop is `false`', () => { + const { queryByTestId } = renderPortalPopover({ active: false }); + expect(queryByTestId('popover-test-id')).toBeNull(); + }); - expect(clickSpy).not.toHaveBeenCalled(); - fireEvent.click(getByText('Popover Content')); - expect(clickSpy).toHaveBeenCalledTimes(1); - }); + test('accepts a `portalRef`', async () => { + const portalRef = createRef(); + renderPortalPopover({ active: true, portalRef }); + + waitFor(() => { + expect(portalRef.current).toBeDefined(); + expect(portalRef.current).toBeInTheDocument(); + }); + }); - test('portals popover content to end of DOM by default', () => { - const { container, getByTestId } = renderPopover({ active: true }); - expect(container).not.toContain(getByTestId('popover-test-id')); + test('applies `portalClassName` to portal element', () => { + const { getByTestId } = renderPortalPopover({ + active: true, + portalClassName: 'test-classname', + }); + expect(getByTestId('popover-test-id').parentElement?.className).toBe( + 'test-classname', + ); + }); }); - test('does not portal popover content to end of DOM when "usePortal" is false', () => { - const { container } = renderPopover({ - active: true, - usePortal: false, + describe(`renderMode=${RenderMode.TopLayer}`, () => { + describe('a11y', () => { + test('does not have basic accessibility issues', async () => { + const { container, rerenderPopover } = renderTopLayerPopover(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + + type AxeResult = Awaited>; + let newResults: AxeResult = {} as AxeResult; + rerenderPopover({ active: true }); + await act(async () => { + newResults = await axe(container); + }); + expect(newResults).toHaveNoViolations(); + }); }); - expect(container.innerHTML.includes('popover-test-id')).toBe(true); + 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, + }); + 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('applies "portalClassName" to root of portal', () => { - const { getByTestId } = renderPopover({ + test('accepts a ref', () => { + const ref = createRef(); + render( + + Popover Content + , + ); + + expect(ref.current).toBeDefined(); + }); + + test('onClick handler is called when popover contents is clicked', () => { + const clickSpy = jest.fn(); + const { getByText } = renderTopLayerPopover({ active: true, - portalClassName: 'test-classname', + onClick: clickSpy, }); - expect(getByTestId('popover-test-id').parentElement?.className).toBe( - 'test-classname', - ); + expect(clickSpy).not.toHaveBeenCalled(); + fireEvent.click(getByText('Popover Content')); + expect(clickSpy).toHaveBeenCalledTimes(1); }); test('removes Popover instance on unmount', () => { - const { container, unmount } = renderPopover(); + const { container, unmount } = renderTopLayerPopover(); unmount(); expect(container.innerHTML).toBe(''); }); @@ -128,7 +405,7 @@ describe('packages/popover', () => { onExiting: jest.fn(), onExited: jest.fn(), }; - const { rerenderPopover } = renderPopover({ + const { button } = renderTopLayerPopover({ ...callbacks, }); @@ -138,7 +415,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); @@ -149,7 +426,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); @@ -164,7 +441,7 @@ describe('packages/popover', () => { describe('within context', () => { const setIsPopoverOpenMock = jest.fn(); - function renderPopoverInContext(props?: Partial) { + function renderPopoverInContext(props?: RTLTopLayerPopoverProps) { const MockPopoverProvider = ({ children }: PropsWithChildren<{}>) => { return ( { const result = render( - - Popover Content - + , ); - const rerenderPopover = (newProps?: Partial) => { - const allProps = { ...props, ...newProps }; - result.rerender( - - - Popover Content - - , - ); - }; + const button = result.getByTestId('popover-reference-element'); - return { ...result, rerenderPopover }; + return { button, ...result }; } afterEach(() => { @@ -205,19 +471,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); + }); }); }); @@ -228,13 +495,72 @@ describe('packages/popover', () => { ; }); - test('Requires only children', () => { + test('only requires children', () => { Popover Content; }); - test('does not allow specifying "portalClassName", when "usePortal" is false', () => { + test(`does not allow specifying portal props, when renderMode is not ${RenderMode.Portal}`, () => { + const scrollContainer = document.createElement('div'); + + // @ts-expect-error + + Popover Content + ; + + //@ts-expect-error + + Popover Content + ; + // @ts-expect-error - + + Popover Content + ; + + //@ts-expect-error + + Popover Content + ; + }); + + test(`does not allow specifying dismissMode or onToggle, when renderMode is not ${RenderMode.TopLayer}`, () => { + // @ts-expect-error + {}} + > + Popover Content + ; + + // @ts-expect-error + {}} + > Popover Content ; }); diff --git a/packages/popover/src/Popover/Popover.styles.ts b/packages/popover/src/Popover/Popover.styles.ts new file mode 100644 index 0000000000..68e7db9af8 --- /dev/null +++ b/packages/popover/src/Popover/Popover.styles.ts @@ -0,0 +1,223 @@ +import { TransitionStatus } from 'react-transition-group'; + +import { css, cx } from '@leafygreen-ui/emotion'; +import { createUniqueClassName } from '@leafygreen-ui/lib'; +import { transitionDuration } from '@leafygreen-ui/tokens'; + +import { ExtendedPlacement, TransformAlign } from './Popover.types'; + +export const TRANSITION_DURATION = transitionDuration.default; + +export const contentClassName = createUniqueClassName('popover-content'); + +export const hiddenPlaceholderStyle = css` + display: none; +`; + +const getBasePopoverStyles = (floatingStyles: React.CSSProperties) => css` + margin: 0; + border: none; + padding: 0; + overflow: visible; + + position: ${floatingStyles.position}; + top: ${floatingStyles.top}px; + left: ${floatingStyles.left}px; + + transition-property: opacity, transform, overlay, display; + transition-duration: ${TRANSITION_DURATION}ms; + transition-timing-function: ease-in-out; + transition-behavior: allow-discrete; + + opacity: 0; + transform: scale(0); + + &::backdrop { + transition-property: background, overlay, display; + transition-duration: ${TRANSITION_DURATION}ms; + transition-timing-function: ease-in-out; + transition-behavior: allow-discrete; + } + + @starting-style { + :popover-open { + opacity: 0; + transform: scale(0); + } + } +`; + +const transformOriginStyles: Record = { + top: css` + transform-origin: bottom; + `, + 'top-start': css` + transform-origin: bottom left; + `, + 'top-end': css` + transform-origin: bottom right; + `, + bottom: css` + transform-origin: top; + `, + 'bottom-start': css` + transform-origin: top left; + `, + 'bottom-end': css` + transform-origin: top right; + `, + left: css` + transform-origin: right; + `, + 'left-start': css` + transform-origin: right top; + `, + 'left-end': css` + transform-origin: right bottom; + `, + right: css` + transform-origin: left; + `, + 'right-start': css` + transform-origin: left top; + `, + 'right-end': css` + transform-origin: left bottom; + `, + center: css` + transform-origin: center; + `, + 'center-start': css` + transform-origin: top; + `, + 'center-end': css` + transform-origin: bottom; + `, +}; + +const baseClosedStyles = css` + opacity: 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); + `, + ); + } +}; + +const baseOpenStyles = css` + opacity: 1; + pointer-events: initial; + + &:popover-open { + opacity: 1; + + pointer-events: initial; + } +`; + +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); + } + `, + ); + } +}; + +export const getPopoverStyles = ({ + className, + floatingStyles, + placement, + popoverZIndex, + spacing, + state, + transformAlign, +}: { + className?: string; + floatingStyles: React.CSSProperties; + placement: ExtendedPlacement; + popoverZIndex?: number; + spacing: number; + state: TransitionStatus; + transformAlign: TransformAlign; +}) => + cx( + getBasePopoverStyles(floatingStyles), + transformOriginStyles[placement], + { + [getClosedStyles(spacing, transformAlign)]: state !== 'entered', + [getOpenStyles(transformAlign)]: state === 'entered', + [css` + z-index: ${popoverZIndex}; + `]: typeof popoverZIndex === 'number', + }, + className, + ); 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 abc8874379..e4ae747402 100644 --- a/packages/popover/src/Popover/Popover.tsx +++ b/packages/popover/src/Popover/Popover.tsx @@ -1,33 +1,39 @@ -import React, { forwardRef, Fragment } from 'react'; +import React, { forwardRef, Fragment, useEffect } from 'react'; import { Transition } from 'react-transition-group'; import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react'; import PropTypes from 'prop-types'; import { useMergeRefs } from '@leafygreen-ui/hooks'; -import { usePopoverContext } from '@leafygreen-ui/leafygreen-provider'; import { consoleOnce } from '@leafygreen-ui/lib'; import Portal from '@leafygreen-ui/portal'; import { spacing as spacingToken } from '@leafygreen-ui/tokens'; -import { useContentNode, useReferenceElement } from '../Popover.hooks'; +import { + getExtendedPlacementValues, + getFloatingPlacement, + getOffsetValue, + getWindowSafePlacementValues, +} from '../utils/positionUtils'; + +import { + useContentNode, + usePopoverContextProps, + useReferenceElement, +} from './Popover.hooks'; import { contentClassName, getPopoverStyles, hiddenPlaceholderStyle, TRANSITION_DURATION, -} from '../Popover.styles'; +} from './Popover.styles'; import { Align, + DismissMode, Justify, PopoverComponentProps, PopoverProps, -} from '../Popover.types'; -import { - getExtendedPlacementValue, - getFloatingPlacement, - getOffsetValue, - getWindowSafePlacementValues, -} from '../utils/positionUtils'; + RenderMode, +} from './Popover.types'; /** * @@ -57,42 +63,50 @@ export const Popover = forwardRef( ( { active = false, - spacing = spacingToken[100], - align = Align.Bottom, - justify = Justify.Start, adjustOnMutation = false, + align = Align.Bottom, children, className, - popoverZIndex, + justify = Justify.Start, refEl, - usePortal = true, + ...rest + }: PopoverProps, + fwdRef, + ) => { + const { + renderMode, + /** top layer props */ + dismissMode = DismissMode.Auto, + onToggle, + /** portal props */ + usePortal, portalClassName, - portalContainer: portalContainerProp, + portalContainer, portalRef, - scrollContainer: scrollContainerProp, + scrollContainer, + /** react-transition-group props */ onEnter, onEntering, onEntered, onExit, onExiting, onExited, - ...rest - }: PopoverProps, - fwdRef, - ) => { - const { - portalContainer: portalContainerCtxVal, - scrollContainer: scrollContainerCtxVal, + /** style props */ + popoverZIndex, + spacing = spacingToken[100], + /** deprecated props */ + isPopoverOpen: _, setIsPopoverOpen, - } = usePopoverContext(); + ...restProps + } = usePopoverContextProps(rest); - const portalContainer = portalContainerProp || portalContainerCtxVal; - const scrollContainer = scrollContainerProp || scrollContainerCtxVal; - - // When usePortal is true and a scrollContainer is passed in - // show 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)) { consoleOnce.warn( @@ -109,12 +123,8 @@ export const Popover = forwardRef( }; const rootProps = usePortal ? portalProps : {}; - const { - placeholderRef, - referenceElement, - referenceElDocumentPos, - renderHiddenPlaceholder, - } = useReferenceElement(refEl); + const { placeholderRef, referenceElement, referenceElDocumentPos } = + useReferenceElement(refEl, scrollContainer); const { contentNodeRef, setContentNode } = useContentNode(); const { context, floatingStyles, placement, refs } = useFloating({ @@ -126,23 +136,27 @@ export const Popover = forwardRef( ({ rects }) => getOffsetValue(align, spacing, rects), [align, spacing], ), - flip(), + flip({ + mainAxis: adjustOnMutation, + crossAxis: adjustOnMutation, + }), ], open: active, placement: getFloatingPlacement(align, justify), strategy: 'absolute', transform: false, - whileElementsMounted: adjustOnMutation ? autoUpdate : undefined, + whileElementsMounted: autoUpdate, }); const popoverRef = useMergeRefs([refs.setFloating, fwdRef]); const { align: windowSafeAlign, justify: windowSafeJustify } = getWindowSafePlacementValues(placement); - const extendedPlacement = getExtendedPlacementValue({ - placement, - align, - }); + const { placement: extendedPlacement, transformAlign } = + getExtendedPlacementValues({ + placement, + align, + }); const renderChildren = () => { if (children === null) { @@ -160,37 +174,74 @@ export const Popover = forwardRef( return children; }; + const handleEntering = (isAppearing: boolean) => { + if (renderMode === RenderMode.TopLayer) { + // @ts-expect-error - `toggle` event not supported pre-typescript v5 + refs.floating.current?.addEventListener('toggle', onToggle); + } + + 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); + } + + onExiting?.(); + }; + + const handleExited = () => { + setIsPopoverOpen(false); + onExited?.(); + }; + + useEffect(() => { + if (!refs.floating.current || renderMode !== RenderMode.TopLayer) { + return; + } + + if (context.open) { + // @ts-expect-error - Popover API not currently supported in react v18 https://github.com/facebook/react/pull/27981 + refs.floating.current?.showPopover?.(); + } else { + // @ts-expect-error - Popover API not currently supported in react v18 https://github.com/facebook/react/pull/27981 + refs.floating.current?.hidePopover?.(); + } + }, [context.open, renderMode]); + return ( { - setIsPopoverOpen(true); - onEntered?.(...args); - }} - onExiting={onExiting} - onExit={onExit} - onExited={(...args) => { - setIsPopoverOpen(false); - onExited?.(...args); - }} > {state => ( <> - {renderHiddenPlaceholder && ( - /* Using as placeholder to prevent validateDOMNesting warnings - Warnings will still show up if `usePortal` is false */ - - )} + {/* Using as placeholder to prevent validateDOMNesting warnings + Warnings will still show up if `usePortal` is false */} +
( 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 + popover={ + renderMode === RenderMode.TopLayer ? dismissMode : undefined + } + {...restProps} > {/* We need to put `setContentNode` ref on this inner wrapper because placing the ref on the parent will create an infinite loop in some cases diff --git a/packages/popover/src/Popover/Popover.types.ts b/packages/popover/src/Popover/Popover.types.ts new file mode 100644 index 0000000000..80b0b008e7 --- /dev/null +++ b/packages/popover/src/Popover/Popover.types.ts @@ -0,0 +1,389 @@ +import React from 'react'; +import { Transition } from 'react-transition-group'; +import { Placement } from '@floating-ui/react'; + +import { HTMLElementProps } from '@leafygreen-ui/lib'; + +type TransitionProps = React.ComponentProps>; + +type TransitionLifecycleCallbacks = Pick< + TransitionProps, + 'onEnter' | 'onEntering' | 'onEntered' | 'onExit' | 'onExiting' | 'onExited' +>; + +/** + * Options to render the popover element + * @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 = { + Inline: 'inline', + Portal: 'portal', + TopLayer: 'top-layer', +} as const; +export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; + +/** + * 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 + */ +export const DismissMode = { + Auto: 'auto', + Manual: 'manual', +} as const; +export type DismissMode = (typeof DismissMode)[keyof typeof DismissMode]; + +/** Local implementation of web-native `ToggleEvent` until we use typescript v5 */ +export interface ToggleEvent extends Event { + type: 'toggle'; + newState: 'open' | 'closed'; + oldState: 'open' | 'closed'; +} + +/** + * Options to determine the alignment of the popover relative to + * the other component + * @param Top will align content above other element + * @param Bottom will align content below other element + * @param Left will align content to the left of other element + * @param Right will align content to the right of other element + */ +const Align = { + Top: 'top', + Bottom: 'bottom', + Left: 'left', + Right: 'right', + CenterVertical: 'center-vertical', + CenterHorizontal: 'center-horizontal', +} as const; + +type Align = (typeof Align)[keyof typeof Align]; + +export { Align }; + +/** + * Options to determine the justification of the popover relative to + * the other component + * @param Start will justify content against the start of other element + * @param Middle will justify content against the middle of other element + * @param End will justify content against the end of other element + */ +const Justify = { + Start: 'start', + Middle: 'middle', + End: 'end', +} as const; + +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' + | 'center-start' + | 'center-end'; + +export interface ElementPosition { + top: number; + bottom: number; + left: number; + right: number; + height: number; + width: number; +} + +export interface ChildrenFunctionParameters { + align: Align; + justify: Justify; + referenceElPos: ElementPosition; +} + +export interface RenderInlineProps { + /** + * 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'; + + /** + * 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; + + /** + * A callback function that is called when the visibility of a popover element rendered in the top layer is toggled + */ + onToggle?: never; + + /** + * When `renderMode="portal"`, it specifies a class name to apply to the portal element + * @deprecated + */ + portalClassName?: never; + + /** + * 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; + + /** + * When `renderMode="portal"`, it passes a ref to forward to the portal element + * @deprecated + */ + portalRef?: never; + + /** + * When `renderMode="portal"`, it specifies the scrollable element to position relative to + * @deprecated + */ + scrollContainer?: never; + + /** + * Duplicated by `renderMode='portal'` + * @deprecated TODO: https://jira.mongodb.org/browse/LG-4526 + */ + usePortal: false; +} + +export interface RenderPortalProps { + /** + * 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'; + + /** + * 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; + + /** + * When `renderMode="top-layer"`, this callback function is called when the visibility of a popover element is toggled + */ + onToggle?: never; + + /** + * When `renderMode="portal"`, it specifies a class name to apply to the portal element + * @deprecated + */ + portalClassName?: string; + + /** + * 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; + + /** + * When `renderMode="portal"`, it passes a ref to forward to the portal element + * @deprecated + */ + portalRef?: React.MutableRefObject; + + /** + * When `renderMode="portal"`, it specifies the scrollable element to position relative to + * @deprecated + */ + scrollContainer?: HTMLElement | null; + + /** + * Duplicated by `renderMode='portal'` + * @deprecated TODO: https://jira.mongodb.org/browse/LG-4526 + */ + usePortal?: true; +} + +export interface RenderTopLayerProps { + /** + * 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'; + + /** + * 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 visibility of a popover element rendered in the top layer is toggled + */ + onToggle?: (e: ToggleEvent) => void; + + /** + * When `renderMode="portal"`, it specifies a class name to apply to the portal element + * @deprecated + */ + portalClassName?: never; + + /** + * 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; + + /** + * When `renderMode="portal"`, it passes a ref to forward to the portal element + * @deprecated + */ + portalRef?: never; + + /** + * When `renderMode="portal"`, it specifies the scrollable element to position relative to + * @deprecated + */ + scrollContainer?: never; + + /** + * Duplicated by `renderMode='portal'` + * @deprecated TODO: https://jira.mongodb.org/browse/LG-4526 + */ + usePortal?: never; +} + +export type PopoverRenderModeProps = + | RenderInlineProps + | RenderPortalProps + | RenderTopLayerProps; + +/** + * Base popover props. + * Use these props to extend popover behavior + */ +export type PopoverProps = { + /** + * Content that will appear inside of the popover component. + */ + children: + | React.ReactNode + | ((Options: ChildrenFunctionParameters) => React.ReactNode); + + /** + * Determines the active state of the popover component + * + * default: `false` + */ + active?: boolean; + + /** + * Should the Popover auto adjust its content when the DOM changes (using MutationObserver). + * + * default: false + */ + adjustOnMutation?: boolean; + + /** + * Determines the alignment of the popover content relative to the trigger element + * + * default: `bottom` + */ + align?: Align; + + /** + * Class name applied to popover container. + */ + className?: string; + + /** + * Determines the justification of the popover content relative to the trigger element + * + * default: `start` + */ + justify?: Justify; + + /** + * Click event handler passed to the root div element within the portal container. + */ + onClick?: React.MouseEventHandler; + + /** + * Number that controls the z-index of the popover element directly. + */ + popoverZIndex?: number; + + /** + * 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` + */ + spacing?: number; +} & PopoverRenderModeProps & + TransitionLifecycleCallbacks; + +/** Props used by the popover component */ +export type PopoverComponentProps = Omit, 'children'> & + PopoverProps; + +export interface UseReferenceElementReturnObj { + /** + * Ref to access hidden placeholder element + */ + placeholderRef: React.MutableRefObject; + + /** + * Element against which the popover component will be positioned + */ + referenceElement: HTMLElement | null; + + /** + * Document position details of the reference element + */ + referenceElDocumentPos: ElementPosition; +} + +export interface UseContentNodeReturnObj { + /** + * `contentNode` is the direct child of the popover element and wraps the children. It + * is used to calculate the position of the popover because its parent has a transition. + * This prevents getting the width of the popover until the transition completes + */ + contentNode: HTMLDivElement | null; + + /** + * We shadow the `contentNode` onto this `contentNodeRef` as `` from + * react-transition-group only accepts a `MutableRefObject` type. Without this, StrictMode + * warnings are produced by react-transition-group. + */ + contentNodeRef: React.MutableRefObject; + + /** + * Dispatch method to attach `contentNode` to the `ContentWrapper` + */ + setContentNode: React.Dispatch>; +} 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 292e3c4840..751c80a239 100644 --- a/packages/popover/src/index.ts +++ b/packages/popover/src/index.ts @@ -1,19 +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 PortalControlProps, -} from './Popover.types'; - -export { contentClassName, Popover }; -export const TestUtils = { - getAlign, - getJustify, -}; + type PopoverRenderModeProps, + RenderMode, + type ToggleEvent, +} from './Popover'; export default Popover; diff --git a/packages/popover/src/utils/getRenderMode.test.ts b/packages/popover/src/utils/getRenderMode.test.ts new file mode 100644 index 0000000000..6adfb4e085 --- /dev/null +++ b/packages/popover/src/utils/getRenderMode.test.ts @@ -0,0 +1,48 @@ +import { RenderMode } from '../Popover/Popover.types'; + +import { getRenderMode } from './getRenderMode'; + +describe('getRenderMode', () => { + describe('when usePortal is defined', () => { + test(`should return ${RenderMode.Portal} when usePortal is true`, () => { + const renderMode = RenderMode.Portal; + const usePortal = true; + const result = getRenderMode(renderMode, usePortal); + expect(result).toBe(RenderMode.Portal); + }); + + test(`should return ${RenderMode.Inline} when usePortal is false`, () => { + const renderMode = RenderMode.Inline; + const usePortal = false; + const result = getRenderMode(renderMode, usePortal); + expect(result).toBe(RenderMode.Inline); + }); + }); + + describe('when usePortal is undefined', () => { + const usePortal = undefined; + test(`should return ${RenderMode.TopLayer} when renderMode is undefined`, () => { + const renderMode = undefined; + const result = getRenderMode(renderMode, usePortal); + expect(result).toBe(RenderMode.TopLayer); + }); + + test(`should return ${RenderMode.TopLayer} when renderMode is ${RenderMode.TopLayer}`, () => { + const renderMode = RenderMode.TopLayer; + const result = getRenderMode(renderMode, usePortal); + expect(result).toBe(RenderMode.TopLayer); + }); + + test(`should return ${RenderMode.Inline} when renderMode is ${RenderMode.Inline}`, () => { + const renderMode = RenderMode.Inline; + const result = getRenderMode(renderMode, usePortal); + expect(result).toBe(RenderMode.Inline); + }); + + test(`should return ${RenderMode.Portal} when renderMode is ${RenderMode.Portal}`, () => { + const renderMode = RenderMode.Portal; + const result = getRenderMode(renderMode, usePortal); + expect(result).toBe(RenderMode.Portal); + }); + }); +}); diff --git a/packages/popover/src/utils/getRenderMode.ts b/packages/popover/src/utils/getRenderMode.ts new file mode 100644 index 0000000000..5f805add2e --- /dev/null +++ b/packages/popover/src/utils/getRenderMode.ts @@ -0,0 +1,20 @@ +import { RenderMode } from '../Popover/Popover.types'; + +export function getRenderMode( + renderMode?: RenderMode, + usePortal?: boolean, +): RenderMode { + if (usePortal !== undefined) { + if (usePortal) { + return RenderMode.Portal; + } + + return RenderMode.Inline; + } + + if (renderMode === undefined) { + return RenderMode.TopLayer; + } + + return 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, + }; }; /** diff --git a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx index 257cc7e160..e684b4d257 100644 --- a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx +++ b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx @@ -32,7 +32,7 @@ export const SearchResultsMenu = React.forwardRef< children, open = false, refEl, - usePortal, + usePortal = true, portalClassName, portalContainer, portalRef, diff --git a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts index ec935c8d91..fae8fe953f 100644 --- a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts +++ b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts @@ -1,10 +1,10 @@ import React, { ReactElement } from 'react'; import { HTMLElementProps } from '@leafygreen-ui/lib'; -import { PortalControlProps } from '@leafygreen-ui/popover'; +import { PopoverRenderModeProps } from '@leafygreen-ui/popover'; export type SearchResultsMenuProps = HTMLElementProps<'ul', HTMLUListElement> & - PortalControlProps & { + Omit & { refEl: React.RefObject; open?: boolean; footerSlot?: ReactElement;