From fc7ca82cb3f41575958fc500842a7a291f14c365 Mon Sep 17 00:00:00 2001 From: Ben Sidelinger Date: Fri, 1 Feb 2019 20:46:47 -0800 Subject: [PATCH] Render on show (#33) * Defer rendering until the first time the tip is shown * Add tipContentsClassName prop * Add forceDirection prop --- .eslintrc | 4 +- README.md | 27 ++++-- example/index.css | 4 - example/index.jsx | 63 +++++++------ src/getDirection.js | 37 ++++++-- src/index.d.ts | 24 ++--- src/index.jsx | 218 +++++++++++++++++++++++++++++--------------- src/position.js | 20 ++-- 8 files changed, 257 insertions(+), 140 deletions(-) diff --git a/.eslintrc b/.eslintrc index ea50997..688459a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -71,12 +71,10 @@ "no-unused-vars": [ 1 ], + "no-unused-expressions": [ 0 ], "react/prefer-stateless-function": [ 0 ], - "react/prop-types": [ - 0 - ], "react/jsx-indent": [ 2, 2 diff --git a/README.md b/README.md index 45e7e0a..f6ca4e5 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,29 @@ You can pass in props to define tip direction, styling, etc. Content is the onl string the tip direction, defaults to up. Possible values are "up", "down", "left", "right" with optional modifer for alignment of "start" and "end". e.g. "left-start" will attempt tooltip on left and align it with the start of the target. If alignment modifier is not specified the default behavior is to align "middle". + + forceDirection + boolean + Tells the tip to allow itself to render out of view if there's not room for the specified direction. If undefined or false, the tip will change direction as needed to render within the confines of the window. + className string - css class added to the rendered wrapper + + css class added to the rendered wrapper (and the tooltip if tooltipClassName is undefined) + NOTE: in future versions className will only be applied to the wrapper element and not the tooltip + + + + tipContentClassName + string + css class added to the tooltip + + + tipContentHover + boolean + defines whether you should be able to hover over the tip contents for links and copying content, + defaults to false. background @@ -130,12 +149,6 @@ You can pass in props to define tip direction, styling, etc. Content is the onl boolean forces open/close state from a prop, overrides hover or click state - - tipContentHover - boolean - defines whether you should be able to hover over the tip contents for links and copying content, - defaults to false. - hoverDelay number diff --git a/example/index.css b/example/index.css index 7f9db08..8af1be0 100644 --- a/example/index.css +++ b/example/index.css @@ -8,10 +8,6 @@ section { margin-bottom: 50px; } -section:last-child { - margin-bottom: 400px; -} - a { display: inline-block; } diff --git a/example/index.jsx b/example/index.jsx index b9442a5..c685344 100644 --- a/example/index.jsx +++ b/example/index.jsx @@ -29,7 +29,7 @@ class App extends React.Component { } bodyClick(e) { - if (this.tipContentRef.contains(e.target) || this.buttonRef.contains(e.target)) { + if ((this.tipContentRef && this.tipContentRef.contains(e.target)) || this.buttonRef.contains(e.target)) { return; } @@ -46,15 +46,15 @@ class App extends React.Component {

Basic:

- + Target - + Target - + t
@@ -73,7 +73,8 @@ class App extends React.Component { the edge . -

+ You can also force the direction of the tip and it will allow itself to go off screen. +

@@ -96,6 +97,7 @@ class App extends React.Component { direction="down" tagName="span" className="target" + tipContentClassName="" > Html content . @@ -112,6 +114,7 @@ class App extends React.Component { tagName="span" direction="right" className="target" + tipContentClassName="" tipContentHover > example @@ -122,7 +125,7 @@ class App extends React.Component {

Colors

- You can pass + You can pass color options as props or use a 

Custom events

- + Close on click

@@ -176,6 +179,7 @@ class App extends React.Component {

- + Toggle on click

@@ -197,7 +201,7 @@ class App extends React.Component { pass the {'"defaultStyles"'} prop as true to get up and running quick and easy

- + See default styles

@@ -220,6 +224,7 @@ class App extends React.Component { isOpen={tipOpen} tagName="span" direction="down" + forceDirection > click the button
@@ -229,10 +234,10 @@ class App extends React.Component {

Distance and arrow size

- Larger arrowSize - Smaller arrowSize - Increase distance - Decrease distance + Larger arrowSize + Smaller arrowSize + Increase distance + Decrease distance
@@ -240,70 +245,70 @@ class App extends React.Component {

Compound Alignment

- + right-start - + right-end - + left-start - + left-end - + top-start - + top-end - + down-start - + down-end


- + right-start with arrow - + right-end with arrow - + left-start with arrow - + left-end with arrow - + down-start with arrow - + down-end with arrow - + up-start with arrow - + up-end with arrow
diff --git a/src/getDirection.js b/src/getDirection.js index 2a073e3..a24fd70 100644 --- a/src/getDirection.js +++ b/src/getDirection.js @@ -1,7 +1,7 @@ /** * Checks the intended tip direction and falls back if not enough space */ -import { getScrollLeft, getArrowSpacing } from './position'; +import { getScrollLeft, getArrowSpacing, minArrowPadding } from './position'; function checkLeftRightWidthSufficient(tip, target, distance, bodyPadding) { const targetRect = target.getBoundingClientRect(); @@ -10,10 +10,31 @@ function checkLeftRightWidthSufficient(tip, target, distance, bodyPadding) { return (tip.offsetWidth + target.offsetWidth + distance + bodyPadding + deadSpace < document.documentElement.clientWidth); } -function checkTargetFullyVisible(target) { - const bottomOverhang = target.getBoundingClientRect().bottom > window.innerHeight; - const topOverhang = target.getBoundingClientRect().top < 0; +function checkTargetSufficientlyVisible(target, tip, props) { + const targetRect = target.getBoundingClientRect(); + const bottomOverhang = targetRect.bottom > window.innerHeight; + const topOverhang = targetRect.top < 0; + + // if the target is taller than the viewport (and we know there's sufficient left/right width before this is called), + // then go with the left/right direction as top/bottom will both be off screen + if (topOverhang && bottomOverhang) { + return true; + } + + // if the target is bigger than the tip, we need to check if enough of the target is visible + if (target.offsetHeight > tip.offsetHeight) { + const halfTargetHeight = target.offsetHeight / 2; + const arrowClearance = props.arrowSize + minArrowPadding; + const bottomOverhangAmount = targetRect.bottom - window.innerHeight; + const topOverhangAmount = -targetRect.top; + + const targetCenterToBottomOfWindow = halfTargetHeight - bottomOverhangAmount; + const targetCenterToTopOfWindow = halfTargetHeight - topOverhangAmount; + + return (targetCenterToBottomOfWindow >= arrowClearance && targetCenterToTopOfWindow >= arrowClearance); + } + // otherwise just check that the whole target is visible return (!bottomOverhang && !topOverhang); } @@ -46,7 +67,7 @@ export default function getDirection(currentDirection, tip, target, props, bodyP switch (currentDirection) { case 'right': // if the window is not wide enough try top (which falls back to down) - if (!checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding) || !checkTargetFullyVisible(target)) { + if (!checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding) || !checkTargetSufficientlyVisible(target, tip, props)) { return getDirection('up', tip, target, arrowSpacing, bodyPadding, arrowStyles, true); } @@ -58,7 +79,7 @@ export default function getDirection(currentDirection, tip, target, props, bodyP case 'left': // if the window is not wide enough try top (which falls back to down) - if (!checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding) || !checkTargetFullyVisible(target)) { + if (!checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding) || !checkTargetSufficientlyVisible(target, tip, props)) { return getDirection('up', tip, target, arrowSpacing, bodyPadding, arrowStyles, true); } @@ -76,7 +97,7 @@ export default function getDirection(currentDirection, tip, target, props, bodyP if (!hasSpaceAbove) { if (hasSpaceBelow) { return 'down'; - } else if (checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding)) { + } else if (!recursive && checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding)) { return getDirection('right', tip, target, arrowSpacing, bodyPadding, arrowStyles, true); } } @@ -95,7 +116,7 @@ export default function getDirection(currentDirection, tip, target, props, bodyP return 'up'; // if there's not space above or below, check if there would be space left or right - } else if (checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding)) { + } else if (!recursive && checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding)) { return getDirection('right', tip, target, arrowSpacing, bodyPadding, arrowStyles, true); } diff --git a/src/index.d.ts b/src/index.d.ts index e74f355..7c0b025 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -3,25 +3,27 @@ declare module 'react-tooltip-lite' { import * as React from 'react'; export interface TooltipProps { - tagName?: string; - direction?: string; - className?: string; - content: React.ReactNode; + arrow?: boolean; + arrowSize?: number; background?: string; + className?: string; color?: string; - padding?: string; + content: React.ReactNode; + direction?: string; distance?: number; - styles?: object; eventOff?: string; eventOn?: string; eventToggle?: string; - useHover?: boolean; - useDefaultStyles?: boolean; - isOpen?: boolean; + forceDirection?: boolean; hoverDelay?: number; + isOpen?: boolean; + padding?: string; + styles?: object; + tagName?: string; tipContentHover?: boolean; - arrow?: boolean; - arrowSize?: number; + tipContentClassName?: string; + useHover?: boolean; + useDefaultStyles?: boolean; } export default class Tooltip extends React.Component { diff --git a/src/index.jsx b/src/index.jsx index 63f07c5..274b8a1 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -12,53 +12,64 @@ import positions from './position'; const defaultColor = '#fff'; const defaultBg = '#333'; +const resizeThrottle = 100; +const resizeThreshold = 5; + const stopProp = e => e.stopPropagation(); class Tooltip extends React.Component { static propTypes = { - // eslint-disable-next-line react/no-unused-prop-types - tagName: PropTypes.string, - direction: PropTypes.string, - className: PropTypes.string, - content: PropTypes.node.isRequired, + arrow: PropTypes.bool, + arrowSize: PropTypes.number, background: PropTypes.string, + children: PropTypes.node.isRequired, + className: PropTypes.string, color: PropTypes.string, - padding: PropTypes.string, - styles: PropTypes.object, + content: PropTypes.node.isRequired, + direction: PropTypes.string, + distance: PropTypes.number, eventOff: PropTypes.string, eventOn: PropTypes.string, eventToggle: PropTypes.string, - useHover: PropTypes.bool, - useDefaultStyles: PropTypes.bool, - isOpen: PropTypes.bool, + forceDirection: PropTypes.bool, hoverDelay: PropTypes.number, + isOpen: PropTypes.bool, + padding: PropTypes.string, + styles: PropTypes.object, + tagName: PropTypes.string, tipContentHover: PropTypes.bool, - arrow: PropTypes.bool, - arrowSize: PropTypes.number, - distance: PropTypes.number, + tipContentClassName: PropTypes.string, + useDefaultStyles: PropTypes.bool, + useHover: PropTypes.bool, } static defaultProps = { - tagName: 'div', - direction: 'up', - className: '', + arrow: true, + arrowSize: 10, background: '', + className: '', color: '', + direction: 'up', + distance: undefined, + forceDirection: false, + hoverDelay: 200, padding: '10px', styles: {}, - useHover: true, - useDefaultStyles: false, - hoverDelay: 200, + tagName: 'div', tipContentHover: false, - arrow: true, - arrowSize: 10, - distance: undefined, + tipContentClassName: undefined, + useDefaultStyles: false, + useHover: true, + } + + static getDerivedStateFromProps(nextProps) { + return nextProps.isOpen ? { hasBeenShown: true } : null; } constructor() { super(); - this.state = { showTip: false, hasHover: false, ignoreShow: false }; + this.state = { showTip: false, hasHover: false, ignoreShow: false, hasBeenShown: false }; this.showTip = this.showTip.bind(this); this.hideTip = this.hideTip.bind(this); @@ -66,6 +77,8 @@ class Tooltip extends React.Component { this.toggleTip = this.toggleTip.bind(this); this.startHover = this.startHover.bind(this); this.endHover = this.endHover.bind(this); + this.listenResizeScroll = this.listenResizeScroll.bind(this); + this.handleResizeScroll = this.handleResizeScroll.bind(this); } componentDidMount() { @@ -75,13 +88,59 @@ class Tooltip extends React.Component { // eslint-disable-next-line react/no-did-mount-set-state this.setState({ isOpen: true }); } + + window.addEventListener('resize', this.listenResizeScroll); + window.addEventListener('scroll', this.listenResizeScroll); + } + + componentDidUpdate(_, prevState) { + // older versions of react won't leverage getDerivedStateFromProps, TODO: remove when < 16.3 support is dropped + if (!this.state.hasBeenShown && this.props.isOpen) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ hasBeenShown: true }); + + return setTimeout(this.showTip, 0); + } + + // we need to render once to get refs in place, then we can make the calculations on a followup render + // this only has to happen the first time the tip is shown, and allows us to not render every tip on the page with initial render. + if (!prevState.hasBeenShown && this.state.hasBeenShown) { + this.showTip(); + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.listenResizeScroll); + window.removeEventListener('scroll', this.listenResizeScroll); + clearTimeout(this.debounceTimeout); + } + + debounceTimeout = false; + + listenResizeScroll() { + clearTimeout(this.debounceTimeout); + + this.debounceTimeout = setTimeout(this.handleResizeScroll, resizeThrottle); + } + + handleResizeScroll() { + if (this.state.showTip) { + // if we're showing the tip and the resize was actually a signifigant change, then setState to re-render and calculate position + const clientWidth = Math.round(document.documentElement.clientWidth / resizeThreshold) * resizeThreshold; + this.setState({ clientWidth }); + } } toggleTip() { - this.setState({ showTip: !this.state.showTip }); + this.state.showTip ? this.hideTip() : this.showTip(); } showTip() { + if (!this.state.hasBeenShown) { + // this will render once, then fire componentDidUpdate, which will show the tip + return this.setState({ hasBeenShown: true }); + } + this.setState({ showTip: true }); } @@ -90,78 +149,61 @@ class Tooltip extends React.Component { this.setState({ showTip: false }); } + hoverTimeout = false; + startHover() { if (!this.state.ignoreShow) { this.setState({ hasHover: true }); - setTimeout(this.checkHover, this.props.hoverDelay); + clearTimeout(this.hoverTimeout); + this.hoverTimeout = setTimeout(this.checkHover, this.props.hoverDelay); } } endHover() { this.setState({ hasHover: false }); - setTimeout(this.checkHover, this.props.hoverDelay); + clearTimeout(this.hoverTimeout); + this.hoverTimeout = setTimeout(this.checkHover, this.props.hoverDelay); } checkHover() { - this.setState({ showTip: this.state.hasHover }); + this.state.hasHover ? this.showTip() : this.hideTip(); } render() { const { - direction, + arrow, + arrowSize, + background, className, - padding, children, + color, content, - styles, - eventOn, + direction, + distance, eventOff, + eventOn, eventToggle, - useHover, - background, - color, - useDefaultStyles, + forceDirection, isOpen, + padding, + styles, + tagName: TagName, tipContentHover, - arrow, - arrowSize, - distance, + tipContentClassName, + useDefaultStyles, + useHover, } = this.props; - const showTip = (typeof isOpen === 'undefined') ? this.state.showTip : isOpen; - const currentPositions = positions(direction, this.tip, this.target, { ...this.state, showTip }, { - background: useDefaultStyles ? defaultBg : background, - arrow, - arrowSize, - distance, - }); + const isControlledByProps = !(typeof isOpen === 'undefined'); + const showTip = isControlledByProps ? isOpen : this.state.showTip; const wrapperStyles = { position: 'relative', ...styles, }; - const tipStyles = { - ...currentPositions.tip, - background: useDefaultStyles ? defaultBg : background, - color: useDefaultStyles ? defaultColor : color, - padding, - boxSizing: 'border-box', - zIndex: 1000, - position: 'absolute', - display: 'inline-block', - }; - - const arrowStyles = { - ...currentPositions.arrow, - position: 'absolute', - width: '0px', - height: '0px', - zIndex: 1001, - }; - const props = { style: wrapperStyles, ref: (target) => { this.target = target; }, @@ -186,7 +228,7 @@ class Tooltip extends React.Component { props[eventToggle] = this.toggleTip; // only use hover if they don't have a toggle event - } else if (useHover) { + } else if (useHover && !isControlledByProps) { props.onMouseOver = this.startHover; props.onMouseOut = tipContentHover ? this.endHover : this.hideTip; props.onTouchStart = this.toggleTip; @@ -198,19 +240,53 @@ class Tooltip extends React.Component { } } - return ( - - {children} - + // conditional rendering of tip + let tipPortal; + + if (this.state.hasBeenShown) { + const currentPositions = positions(direction, forceDirection, this.tip, this.target, { ...this.state, showTip }, { + background: useDefaultStyles ? defaultBg : background, + arrow, + arrowSize, + distance, + }); + + const tipStyles = { + ...currentPositions.tip, + background: useDefaultStyles ? defaultBg : background, + color: useDefaultStyles ? defaultColor : color, + padding, + boxSizing: 'border-box', + zIndex: 1000, + position: 'absolute', + display: 'inline-block', + }; + + const arrowStyles = { + ...currentPositions.arrow, + position: 'absolute', + width: '0px', + height: '0px', + zIndex: 1001, + }; + + tipPortal = ( -
+
{ this.tip = tip; }}> {content}
- + ); + } + + return ( + + {children} + {tipPortal} + ); } } diff --git a/src/position.js b/src/position.js index 87316d1..bec5f19 100644 --- a/src/position.js +++ b/src/position.js @@ -5,8 +5,8 @@ import getDirection from './getDirection'; +export const minArrowPadding = 5; const bodyPadding = 10; -const minArrowPadding = 5; const noArrowDistance = 3; /** @@ -51,9 +51,11 @@ function getUpDownPosition(tip, target, state, direction, alignMode, props) { let left = -10000000; let top; + const transform = state.showTip ? undefined : 'translateX(-10000000px)'; + const arrowSpacing = getArrowSpacing(props); - if (tip && state.showTip) { + if (tip) { // get wrapper left position const scrollLeft = getScrollLeft(); @@ -97,6 +99,7 @@ function getUpDownPosition(tip, target, state, direction, alignMode, props) { return { left, top, + transform, }; } @@ -108,10 +111,12 @@ function getLeftRightPosition(tip, target, state, direction, alignMode, props) { let left = -10000000; let top = 0; + const transform = state.showTip ? undefined : 'translateX(-10000000px)'; + const arrowSpacing = getArrowSpacing(props); const arrowPadding = props.arrow ? minArrowPadding : 0; - if (tip && state.showTip) { + if (tip) { const scrollTop = getScrollTop(); const scrollLeft = getScrollLeft(); const targetRect = target.getBoundingClientRect(); @@ -151,6 +156,7 @@ function getLeftRightPosition(tip, target, state, direction, alignMode, props) { return { left, top, + transform, }; } @@ -249,21 +255,21 @@ function getArrowStyles(target, tip, direction, state, props) { /** * Returns the positions style rules */ -export default function positions(direction, tip, target, state, props) { +export default function positions(direction, forceDirection, tip, target, state, props) { const alignMode = parseAlignMode(direction); const trimmedDirection = direction.split('-')[0]; let realDirection = trimmedDirection; - if (tip && state.showTip) { + if (!forceDirection && tip) { const testArrowStyles = props.arrow && getArrowStyles(target, tip, trimmedDirection, state, props); realDirection = getDirection(trimmedDirection, tip, target, props, bodyPadding, testArrowStyles); } const maxWidth = getTipMaxWidth(); - // force the tip to display the width we measured everything at when visible, when scrolled + // force the tip to display the width we measured everything at when visible let width; - if (tip && state.showTip && getScrollLeft() > 0) { + if (tip) { // adding the exact width on the first render forces a bogus line break, so add 1px the first time const spacer = tip.style.width ? 0 : 1; width = Math.min(tip.offsetWidth, maxWidth) + spacer;