From b1e3259cdedf46b30c57917a0078ecd2917cfd28 Mon Sep 17 00:00:00 2001 From: renrizzolo Date: Tue, 20 Dec 2022 14:36:16 +1100 Subject: [PATCH] feat: popover menu support --- src/components/HelpIconPopover/index.jsx | 4 +- src/components/HelpIconPopover/index.spec.jsx | 4 +- src/components/HoverDropdownMenu/index.jsx | 37 +- .../HoverDropdownMenu/index.spec.jsx | 29 +- src/components/Popover/Popper.d.ts | 2 +- src/components/Popover/Popper.jsx | 9 +- src/components/Popover/WithRef.d.ts | 1 + src/components/Popover/WithRef.jsx | 4 +- src/components/Popover/index.d.ts | 51 ++- src/components/Popover/index.jsx | 190 ++++++--- src/components/Popover/index.spec.jsx | 366 +++++++++++++++--- src/components/Popover/styles.css | 45 ++- src/components/Popover/usePopover.js | 201 ++++++++++ www/containers/props.json | 104 ++++- www/examples/Popover.mdx | 90 ++++- 15 files changed, 922 insertions(+), 215 deletions(-) create mode 100644 src/components/Popover/usePopover.js diff --git a/src/components/HelpIconPopover/index.jsx b/src/components/HelpIconPopover/index.jsx index 0f9d7a17b..416e45c05 100644 --- a/src/components/HelpIconPopover/index.jsx +++ b/src/components/HelpIconPopover/index.jsx @@ -6,8 +6,8 @@ import './styles.css'; const HelpIconPopover = ({ children, id, placement }) => (
- -
+ +
); diff --git a/src/components/HelpIconPopover/index.spec.jsx b/src/components/HelpIconPopover/index.spec.jsx index 384cf4027..e82d29094 100644 --- a/src/components/HelpIconPopover/index.spec.jsx +++ b/src/components/HelpIconPopover/index.spec.jsx @@ -20,7 +20,7 @@ describe('', () => { expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); act(() => { - fireEvent.mouseEnter(getByTestId('help-icon-popover-trigger')); + fireEvent.pointerOver(getByTestId('help-icon-popover-trigger')); jest.runAllTimers(); }); @@ -37,7 +37,7 @@ describe('', () => { ); act(() => { - fireEvent.mouseEnter(getByTestId('help-icon-popover-trigger')); + fireEvent.pointerOver(getByTestId('help-icon-popover-trigger')); jest.runAllTimers(); }); diff --git a/src/components/HoverDropdownMenu/index.jsx b/src/components/HoverDropdownMenu/index.jsx index 7bff4f1a3..5c74c777c 100644 --- a/src/components/HoverDropdownMenu/index.jsx +++ b/src/components/HoverDropdownMenu/index.jsx @@ -1,4 +1,3 @@ -import _ from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import Popover from '../Popover'; @@ -6,48 +5,16 @@ import PopoverLinkItem from './PopoverLinkItem'; import './styles.css'; const HoverDropdownMenu = ({ arrowPosition, headerText, hoverComponent, children }) => { - const [popperNode, setPopperNode] = React.useState(null); - const [isOpen, setIsOpen] = React.useState(false); - const [mouseInPopover, setMouseInPopover] = React.useState(false); - - const closeMenu = _.debounce(() => { - setIsOpen(false); - }, 100); - - const popoverEnterHandler = React.useCallback(() => setMouseInPopover(true), [setMouseInPopover]); - const popoverLeaveHandler = React.useCallback(() => { - setMouseInPopover(false); - closeMenu(); - }, [setMouseInPopover, closeMenu]); - - React.useEffect(() => { - if (popperNode) { - popperNode.addEventListener('mouseenter', popoverEnterHandler); - popperNode.addEventListener('mouseleave', popoverLeaveHandler); - } - }, [popperNode, popoverEnterHandler, popoverLeaveHandler]); - - const openMenu = () => { - setIsOpen(true); - setMouseInPopover(false); - }; - - const element = ( -
- {hoverComponent} -
- ); + const element =
{hoverComponent}
; return (
{children && children.length > 0 ? ( {children}} - popperRef={setPopperNode} > {element} diff --git a/src/components/HoverDropdownMenu/index.spec.jsx b/src/components/HoverDropdownMenu/index.spec.jsx index ec882f4c6..5a5fd9783 100644 --- a/src/components/HoverDropdownMenu/index.spec.jsx +++ b/src/components/HoverDropdownMenu/index.spec.jsx @@ -62,7 +62,7 @@ describe('', () => { ); act(() => { - fireEvent.mouseEnter(getByTestId('hover-dropdown-element')); + fireEvent.pointerOver(getByTestId('hover-dropdown-element')); jest.runAllTimers(); }); @@ -83,19 +83,19 @@ describe('', () => { ); act(() => { - fireEvent.mouseEnter(getByTestId('hover-dropdown-element')); + fireEvent.pointerOver(getByTestId('hover-dropdown-element')); jest.runAllTimers(); }); expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); act(() => { - fireEvent.mouseLeave(getByTestId('hover-dropdown-element')); + fireEvent.pointerLeave(getByTestId('popover-wrapper')); jest.runAllTimers(); }); expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); act(() => { - fireEvent.mouseEnter(getByTestId('hover-dropdown-element')); + fireEvent.pointerOver(getByTestId('hover-dropdown-element')); jest.runAllTimers(); }); expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); @@ -111,34 +111,27 @@ describe('', () => { ); act(() => { - fireEvent.mouseEnter(getByTestId('hover-dropdown-element')); + fireEvent.pointerOver(getByTestId('hover-dropdown-element')); jest.runAllTimers(); }); expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); act(() => { - fireEvent.mouseLeave(getByTestId('hover-dropdown-element')); - fireEvent.mouseEnter(getByTestId('popover-wrapper')); - jest.advanceTimersByTime(50); - }); - expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); - - act(() => { - fireEvent.mouseLeave(getByTestId('popover-wrapper')); - fireEvent.mouseEnter(getByTestId('popover-title')); - jest.advanceTimersByTime(50); + fireEvent.pointerLeave(getByTestId('hover-dropdown-element')); + fireEvent.pointerOver(getByTestId('popover-wrapper')); + jest.runAllTimers(); }); expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); act(() => { - fireEvent.mouseLeave(getByTestId('popover-title')); - fireEvent.mouseEnter(queryAllByTestId('popover-link-item-wrapper')[0]); + fireEvent.pointerLeave(getByTestId('popover-title')); + fireEvent.pointerOver(queryAllByTestId('popover-link-item-wrapper')[0]); jest.advanceTimersByTime(49); }); expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); act(() => { - fireEvent.mouseLeave(getByTestId('popover-wrapper')); + fireEvent.pointerLeave(getByTestId('popover-wrapper')); jest.runAllTimers(); }); diff --git a/src/components/Popover/Popper.d.ts b/src/components/Popover/Popper.d.ts index 1e0a77adb..ed3f6e51a 100644 --- a/src/components/Popover/Popper.d.ts +++ b/src/components/Popover/Popper.d.ts @@ -31,10 +31,10 @@ export interface PopperProps extends PopperModifiers { popoverClass?: string; popoverContent: PopperPopoverContent; refElement?: Element; - boundariesElement?: Element; title?: string; wrapperStyles?: Object; popperRef?: (...args: any[]) => any; + hasHoverRegion?: boolean; } declare const Popper: (props: PopperProps) => React.ReactElement | null; diff --git a/src/components/Popover/Popper.jsx b/src/components/Popover/Popper.jsx index 5d413bba3..437d284fa 100644 --- a/src/components/Popover/Popper.jsx +++ b/src/components/Popover/Popper.jsx @@ -55,6 +55,7 @@ const Popper = ({ refElement, modifiers = [], popperRef, + hasHoverRegion, }) => { const [popperElement, setPopperElement] = React.useState(null); const [arrowElement, setArrowElement] = React.useState(null); @@ -108,6 +109,12 @@ const Popper = ({ {_.isFunction(popoverContent) ? popoverContent({ update }) : popoverContent}
+ {hasHoverRegion && ( +
+ )}
extends PopperModifiers { popoverContent: WithRefPopoverContent; isOpen?: boolean; popperRef?: (...args: any[]) => any; + hasHoverRegion?: boolean; dts?: string; } diff --git a/src/components/Popover/WithRef.jsx b/src/components/Popover/WithRef.jsx index 8c7ea75c8..06496fff7 100644 --- a/src/components/Popover/WithRef.jsx +++ b/src/components/Popover/WithRef.jsx @@ -22,6 +22,7 @@ const WithRefM = ({ arrowStyles, getContainer, popperRef, + hasHoverRegion, }) => { const themeClass = _.includes(themes, theme) ? `popover-${theme}` : 'popover-light'; const popoverClass = classnames('aui--popover-wrapper', themeClass, popoverClassNames); @@ -37,12 +38,12 @@ const WithRefM = ({ dts={dts} title={title} popoverContent={popoverContent} - boundariesElement={boundariesElement} arrowStyles={arrowStyles} placement={placement} strategy={strategy} modifiers={modifiers} popperRef={popperRef} + hasHoverRegion={hasHoverRegion} />, boundariesElement ); @@ -64,6 +65,7 @@ WithRef.propTypes = { popoverContent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, isOpen: PropTypes.bool, popperRef: PropTypes.func, + hasHoverRegion: PropTypes.bool, dts: PropTypes.string, }; diff --git a/src/components/Popover/index.d.ts b/src/components/Popover/index.d.ts index 7321afcd1..21119bb7a 100644 --- a/src/components/Popover/index.d.ts +++ b/src/components/Popover/index.d.ts @@ -27,11 +27,54 @@ export type PopoverPopoverContent = React.ReactNode | ((...args: any[]) => any); export type PopoverTriggers = 'click' | 'hover' | 'focus' | 'disabled' | ('click' | 'hover' | 'focus' | 'disabled')[]; +export interface PopoverAnchorRef { + current?: any; +} + +export interface PopoverTriggerRef { + current?: any; +} + export interface PopoverProps extends PopperModifiers { theme?: PopoverTheme; title?: React.ReactNode; className?: string; popoverClassNames?: string; + /** + * hover show delay in ms + */ + delayShow?: number; + /** + * hover hide delay in ms + */ + delayHide?: number; + /** + * when used with the hover trigger, hovering the popover content + * will keep the popover open. + * Popover will close when mousing out of the popover content. + * For the best UX, use with `delayHide` of at least 200 + */ + contentHoverable?: boolean; + /** + * when true: + * - the popover content will be focused after opening + * - the popover content will trap focus + * - the popover trigger will re-focus on close + */ + isMenu?: boolean; + /** + * callback fired when Popover open state changes + * @param {boolean} openState + * @param {object} event - event object + * @param {string} eventType - the type of event that triggered this change. + * Either a dom event (`keydown`, `pointerover`, `pointerleave`, `click`), + * or `clickOutside`, when closed via clicking outside, `disabed` when disabled trigger is applied. + */ + onOpenChange?: (...args: any[]) => any; + /** + * [`isMenu`] callback called when closing on outside click + */ + onClickOutside?: (...args: any[]) => any; /** * arrow css styles, mainly for positioning the arrow */ @@ -41,11 +84,15 @@ export interface PopoverProps extends PopperModifiers { placement?: PopoverPlacement; strategy?: PopoverStrategy; popoverContent: PopoverPopoverContent; - children: React.ReactNode; + /** + * children is optional when using `triggerRef` + */ + children?: React.ReactNode; triggers?: PopoverTriggers; isOpen?: boolean; getContainer?: (...args: any[]) => any; - popperRef?: (...args: any[]) => any; + anchorRef?: PopoverAnchorRef; + triggerRef?: PopoverTriggerRef; dts?: string; } diff --git a/src/components/Popover/index.jsx b/src/components/Popover/index.jsx index 8057921e9..ecdd7d9aa 100644 --- a/src/components/Popover/index.jsx +++ b/src/components/Popover/index.jsx @@ -2,74 +2,106 @@ import _ from 'lodash'; import classnames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useState } from 'react'; +import React from 'react'; import { themes, popoverPlacements, popoverStrategies } from './constants'; import WithRef from './WithRef'; +import usePopover from './usePopover'; +import DismissibleFocusTrap from '../DismissibleFocusTrap'; import './styles.css'; const triggerPropTypes = PropTypes.oneOf(['click', 'hover', 'focus', 'disabled']); -const Popover = (props) => { - const { isOpen } = props; - const [isPopoverOpen, setIsPopoverOpen] = useState(isOpen); +const Popover = ({ + isOpen: isOpenProp, + onOpenChange, + theme, + title, + children, + dts, + triggers: triggersProp, + popoverClassNames, + className, + wrapperStyles, + popoverContent, + getContainer, + arrowStyles, + placement, + strategy, + modifiers, + delayShow = 0, + delayHide = 0, + contentHoverable = false, + isMenu, + triggerRef: triggerRefProp, + onClickOutside: onClickOutsideProp, + anchorRef, +}) => { + const triggers = _.castArray(triggersProp); + + const { triggerRef, withRefProps, closePopover, onClickOutside, triggerSource } = usePopover({ + triggers, + triggerRef: triggerRefProp, + anchorRef, + onOpenChange, + isOpen: isOpenProp, + onClickOutside: onClickOutsideProp, + isMenu, + contentHoverable, + delayHide, + delayShow, + }); - React.useEffect(() => { - setIsPopoverOpen(isOpen); - }, [setIsPopoverOpen, isOpen]); - - const closePopover = React.useCallback(() => setIsPopoverOpen(false), [setIsPopoverOpen]); - - const openPopover = React.useCallback(() => setIsPopoverOpen(true), [setIsPopoverOpen]); - - const togglePopover = React.useCallback(() => setIsPopoverOpen(!isPopoverOpen), [setIsPopoverOpen, isPopoverOpen]); - - const onClick = () => togglePopover(); - - const onFocus = () => openPopover(); - - const onBlur = () => closePopover(); - - const onMouseOver = () => openPopover(); - - const onMouseOut = () => closePopover(); - - const { title, children, className, dts, popoverContent, popperRef } = props; const elementClass = classnames('aui--popover-element', className); - const triggers = _.flattenDeep([props.triggers]); - - const [elementRef, setReferenceElement] = useState(null); + const refElement = anchorRef?.current ?? triggerRef?.current; + + const content = isMenu ? ( + _.isFunction(popoverContent) ? ( + (...args) => ( + + {popoverContent(...args)} + + ) + ) : ( + + {popoverContent} + + ) + ) : ( + popoverContent + ); return ( <> - - {children} - + {!triggerRefProp ? ( + + {children} + + ) : ( + children + )} + ); @@ -80,6 +112,43 @@ Popover.propTypes = { title: PropTypes.node, className: PropTypes.string, popoverClassNames: PropTypes.string, + /** + * hover show delay in ms + */ + delayShow: PropTypes.number, + /** + * hover hide delay in ms + */ + delayHide: PropTypes.number, + /** + * when used with the hover trigger, hovering the popover content + * will keep the popover open. + * + * Popover will close when mousing out of the popover content. + * + * For the best UX, use with `delayHide` of at least 200 + */ + contentHoverable: PropTypes.bool, + /** + * when true: + * - the popover content will be focused after opening + * - the popover content will trap focus + * - the popover trigger will re-focus on close + */ + isMenu: PropTypes.bool, + /** + * callback fired when Popover open state changes + * @param {boolean} openState + * @param {object} event - event object + * @param {string} eventType - the type of event that triggered this change. + * Either a dom event (`keydown`, `pointerover`, `pointerleave`, `click`), + * or `clickOutside`, when closed via clicking outside, `disabed` when disabled trigger is applied. + */ + onOpenChange: PropTypes.func, + /** + * [`isMenu`] callback called when closing on outside click + */ + onClickOutside: PropTypes.func, /** * arrow css styles, mainly for positioning the arrow */ @@ -89,11 +158,15 @@ Popover.propTypes = { placement: PropTypes.oneOf(popoverPlacements), strategy: PropTypes.oneOf(popoverStrategies), popoverContent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, - children: PropTypes.node.isRequired, + /** + * children is optional when using `triggerRef` + */ + children: PropTypes.node, triggers: PropTypes.oneOfType([triggerPropTypes, PropTypes.arrayOf(triggerPropTypes)]), isOpen: PropTypes.bool, getContainer: PropTypes.func, - popperRef: PropTypes.func, + anchorRef: PropTypes.shape({ current: PropTypes.any }), + triggerRef: PropTypes.shape({ current: PropTypes.any }), dts: PropTypes.string, }; @@ -101,10 +174,11 @@ Popover.defaultProps = { theme: 'light', placement: 'auto', strategy: 'absolute', - triggers: 'hover', - isOpen: false, + triggers: ['hover', 'focus'], }; Popover.WithRef = WithRef; +export { default as usePopover } from './usePopover'; + export default Popover; diff --git a/src/components/Popover/index.spec.jsx b/src/components/Popover/index.spec.jsx index b90a9de47..f622e9d26 100644 --- a/src/components/Popover/index.spec.jsx +++ b/src/components/Popover/index.spec.jsx @@ -1,8 +1,17 @@ import React from 'react'; -import { render, cleanup, fireEvent } from '@testing-library/react'; +import { render, cleanup, fireEvent, act, createEvent } from '@testing-library/react'; import Popover from '.'; import { renderArrowStyles } from './Popper'; +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runAllTimers(); + jest.useRealTimers(); +}); + afterEach(cleanup); describe('', () => { @@ -16,17 +25,23 @@ describe('', () => { expect(queryByTestId('popover-element')).toBeInTheDocument(); expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); - fireEvent.click(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + act(() => { + fireEvent.click(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + }); - fireEvent.click(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + }); }); }); - describe('onMouseOver() and onMouseOut()', () => { + describe('onPointerOver() and onPointerLeave()', () => { it('should trigger popover open or close', () => { const { getByTestId, queryByTestId } = render( } triggers="hover"> @@ -36,14 +51,20 @@ describe('', () => { expect(queryByTestId('popover-element')).toBeInTheDocument(); expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); - - fireEvent.mouseOver(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); - - fireEvent.mouseOut(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + act(() => { + fireEvent.pointerOver(getByTestId('popover-element')); + jest.runAllTimers(); + fireEvent.pointerOver(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + }); + act(() => { + fireEvent.pointerLeave(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + }); }); }); @@ -58,17 +79,221 @@ describe('', () => { expect(queryByTestId('popover-element')).toBeInTheDocument(); expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); - fireEvent.focus(getByTestId('popover-element')); + act(() => { + fireEvent.focus(getByTestId('popover-element')); + jest.runAllTimers(); + + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.blur(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + }); + }); + }); + + it('should work with contentHoverable, keeping content open when hovered, and re-hovered within the delay hide time', () => { + const { getByTestId, queryByTestId } = render( +
+
} + title="Some title" + isOpen + > + +
+
+ ); + act(() => { + expect(queryByTestId('popover-element')).toBeInTheDocument(); + fireEvent.pointerOver(getByTestId('popover-element')); + jest.runAllTimers(); expect(queryByTestId('popover-element')).toBeInTheDocument(); expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + fireEvent.pointerLeave(getByTestId('popover-element')); + + fireEvent.pointerEnter(getByTestId('popover-wrapper')); + jest.runAllTimers(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + fireEvent.pointerLeave(getByTestId('popover-wrapper')); + jest.advanceTimersByTime(250); + fireEvent.pointerEnter(getByTestId('popover-wrapper')); + fireEvent.pointerLeave(getByTestId('popover-wrapper')); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + jest.advanceTimersByTime(501); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + }); + + act(() => { + fireEvent.pointerOver(getByTestId('popover-element')); + jest.runAllTimers(); + fireEvent.pointerOver(getByTestId('content')); - fireEvent.blur(getByTestId('popover-element')); expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + fireEvent.pointerEnter(getByTestId('popover-wrapper')); + jest.runAllTimers(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + }); + }); + describe('isMenu', () => { + it('should be able to work as a menu', () => { + const onClickOutside = jest.fn(); + const { getByTestId, queryByTestId } = render( +
+
} + onClickOutside={onClickOutside} + title="Some title" + > + + +
+ ); + act(() => { + expect(queryByTestId('popover-element')).toBeInTheDocument(); + fireEvent.click(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + fireEvent.mouseDown(queryByTestId('outisde')); + jest.runAllTimers(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + expect(onClickOutside).toBeCalledTimes(1); + }); + }); + + it('click outside should be preventable', () => { + const onClickOutside = jest.fn(); + + const { getByTestId } = render( +
+
} + onClickOutside={(e) => e.preventDefault()} + title="Some title" + > + +
+
+ ); + act(() => { + expect(getByTestId('popover-element')).toBeInTheDocument(); + fireEvent.click(getByTestId('popover-element')); + jest.runAllTimers(); + expect(getByTestId('popover-wrapper')).toBeInTheDocument(); + fireEvent.mouseDown(getByTestId('outisde')); + jest.runAllTimers(); + expect(getByTestId('popover-wrapper')).toBeInTheDocument(); + expect(onClickOutside).toBeCalledTimes(0); + }); + }); + + it('should be able to work as a menu with render function content', () => { + const { getByTestId, queryByTestId } = render( +
+
} + title="Some title" + > + +
+
+ ); + act(() => { + expect(queryByTestId('popover-element')).toBeInTheDocument(); + fireEvent.click(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + fireEvent.keyDown(queryByTestId('focus-trap'), { key: 'Escape' }); + jest.runAllTimers(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + }); + }); + }); + + it('should call onOpenChange', () => { + const onOpenChange = jest.fn(); + const { getByTestId, queryByTestId, rerender } = render( +
+
} + title="Some title" + > + + + + ); + act(() => { + expect(queryByTestId('popover-element')).toBeInTheDocument(); + fireEvent.pointerOver(getByTestId('popover-element'), {}); + jest.runAllTimers(); + const evt = createEvent.pointerOver(getByTestId('popover-element')); + + expect(onOpenChange).toBeCalledWith(true, evt, 'pointerover'); + }); + + act(() => { + rerender( +
+
} + title="Some title" + > + + + + ); + + jest.runAllTimers(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); }); + + act(() => { + rerender( +
+
} + title="Some title" + > + + + + ); + + jest.runAllTimers(); + + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + }); }); it('should render without error', () => { + console.error = jest.fn(); const { getByTestId, queryByTestId } = render(
} title="Some title" isOpen> @@ -76,13 +301,13 @@ describe('', () => {
); + act(() => { + expect(queryByTestId('popover-element')).toBeInTheDocument(); //Manager + expect(getByTestId('popover-element')).toHaveTextContent('Test message'); + expect(getByTestId('popover-wrapper')).toHaveTextContent('Some title'); //Popper - expect(queryByTestId('popover-element')).toBeInTheDocument(); //Manager - expect(getByTestId('popover-element')).toHaveTextContent('Test message'); - expect(getByTestId('popover-wrapper')).toHaveTextContent('Some title'); //Popper - - console.error = jest.fn(); - expect(console.error).toHaveBeenCalledTimes(0); + expect(console.error).toHaveBeenCalledTimes(0); + }); }); it('should be able to set theme', () => { @@ -211,25 +436,29 @@ describe('', () => { id="popover-example" popoverContent={() =>
test
} placement="bottom-end" - triggers={['focus', 'click']} + triggers={['focus', 'hover']} > Test message
); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); - - fireEvent.click(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); - fireEvent.blur(getByTestId('popover-element')); expect(queryByTestId('popover-element')).toBeInTheDocument(); expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); - fireEvent.focus(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + act(() => { + jest.runAllTimers(); + fireEvent.focus(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.pointerLeave(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + }); }); it('should not include any event handler if trigger is disabled', () => { @@ -240,26 +469,59 @@ describe('', () => { ); expect(queryByTestId('popover-element')).toBeInTheDocument(); expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + + fireEvent.blur(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + + fireEvent.focus(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + + fireEvent.pointerOver(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + + fireEvent.pointerLeave(getByTestId('popover-element')); + jest.runAllTimers(); + expect(queryByTestId('popover-element')).toBeInTheDocument(); + expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + }); + }); + }); - fireEvent.click(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); - - fireEvent.blur(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); - - fireEvent.focus(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + it('should work with an external trigger via triggerRef', () => { + const TestRef = ({ children }) => { + const ref = React.useRef(); + return children(ref); + }; - fireEvent.mouseOver(getByTestId('popover-element')); - expect(queryByTestId('popover-element')).toBeInTheDocument(); - expect(queryByTestId('popover-wrapper')).not.toBeInTheDocument(); + const { getByTestId, queryByTestId } = render( + + {(ref) => ( + <> + ``` -
+## hoverable Popover -## Popover.WithRef +Setting `contentHoverable` keeps the popover open when hovering over its content. +A small `delayHide` can help prevent unintended closes. +Use `isMenu` if the content is interactable. -

There might be use cases where the element which will display the popover will be decided on the fly.

-

- We support the implementation of dynamic popovers via Popover.WithRef -

+```jsx live=true +const Input = () => { + const [value, setValue] = React.useState(''); + const onChange = (e) => { + setValue(e.target.value); + }; + return ; +}; + +const Example = () => { + const [open, setIsOpen] = React.useState(); + const onOpen = (open, event, eventType) => { + setIsOpen(open); + }; + return ( +
+ +

Hoverable popover

+

You can keep this popover open

+

by hovering over it.

+
+ + +
+
+ } + > + +
+ + ); +}; +render(); +``` + +
+ +This example shows how to use an external trigger with `triggerRef` and an external anchor with `anchorRef`. ```jsx live=true const myRef = React.createRef(null); @@ -36,10 +87,10 @@ const myRef = React.createRef(null); const Example = () => { const [isOpen, setIsOpen] = React.useState(false); const togglePopover = () => setIsOpen(!isOpen); - + const triggerRef = React.useRef(); return ( - +
{ > External Anchor
- {isOpen && ( - - )} + +
); };