From 68f5f83717ed9fb5be3c2ac5d307ca2aaf9e7d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 12 Nov 2018 13:36:46 +0100 Subject: [PATCH 01/22] feat(popup): implement --- src/Popup.tsx | 25 +++++++++++++++++++++++++ stories/Popup.tsx | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/Popup.tsx create mode 100644 stories/Popup.tsx diff --git a/src/Popup.tsx b/src/Popup.tsx new file mode 100644 index 00000000..f839b387 --- /dev/null +++ b/src/Popup.tsx @@ -0,0 +1,25 @@ +import { Box, IBoxProps } from './Box'; +import { styled } from './utils'; + +export interface IPopupProps extends IBoxProps { + show: boolean; +} + +export const Popup = styled(Box as any)( + { + // @ts-ignore + position: 'absolute', + }, + (props: IPopupProps) => ({ + display: props.show ? '' : 'none' + }), +); + +Popup.defaultProps = { + shadow: 'md', + p: 'xl', + m: '0 auto', + show: false, + top: 'auto', +}; + diff --git a/stories/Popup.tsx b/stories/Popup.tsx new file mode 100644 index 00000000..8c1ab628 --- /dev/null +++ b/stories/Popup.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { boolean, text } from '@storybook/addon-knobs/react'; +import { withKnobs } from '@storybook/addon-knobs'; +import { Popup } from '../src/Popup'; +import { boxKnobs } from './Box'; + +storiesOf('components/Popup', module) + .addDecorator(withKnobs) + .add('with defaults', () => ( + + Some Popup content + + )); From 0c78af5c73fef8deec32e71233fca05b81c72b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 12 Nov 2018 19:05:59 +0100 Subject: [PATCH 02/22] feat(popup): css positioning --- src/Popup.tsx | 71 +++++++++++++++++++++++++++++++++------ src/__stories__/_utils.ts | 12 +++++++ stories/Popup.tsx | 27 +++++++++++---- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/Popup.tsx b/src/Popup.tsx index f839b387..7c020196 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -1,25 +1,76 @@ +import { themeGet } from 'styled-system'; import { Box, IBoxProps } from './Box'; -import { styled } from './utils'; +import { IThemeInterface } from './types'; +import { position, styled } from './utils'; export interface IPopupProps extends IBoxProps { show: boolean; + tip: boolean; + position: 'left-top' | 'top' | 'right-top' | 'right' | 'right-bottom' | 'bottom' | 'left-bottom' | 'left'; + theme: IThemeInterface; + offset: number; } -export const Popup = styled(Box as any)( - { - // @ts-ignore - position: 'absolute', +const getFGColor = themeGet('colors.fg', '#000'); + +const styles = { + 'left-top': { + bottom: '100%', + right: '100%', + }, + top: { + bottom: '100%', + left: '50%', + transform: 'translateX(-50%)', + }, + 'right-top': { + bottom: '100%', + left: '100%', + }, + right: { + left: '100%', + top: '50%', + transform: 'translateY(-50%)', + }, + 'right-bottom': { + left: '100%', + top: '100%', + }, + bottom: { + top: '100%', + left: '50%', + transform: 'translateX(-50%)', }, + 'left-bottom': { + right: '100%', + top: '100%', + }, + left: { + right: '100%', + top: '50%%', + transform: 'translateX(-50%)', + }, +}; + +export const Popup = styled(Box as any)( + // @ts-ignore (props: IPopupProps) => ({ - display: props.show ? '' : 'none' + height: 'auto', + margin: `${props.offset}px`, + position: 'absolute', + boxShadow: `0 0 4px ${getFGColor(props)}`, + width: 'auto', + ...styles[props.position], }), + (props: IPopupProps) => + !props.show && { + display: 'none', + } ); Popup.defaultProps = { - shadow: 'md', + offset: 10, p: 'xl', + position: 'top', m: '0 auto', - show: false, - top: 'auto', }; - diff --git a/src/__stories__/_utils.ts b/src/__stories__/_utils.ts index 26d948aa..96979444 100644 --- a/src/__stories__/_utils.ts +++ b/src/__stories__/_utils.ts @@ -146,3 +146,15 @@ export const InlineInputType = [ ]; export const AutosizeInputType = ['text', 'email', 'password', 'search', 'url']; + +export const PopupPosition = [ + undefined, + 'left-top', + 'top', + 'right-top', + 'right', + 'right-bottom', + 'bottom', + 'left-bottom', + 'left', +]; diff --git a/stories/Popup.tsx b/stories/Popup.tsx index 8c1ab628..fe8e2d95 100644 --- a/stories/Popup.tsx +++ b/stories/Popup.tsx @@ -1,17 +1,32 @@ -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { boolean, text } from '@storybook/addon-knobs/react'; import { withKnobs } from '@storybook/addon-knobs'; -import { Popup } from '../src/Popup'; +import { boolean, number, select } from '@storybook/addon-knobs/react'; +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { PopupPosition } from './_utils'; import { boxKnobs } from './Box'; +import { Popup } from '../src/Popup'; storiesOf('components/Popup', module) .addDecorator(withKnobs) + .addDecorator(story => ( +
+ content + {story()} +
+ )) .add('with defaults', () => ( Some Popup content From e25250c49641892df628ef03576adc4267c50258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Tue, 13 Nov 2018 10:24:19 +0100 Subject: [PATCH 03/22] fix(popup): rename position to placement and attach story correctly --- src/Popup.tsx | 8 ++++---- src/__stories__/_utils.ts | 4 ++-- src/__stories__/index.ts | 1 + stories/Popup.tsx | 18 +++++++++++++----- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Popup.tsx b/src/Popup.tsx index 7c020196..0b5fe585 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -1,19 +1,19 @@ import { themeGet } from 'styled-system'; import { Box, IBoxProps } from './Box'; import { IThemeInterface } from './types'; -import { position, styled } from './utils'; +import { styled } from './utils'; export interface IPopupProps extends IBoxProps { show: boolean; tip: boolean; - position: 'left-top' | 'top' | 'right-top' | 'right' | 'right-bottom' | 'bottom' | 'left-bottom' | 'left'; + placement: 'left-top' | 'top' | 'right-top' | 'right' | 'right-bottom' | 'bottom' | 'left-bottom' | 'left'; theme: IThemeInterface; offset: number; } const getFGColor = themeGet('colors.fg', '#000'); -const styles = { +const placements = { 'left-top': { bottom: '100%', right: '100%', @@ -60,7 +60,7 @@ export const Popup = styled(Box as any)( position: 'absolute', boxShadow: `0 0 4px ${getFGColor(props)}`, width: 'auto', - ...styles[props.position], + ...placements[props.placement], }), (props: IPopupProps) => !props.show && { diff --git a/src/__stories__/_utils.ts b/src/__stories__/_utils.ts index 96979444..c3d1e7da 100644 --- a/src/__stories__/_utils.ts +++ b/src/__stories__/_utils.ts @@ -147,8 +147,8 @@ export const InlineInputType = [ export const AutosizeInputType = ['text', 'email', 'password', 'search', 'url']; -export const PopupPosition = [ - undefined, +export const PopupPlacements = [ + '', 'left-top', 'top', 'right-top', diff --git a/src/__stories__/index.ts b/src/__stories__/index.ts index 592e82a0..b1cac410 100644 --- a/src/__stories__/index.ts +++ b/src/__stories__/index.ts @@ -13,6 +13,7 @@ import './Input'; import './KitchenSink'; import './List'; import './Menu'; +import './Popup'; import './Table'; import './Text'; import './Textarea'; diff --git a/stories/Popup.tsx b/stories/Popup.tsx index fe8e2d95..419cd6de 100644 --- a/stories/Popup.tsx +++ b/stories/Popup.tsx @@ -1,12 +1,14 @@ +import * as React from 'react'; + import { withKnobs } from '@storybook/addon-knobs'; import { boolean, number, select } from '@storybook/addon-knobs/react'; import { storiesOf } from '@storybook/react'; -import * as React from 'react'; -import { PopupPosition } from './_utils'; + +import { PopupPlacements } from './_utils'; import { boxKnobs } from './Box'; import { Popup } from '../src/Popup'; -storiesOf('components/Popup', module) +storiesOf('Popup', module) .addDecorator(withKnobs) .addDecorator(story => (
( Some Popup content From 1090e3c81994cf0545574fd79788a5e364166a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Tue, 13 Nov 2018 10:28:55 +0100 Subject: [PATCH 04/22] docs(popup): update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ab8caeea..9a0af601 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ TODO - Flex - Heading - List + - Popup - Portal - Menu - Text From 31ef31c64f8aa2e0c573089e2b28a61a1521ee8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Wed, 14 Nov 2018 12:49:43 +0100 Subject: [PATCH 05/22] feat(popup): adopt Popup from core --- src/Popup.tsx | 439 ++++++++++++++++++++++++++++++++++++++-------- src/index.tsx | 2 + stories/Popup.tsx | 123 +++++++++---- 3 files changed, 457 insertions(+), 107 deletions(-) diff --git a/src/Popup.tsx b/src/Popup.tsx index 0b5fe585..b841759a 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -1,76 +1,371 @@ -import { themeGet } from 'styled-system'; -import { Box, IBoxProps } from './Box'; -import { IThemeInterface } from './types'; -import { styled } from './utils'; - -export interface IPopupProps extends IBoxProps { - show: boolean; - tip: boolean; - placement: 'left-top' | 'top' | 'right-top' | 'right' | 'right-bottom' | 'bottom' | 'left-bottom' | 'left'; - theme: IThemeInterface; - offset: number; -} +import * as React from 'react'; +import { debounce } from 'lodash'; -const getFGColor = themeGet('colors.fg', '#000'); +import { Portal } from './Portal'; -const placements = { - 'left-top': { - bottom: '100%', - right: '100%', - }, - top: { - bottom: '100%', - left: '50%', - transform: 'translateX(-50%)', - }, - 'right-top': { - bottom: '100%', - left: '100%', - }, - right: { - left: '100%', - top: '50%', - transform: 'translateY(-50%)', - }, - 'right-bottom': { - left: '100%', - top: '100%', - }, - bottom: { - top: '100%', - left: '50%', - transform: 'translateX(-50%)', +export type PopupTriggerRenderer = ( + attributes: { + onMouseEnter: (e: MouseEvent) => void; + onMouseLeave: (e: MouseEvent) => void; + ref: (el: any) => void; }, - 'left-bottom': { - right: '100%', - top: '100%', - }, - left: { - right: '100%', - top: '50%%', - transform: 'translateX(-50%)', - }, -}; - -export const Popup = styled(Box as any)( - // @ts-ignore - (props: IPopupProps) => ({ - height: 'auto', - margin: `${props.offset}px`, - position: 'absolute', - boxShadow: `0 0 4px ${getFGColor(props)}`, - width: 'auto', - ...placements[props.placement], - }), - (props: IPopupProps) => - !props.show && { - display: 'none', + props: { + isVisible: boolean; + isOver: boolean; + showPopup: () => void; + hidePopup: () => void; + } +) => any; + +export type PopupContentRenderer = ( + attributes: {}, + props: { + isVisible: boolean; + isOver: boolean; + showPopup: () => void; + hidePopup: () => void; + } +) => any; + +export interface IPopupPosition { + width?: number; // force a width + padding: number; // transparent space around the popup + offset?: { + top?: number; + bottom?: number; + left?: number; + right?: number; + }; + posX: 'left' | 'center' | 'right'; + posY: 'top' | 'center' | 'bottom'; + show?: boolean; // controlled or debugging +} + +export interface IPopup extends IPopupPosition { + hideDelay: number; // how long popup will show for after user mouses out + renderTrigger: PopupTriggerRenderer; + renderContent: PopupContentRenderer; +} + +export interface IPopupStyle { + position?: string; + zIndex?: number; + minWidth?: number; + width?: number; + left?: number; + top?: number; + bottom?: number; + right?: number; + visibility?: string; + overflow?: string; +} + +export interface IPopupState { + style?: any; +} + +interface IPopupDefaultProps { + padding: 15; + hideDelay: 200; + posX: 'left'; + posY: 'top'; +} + +// TODO: allow specifying target container (so that can optionally scroll with content) +export class Popup extends React.PureComponent { + public static defaultProps: IPopupDefaultProps = { + padding: 15, + hideDelay: 200, + posX: 'left', + posY: 'top', + }; + + private _resizeHandler?: EventListener; + private _isOverTrigger: boolean = false; + private _isOverContent: boolean = false; + private _willHide: any; + private _trigger?: HTMLDivElement; + private _content?: HTMLDivElement; + + public state: IPopupState = {}; + + public render() { + const { renderTrigger, renderContent } = this.props; + + const funcs = { + isVisible: this.isVisible, + showPopup: this.showPopup, + hidePopup: this.hidePopup, + }; + + const style: IPopupStyle & { padding: number } = { + position: 'fixed', + zIndex: 999, + padding: this.props.padding, + width: this.props.width, + }; + + if (this.state.style) { + Object.assign(style, this.state.style); + } else { + style.visibility = this.isVisible ? 'visible' : 'hidden'; + } + + return ( + <> + {renderTrigger( + { + onMouseEnter: () => this.handleMouseEnter('_isOverTrigger'), + onMouseLeave: e => this.handleMouseLeave(e, '_isOverTrigger', '_isOverContent'), + ref: el => (this._trigger = el), + }, + { + ...funcs, + isOver: this._isOverTrigger, + } + )} + {this.isVisible && ( + +
this.handleMouseEnter('_isOverContent')} + onMouseLeave={e => this.handleMouseLeave(e, '_isOverContent', '_isOverTrigger')} + ref={el => (this._content = el || undefined)} + style={style} + > + {renderContent( + {}, + { + ...funcs, + isOver: this._isOverContent, + } + )} +
+
+ )} + + ); + } + + public componentDidMount() { + if (this.isVisible && !this.state.style) { + this.repaint(); + } + + this._resizeHandler = debounce(this.repaint, 50) as EventListener; + + window.addEventListener('resize', this._resizeHandler); + } + + public componentWillUnmount() { + if (this._resizeHandler !== undefined) { + window.removeEventListener('resize', this._resizeHandler); + } + } + + public componentDidUpdate(prevProps: IPopup) { + if ((this.isVisible || this._controlled) && (!this.state.style || this.propsChanged(prevProps))) { + this.repaint(); + + if (this.propsChanged(prevProps)) { + this.forceUpdate(); + } + } + } + + private get isVisible() { + if (this._controlled) { + return this.props.show || false; + } + + return this._isOverContent || this._isOverTrigger || false; + } + + private propsChanged(props: IPopupPosition) { + return ( + this.props.width !== props.width || + this.props.posX !== props.posX || + this.props.posY !== props.posY || + this.props.padding !== props.padding || + this.props.show !== props.show + ); + } + + private handleMouseEnter = (_isOver: string) => { + if (this.isVisible || this._controlled) return; + this[_isOver] = true; + this.showPopup(); + }; + + private handleMouseLeave = (e: any, _isLeaving: string, _isOver: string) => { + const newTarget = e.toElement || e.relatedTarget; + if (newTarget === this._content) { + this[_isOver] = true; } -); - -Popup.defaultProps = { - offset: 10, - p: 'xl', - position: 'top', - m: '0 auto', -}; + + this[_isLeaving] = false; + if (!this.isVisible || this._controlled) { + this.hidePopup(); + } + }; + + private showPopup = () => { + if (this._willHide) { + clearTimeout(this._willHide); + this._willHide = undefined; + } + + // this just triggers a re-render, so that componentDidUpdate can trigger a repaint + // after the content element has been rendered + // we have to do it this way so that repaint can access this._content to calculate dimensions + this.forceUpdate(); + }; + + private hidePopup = () => { + if (this._willHide) { + return; + } + + this._willHide = setTimeout(() => { + this._isOverTrigger = false; + this._isOverContent = false; + this.setState({ style: undefined }); + }, this.props.hideDelay); + }; + + private repaint = () => { + if (!this._trigger || !this._content) return; + + // TODO: smart choice based on content size and screen space if this.props.posX not defined + let posX = this.props.posX; + + // TODO: smart choice based on content size and screen space if this.props.posY not defined + const posY = this.props.posY; + + // calculate the tooltip position + // this style object will be passed to the renderContent function + const style: IPopupStyle = {}; + + // where on the screen is the target + const triggerDimensions = this._trigger.getBoundingClientRect(); + + // where on the screen is the content, and some basics on its size + const contentDimensions = this._content.getBoundingClientRect(); + const contentWidth = this.props.width || contentDimensions.width; + + style.minWidth = triggerDimensions.width + 25; + + if (posY === 'center') { + if (posX === 'left') { + posX = 'right'; + } else { + posX = 'left'; + } + } + + if (posX === 'left' || posX === 'center') { + style.left = triggerDimensions.left; + + if (posX === 'center') { + // center align the popup by taking both the target and popup widths into account + style.left += triggerDimensions.width / 2 - contentWidth / 2; + } else { + style.left -= this.props.padding; + + // room for tip + if (posY === 'center') { + style.left += triggerDimensions.width + this.props.padding; + } + } + + // account for desired offsets + style.left += this._offset.left || 0; + style.left -= this._offset.right || 0; + + // make sure it doesn't poke off the left side of the page + style.left = Math.max(0, style.left); + + let clientWidth = 0; + if (typeof document !== 'undefined') { + clientWidth = document.body.clientWidth; + } + + // or off the right + style.left = Math.min(style.left, clientWidth - contentWidth - 5); + } else { + // right + // coming in from the right of the screen + style.right = window.innerWidth - triggerDimensions.left - triggerDimensions.width - this.props.padding; + + // room for tip + if (posY === 'center') { + style.right += triggerDimensions.width + this.props.padding; + } + + // account for desired offsets + style.right -= this._offset.left || 0; + style.right += this._offset.right || 0; + + // TODO: make sure it doesn't poke off the left side of the page + + // or off the right + style.right = Math.max(0, style.right); + } + + if (posY === 'top') { + // when positioning above, set the bottom of the popup just above the top of the target (it will stretch upwards) + style.bottom = window.innerHeight - triggerDimensions.top + (this._offset.bottom || 0); + } else if (posY === 'bottom') { + // when positioning below, position the top of the popup just below the target (it will stretch downwards) + style.top = triggerDimensions.top + triggerDimensions.height + (this._offset.top || 0); + } else { + style.top = style.top = triggerDimensions.top + triggerDimensions.height / 2 - contentDimensions.height / 2; + + style.top += this._offset.top || 0; + style.top -= this._offset.bottom || 0; + } + + if (style.top) { + // make sure it doesn't poke off the bottom of the page + style.top = Math.min(window.innerHeight - contentDimensions.height, style.top); + + // make sure it doesn't poke off the top of the page + style.top = Math.max(0, style.top); + + if (style.top + contentDimensions.height > window.innerHeight) { + style.bottom = 0; + style.overflow = 'auto'; + } + } else if (style.bottom) { + // make sure it doesn't poke off the top of the page + style.bottom = Math.min(window.innerHeight, style.bottom); + + // make sure it doesn't poke off the top of the page + style.bottom = Math.max(0, style.bottom); + + if (style.bottom + contentDimensions.height > window.innerHeight) { + style.top = 0; + style.overflow = 'auto'; + } + } + + this.setState({ + style, + }); + }; + + private get _controlled() { + return this.props.hasOwnProperty('show'); + } + + private get _offset() { + return Object.assign( + { + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + this.props.offset || {} + ); + } +} diff --git a/src/index.tsx b/src/index.tsx index a7f804ca..ff1accc1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,8 @@ export * from './Flex'; export * from './Heading'; export * from './Icon'; export * from './List'; +export * from './Popup'; +export * from './Portal'; export * from './Menu'; export * from './Table'; export * from './Text'; diff --git a/stories/Popup.tsx b/stories/Popup.tsx index 419cd6de..67cf88c4 100644 --- a/stories/Popup.tsx +++ b/stories/Popup.tsx @@ -1,41 +1,94 @@ +import { NumberOptions, withKnobs } from '@storybook/addon-knobs'; +import { boolean, number, select, text } from '@storybook/addon-knobs/react'; +import { storiesOf } from '@storybook/react'; +import { omitBy } from 'lodash'; import * as React from 'react'; -import { withKnobs } from '@storybook/addon-knobs'; -import { boolean, number, select } from '@storybook/addon-knobs/react'; -import { storiesOf } from '@storybook/react'; +import { Box, Popup } from '../src/'; -import { PopupPlacements } from './_utils'; -import { boxKnobs } from './Box'; -import { Popup } from '../src/Popup'; +export const popupKnobs = (tabName = 'Popup'): any => { + return omitBy( + { + show: boolean('show', true, tabName), + posX: select( + 'posX', + { + left: 'left', + center: 'center', + right: 'right', + }, + 'left', + tabName + ), + posY: select( + 'posY', + { + top: 'top', + center: 'center', + bottom: 'bottom', + }, + 'top', + tabName + ), + offset: { + top: number('offset.top', 0, { min: 0 } as NumberOptions, tabName), + bottom: number('offset.bottom', 0, { min: 0 } as NumberOptions, tabName), + left: number('offset.left', 0, { min: 0 } as NumberOptions, tabName), + right: number('offset.right', 0, { min: 0 } as NumberOptions, tabName), + }, + padding: number('padding', 0, { min: 0 } as NumberOptions, tabName), + width: number('width', 0, { min: 0 } as NumberOptions, tabName), + }, + val => !val + ); +}; storiesOf('Popup', module) .addDecorator(withKnobs) - .addDecorator(story => ( -
- content - {story()} -
- )) - .add('with defaults', () => ( - - Some Popup content - - )); + .add('with defaults', () => { + return ( + { + return ( +
+ With Defaults +
+ ); + }} + renderContent={(attributes: object) => { + return ( + + {text('content', 'here is the popup content')} + + ); + }} + /> + ); + }) + .add('with large content', () => { + return ( + { + return ( +
+ With Large Content +
+ ); + }} + renderContent={(attributes: object) => { + const elems = []; + for (let i = 0; i < 100; i++) { + elems.push(
  • item {i}
  • ); + } + + return ( +
    +
      {elems}
    +
    + ); + }} + /> + ); + }); From dd5d799600b6a0a7089036579c5a2a7c79ed1c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Wed, 14 Nov 2018 13:59:18 +0100 Subject: [PATCH 06/22] test(popup): test basic funcionality --- __mocks__/lodash.js | 4 ++ setupTests.ts | 1 + src/Popup.tsx | 8 +-- src/__tests__/Popup.spec.tsx | 134 +++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 __mocks__/lodash.js create mode 100644 src/__tests__/Popup.spec.tsx diff --git a/__mocks__/lodash.js b/__mocks__/lodash.js new file mode 100644 index 00000000..032a4a43 --- /dev/null +++ b/__mocks__/lodash.js @@ -0,0 +1,4 @@ +const _ = require.requireActual('lodash'); + +module.exports = _; +module.exports.debounce = jest.fn(_.debounce); diff --git a/setupTests.ts b/setupTests.ts index a256f2df..21cd7a69 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -3,3 +3,4 @@ const Adapter = require('enzyme-adapter-react-16'); Enzyme.configure({ adapter: new Adapter() }); jest.mock('react'); +jest.mock('lodash'); diff --git a/src/Popup.tsx b/src/Popup.tsx index b841759a..84b8c0ce 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -41,7 +41,7 @@ export interface IPopupPosition { show?: boolean; // controlled or debugging } -export interface IPopup extends IPopupPosition { +export interface IPopupProps extends IPopupPosition { hideDelay: number; // how long popup will show for after user mouses out renderTrigger: PopupTriggerRenderer; renderContent: PopupContentRenderer; @@ -64,7 +64,7 @@ export interface IPopupState { style?: any; } -interface IPopupDefaultProps { +export interface IPopupDefaultProps { padding: 15; hideDelay: 200; posX: 'left'; @@ -72,7 +72,7 @@ interface IPopupDefaultProps { } // TODO: allow specifying target container (so that can optionally scroll with content) -export class Popup extends React.PureComponent { +export class Popup extends React.PureComponent { public static defaultProps: IPopupDefaultProps = { padding: 15, hideDelay: 200, @@ -162,7 +162,7 @@ export class Popup extends React.PureComponent { } } - public componentDidUpdate(prevProps: IPopup) { + public componentDidUpdate(prevProps: IPopupProps) { if ((this.isVisible || this._controlled) && (!this.state.style || this.propsChanged(prevProps))) { this.repaint(); diff --git a/src/__tests__/Popup.spec.tsx b/src/__tests__/Popup.spec.tsx new file mode 100644 index 00000000..2da6c78a --- /dev/null +++ b/src/__tests__/Popup.spec.tsx @@ -0,0 +1,134 @@ +/** + * @jest-environment jsdom + */ +import { shallow } from 'enzyme'; +import { debounce } from 'lodash'; +import 'jest-enzyme'; +import * as React from 'react'; +import { Popup, Portal } from '../'; + +describe('Popup', () => { + let props: any; + let addEventListenerSpy: jest.SpyInstance; + let removeEventListenerSpy: jest.SpyInstance; + + beforeEach(() => { + addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + props = { + renderContent: jest.fn(() =>
    ), + renderTrigger: jest.fn(() =>
    ), + }; + }); + + afterEach(() => { + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + it('always calls renderTrigger on render with attributes and some internal methods', () => { + const renderContentSpy = jest.spyOn(props, 'renderTrigger'); + const wrapper = shallow(); + // @ts-ignore + const { showPopup, hidePopup } = wrapper.instance(); + + expect(renderContentSpy).toHaveBeenCalledWith( + { + onMouseEnter: expect.any(Function), + onMouseLeave: expect.any(Function), + ref: expect.any(Function), + }, + { + hidePopup, + showPopup, + isOver: false, + isVisible: false, + } + ); + }); + + it('does not call renderContent by default', () => { + const renderTriggerSpy = jest.spyOn(props, 'renderContent'); + shallow(); + + expect(renderTriggerSpy).not.toHaveBeenCalledWith(); + }); + + it('attaches debounced paint resize handler', () => { + const handler = () => true; + (debounce as any).mockReturnValueOnce(handler); + + const wrapper = shallow(); + // @ts-ignore + const { repaint } = wrapper.instance(); + + // let's test if debounce was invoked properly + expect(debounce).toHaveBeenCalledWith(repaint, expect.any(Number)); + // and now if event listener was correctly attached + + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', handler); + }); + + it('removes the previously attached resize handler', () => { + const handler = () => true; + (debounce as any).mockReturnValueOnce(handler); + + const wrapper = shallow(); + wrapper.unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', handler); + }); + + describe('when Popup is visible', () => { + beforeEach(() => { + props = { + ...props, + show: true, + }; + }); + + it('calls renderTrigger and passes proper isVisible', () => { + const renderContentSpy = jest.spyOn(props, 'renderTrigger'); + shallow(); + + expect(renderContentSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + isVisible: true, + }) + ); + }); + + it('renders Portal', () => { + const wrapper = shallow(); + + expect(wrapper.find(Portal)).toExist(); + }); + + it('calls on renderContent and passes some internal methods', () => { + const renderContentSpy = jest.spyOn(props, 'renderContent'); + const wrapper = shallow(); + // @ts-ignore + const { showPopup, hidePopup } = wrapper.instance(); + + expect(renderContentSpy).toHaveBeenCalledWith( + {}, + { + showPopup, + hidePopup, + isVisible: true, + isOver: false, + } + ); + }); + + it('repaints the popup when props change', () => { + const wrapper = shallow(); + const repaintSpy = jest.spyOn(wrapper.instance() as any, 'repaint'); + + wrapper.setProps({ padding: 10 }); + expect(repaintSpy).toHaveBeenCalled(); + }); + }); +}); From ad2686be1ed560b46c557cc891285a86f19d2521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Wed, 14 Nov 2018 17:41:26 +0100 Subject: [PATCH 07/22] fix(popup): support ssr --- src/Popup.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Popup.tsx b/src/Popup.tsx index 84b8c0ce..e8c681df 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -151,9 +151,11 @@ export class Popup extends React.PureComponent { this.repaint(); } - this._resizeHandler = debounce(this.repaint, 50) as EventListener; + if (typeof window !== 'undefined') { + this._resizeHandler = debounce(this.repaint, 50) as EventListener; - window.addEventListener('resize', this._resizeHandler); + window.addEventListener('resize', this._resizeHandler); + } } public componentWillUnmount() { From 7d7952277a684493249036263d9ec7c1a1b00631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 15 Nov 2018 09:12:33 +0100 Subject: [PATCH 08/22] feat(popup): theming --- __mocks__/styled-components.js | 7 +++++ setupTests.ts | 1 + src/Popup.tsx | 35 +++++++++++++------------ src/Portal.tsx | 20 +++++++++++--- src/__tests__/Popup.spec.tsx | 48 +++++++++++++++++++++++++--------- src/__tests__/Portal.spec.tsx | 32 +++++++++++++++++++---- stories/Popup.tsx | 20 +++++++------- 7 files changed, 116 insertions(+), 47 deletions(-) create mode 100644 __mocks__/styled-components.js diff --git a/__mocks__/styled-components.js b/__mocks__/styled-components.js new file mode 100644 index 00000000..c38c3e3d --- /dev/null +++ b/__mocks__/styled-components.js @@ -0,0 +1,7 @@ +const styledComponents = require.requireActual('styled-components'); + +const ThemeConsumer = jest.fn(styledComponents.ThemeConsumer); +ThemeConsumer.displayName = 'ThemeConsumer'; + +module.exports = styledComponents; +module.exports.ThemeConsumer = ThemeConsumer; diff --git a/setupTests.ts b/setupTests.ts index 21cd7a69..5db4a8e3 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -4,3 +4,4 @@ const Adapter = require('enzyme-adapter-react-16'); Enzyme.configure({ adapter: new Adapter() }); jest.mock('react'); jest.mock('lodash'); +jest.mock('styled-components'); diff --git a/src/Popup.tsx b/src/Popup.tsx index e8c681df..b8e71bd3 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { debounce } from 'lodash'; import { Portal } from './Portal'; +import { IThemeInterface } from './types'; export type PopupTriggerRenderer = ( attributes: { - onMouseEnter: (e: MouseEvent) => void; - onMouseLeave: (e: MouseEvent) => void; + onMouseEnter: (e: React.MouseEvent) => void; + onMouseLeave: (e: React.MouseEvent) => void; ref: (el: any) => void; }, props: { @@ -126,20 +127,22 @@ export class Popup extends React.PureComponent { )} {this.isVisible && ( -
    this.handleMouseEnter('_isOverContent')} - onMouseLeave={e => this.handleMouseLeave(e, '_isOverContent', '_isOverTrigger')} - ref={el => (this._content = el || undefined)} - style={style} - > - {renderContent( - {}, - { - ...funcs, - isOver: this._isOverContent, - } - )} -
    + {(theme: IThemeInterface) => ( +
    this.handleMouseEnter('_isOverContent')} + onMouseLeave={e => this.handleMouseLeave(e, '_isOverContent', '_isOverTrigger')} + ref={el => (this._content = el || undefined)} + style={style} + > + {renderContent( + { theme }, + { + ...funcs, + isOver: this._isOverContent, + } + )} +
    + )}
    )} diff --git a/src/Portal.tsx b/src/Portal.tsx index 543dfe69..7cb4afe1 100644 --- a/src/Portal.tsx +++ b/src/Portal.tsx @@ -1,12 +1,14 @@ -import { PureComponent } from 'react'; +import * as React from 'react'; import * as ReactDOM from 'react-dom'; +import { ThemeConsumer } from 'styled-components'; +import { IThemeInterface } from './types'; export interface IPortalProps { - children: JSX.Element | string | number; + children: any; className?: string; } -export class Portal extends PureComponent { +export class Portal extends React.PureComponent { private readonly el?: HTMLDivElement; private readonly root = typeof document === 'object' ? document.body : null; @@ -34,11 +36,21 @@ export class Portal extends PureComponent { } } + private renderChildren = (theme: IThemeInterface) => { + const { children } = this.props; + + if (typeof children === 'function') { + return children(theme); + } + + return React.Children.map(this.props.children, child => React.cloneElement(child, { theme })); + }; + public render() { if (this.el === undefined) { return null; } - return ReactDOM.createPortal(this.props.children, this.el); + return ReactDOM.createPortal({this.renderChildren}, this.el); } } diff --git a/src/__tests__/Popup.spec.tsx b/src/__tests__/Popup.spec.tsx index 2da6c78a..b10a5ed7 100644 --- a/src/__tests__/Popup.spec.tsx +++ b/src/__tests__/Popup.spec.tsx @@ -2,17 +2,32 @@ * @jest-environment jsdom */ import { shallow } from 'enzyme'; -import { debounce } from 'lodash'; import 'jest-enzyme'; import * as React from 'react'; -import { Popup, Portal } from '../'; +import { mount } from 'enzyme'; describe('Popup', () => { let props: any; let addEventListenerSpy: jest.SpyInstance; let removeEventListenerSpy: jest.SpyInstance; + let Popup: any; + let debounce: any; + let theme: { color: string }; + + beforeEach(async () => { + theme = { + color: '#fff', + }; + + jest.mock('../Portal', () => ({ + Portal: function Portal({ children }: { children: Function }) { + return children(theme); + }, + })); + + ({ Popup } = await import('../')); + ({ debounce } = await import('lodash')); - beforeEach(() => { addEventListenerSpy = jest.spyOn(window, 'addEventListener'); removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); @@ -23,17 +38,19 @@ describe('Popup', () => { }); afterEach(() => { + jest.resetModules(); + jest.unmock('../Portal'); addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); }); it('always calls renderTrigger on render with attributes and some internal methods', () => { - const renderContentSpy = jest.spyOn(props, 'renderTrigger'); - const wrapper = shallow(); + const renderTriggerSpy = jest.spyOn(props, 'renderTrigger'); + const wrapper = mount(); // @ts-ignore const { showPopup, hidePopup } = wrapper.instance(); - expect(renderContentSpy).toHaveBeenCalledWith( + expect(renderTriggerSpy).toHaveBeenCalledWith( { onMouseEnter: expect.any(Function), onMouseLeave: expect.any(Function), @@ -46,13 +63,16 @@ describe('Popup', () => { isVisible: false, } ); + + wrapper.unmount(); }); it('does not call renderContent by default', () => { const renderTriggerSpy = jest.spyOn(props, 'renderContent'); - shallow(); + const wrapper = mount(); expect(renderTriggerSpy).not.toHaveBeenCalledWith(); + wrapper.unmount(); }); it('attaches debounced paint resize handler', () => { @@ -89,10 +109,10 @@ describe('Popup', () => { }); it('calls renderTrigger and passes proper isVisible', () => { - const renderContentSpy = jest.spyOn(props, 'renderTrigger'); + const renderTriggerSpy = jest.spyOn(props, 'renderTrigger'); shallow(); - expect(renderContentSpy).toHaveBeenCalledWith( + expect(renderTriggerSpy).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ isVisible: true, @@ -101,19 +121,19 @@ describe('Popup', () => { }); it('renders Portal', () => { - const wrapper = shallow(); + const wrapper = mount(); - expect(wrapper.find(Portal)).toExist(); + expect(wrapper.find('Portal')).toExist(); }); it('calls on renderContent and passes some internal methods', () => { const renderContentSpy = jest.spyOn(props, 'renderContent'); - const wrapper = shallow(); + const wrapper = mount(); // @ts-ignore const { showPopup, hidePopup } = wrapper.instance(); expect(renderContentSpy).toHaveBeenCalledWith( - {}, + { theme }, { showPopup, hidePopup, @@ -121,6 +141,8 @@ describe('Popup', () => { isOver: false, } ); + + wrapper.unmount(); }); it('repaints the popup when props change', () => { diff --git a/src/__tests__/Portal.spec.tsx b/src/__tests__/Portal.spec.tsx index 4f88dc95..fa89f2eb 100644 --- a/src/__tests__/Portal.spec.tsx +++ b/src/__tests__/Portal.spec.tsx @@ -1,17 +1,26 @@ -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import 'jest-enzyme'; import * as React from 'react'; +import { ThemeConsumer } from 'styled-components'; import { Portal } from '../Portal'; describe('Portal', () => { let appendChildSpy: jest.SpyInstance; let removeChildSpy: jest.SpyInstance; let setClassNameSpy: jest.SpyInstance; + let theme: any; beforeEach(() => { setClassNameSpy = jest.spyOn(HTMLDivElement.prototype, 'className', 'set'); appendChildSpy = jest.spyOn(document.body, 'appendChild'); removeChildSpy = jest.spyOn(document.body, 'removeChild'); + theme = { + color: 'test', + }; + + (ThemeConsumer as any).mockImplementationOnce(({ children }: { children: Function }) => { + return children(theme); + }); }); afterEach(() => { @@ -28,7 +37,7 @@ describe('Portal', () => { expect(appendChildSpy).toHaveBeenCalledWith(node); }); - it('removes the previously appended div to unmount', () => { + it('removes the previously appended div on unmount', () => { const wrapper = shallow(test); // @ts-ignore; const { el } = wrapper.instance(); @@ -39,10 +48,23 @@ describe('Portal', () => { }); it('renders children', () => { - const children =
    some children
    ; - const wrapper = shallow({children}); + const content = 'some text'; + const children =
    {content}
    ; + // shallow cannot traverse Portals, therefore we need to use deep mounting + const wrapper = mount({children}); - expect(wrapper).toContainReact(children); + expect(wrapper).toHaveText(content); + wrapper.unmount(); // let's clean up + }); + + it('passes theme to children', () => { + const wrapper = mount( + +
    + + ); + expect(wrapper.find('div')).toHaveProp('theme', theme); + wrapper.unmount(); }); it('attaches className when given', () => { diff --git a/stories/Popup.tsx b/stories/Popup.tsx index 67cf88c4..d0f650f3 100644 --- a/stories/Popup.tsx +++ b/stories/Popup.tsx @@ -4,7 +4,7 @@ import { storiesOf } from '@storybook/react'; import { omitBy } from 'lodash'; import * as React from 'react'; -import { Box, Popup } from '../src/'; +import { Box, IThemeInterface, Popup } from '../src/'; export const popupKnobs = (tabName = 'Popup'): any => { return omitBy( @@ -56,11 +56,11 @@ storiesOf('Popup', module)
    ); }} - renderContent={(attributes: object) => { + renderContent={({ theme }: { theme: IThemeInterface }) => { + const color = theme.colors !== undefined ? theme.colors.fg : '#000'; + return ( - - {text('content', 'here is the popup content')} - +
    {text('content', 'here is the popup content')}
    ); }} /> @@ -72,19 +72,21 @@ storiesOf('Popup', module) {...popupKnobs()} renderTrigger={(attributes: object) => { return ( -
    + With Large Content -
    + ); }} - renderContent={(attributes: object) => { + renderContent={({ theme }: { theme: IThemeInterface }) => { + const color = theme.colors !== undefined ? theme.colors.fg : '#000'; + const elems = []; for (let i = 0; i < 100; i++) { elems.push(
  • item {i}
  • ); } return ( -
    +
      {elems}
    ); From 2b60f901636af59f07420347b4ae929966eb8952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Fri, 23 Nov 2018 11:08:20 +0100 Subject: [PATCH 09/22] test(popup): add more tests --- src/__tests__/Button.spec.tsx | 2 +- src/__tests__/Popup.spec.tsx | 96 +++++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/__tests__/Button.spec.tsx b/src/__tests__/Button.spec.tsx index dc316ab4..1be053a7 100644 --- a/src/__tests__/Button.spec.tsx +++ b/src/__tests__/Button.spec.tsx @@ -1,4 +1,4 @@ -describe('Button', () => { +xdescribe('Button', () => { test.skip('TODO', () => { // TODO }); diff --git a/src/__tests__/Popup.spec.tsx b/src/__tests__/Popup.spec.tsx index b10a5ed7..d5a2b7ac 100644 --- a/src/__tests__/Popup.spec.tsx +++ b/src/__tests__/Popup.spec.tsx @@ -1,10 +1,6 @@ -/** - * @jest-environment jsdom - */ -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import 'jest-enzyme'; import * as React from 'react'; -import { mount } from 'enzyme'; describe('Popup', () => { let props: any; @@ -30,6 +26,7 @@ describe('Popup', () => { addEventListenerSpy = jest.spyOn(window, 'addEventListener'); removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + jest.useFakeTimers(); props = { renderContent: jest.fn(() =>
    ), @@ -40,6 +37,7 @@ describe('Popup', () => { afterEach(() => { jest.resetModules(); jest.unmock('../Portal'); + jest.useRealTimers(); addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); }); @@ -152,5 +150,93 @@ describe('Popup', () => { wrapper.setProps({ padding: 10 }); expect(repaintSpy).toHaveBeenCalled(); }); + + it('handleMouseEnter does nothing', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as any; + const showPopupSpy = jest.spyOn(instance, 'showPopup'); + + instance.handleMouseEnter(false); + expect(showPopupSpy).not.toHaveBeenCalled(); + }); + }); + + describe('hidePopup', () => { + it('aborts the request if already in progress', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as any; + instance._willHide = 2343; + + instance.hidePopup(); + expect(setTimeout).not.toHaveBeenCalled(); + }); + + it('sets a timeout according to given hideDelay', () => { + const hideDelay = parseInt(String(Math.random() * 100000), 10); + const wrapper = shallow(); + const instance = wrapper.instance() as any; + instance.hidePopup(); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), hideDelay); + }); + + it('clears styles once timeouts', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as any; + instance.hidePopup(); + jest.runAllTimers(); + expect(wrapper).toHaveState('style', undefined); + }); + }); + + describe('showPopup', () => { + it('clears willHide timeout', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as any; + const timeout = 50; + instance._willHide = timeout; + instance.showPopup(); + expect(clearTimeout).toHaveBeenCalledWith(timeout); + expect(instance).toHaveProperty('_willHide', undefined); + }); + + it('triggers forced re-render', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as any; + const forceUpdate = jest.spyOn(instance, 'forceUpdate'); + instance.showPopup(); + expect(forceUpdate).toHaveBeenCalledWith(); + }); + }); + + it('handleMouseEnter calls showPopup', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as any; + const showPopupSpy = jest.spyOn(instance, 'showPopup'); + + instance.handleMouseEnter(false); + expect(showPopupSpy).toHaveBeenCalled(); + }); + + describe('repaint', () => { + it('always sets style', () => { + const wrapper = shallow(); + const instance = wrapper.instance() as any; + instance._trigger = { + getBoundingClientRect: () => ({}), + }; + instance._content = { + getBoundingClientRect: () => ({}), + }; + + instance.repaint(); + expect(wrapper).toHaveState( + 'style', + expect.objectContaining({ + minWidth: expect.anything(), + left: expect.anything(), + bottom: expect.anything(), + }) + ); + }); }); }); From b60bc97183b8975dc0c5a01da05bf6faee79e886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Fri, 23 Nov 2018 11:09:22 +0100 Subject: [PATCH 10/22] test(popup): remove jsenv comments --- src/__tests__/Break.spec.tsx | 3 --- src/__tests__/Menu.spec.tsx | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/__tests__/Break.spec.tsx b/src/__tests__/Break.spec.tsx index a93d051c..124eab2e 100644 --- a/src/__tests__/Break.spec.tsx +++ b/src/__tests__/Break.spec.tsx @@ -1,6 +1,3 @@ -/** - * @jest-environment jsdom - */ import { shallow } from 'enzyme'; import 'jest-enzyme'; import * as React from 'react'; diff --git a/src/__tests__/Menu.spec.tsx b/src/__tests__/Menu.spec.tsx index b30d1838..2ad1a88a 100644 --- a/src/__tests__/Menu.spec.tsx +++ b/src/__tests__/Menu.spec.tsx @@ -1,6 +1,3 @@ -/** - * @jest-environment jsdom - */ import { mount, shallow } from 'enzyme'; import 'jest-enzyme'; import * as React from 'react'; From 32808c07c57a339f042352f5b8cc90cc797f6e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Fri, 23 Nov 2018 11:15:39 +0100 Subject: [PATCH 11/22] chore(popup): move story --- {stories => src/__stories__}/Popup.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) rename {stories => src/__stories__}/Popup.tsx (93%) diff --git a/stories/Popup.tsx b/src/__stories__/Popup.tsx similarity index 93% rename from stories/Popup.tsx rename to src/__stories__/Popup.tsx index d0f650f3..e0f4ec9f 100644 --- a/stories/Popup.tsx +++ b/src/__stories__/Popup.tsx @@ -4,7 +4,7 @@ import { storiesOf } from '@storybook/react'; import { omitBy } from 'lodash'; import * as React from 'react'; -import { Box, IThemeInterface, Popup } from '../src/'; +import { Box, IThemeInterface, Popup } from '..'; export const popupKnobs = (tabName = 'Popup'): any => { return omitBy( @@ -71,11 +71,7 @@ storiesOf('Popup', module) { - return ( - - With Large Content - - ); + return With Large Content; }} renderContent={({ theme }: { theme: IThemeInterface }) => { const color = theme.colors !== undefined ? theme.colors.fg : '#000'; From 9448dc25065bf2877318453a9571bf7a3f0db5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Sun, 25 Nov 2018 21:18:10 +0100 Subject: [PATCH 12/22] refactor(popup): simplify the code --- setupTests.ts | 1 + src/Popup.tsx | 54 ++++++++---------- src/__stories__/Popup.tsx | 105 +++++++++++++---------------------- src/__tests__/Popup.spec.tsx | 86 +++++++++------------------- 4 files changed, 87 insertions(+), 159 deletions(-) diff --git a/setupTests.ts b/setupTests.ts index e839daaa..40aba23a 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -4,5 +4,6 @@ const Adapter = require('enzyme-adapter-react-16'); Enzyme.configure({ adapter: new Adapter() }); jest.mock('react'); jest.mock('lodash'); +jest.mock('lodash/debounce'); jest.mock('styled-components'); jest.mock('react-virtualized'); diff --git a/src/Popup.tsx b/src/Popup.tsx index b8e71bd3..56b5d3a8 100644 --- a/src/Popup.tsx +++ b/src/Popup.tsx @@ -1,15 +1,9 @@ +import debounce = require('lodash/debounce'); import * as React from 'react'; -import { debounce } from 'lodash'; import { Portal } from './Portal'; -import { IThemeInterface } from './types'; export type PopupTriggerRenderer = ( - attributes: { - onMouseEnter: (e: React.MouseEvent) => void; - onMouseLeave: (e: React.MouseEvent) => void; - ref: (el: any) => void; - }, props: { isVisible: boolean; isOver: boolean; @@ -19,7 +13,6 @@ export type PopupTriggerRenderer = ( ) => any; export type PopupContentRenderer = ( - attributes: {}, props: { isVisible: boolean; isOver: boolean; @@ -85,7 +78,7 @@ export class Popup extends React.PureComponent { private _isOverTrigger: boolean = false; private _isOverContent: boolean = false; private _willHide: any; - private _trigger?: HTMLDivElement; + private _trigger?: HTMLElement; private _content?: HTMLDivElement; public state: IPopupState = {}; @@ -114,35 +107,32 @@ export class Popup extends React.PureComponent { return ( <> - {renderTrigger( + {React.cloneElement( + renderTrigger({ + ...funcs, + isOver: this._isOverTrigger, + }), { onMouseEnter: () => this.handleMouseEnter('_isOverTrigger'), onMouseLeave: e => this.handleMouseLeave(e, '_isOverTrigger', '_isOverContent'), - ref: el => (this._trigger = el), - }, - { - ...funcs, - isOver: this._isOverTrigger, + ref: (el: HTMLElement) => { + this._trigger = el; + }, } )} {this.isVisible && ( - {(theme: IThemeInterface) => ( -
    this.handleMouseEnter('_isOverContent')} - onMouseLeave={e => this.handleMouseLeave(e, '_isOverContent', '_isOverTrigger')} - ref={el => (this._content = el || undefined)} - style={style} - > - {renderContent( - { theme }, - { - ...funcs, - isOver: this._isOverContent, - } - )} -
    - )} +
    this.handleMouseEnter('_isOverContent')} + onMouseLeave={e => this.handleMouseLeave(e, '_isOverContent', '_isOverTrigger')} + ref={el => (this._content = el || undefined)} + style={style} + > + {renderContent({ + ...funcs, + isOver: this._isOverContent, + })} +
    )} @@ -155,7 +145,7 @@ export class Popup extends React.PureComponent { } if (typeof window !== 'undefined') { - this._resizeHandler = debounce(this.repaint, 50) as EventListener; + this._resizeHandler = debounce(this.repaint, 50); window.addEventListener('resize', this._resizeHandler); } diff --git a/src/__stories__/Popup.tsx b/src/__stories__/Popup.tsx index e0f4ec9f..ba2826a7 100644 --- a/src/__stories__/Popup.tsx +++ b/src/__stories__/Popup.tsx @@ -4,32 +4,13 @@ import { storiesOf } from '@storybook/react'; import { omitBy } from 'lodash'; import * as React from 'react'; -import { Box, IThemeInterface, Popup } from '..'; +import { Box, Icon, Popup } from '..'; export const popupKnobs = (tabName = 'Popup'): any => { return omitBy( { - show: boolean('show', true, tabName), - posX: select( - 'posX', - { - left: 'left', - center: 'center', - right: 'right', - }, - 'left', - tabName - ), - posY: select( - 'posY', - { - top: 'top', - center: 'center', - bottom: 'bottom', - }, - 'top', - tabName - ), + posX: select('posX', ['left', 'center', 'right'], 'left', tabName), + posY: select('posY', ['top', 'center', 'bottom'], 'top', tabName), offset: { top: number('offset.top', 0, { min: 0 } as NumberOptions, tabName), bottom: number('offset.bottom', 0, { min: 0 } as NumberOptions, tabName), @@ -45,48 +26,38 @@ export const popupKnobs = (tabName = 'Popup'): any => { storiesOf('Popup', module) .addDecorator(withKnobs) - .add('with defaults', () => { - return ( - { - return ( -
    - With Defaults -
    - ); - }} - renderContent={({ theme }: { theme: IThemeInterface }) => { - const color = theme.colors !== undefined ? theme.colors.fg : '#000'; - - return ( -
    {text('content', 'here is the popup content')}
    - ); - }} - /> - ); - }) - .add('with large content', () => { - return ( - { - return With Large Content; - }} - renderContent={({ theme }: { theme: IThemeInterface }) => { - const color = theme.colors !== undefined ? theme.colors.fg : '#000'; - - const elems = []; - for (let i = 0; i < 100; i++) { - elems.push(
  • item {i}
  • ); - } - - return ( -
    -
      {elems}
    -
    - ); - }} - /> - ); - }); + .addDecorator(storyFn => ( + + {storyFn()} + + )) + .add('with defaults', () => ( + With Defaults} + renderContent={() => {text('content', 'here is the popup content')}} + /> + )) + .add('with icon', () => ( + ( + + Hover me + + )} + renderContent={() => ( + + Globe + + )} + /> + )) + .add('controlled', () => ( + Controlled} + renderContent={() => {text('content', 'here is the popup content')}} + /> + )); diff --git a/src/__tests__/Popup.spec.tsx b/src/__tests__/Popup.spec.tsx index d5a2b7ac..4a267c97 100644 --- a/src/__tests__/Popup.spec.tsx +++ b/src/__tests__/Popup.spec.tsx @@ -1,29 +1,16 @@ -import { mount, shallow } from 'enzyme'; +import { shallow } from 'enzyme'; import 'jest-enzyme'; +import debounce = require('lodash/debounce'); import * as React from 'react'; +import { Popup, Portal } from '../'; + describe('Popup', () => { let props: any; let addEventListenerSpy: jest.SpyInstance; let removeEventListenerSpy: jest.SpyInstance; - let Popup: any; - let debounce: any; - let theme: { color: string }; - - beforeEach(async () => { - theme = { - color: '#fff', - }; - - jest.mock('../Portal', () => ({ - Portal: function Portal({ children }: { children: Function }) { - return children(theme); - }, - })); - - ({ Popup } = await import('../')); - ({ debounce } = await import('lodash')); + beforeEach(() => { addEventListenerSpy = jest.spyOn(window, 'addEventListener'); removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); jest.useFakeTimers(); @@ -35,42 +22,29 @@ describe('Popup', () => { }); afterEach(() => { - jest.resetModules(); - jest.unmock('../Portal'); jest.useRealTimers(); addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); }); - it('always calls renderTrigger on render with attributes and some internal methods', () => { + it('always calls renderTrigger on render with internal properties', () => { const renderTriggerSpy = jest.spyOn(props, 'renderTrigger'); - const wrapper = mount(); - // @ts-ignore - const { showPopup, hidePopup } = wrapper.instance(); - - expect(renderTriggerSpy).toHaveBeenCalledWith( - { - onMouseEnter: expect.any(Function), - onMouseLeave: expect.any(Function), - ref: expect.any(Function), - }, - { - hidePopup, - showPopup, - isOver: false, - isVisible: false, - } - ); + const wrapper = shallow(); + const { showPopup, hidePopup } = wrapper.instance() as any; - wrapper.unmount(); + expect(renderTriggerSpy).toHaveBeenCalledWith({ + hidePopup, + showPopup, + isOver: false, + isVisible: false, + }); }); 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', () => { @@ -78,8 +52,7 @@ describe('Popup', () => { (debounce as any).mockReturnValueOnce(handler); const wrapper = shallow(); - // @ts-ignore - const { repaint } = wrapper.instance(); + const { repaint } = wrapper.instance() as any; // let's test if debounce was invoked properly expect(debounce).toHaveBeenCalledWith(repaint, expect.any(Number)); @@ -111,7 +84,6 @@ describe('Popup', () => { shallow(); expect(renderTriggerSpy).toHaveBeenCalledWith( - expect.any(Object), expect.objectContaining({ isVisible: true, }) @@ -119,28 +91,22 @@ describe('Popup', () => { }); it('renders Portal', () => { - const wrapper = mount(); + const wrapper = shallow(); - expect(wrapper.find('Portal')).toExist(); + expect(wrapper.find(Portal)).toExist(); }); it('calls on renderContent and passes some internal methods', () => { const renderContentSpy = jest.spyOn(props, 'renderContent'); - const wrapper = mount(); - // @ts-ignore - const { showPopup, hidePopup } = wrapper.instance(); - - expect(renderContentSpy).toHaveBeenCalledWith( - { theme }, - { - showPopup, - hidePopup, - isVisible: true, - isOver: false, - } - ); + const wrapper = shallow(); + const { showPopup, hidePopup } = wrapper.instance() as any; - wrapper.unmount(); + expect(renderContentSpy).toHaveBeenCalledWith({ + showPopup, + hidePopup, + isVisible: true, + isOver: false, + }); }); it('repaints the popup when props change', () => { From 6c8f81f48154c8128e08fdd1c2bd874904a78ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 26 Nov 2018 12:01:52 +0100 Subject: [PATCH 13/22] refactor(popup): make it hooked --- src/Popup.tsx | 366 ------------------------------------- src/Popup/PopupContent.tsx | 28 +++ src/Popup/index.tsx | 136 ++++++++++++++ src/Popup/types.ts | 53 ++++++ src/Popup/utils.ts | 145 +++++++++++++++ src/__stories__/Popup.tsx | 11 +- 6 files changed, 364 insertions(+), 375 deletions(-) delete mode 100644 src/Popup.tsx create mode 100644 src/Popup/PopupContent.tsx create mode 100644 src/Popup/index.tsx create mode 100644 src/Popup/types.ts create mode 100644 src/Popup/utils.ts diff --git a/src/Popup.tsx b/src/Popup.tsx deleted file mode 100644 index 56b5d3a8..00000000 --- a/src/Popup.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import debounce = require('lodash/debounce'); -import * as React from 'react'; - -import { Portal } from './Portal'; - -export type PopupTriggerRenderer = ( - props: { - isVisible: boolean; - isOver: boolean; - showPopup: () => void; - hidePopup: () => void; - } -) => any; - -export type PopupContentRenderer = ( - props: { - isVisible: boolean; - isOver: boolean; - showPopup: () => void; - hidePopup: () => void; - } -) => any; - -export interface IPopupPosition { - width?: number; // force a width - padding: number; // transparent space around the popup - offset?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; - posX: 'left' | 'center' | 'right'; - posY: 'top' | 'center' | 'bottom'; - show?: boolean; // controlled or debugging -} - -export interface IPopupProps extends IPopupPosition { - hideDelay: number; // how long popup will show for after user mouses out - renderTrigger: PopupTriggerRenderer; - renderContent: PopupContentRenderer; -} - -export interface IPopupStyle { - position?: string; - zIndex?: number; - minWidth?: number; - width?: number; - left?: number; - top?: number; - bottom?: number; - right?: number; - visibility?: string; - overflow?: string; -} - -export interface IPopupState { - style?: any; -} - -export interface IPopupDefaultProps { - padding: 15; - hideDelay: 200; - posX: 'left'; - posY: 'top'; -} - -// TODO: allow specifying target container (so that can optionally scroll with content) -export class Popup extends React.PureComponent { - public static defaultProps: IPopupDefaultProps = { - padding: 15, - hideDelay: 200, - posX: 'left', - posY: 'top', - }; - - private _resizeHandler?: EventListener; - private _isOverTrigger: boolean = false; - private _isOverContent: boolean = false; - private _willHide: any; - private _trigger?: HTMLElement; - private _content?: HTMLDivElement; - - public state: IPopupState = {}; - - public render() { - const { renderTrigger, renderContent } = this.props; - - const funcs = { - isVisible: this.isVisible, - showPopup: this.showPopup, - hidePopup: this.hidePopup, - }; - - const style: IPopupStyle & { padding: number } = { - position: 'fixed', - zIndex: 999, - padding: this.props.padding, - width: this.props.width, - }; - - if (this.state.style) { - Object.assign(style, this.state.style); - } else { - style.visibility = this.isVisible ? 'visible' : 'hidden'; - } - - return ( - <> - {React.cloneElement( - renderTrigger({ - ...funcs, - isOver: this._isOverTrigger, - }), - { - onMouseEnter: () => this.handleMouseEnter('_isOverTrigger'), - onMouseLeave: e => this.handleMouseLeave(e, '_isOverTrigger', '_isOverContent'), - ref: (el: HTMLElement) => { - this._trigger = el; - }, - } - )} - {this.isVisible && ( - -
    this.handleMouseEnter('_isOverContent')} - onMouseLeave={e => this.handleMouseLeave(e, '_isOverContent', '_isOverTrigger')} - ref={el => (this._content = el || undefined)} - style={style} - > - {renderContent({ - ...funcs, - isOver: this._isOverContent, - })} -
    -
    - )} - - ); - } - - public componentDidMount() { - if (this.isVisible && !this.state.style) { - this.repaint(); - } - - if (typeof window !== 'undefined') { - this._resizeHandler = debounce(this.repaint, 50); - - window.addEventListener('resize', this._resizeHandler); - } - } - - public componentWillUnmount() { - if (this._resizeHandler !== undefined) { - window.removeEventListener('resize', this._resizeHandler); - } - } - - public componentDidUpdate(prevProps: IPopupProps) { - if ((this.isVisible || this._controlled) && (!this.state.style || this.propsChanged(prevProps))) { - this.repaint(); - - if (this.propsChanged(prevProps)) { - this.forceUpdate(); - } - } - } - - private get isVisible() { - if (this._controlled) { - return this.props.show || false; - } - - return this._isOverContent || this._isOverTrigger || false; - } - - private propsChanged(props: IPopupPosition) { - return ( - this.props.width !== props.width || - this.props.posX !== props.posX || - this.props.posY !== props.posY || - this.props.padding !== props.padding || - this.props.show !== props.show - ); - } - - private handleMouseEnter = (_isOver: string) => { - if (this.isVisible || this._controlled) return; - this[_isOver] = true; - this.showPopup(); - }; - - private handleMouseLeave = (e: any, _isLeaving: string, _isOver: string) => { - const newTarget = e.toElement || e.relatedTarget; - if (newTarget === this._content) { - this[_isOver] = true; - } - - this[_isLeaving] = false; - if (!this.isVisible || this._controlled) { - this.hidePopup(); - } - }; - - private showPopup = () => { - if (this._willHide) { - clearTimeout(this._willHide); - this._willHide = undefined; - } - - // this just triggers a re-render, so that componentDidUpdate can trigger a repaint - // after the content element has been rendered - // we have to do it this way so that repaint can access this._content to calculate dimensions - this.forceUpdate(); - }; - - private hidePopup = () => { - if (this._willHide) { - return; - } - - this._willHide = setTimeout(() => { - this._isOverTrigger = false; - this._isOverContent = false; - this.setState({ style: undefined }); - }, this.props.hideDelay); - }; - - private repaint = () => { - if (!this._trigger || !this._content) return; - - // TODO: smart choice based on content size and screen space if this.props.posX not defined - let posX = this.props.posX; - - // TODO: smart choice based on content size and screen space if this.props.posY not defined - const posY = this.props.posY; - - // calculate the tooltip position - // this style object will be passed to the renderContent function - const style: IPopupStyle = {}; - - // where on the screen is the target - const triggerDimensions = this._trigger.getBoundingClientRect(); - - // where on the screen is the content, and some basics on its size - const contentDimensions = this._content.getBoundingClientRect(); - const contentWidth = this.props.width || contentDimensions.width; - - style.minWidth = triggerDimensions.width + 25; - - if (posY === 'center') { - if (posX === 'left') { - posX = 'right'; - } else { - posX = 'left'; - } - } - - if (posX === 'left' || posX === 'center') { - style.left = triggerDimensions.left; - - if (posX === 'center') { - // center align the popup by taking both the target and popup widths into account - style.left += triggerDimensions.width / 2 - contentWidth / 2; - } else { - style.left -= this.props.padding; - - // room for tip - if (posY === 'center') { - style.left += triggerDimensions.width + this.props.padding; - } - } - - // account for desired offsets - style.left += this._offset.left || 0; - style.left -= this._offset.right || 0; - - // make sure it doesn't poke off the left side of the page - style.left = Math.max(0, style.left); - - let clientWidth = 0; - if (typeof document !== 'undefined') { - clientWidth = document.body.clientWidth; - } - - // or off the right - style.left = Math.min(style.left, clientWidth - contentWidth - 5); - } else { - // right - // coming in from the right of the screen - style.right = window.innerWidth - triggerDimensions.left - triggerDimensions.width - this.props.padding; - - // room for tip - if (posY === 'center') { - style.right += triggerDimensions.width + this.props.padding; - } - - // account for desired offsets - style.right -= this._offset.left || 0; - style.right += this._offset.right || 0; - - // TODO: make sure it doesn't poke off the left side of the page - - // or off the right - style.right = Math.max(0, style.right); - } - - if (posY === 'top') { - // when positioning above, set the bottom of the popup just above the top of the target (it will stretch upwards) - style.bottom = window.innerHeight - triggerDimensions.top + (this._offset.bottom || 0); - } else if (posY === 'bottom') { - // when positioning below, position the top of the popup just below the target (it will stretch downwards) - style.top = triggerDimensions.top + triggerDimensions.height + (this._offset.top || 0); - } else { - style.top = style.top = triggerDimensions.top + triggerDimensions.height / 2 - contentDimensions.height / 2; - - style.top += this._offset.top || 0; - style.top -= this._offset.bottom || 0; - } - - if (style.top) { - // make sure it doesn't poke off the bottom of the page - style.top = Math.min(window.innerHeight - contentDimensions.height, style.top); - - // make sure it doesn't poke off the top of the page - style.top = Math.max(0, style.top); - - if (style.top + contentDimensions.height > window.innerHeight) { - style.bottom = 0; - style.overflow = 'auto'; - } - } else if (style.bottom) { - // make sure it doesn't poke off the top of the page - style.bottom = Math.min(window.innerHeight, style.bottom); - - // make sure it doesn't poke off the top of the page - style.bottom = Math.max(0, style.bottom); - - if (style.bottom + contentDimensions.height > window.innerHeight) { - style.top = 0; - style.overflow = 'auto'; - } - } - - this.setState({ - style, - }); - }; - - private get _controlled() { - return this.props.hasOwnProperty('show'); - } - - private get _offset() { - return Object.assign( - { - top: 0, - bottom: 0, - left: 0, - right: 0, - }, - this.props.offset || {} - ); - } -} diff --git a/src/Popup/PopupContent.tsx b/src/Popup/PopupContent.tsx new file mode 100644 index 00000000..2f3e48d0 --- /dev/null +++ b/src/Popup/PopupContent.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import { Portal } from '../Portal'; +import { IPopupContentProps } from './types'; + +export const PopupContent = React.forwardRef((props, ref) => { + const { onMouseEnter, onMouseLeave, repaint, style } = props; + const lastChildRef = React.useRef(null); + + React.useEffect(repaint, [lastChildRef.current]); + + const count = React.Children.count(props.children); + + return ( + +
    + {React.Children.map(props.children, (child, i) => { + if (i !== count - 1) { + return child; + } + + // the purpose of doing this is to get rid of that forced re-render + return React.cloneElement(child as React.ReactElement, { ref: lastChildRef }); + })} +
    +
    + ); +}); diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx new file mode 100644 index 00000000..858f2095 --- /dev/null +++ b/src/Popup/index.tsx @@ -0,0 +1,136 @@ +import debounce = require('lodash/debounce'); +import * as React from 'react'; + +import { PopupContent } from './PopupContent'; +import { IPopupDefaultProps, IPopupProps } from './types'; +import { calculateStyles, getDefaultStyle } from './utils'; + +export const Popup = (props: IPopupProps) => { + const triggerRef = React.useRef(null); + const contentRef = React.createRef(); + const [isVisible, setVisibility] = React.useState(false); + const [style, setStyle] = React.useState(undefined); + let isOverTrigger: boolean = false; + let isOverContent: boolean = false; + let lastResizeTimestamp: number = 0; + // Number could be set here, but unfortunately Node returns Timeout which is not exported + let willHide: any; + + const repaint = React.useCallback( + () => { + if (isVisible) { + setStyle({ + ...getDefaultStyle(props), + ...calculateStyles(triggerRef, contentRef, props), + }); + } + }, + [props.width, props.offset, props.posX, props.posY, contentRef, lastResizeTimestamp] + ); + + if (typeof window !== 'undefined') { + React.useEffect( + () => { + const resizeHandler = debounce((e: Event) => { + lastResizeTimestamp = e.timeStamp; + repaint(); + }, 16); + + window.addEventListener('resize', resizeHandler); + + return () => { + window.removeEventListener('resize', resizeHandler); + }; + }, + [contentRef] + ); + } + + const showPopup = () => { + if (willHide !== undefined) { + clearTimeout(willHide); + willHide = undefined; + } + + setVisibility(true); + }; + + const hidePopup = () => { + if (willHide !== undefined) { + return; + } + + willHide = setTimeout(() => { + isOverTrigger = false; + isOverContent = false; + setVisibility(false); + }, props.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, + }), + { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + ref: triggerRef, + } + )} + {isVisible && ( + + {renderContent({ + ...funcs, + isOver: isOverContent, + })} + + )} + + ); +}; + +Popup.defaultProps = { + padding: 15, + hideDelay: 200, + posX: 'left', + posY: 'top', +} as IPopupDefaultProps; diff --git a/src/Popup/types.ts b/src/Popup/types.ts new file mode 100644 index 00000000..7cf321e7 --- /dev/null +++ b/src/Popup/types.ts @@ -0,0 +1,53 @@ +import * as React from 'react'; + +export type PopupTriggerRenderer = ( + props: { + isVisible: boolean; + isOver: boolean; + showPopup: () => void; + hidePopup: () => void; + } +) => any; + +export type PopupContentRenderer = ( + props: { + isVisible: boolean; + isOver: boolean; + showPopup: () => void; + hidePopup: () => void; + } +) => any; + +export interface IPopupPosition { + width?: number; // force a width + padding: number; // transparent space around the popup + offset?: { + top?: number; + bottom?: number; + left?: number; + right?: number; + }; + posX: 'left' | 'center' | 'right'; + posY: 'top' | 'center' | 'bottom'; +} + +export interface IPopupProps extends IPopupPosition { + hideDelay: number; // how long popup will show for after user mouses out + renderTrigger: PopupTriggerRenderer; + renderContent: PopupContentRenderer; +} + +export interface IPopupDefaultProps { + padding: 15; + hideDelay: 200; + posX: 'left'; + posY: 'top'; +} + +export interface IPopupContentProps { + children: React.ReactChildren; + onMouseEnter: (e: React.SyntheticEvent) => any; + onMouseLeave: (e: React.SyntheticEvent) => any; + repaint: () => any; + style?: React.CSSProperties; +} diff --git a/src/Popup/utils.ts b/src/Popup/utils.ts new file mode 100644 index 00000000..e70047f0 --- /dev/null +++ b/src/Popup/utils.ts @@ -0,0 +1,145 @@ +import * as React from 'react'; +import { IPopupProps } from './types'; + +const getOffset = ({ offset }: IPopupProps) => + Object.assign( + { + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + offset || null + ); + +export const getDefaultStyle = ({ width, padding }: IPopupProps): React.CSSProperties => ({ + position: 'fixed', + zIndex: 999, + padding, + width, +}); + +export const calculateStyles = ( + trigger: React.RefObject, + content: React.RefObject, + props: IPopupProps +): React.CSSProperties | null => { + if (!trigger || !content || !trigger.current || !content.current) return null; + + const offset = getOffset(props); + + // TODO: smart choice based on content size and screen space if props.posX not defined + let posX = props.posX; + + // TODO: smart choice based on content size and screen space if props.posY not defined + const posY = props.posY; + + // calculate the tooltip position + // this style object will be passed to the renderContent function + const style: React.CSSProperties = {}; + + // where on the screen is the target + const triggerDimensions = trigger.current.getBoundingClientRect(); + + // where on the screen is the content, and some basics on its size + const contentDimensions = content.current.getBoundingClientRect(); + const contentWidth = props.width || contentDimensions.width; + + style.minWidth = triggerDimensions.width + 25; + + if (posY === 'center') { + if (posX === 'left') { + posX = 'right'; + } else { + posX = 'left'; + } + } + + if (posX === 'left' || posX === 'center') { + style.left = triggerDimensions.left; + + if (posX === 'center') { + // center align the popup by taking both the target and popup widths into account + style.left += triggerDimensions.width / 2 - contentWidth / 2; + } else { + style.left -= props.padding; + + // room for tip + if (posY === 'center') { + style.left += triggerDimensions.width + props.padding; + } + } + + // account for desired offsets + style.left += offset.left || 0; + style.left -= offset.right || 0; + + // make sure it doesn't poke off the left side of the page + style.left = Math.max(0, style.left); + + let clientWidth = 0; + if (typeof document !== 'undefined') { + clientWidth = document.body.clientWidth; + } + + // or off the right + style.left = Math.min(style.left, clientWidth - contentWidth - 5); + } else { + // right + // coming in from the right of the screen + style.right = window.innerWidth - triggerDimensions.left - triggerDimensions.width - props.padding; + + // room for tip + if (posY === 'center') { + style.right += triggerDimensions.width + props.padding; + } + + // account for desired offsets + style.right -= offset.left; + style.right += offset.right; + + // TODO: make sure it doesn't poke off the left side of the page + + // or off the right + style.right = Math.max(0, style.right); + } + + if (posY === 'top') { + // when positioning above, set the bottom of the popup just above the top of the target (it will stretch upwards) + style.bottom = window.innerHeight - triggerDimensions.top + offset.bottom; + } else if (posY === 'bottom') { + // when positioning below, position the top of the popup just below the target (it will stretch downwards) + style.top = triggerDimensions.top + triggerDimensions.height + offset.top; + } else { + style.top = style.top = triggerDimensions.top + triggerDimensions.height / 2 - contentDimensions.height / 2; + + style.top += offset.top; + style.top -= offset.bottom; + } + + if (style.top) { + // make sure it doesn't poke off the bottom of the page + style.top = Math.min(window.innerHeight - contentDimensions.height, style.top as number); + + // make sure it doesn't poke off the top of the page + style.top = Math.max(0, style.top); + + if (style.top + contentDimensions.height > window.innerHeight) { + style.bottom = 0; + style.overflow = 'auto'; + } + } else if (style.bottom) { + // make sure it doesn't poke off the top of the page + style.bottom = Math.min(window.innerHeight, style.bottom as number); + + // make sure it doesn't poke off the top of the page + style.bottom = Math.max(0, style.bottom); + + if (style.bottom + contentDimensions.height > window.innerHeight) { + style.top = 0; + style.overflow = 'auto'; + } + } + + return style; +}; diff --git a/src/__stories__/Popup.tsx b/src/__stories__/Popup.tsx index ba2826a7..799c19cb 100644 --- a/src/__stories__/Popup.tsx +++ b/src/__stories__/Popup.tsx @@ -1,5 +1,5 @@ import { NumberOptions, withKnobs } from '@storybook/addon-knobs'; -import { boolean, number, select, text } from '@storybook/addon-knobs/react'; +import { number, select, text } from '@storybook/addon-knobs/react'; import { storiesOf } from '@storybook/react'; import { omitBy } from 'lodash'; import * as React from 'react'; @@ -19,6 +19,7 @@ export const popupKnobs = (tabName = 'Popup'): any => { }, padding: number('padding', 0, { min: 0 } as NumberOptions, tabName), width: number('width', 0, { min: 0 } as NumberOptions, tabName), + hideDelay: number('hideDelay', 200, { min: 0 } as NumberOptions, tabName), }, val => !val ); @@ -52,12 +53,4 @@ storiesOf('Popup', module) )} /> - )) - .add('controlled', () => ( - Controlled} - renderContent={() => {text('content', 'here is the popup content')}} - /> )); From b893efb0e3aefd0a170051084f84809a36a8367d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 26 Nov 2018 12:07:36 +0100 Subject: [PATCH 14/22] refactor(popup): export props interfaces --- src/Popup/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 858f2095..54ac9f3e 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -5,6 +5,8 @@ import { PopupContent } from './PopupContent'; import { IPopupDefaultProps, IPopupProps } from './types'; import { calculateStyles, getDefaultStyle } from './utils'; +export { IPopupProps, IPopupDefaultProps }; + export const Popup = (props: IPopupProps) => { const triggerRef = React.useRef(null); const contentRef = React.createRef(); From 724e159b8c982f198652e88b738ff66841d4eb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 26 Nov 2018 13:43:29 +0100 Subject: [PATCH 15/22] test(popup): update tests --- __mocks__/styled-components.js | 7 -- setupTests.ts | 1 - src/__tests__/Popup.spec.tsx | 197 ++++++++++++++++----------------- src/__tests__/Portal.spec.tsx | 16 ++- 4 files changed, 105 insertions(+), 116 deletions(-) delete mode 100644 __mocks__/styled-components.js diff --git a/__mocks__/styled-components.js b/__mocks__/styled-components.js deleted file mode 100644 index c38c3e3d..00000000 --- a/__mocks__/styled-components.js +++ /dev/null @@ -1,7 +0,0 @@ -const styledComponents = require.requireActual('styled-components'); - -const ThemeConsumer = jest.fn(styledComponents.ThemeConsumer); -ThemeConsumer.displayName = 'ThemeConsumer'; - -module.exports = styledComponents; -module.exports.ThemeConsumer = ThemeConsumer; diff --git a/setupTests.ts b/setupTests.ts index 40aba23a..311b4927 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -5,5 +5,4 @@ Enzyme.configure({ adapter: new Adapter() }); jest.mock('react'); jest.mock('lodash'); jest.mock('lodash/debounce'); -jest.mock('styled-components'); jest.mock('react-virtualized'); diff --git a/src/__tests__/Popup.spec.tsx b/src/__tests__/Popup.spec.tsx index 4a267c97..155deb44 100644 --- a/src/__tests__/Popup.spec.tsx +++ b/src/__tests__/Popup.spec.tsx @@ -1,16 +1,17 @@ -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; import 'jest-enzyme'; import debounce = require('lodash/debounce'); import * as React from 'react'; -import { Popup, Portal } from '../'; +import { Popup } from '../Popup'; +import { PopupContent } from '../Popup/PopupContent'; describe('Popup', () => { let props: any; let addEventListenerSpy: jest.SpyInstance; let removeEventListenerSpy: jest.SpyInstance; - beforeEach(() => { + beforeEach(async () => { addEventListenerSpy = jest.spyOn(window, 'addEventListener'); removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); jest.useFakeTimers(); @@ -18,6 +19,7 @@ describe('Popup', () => { props = { renderContent: jest.fn(() =>
    ), renderTrigger: jest.fn(() =>
    ), + hideDelay: 300, }; }); @@ -28,9 +30,9 @@ describe('Popup', () => { }); it('always calls renderTrigger on render with internal properties', () => { - const renderTriggerSpy = jest.spyOn(props, 'renderTrigger'); - const wrapper = shallow(); - const { showPopup, hidePopup } = wrapper.instance() as any; + const { renderTrigger: renderTriggerSpy } = props; + const wrapper = mount(); + const [[{ showPopup, hidePopup }]] = renderTriggerSpy.mock.calls; expect(renderTriggerSpy).toHaveBeenCalledWith({ hidePopup, @@ -38,171 +40,168 @@ describe('Popup', () => { isOver: false, isVisible: false, }); + + wrapper.unmount(); }); it('does not call renderContent by default', () => { const renderTriggerSpy = jest.spyOn(props, 'renderContent'); - shallow(); + const wrapper = mount(); expect(renderTriggerSpy).not.toHaveBeenCalledWith(); + wrapper.unmount(); }); it('attaches debounced paint resize handler', () => { const handler = () => true; (debounce as any).mockReturnValueOnce(handler); - const wrapper = shallow(); - const { repaint } = wrapper.instance() as any; + const wrapper = mount(); // let's test if debounce was invoked properly - expect(debounce).toHaveBeenCalledWith(repaint, expect.any(Number)); + 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 = shallow(); + const wrapper = mount(); wrapper.unmount(); expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', handler); }); describe('when Popup is visible', () => { + let setStateSpy: jest.SpyInstance; + let repaintSpy: jest.SpyInstance; + let useCallbackSpy: jest.SpyInstance; + let setVisibilitySpy: jest.SpyInstance; + beforeEach(() => { - props = { - ...props, - show: true, - }; + 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(); }); it('calls renderTrigger and passes proper isVisible', () => { - const renderTriggerSpy = jest.spyOn(props, 'renderTrigger'); - shallow(); + const wrapper = mount(); - expect(renderTriggerSpy).toHaveBeenCalledWith( + expect(props.renderTrigger).toHaveBeenCalledWith( expect.objectContaining({ isVisible: true, }) ); + wrapper.unmount(); }); - it('renders Portal', () => { - const wrapper = shallow(); + it('renders PopupContent', () => { + const wrapper = mount(); - expect(wrapper.find(Portal)).toExist(); + expect(wrapper.find(PopupContent)).toExist(); + wrapper.unmount(); }); it('calls on renderContent and passes some internal methods', () => { const renderContentSpy = jest.spyOn(props, 'renderContent'); - const wrapper = shallow(); - const { showPopup, hidePopup } = wrapper.instance() as any; + const wrapper = mount(); expect(renderContentSpy).toHaveBeenCalledWith({ - showPopup, - hidePopup, + showPopup: expect.any(Function), + hidePopup: expect.any(Function), isVisible: true, isOver: false, }); + wrapper.unmount(); }); it('repaints the popup when props change', () => { - const wrapper = shallow(); - const repaintSpy = jest.spyOn(wrapper.instance() as any, 'repaint'); - + const wrapper = mount(); wrapper.setProps({ padding: 10 }); expect(repaintSpy).toHaveBeenCalled(); + wrapper.unmount(); }); - it('handleMouseEnter does nothing', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as any; - const showPopupSpy = jest.spyOn(instance, 'showPopup'); + describe('hidePopup', () => { + it('aborts the request if already in progress', () => { + const wrapper = mount(); + const [[{ hidePopup, showPopup }]] = props.renderTrigger.mock.calls; - instance.handleMouseEnter(false); - expect(showPopupSpy).not.toHaveBeenCalled(); - }); - }); + showPopup(); + hidePopup(); + + expect(setTimeout).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); - describe('hidePopup', () => { - it('aborts the request if already in progress', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as any; - instance._willHide = 2343; + it('sets a timeout according to given hideDelay', () => { + const hideDelay = parseInt(String(Math.random() * 100000), 10); + const wrapper = mount(); - instance.hidePopup(); - expect(setTimeout).not.toHaveBeenCalled(); - }); + const [[{ hidePopup }]] = props.renderTrigger.mock.calls; - it('sets a timeout according to given hideDelay', () => { - const hideDelay = parseInt(String(Math.random() * 100000), 10); - const wrapper = shallow(); - const instance = wrapper.instance() as any; - instance.hidePopup(); - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), hideDelay); - }); + hidePopup(); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), hideDelay); + wrapper.unmount(); + }); - it('clears styles once timeouts', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as any; - instance.hidePopup(); - jest.runAllTimers(); - expect(wrapper).toHaveState('style', undefined); + it('hides popup once timeouts', () => { + const wrapper = mount(); + const [[{ hidePopup }]] = props.renderTrigger.mock.calls; + hidePopup(); + + jest.runAllTimers(); + expect(setVisibilitySpy).toHaveBeenCalledWith(false); + wrapper.unmount(); + }); + + describe('repaint', () => { + it('always sets style', () => { + const wrapper = mount(); + + expect(wrapper.find(PopupContent)).toHaveProp('style'); + + wrapper.unmount(); + }); + }); }); }); describe('showPopup', () => { it('clears willHide timeout', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as any; - const timeout = 50; - instance._willHide = timeout; - instance.showPopup(); - expect(clearTimeout).toHaveBeenCalledWith(timeout); - expect(instance).toHaveProperty('_willHide', undefined); - }); + const wrapper = mount(); + const [[{ showPopup, hidePopup }]] = props.renderTrigger.mock.calls; + + hidePopup(); + showPopup(); - it('triggers forced re-render', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as any; - const forceUpdate = jest.spyOn(instance, 'forceUpdate'); - instance.showPopup(); - expect(forceUpdate).toHaveBeenCalledWith(); + expect(clearTimeout).toHaveBeenCalled(); + wrapper.unmount(); }); }); - it('handleMouseEnter calls showPopup', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as any; - const showPopupSpy = jest.spyOn(instance, 'showPopup'); + it('handleMouseEnter shows popup', () => { + const trigger = foo; - instance.handleMouseEnter(false); - expect(showPopupSpy).toHaveBeenCalled(); - }); + props.renderTrigger.mockReturnValue(trigger); - describe('repaint', () => { - it('always sets style', () => { - const wrapper = shallow(); - const instance = wrapper.instance() as any; - instance._trigger = { - getBoundingClientRect: () => ({}), - }; - instance._content = { - getBoundingClientRect: () => ({}), - }; - - instance.repaint(); - expect(wrapper).toHaveState( - 'style', - expect.objectContaining({ - minWidth: expect.anything(), - left: expect.anything(), - bottom: expect.anything(), - }) - ); - }); + const wrapper = mount(); + + wrapper.find('#trigger').simulate('mouseenter'); + + expect(wrapper.find(PopupContent)).toExist(); + wrapper.unmount(); }); }); diff --git a/src/__tests__/Portal.spec.tsx b/src/__tests__/Portal.spec.tsx index fa89f2eb..e7276d39 100644 --- a/src/__tests__/Portal.spec.tsx +++ b/src/__tests__/Portal.spec.tsx @@ -1,14 +1,14 @@ import { mount, shallow } from 'enzyme'; import 'jest-enzyme'; import * as React from 'react'; -import { ThemeConsumer } from 'styled-components'; +import { ThemeProvider } from 'styled-components'; import { Portal } from '../Portal'; describe('Portal', () => { let appendChildSpy: jest.SpyInstance; let removeChildSpy: jest.SpyInstance; let setClassNameSpy: jest.SpyInstance; - let theme: any; + let theme: object; beforeEach(() => { setClassNameSpy = jest.spyOn(HTMLDivElement.prototype, 'className', 'set'); @@ -17,10 +17,6 @@ describe('Portal', () => { theme = { color: 'test', }; - - (ThemeConsumer as any).mockImplementationOnce(({ children }: { children: Function }) => { - return children(theme); - }); }); afterEach(() => { @@ -59,9 +55,11 @@ describe('Portal', () => { it('passes theme to children', () => { const wrapper = mount( - -
    - + + +
    + + ); expect(wrapper.find('div')).toHaveProp('theme', theme); wrapper.unmount(); From 241c2a7c751e3fdc0dc9cf9dcb446e716701ad7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 26 Nov 2018 14:08:35 +0100 Subject: [PATCH 16/22] fix(list-scroller): update annotation --- src/ListScroller.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ListScroller.tsx b/src/ListScroller.tsx index 0c10dcd9..84318010 100644 --- a/src/ListScroller.tsx +++ b/src/ListScroller.tsx @@ -15,7 +15,7 @@ export interface IListScrollerProps { rowHeight: number | ((params: Index) => number); // Responsible for rendering a row - rowRenderer: ({ key, index, value }: IListScrollerItemProps) => JSX.Element; + rowRenderer: ({ key, index, value, style }: IListScrollerItemProps) => JSX.Element; noRowsRenderer?: () => JSX.Element; onScroll?: () => void; list: any[]; From 64d37ccd78a7eea1650ad785d293cc24a8a0f717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 26 Nov 2018 15:09:33 +0100 Subject: [PATCH 17/22] style(menu): add absolute positioning --- src/Menu.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Menu.tsx b/src/Menu.tsx index a501cc8c..dee00b0d 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -49,6 +49,7 @@ const MenuView = (props: IMenuProps & { className: string }) => { border="xs" radius="md" direction={direction} + position={renderTrigger ? 'absolute' : 'relative'} {...attributes} > {menuItems.map(renderMenuItem)} @@ -65,7 +66,9 @@ const MenuView = (props: IMenuProps & { className: string }) => { }; export const Menu = styled(MenuView as any)` - &:hover ${Flex} { + position: relative; + + &:hover > ${Flex} { display: flex !important; } From b8298447813f99c572553bbcb46c2a9fa7470d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Mon, 26 Nov 2018 15:53:36 +0100 Subject: [PATCH 18/22] feat(menu): implement onMouse{Enter,Leave} handlers --- src/Menu.tsx | 6 +++++- src/__stories__/Menu.tsx | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Menu.tsx b/src/Menu.tsx index dee00b0d..45091a78 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -20,6 +20,8 @@ export interface IMenuProps { menuItems: IMenuItemProps[]; direction?: IFlexProps['direction']; attributes?: IFlexProps; + onMouseEnter?: React.EventHandler>; + onMouseLeave?: React.EventHandler>; renderTrigger?: () => any; renderMenuItem?: RenderMenuItem; renderMenu?: ( @@ -38,6 +40,8 @@ const MenuView = (props: IMenuProps & { className: string }) => { attributes = null, className, direction = 'column', + onMouseEnter, + onMouseLeave, menuItems = [], renderTrigger, renderMenuItem = (item: IMenuItemProps, index: number) => , @@ -58,7 +62,7 @@ const MenuView = (props: IMenuProps & { className: string }) => { } = props; return ( - + {renderTrigger && {renderTrigger()}} {renderMenu(props, menuItems, renderMenuItem)} diff --git a/src/__stories__/Menu.tsx b/src/__stories__/Menu.tsx index 08758074..eb174a3d 100644 --- a/src/__stories__/Menu.tsx +++ b/src/__stories__/Menu.tsx @@ -16,6 +16,16 @@ export const menuKnobs = (tabName = 'Menu'): any => { ); }; +export const menuActions = (): any => { + return omitBy( + { + onMouseEnter: action('onMouseEnter'), + onMouseLeave: action('onMouseLeave'), + }, + val => !val + ); +}; + storiesOf('Menu', module) .addDecorator(withKnobs) .add('with defaults', () => ( @@ -38,6 +48,17 @@ storiesOf('Menu', module) ]} /> )) + .add('with actions', () => ( + Has onClick, icon: 'marker' }, + { title: 'No onClick', icon: 'image' }, + { title: 'Disabled Item', disabled: true, icon: 'times-circle' }, + ]} + /> + )) .add('with icons only', () => ( ( } menuItems={[ { onClick: action('onClick'), title: Has onClick, subtitle: 'has subtitle', icon: 'marker' }, From a79ecff9884d64d3453fa3b37e7b0e25d06c1f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Tue, 27 Nov 2018 13:13:49 +0100 Subject: [PATCH 19/22] refactor: extract hook --- src/Popup/index.tsx | 24 ++++++------------------ src/__tests__/useWindowResize.spec.ts | 0 src/hooks/useWindowResize.ts | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 src/__tests__/useWindowResize.spec.ts create mode 100644 src/hooks/useWindowResize.ts diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 54ac9f3e..836bc26b 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -1,6 +1,6 @@ -import debounce = require('lodash/debounce'); import * as React from 'react'; +import { useWindowResize } from '../hooks/useWindowResize'; import { PopupContent } from './PopupContent'; import { IPopupDefaultProps, IPopupProps } from './types'; import { calculateStyles, getDefaultStyle } from './utils'; @@ -11,41 +11,29 @@ export const Popup = (props: IPopupProps) => { 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(undefined); let isOverTrigger: boolean = false; let isOverContent: boolean = false; - let lastResizeTimestamp: number = 0; // Number could be set here, but unfortunately Node returns Timeout which is not exported let willHide: any; const repaint = React.useCallback( () => { if (isVisible) { + lastRepaintTimestamp = Date.now(); setStyle({ ...getDefaultStyle(props), ...calculateStyles(triggerRef, contentRef, props), }); } }, - [props.width, props.offset, props.posX, props.posY, contentRef, lastResizeTimestamp] + [props.width, props.offset, props.posX, props.posY, contentRef, lastRepaintTimestamp] ); if (typeof window !== 'undefined') { - React.useEffect( - () => { - const resizeHandler = debounce((e: Event) => { - lastResizeTimestamp = e.timeStamp; - repaint(); - }, 16); - - window.addEventListener('resize', resizeHandler); - - return () => { - window.removeEventListener('resize', resizeHandler); - }; - }, - [contentRef] - ); + React.useEffect(repaint, [lastResizeTimestamp]); } const showPopup = () => { diff --git a/src/__tests__/useWindowResize.spec.ts b/src/__tests__/useWindowResize.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/hooks/useWindowResize.ts b/src/hooks/useWindowResize.ts new file mode 100644 index 00000000..6c8ac3da --- /dev/null +++ b/src/hooks/useWindowResize.ts @@ -0,0 +1,22 @@ +import debounce = require('lodash/debounce'); +import * as React from 'react'; + +export function useWindowResize() { + const [timestamp, setTimestamp] = React.useState(Date.now()); + + if (typeof window !== 'undefined') { + React.useEffect(() => { + const resizeHandler = debounce((e: Event) => { + setTimestamp(e.timeStamp); + }, 16); + + window.addEventListener('resize', resizeHandler); + + return () => { + window.removeEventListener('resize', resizeHandler); + }; + }, []); + } + + return timestamp; +} From a030ad40233f30a8aacab05d32113bfa35e15d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Tue, 27 Nov 2018 14:03:08 +0100 Subject: [PATCH 20/22] test: useWindowResize hook --- src/__tests__/useWindowResize.spec.ts | 0 src/__tests__/useWindowResize.spec.tsx | 64 ++++++++++++++++++++++++++ src/hooks/useWindowResize.ts | 2 +- 3 files changed, 65 insertions(+), 1 deletion(-) delete mode 100644 src/__tests__/useWindowResize.spec.ts create mode 100644 src/__tests__/useWindowResize.spec.tsx diff --git a/src/__tests__/useWindowResize.spec.ts b/src/__tests__/useWindowResize.spec.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/__tests__/useWindowResize.spec.tsx b/src/__tests__/useWindowResize.spec.tsx new file mode 100644 index 00000000..cbf033ad --- /dev/null +++ b/src/__tests__/useWindowResize.spec.tsx @@ -0,0 +1,64 @@ +import { mount } from 'enzyme'; +import 'jest-enzyme'; +import debounce = require('lodash/debounce'); +import * as React from 'react'; + +import { useWindowResize } from '../hooks/useWindowResize'; + +describe('useWindowResize hook', () => { + let addEventListenerSpy: jest.SpyInstance; + let removeEventListenerSpy: jest.SpyInstance; + let debouncedHandlerMock: jest.MockInstance; + const Wrapper = () => { + const timestamp = useWindowResize(); + + return {timestamp} + }; + beforeEach(async () => { + addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + debouncedHandlerMock = jest.fn(); + (debounce as any).mockReturnValueOnce(debouncedHandlerMock); + }); + + afterEach(() => { + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + it('attaches debounced resize listener', () => { + const wrapper = mount(React.createElement(Wrapper)); + + expect(debounce).toHaveBeenCalledWith(expect.any(Function), 16); + expect(addEventListenerSpy).toHaveBeenLastCalledWith('resize', debouncedHandlerMock); + + wrapper.unmount(); + }); + + it('detaches listener on unmount', () => { + const wrapper = mount(React.createElement(Wrapper)); + wrapper.unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', debouncedHandlerMock); + }); + + it('debounced listener sets timestamp', () => { + let resize: EventListener = () => { + // nada + }; + (debounce as any).mockReset(); + (debounce as any).mockImplementationOnce((cb: EventListener) => { + resize = cb; + }); + + const wrapper = mount(); + + const event = new Event('resize'); + resize(event); + + expect(wrapper).toHaveText(String(event.timeStamp)); + + wrapper.unmount(); + }); +}); diff --git a/src/hooks/useWindowResize.ts b/src/hooks/useWindowResize.ts index 6c8ac3da..fcff601a 100644 --- a/src/hooks/useWindowResize.ts +++ b/src/hooks/useWindowResize.ts @@ -2,7 +2,7 @@ import debounce = require('lodash/debounce'); import * as React from 'react'; export function useWindowResize() { - const [timestamp, setTimestamp] = React.useState(Date.now()); + const [timestamp, setTimestamp] = React.useState(0); if (typeof window !== 'undefined') { React.useEffect(() => { From f6d12d06cbc708360b3522970cf5e684466680bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Tue, 27 Nov 2018 14:05:34 +0100 Subject: [PATCH 21/22] test: remove irrelevant test --- src/__tests__/Popup.spec.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/__tests__/Popup.spec.tsx b/src/__tests__/Popup.spec.tsx index 155deb44..b659f467 100644 --- a/src/__tests__/Popup.spec.tsx +++ b/src/__tests__/Popup.spec.tsx @@ -127,13 +127,6 @@ describe('Popup', () => { wrapper.unmount(); }); - it('repaints the popup when props change', () => { - const wrapper = mount(); - wrapper.setProps({ padding: 10 }); - expect(repaintSpy).toHaveBeenCalled(); - wrapper.unmount(); - }); - describe('hidePopup', () => { it('aborts the request if already in progress', () => { const wrapper = mount(); From 6bf60caea32b7e88cbd7058279df6a2beba1cadb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Tue, 27 Nov 2018 14:51:02 +0100 Subject: [PATCH 22/22] test: move hook test to its own directory --- src/__tests__/{ => hooks/__tests__}/useWindowResize.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/__tests__/{ => hooks/__tests__}/useWindowResize.spec.tsx (96%) diff --git a/src/__tests__/useWindowResize.spec.tsx b/src/__tests__/hooks/__tests__/useWindowResize.spec.tsx similarity index 96% rename from src/__tests__/useWindowResize.spec.tsx rename to src/__tests__/hooks/__tests__/useWindowResize.spec.tsx index cbf033ad..c4e1181b 100644 --- a/src/__tests__/useWindowResize.spec.tsx +++ b/src/__tests__/hooks/__tests__/useWindowResize.spec.tsx @@ -3,7 +3,7 @@ import 'jest-enzyme'; import debounce = require('lodash/debounce'); import * as React from 'react'; -import { useWindowResize } from '../hooks/useWindowResize'; +import { useWindowResize } from '../../../hooks/useWindowResize'; describe('useWindowResize hook', () => { let addEventListenerSpy: jest.SpyInstance;