diff --git a/src/Popup/Popup.tsx b/src/Popup/Popup.tsx new file mode 100644 index 00000000..d94dea08 --- /dev/null +++ b/src/Popup/Popup.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; + +import { useWindowResize } from '../hooks/useWindowResize'; +import { useTheme } from '../theme'; +import { PopupContent } from './PopupContent'; +import { IPopupDefaultProps, IPopupProps } from './types'; +import { calculateStyles, getDefaultStyle } from './utils'; + +export { IPopupProps, IPopupDefaultProps }; + +export interface IPopup extends IPopupProps {} + +export const Popup: React.FunctionComponent = props => { + const { hideDelay, width, offset, posX, posY, show = false } = props; + + const theme = useTheme(); + + const controlled = 'show' in props; + const triggerRef = React.useRef(null); + const contentRef = React.useRef(null); + const [visibility, setVisibility] = React.useState(false); + const isVisible = controlled ? show : visibility; + const lastResizeTimestamp = useWindowResize(); + const [style, setStyle] = React.useState({}); + let isOverTrigger: boolean = false; + let isOverContent: boolean = false; + let willHide: NodeJS.Timer | number | null = null; + + const repaint = React.useCallback( + () => { + if (isVisible) { + setStyle({ + ...getDefaultStyle(props), + ...calculateStyles(triggerRef, contentRef, props), + }); + } + }, + [triggerRef.current, contentRef.current, width, offset, posX, posY, isVisible] + ); + + if (typeof window !== 'undefined') { + React.useEffect(repaint, [lastResizeTimestamp, contentRef.current]); + } + + const showPopup = React.useCallback( + () => { + if (controlled) return; + if (willHide !== null) { + clearTimeout(willHide as number); + willHide = null; + } + + setVisibility(true); + }, + [willHide, isVisible, controlled] + ); + + const hidePopup = React.useCallback( + () => { + if (willHide !== null || controlled) { + return; + } + + willHide = setTimeout(() => { + isOverTrigger = false; + isOverContent = false; + setVisibility(false); + }, hideDelay); + }, + [willHide, isVisible, controlled] + ); + + const { renderTrigger, renderContent } = props; + + const funcs = { + isVisible, + showPopup, + hidePopup, + }; + + const handleMouseEnter = React.useCallback>( + ({ target }) => { + if (target === triggerRef.current) { + isOverTrigger = true; + } else if (target === contentRef.current) { + isOverContent = true; + } + + showPopup(); + }, + [triggerRef.current, contentRef.current, isVisible] + ); + + const handleMouseLeave = React.useCallback>( + ({ target }) => { + if (target === triggerRef.current) { + isOverTrigger = false; + } else if (target === contentRef.current) { + isOverContent = false; + } + + if (isVisible && !isOverTrigger && !isOverContent) { + hidePopup(); + } + }, + [triggerRef.current, contentRef.current, isVisible] + ); + + return ( + <> + {React.cloneElement( + renderTrigger({ + ...funcs, + isOver: isOverTrigger, + }), + { + ref: triggerRef, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + } + )} + {isVisible && ( + + {renderContent({ + ...funcs, + isOver: isOverContent, + theme, + })} + + )} + + ); +}; + +Popup.defaultProps = { + padding: 15, + hideDelay: 200, + posX: 'left', + posY: 'top', +} as IPopupDefaultProps; diff --git a/src/Popup/PopupContent.tsx b/src/Popup/PopupContent.tsx index 407b88d5..cb60111f 100644 --- a/src/Popup/PopupContent.tsx +++ b/src/Popup/PopupContent.tsx @@ -6,7 +6,7 @@ import { IPopupContentProps } from './types'; export const PopupContent = React.forwardRef((props, ref) => { const { children, onMouseEnter, onMouseLeave, repaint, style } = props; - React.useEffect(repaint); + React.useEffect(repaint, []); return ( diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 02b49c1d..95a491cb 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -1,133 +1 @@ -import * as React from 'react'; - -import { useWindowResize } from '../hooks/useWindowResize'; -import { useTheme } from '../theme'; -import { PopupContent } from './PopupContent'; -import { IPopupDefaultProps, IPopupProps } from './types'; -import { calculateStyles, getDefaultStyle } from './utils'; - -export { IPopupProps, IPopupDefaultProps }; - -export interface IPopup extends IPopupProps {} - -export const Popup: React.FunctionComponent = props => { - const { hideDelay, width, offset, posX, posY } = props; - - const theme = useTheme(); - - const triggerRef = React.useRef(null); - const contentRef = React.createRef(); - const [isVisible, setVisibility] = React.useState(false); - const lastResizeTimestamp = useWindowResize(); - let lastRepaintTimestamp = 0; - const [style, setStyle] = React.useState({}); - let isOverTrigger: boolean = false; - let isOverContent: boolean = false; - let willHide: NodeJS.Timer | number | null = null; - - const repaint = React.useCallback( - () => { - if (isVisible) { - lastRepaintTimestamp = Date.now(); - setStyle({ - ...getDefaultStyle(props), - ...calculateStyles(triggerRef, contentRef, props), - }); - } - }, - [width, offset, posX, posY, contentRef, lastRepaintTimestamp] - ); - - if (typeof window !== 'undefined') { - React.useEffect(repaint, [lastResizeTimestamp]); - } - - const showPopup = () => { - if (willHide !== null) { - clearTimeout(willHide as number); - willHide = null; - } - - setVisibility(true); - }; - - const hidePopup = () => { - if (willHide !== null) { - return; - } - - willHide = setTimeout(() => { - isOverTrigger = false; - isOverContent = false; - setVisibility(false); - }, hideDelay); - }; - - const { renderTrigger, renderContent } = props; - - const funcs = { - isVisible, - showPopup, - hidePopup, - }; - - const handleMouseEnter = ({ target }: React.SyntheticEvent) => { - if (target === triggerRef.current) { - isOverTrigger = true; - } else if (target === contentRef.current) { - isOverContent = true; - } - - showPopup(); - }; - - const handleMouseLeave = ({ target }: React.SyntheticEvent) => { - if (target === triggerRef.current) { - isOverTrigger = false; - } else if (target === contentRef.current) { - isOverContent = false; - } - - if (isVisible && !isOverTrigger && !isOverContent) { - hidePopup(); - } - }; - - return ( - <> - {React.cloneElement( - renderTrigger({ - ...funcs, - isOver: isOverTrigger, - }), - { - ref: triggerRef, - onMouseEnter: handleMouseEnter, - onMouseLeave: handleMouseLeave, - } - )} - {isVisible && ( - - {renderContent({ - ...funcs, - isOver: isOverContent, - theme, - })} - - )} - - ); -}; - -Popup.defaultProps = { - padding: 15, - hideDelay: 200, - posX: 'left', - posY: 'top', -} as IPopupDefaultProps; +export * from './Popup'; diff --git a/src/Popup/types.ts b/src/Popup/types.ts index 546ee34b..b658475f 100644 --- a/src/Popup/types.ts +++ b/src/Popup/types.ts @@ -39,6 +39,7 @@ export interface IPopupProps extends IPopupPosition { hideDelay?: number; // how long popup will show for after user mouses out renderTrigger: PopupTriggerRenderer; renderContent: PopupContentRenderer; + show?: boolean; } export interface IPopupDefaultProps { diff --git a/src/Popup/utils.ts b/src/Popup/utils.ts index 97d87a60..f883c1cd 100644 --- a/src/Popup/utils.ts +++ b/src/Popup/utils.ts @@ -24,7 +24,7 @@ export const calculateStyles = ( content: RefObject, props: IPopupProps ): CSSProperties | null => { - if (!trigger || !content || !trigger.current || !content.current) return null; + if (!trigger.current || !content.current) return null; const offset = getOffset(props.offset); diff --git a/src/__stories__/Misc/Popup.tsx b/src/__stories__/Misc/Popup.tsx index f63e0f26..04688eb9 100644 --- a/src/__stories__/Misc/Popup.tsx +++ b/src/__stories__/Misc/Popup.tsx @@ -1,13 +1,15 @@ /* @jsx jsx */ import { jsx } from '@emotion/core'; -import { NumberOptions, withKnobs } from '@storybook/addon-knobs'; +import { boolean, NumberOptions, withKnobs } from '@storybook/addon-knobs'; import { number, select, text } from '@storybook/addon-knobs/react'; import { storiesOf } from '@storybook/react'; import { Box, Icon, Popup } from '../..'; -export const popupKnobs = (tabName = 'Popup'): any => ({ +const TAB_NAME = 'Popup'; + +export const popupKnobs = (tabName = TAB_NAME): any => ({ posX: select('posX', ['left', 'center', 'right'], 'left', tabName), posY: select('posY', ['top', 'center', 'bottom'], 'top', tabName), offset: { @@ -47,4 +49,12 @@ storiesOf('Miscellaneous:Popup', module) }} renderContent={() => Globe} /> + )) + .add('with controlled mode', () => ( + I am controlled, so hovering is no-op!} + renderContent={() => {text('content', 'here is the popup content')}} + /> )); diff --git a/src/__tests__/Popup.spec.tsx b/src/__tests__/Popup.spec.tsx index 0358943b..ffbb901b 100644 --- a/src/__tests__/Popup.spec.tsx +++ b/src/__tests__/Popup.spec.tsx @@ -1,122 +1,113 @@ -import { mount } from 'enzyme'; +import { shallow } from 'enzyme'; import 'jest-enzyme'; -import debounce = require('lodash/debounce'); import * as React from 'react'; - -import { Popup } from '../Popup'; +import { Popup } from '../'; +import { useWindowResize } from '../hooks/useWindowResize'; import { PopupContent } from '../Popup/PopupContent'; -describe('Popup', () => { +jest.mock('../theme', () => ({ + useTheme: () => ({ base: 'dark' }), +})); +jest.mock('../hooks/useWindowResize'); + +describe('Popup component', () => { let props: any; let addEventListenerSpy: jest.SpyInstance; + let useEffectSpy: jest.SpyInstance; let removeEventListenerSpy: jest.SpyInstance; - - beforeEach(async () => { + let useStateSpy: jest.SpyInstance; + let useRefSpy: jest.SpyInstance; + let setVisibilitySpy: jest.SpyInstance; + + beforeAll(() => { + useStateSpy = jest.spyOn(React, 'useState'); + useRefSpy = jest.spyOn(React, 'useRef'); + useEffectSpy = jest.spyOn(React, 'useEffect'); addEventListenerSpy = jest.spyOn(window, 'addEventListener'); removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - jest.useFakeTimers(); + }); + beforeEach(() => { + jest.useFakeTimers(); + useEffectSpy.mockImplementation((fn: Function) => fn); + useRefSpy.mockImplementation((defaultValue: any) => ({ current: defaultValue })); + setVisibilitySpy = jest.fn(); + useStateSpy + .mockReturnValueOnce([false, setVisibilitySpy]) + .mockImplementation((defaultValue: any) => [defaultValue, jest.fn()]); props = { renderContent: jest.fn(() =>
), - renderTrigger: jest.fn(() =>
), + renderTrigger: jest.fn(() =>
), hideDelay: 300, }; }); afterEach(() => { jest.useRealTimers(); + useRefSpy.mockReset(); + useEffectSpy.mockReset(); + useRefSpy.mockReset(); + }); + + afterAll(() => { addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); + useStateSpy.mockRestore(); + useRefSpy.mockRestore(); }); it('always calls renderTrigger on render with internal properties', () => { const { renderTrigger: renderTriggerSpy } = props; - const wrapper = mount(); - const [[{ showPopup, hidePopup }]] = renderTriggerSpy.mock.calls; + shallow(); expect(renderTriggerSpy).toHaveBeenCalledWith({ - hidePopup, - showPopup, + hidePopup: expect.any(Function), + showPopup: expect.any(Function), isOver: false, isVisible: false, }); - - wrapper.unmount(); }); it('does not call renderContent by default', () => { const renderTriggerSpy = jest.spyOn(props, 'renderContent'); - const wrapper = mount(); + shallow(); expect(renderTriggerSpy).not.toHaveBeenCalledWith(); - wrapper.unmount(); }); - it('attaches debounced paint resize handler', () => { - const handler = () => true; - (debounce as any).mockReturnValueOnce(handler); + it('uses useWindowResize hook', () => { + shallow(); - const wrapper = mount(); - - // let's test if debounce was invoked properly - expect(debounce).toHaveBeenCalledWith(expect.any(Function), expect.any(Number)); - // and now if event listener was correctly attached - - expect(addEventListenerSpy).toHaveBeenCalledWith('resize', handler); - wrapper.unmount(); - }); - - it('removes the previously attached resize handler', () => { - const handler = () => true; - (debounce as any).mockReturnValueOnce(handler); - - const wrapper = mount(); - wrapper.unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', handler); + expect(useWindowResize).toHaveBeenCalled(); }); describe('when Popup is visible', () => { - let setStateSpy: jest.SpyInstance; - let repaintSpy: jest.SpyInstance; - let useCallbackSpy: jest.SpyInstance; - let setVisibilitySpy: jest.SpyInstance; - beforeEach(() => { - repaintSpy = jest.fn(); - useCallbackSpy = jest.spyOn(React, 'useCallback').mockReturnValue(repaintSpy); - - setStateSpy = jest.spyOn(React, 'useState'); - setVisibilitySpy = jest.fn(); - setStateSpy.mockReturnValueOnce([true, setVisibilitySpy]); - }); - - afterEach(() => { - setStateSpy.mockRestore(); - useCallbackSpy.mockRestore(); + useStateSpy.mockReset(); + useStateSpy + .mockReturnValueOnce([true, setVisibilitySpy]) + .mockImplementation((defaultValue: any) => [defaultValue, jest.fn()]); }); it('calls renderTrigger and passes proper isVisible', () => { - const wrapper = mount(); + shallow(); expect(props.renderTrigger).toHaveBeenCalledWith( expect.objectContaining({ isVisible: true, }) ); - wrapper.unmount(); }); it('renders PopupContent', () => { - const wrapper = mount(); + const wrapper = shallow(); expect(wrapper.find(PopupContent)).toExist(); - wrapper.unmount(); }); it('calls on renderContent and passes some internal methods', () => { const renderContentSpy = jest.spyOn(props, 'renderContent'); - const wrapper = mount(); + shallow(); expect(renderContentSpy).toHaveBeenCalledWith({ showPopup: expect.any(Function), @@ -127,77 +118,57 @@ describe('Popup', () => { base: expect.any(String), }), }); - wrapper.unmount(); }); - describe('hidePopup', () => { - it('aborts the request if already in progress', () => { - const wrapper = mount(); - const [[{ hidePopup, showPopup }]] = props.renderTrigger.mock.calls; - - showPopup(); - hidePopup(); - - expect(setTimeout).toHaveBeenCalledTimes(1); - wrapper.unmount(); - }); - - it('sets a timeout according to given hideDelay', () => { - const hideDelay = parseInt(String(Math.random() * 100000), 10); - const wrapper = mount(); + it('aborts hiding request if already in progress', () => { + const wrapper = shallow(); + wrapper.find('#trigger').simulate('mouseenter', {}); + wrapper.find('#trigger').simulate('mouseleave', {}); - const [[{ hidePopup }]] = props.renderTrigger.mock.calls; + expect(setTimeout).toHaveBeenCalledTimes(1); + }); - hidePopup(); - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), hideDelay); - wrapper.unmount(); - }); + it('schedules a hide timeout according to given hideDelay', () => { + const hideDelay = parseInt(String(Math.random() * 100000), 10); + const wrapper = shallow(); - it('hides popup once timeouts', () => { - const wrapper = mount(); - const [[{ hidePopup }]] = props.renderTrigger.mock.calls; - hidePopup(); + wrapper.find('#trigger').simulate('mouseleave', {}); - jest.runAllTimers(); - expect(setVisibilitySpy).toHaveBeenCalledWith(false); - wrapper.unmount(); - }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), hideDelay); + }); - describe('repaint', () => { - it('always sets style', () => { - const wrapper = mount(); + it('hides popup once timeouts', () => { + const wrapper = shallow(); - expect(wrapper.find(PopupContent)).toHaveProp('style'); + wrapper.find('#trigger').simulate('mouseleave', {}); - wrapper.unmount(); - }); - }); + jest.runAllTimers(); + expect(setVisibilitySpy).toHaveBeenCalledWith(false); }); - }); - xdescribe('showPopup', () => { - it('clears willHide timeout', () => { - const wrapper = mount(); - const [[{ showPopup, hidePopup }]] = props.renderTrigger.mock.calls; + it('clears willHide once mouse enters trigger', () => { + const wrapper = shallow(); - hidePopup(); - showPopup(); + wrapper.find('#trigger').simulate('mouseleave', {}); + wrapper.find('#trigger').simulate('mouseenter', {}); expect(clearTimeout).toHaveBeenCalled(); - wrapper.unmount(); }); - }); - xit('handleMouseEnter shows popup', () => { - const trigger = foo; + describe('repaint', () => { + it('always sets style', () => { + const wrapper = shallow(); - props.renderTrigger.mockReturnValue(trigger); + expect(wrapper.find(PopupContent)).toHaveProp('style'); + }); + }); + }); - const wrapper = mount(); + it('shows popup when mouse enters trigger', () => { + const wrapper = shallow(); - wrapper.find('#trigger').simulate('mouseenter'); + wrapper.find('#trigger').simulate('mouseenter', {}); - expect(wrapper.find(PopupContent)).toExist(); - wrapper.unmount(); + expect(setVisibilitySpy).toHaveBeenCalledWith(true); }); });