diff --git a/CHANGELOG.md b/CHANGELOG.md index 658b1a768af..f3e1ee46211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ - Added `stop` and `stopFilled` icons ([#543](https://github.com/elastic/eui/pull/543)) +**Bug fixes** + +- Fix `EuiToolTip` smart positioning to prevent tooltip from being clipped by the window where possible ([#550]https://github.com/elastic/eui/pull/550) + # [`0.0.31`](https://github.com/elastic/eui/tree/v0.0.31) - Made `` TypeScript types more specific ([#518](https://github.com/elastic/eui/pull/518)) diff --git a/src-docs/src/views/tool_tip/icon_tip.js b/src-docs/src/views/tool_tip/icon_tip.js index e94c55bde76..1c40b4fbd10 100644 --- a/src-docs/src/views/tool_tip/icon_tip.js +++ b/src-docs/src/views/tool_tip/icon_tip.js @@ -10,7 +10,7 @@ import { export default () => ( - + ( - - alert('Buttons are still clickable within tooltips.')}>Hover over me + Works on any kind of element — buttons, inputs, you name it!

}> + alert('Buttons are still clickable within tooltips.')}>Hover me
); diff --git a/src/components/tool_tip/tool_tip.js b/src/components/tool_tip/tool_tip.js index b56bc3da12a..a9883c3f94f 100644 --- a/src/components/tool_tip/tool_tip.js +++ b/src/components/tool_tip/tool_tip.js @@ -8,7 +8,7 @@ import classNames from 'classnames'; import { EuiPortal } from '../portal'; import { EuiToolTipPopover } from './tool_tip_popover'; -import { calculatePopoverPosition, calculatePopoverStyles } from '../../services'; +import { calculatePopoverPosition } from '../../services'; import makeId from '../form/form_row/make_id'; @@ -38,16 +38,20 @@ export class EuiToolTip extends Component { this.setState({ visible: true }); }; - positionToolTip = (toolTipRect) => { - const wrapperRect = this.wrapper.getBoundingClientRect(); - const userPosition = this.props.position; + positionToolTip = (toolTipBounds) => { + const anchorBounds = this.anchor.getBoundingClientRect(); + const requestedPosition = this.props.position; - const calculatedPosition = calculatePopoverPosition(wrapperRect, toolTipRect, userPosition); - const toolTipStyles = calculatePopoverStyles(wrapperRect, toolTipRect, calculatedPosition); + const { position, left, top } = calculatePopoverPosition(anchorBounds, toolTipBounds, requestedPosition); + + const toolTipStyles = { + top: top + window.scrollY, + left, + }; this.setState({ visible: true, - calculatedPosition, + calculatedPosition: position, toolTipStyles, }); }; @@ -111,7 +115,7 @@ export class EuiToolTip extends Component { } const trigger = ( - this.wrapper = wrapper}> + this.anchor = anchor}> {cloneElement(children, { onFocus: this.showToolTip, onBlur: this.hideToolTip, diff --git a/src/components/tool_tip/tool_tip_popover.js b/src/components/tool_tip/tool_tip_popover.js index 41063bff46b..3d5c1789c22 100644 --- a/src/components/tool_tip/tool_tip_popover.js +++ b/src/components/tool_tip/tool_tip_popover.js @@ -12,26 +12,20 @@ export class EuiToolTipPopover extends Component { positionToolTip: PropTypes.func.isRequired, } - constructor(props) { - super(props); - - this.updateDimensions = this.updateDimensions.bind(this); - } - - componentDidMount() { - document.body.classList.add('euiBody-hasToolTip'); - - this.updateDimensions(); - window.addEventListener('resize', this.updateDimensions); - } - - updateDimensions() { + updateDimensions = () => { requestAnimationFrame(() => { // Because of this delay, sometimes `positionToolTip` becomes unavailable. if (this.popover) { this.props.positionToolTip(this.popover.getBoundingClientRect()); } }); + }; + + componentDidMount() { + document.body.classList.add('euiBody-hasToolTip'); + + this.updateDimensions(); + window.addEventListener('resize', this.updateDimensions); } componentWillUnmount() { diff --git a/src/services/index.js b/src/services/index.js index b4598f5f9e1..e4f06d64ea8 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -64,5 +64,4 @@ export { export { calculatePopoverPosition, - calculatePopoverStyles, } from './popover'; diff --git a/src/services/popover/calculate_popover_position.js b/src/services/popover/calculate_popover_position.js new file mode 100644 index 00000000000..00b824df525 --- /dev/null +++ b/src/services/popover/calculate_popover_position.js @@ -0,0 +1,80 @@ +const getVisibleArea = (bounds, windowWidth, windowHeight) => { + const { left, top, width, height } = bounds; + // This is a common algorithm for finding the intersected area among two rectangles. + const dx = Math.min(left + width, windowWidth) - Math.max(left, 0); + const dy = Math.min(top + height, windowHeight) - Math.max(top, 0); + return dx * dy; +}; + +const positionAtTop = (anchorBounds, width, height, buffer) => { + const widthDifference = width - anchorBounds.width; + const left = anchorBounds.left - widthDifference * 0.5; + const top = anchorBounds.top - height - buffer; + return { left, top, width, height }; +}; + +const positionAtRight = (anchorBounds, width, height, buffer) => { + const left = anchorBounds.right + buffer; + const heightDifference = height - anchorBounds.height; + const top = anchorBounds.top - heightDifference * 0.5; + return { left, top, width, height }; +}; + +const positionAtBottom = (anchorBounds, width, height, buffer) => { + const widthDifference = width - anchorBounds.width; + const left = anchorBounds.left - widthDifference * 0.5; + const top = anchorBounds.bottom + buffer; + return { left, top, width, height }; +}; + +const positionAtLeft = (anchorBounds, width, height, buffer) => { + const left = anchorBounds.left - width - buffer; + const heightDifference = height - anchorBounds.height; + const top = anchorBounds.top - heightDifference * 0.5; + return { left, top, width, height }; +}; + +/** + * Determine the best position for a popover that avoids clipping by the window view port. + * + * @param {Object} anchorBounds - getBoundingClientRect() of the node the popover is tethered to (e.g. a button). + * @param {Object} popoverBounds - getBoundingClientRect() of the popover node (e.g. the tooltip). + * @param {string} requestedPosition - Position the user wants. One of ["top", "right", "bottom", "left"] + * @param {number} buffer - The space between the wrapper and the popover. Also the minimum space between the popover and the window. + * + * @returns {Object} With properties position (one of ["top", "right", "bottom", "left"]), left, top, width, and height. + */ +export function calculatePopoverPosition(anchorBounds, popoverBounds, requestedPosition, buffer = 16) { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const { width: popoverWidth, height: popoverHeight } = popoverBounds; + + const positionToBoundsMap = { + top: positionAtTop(anchorBounds, popoverWidth, popoverHeight, buffer), + right: positionAtRight(anchorBounds, popoverWidth, popoverHeight, buffer), + bottom: positionAtBottom(anchorBounds, popoverWidth, popoverHeight, buffer), + left: positionAtLeft(anchorBounds, popoverWidth, popoverHeight, buffer), + }; + + const positions = Object.keys(positionToBoundsMap); + + // Calculate how much area of the popover is visible at each position. + const positionToVisibleAreaMap = {}; + positions.forEach((position) => { + positionToVisibleAreaMap[position] = getVisibleArea(positionToBoundsMap[position], windowWidth, windowHeight); + }); + + // If the requested position clips the popover, find the position which clips the popover the least. + // Default to use the requested position. + let calculatedPopoverPosition = positions.reduce((mostVisiblePosition, position) => { + if (positionToVisibleAreaMap[position] > positionToVisibleAreaMap[mostVisiblePosition]) { + return position; + } + return mostVisiblePosition; + }, requestedPosition); + + return { + position: calculatedPopoverPosition, + ...positionToBoundsMap[calculatedPopoverPosition], + }; +} diff --git a/src/services/popover/index.js b/src/services/popover/index.js index 28cdd4279cc..5fc1aeb6ec1 100644 --- a/src/services/popover/index.js +++ b/src/services/popover/index.js @@ -1,2 +1 @@ -export { calculatePopoverPosition } from './popover_calculate_position'; -export { calculatePopoverStyles } from './popover_calculate_styles'; +export { calculatePopoverPosition } from './calculate_popover_position'; diff --git a/src/services/popover/popover_calculate_position.js b/src/services/popover/popover_calculate_position.js deleted file mode 100644 index afa6e9f3ab5..00000000000 --- a/src/services/popover/popover_calculate_position.js +++ /dev/null @@ -1,60 +0,0 @@ - -/** - * Determine the best position for a popup that avoids clipping by the window view port. - * - * @param {native DOM Element} wrapperRect - getBoundingClientRect() of wrapping node around the popover. - * @param {native DOM Element} popupRect - getBoundingClientRect() of the popup node. - * @param {string} requestedPosition - Position the user wants. One of ["top", "right", "bottom", "left"] - * @param {number} buffer - The space between the wrapper and the popup. Also the minimum space between the popup and the window. - * - * @returns {string} One of ["top", "right", "bottom", "left"] that ensures no window overflow. - */ -export function calculatePopoverPosition(wrapperRect, popupRect, requestedPosition, buffer = 16) { - - // determine popup overflow in each direction - // negative values signal window overflow, large values signal lots of free space - const popupOverflow = { - top: wrapperRect.top - (popupRect.height + (2 * buffer)), - right: window.innerWidth - wrapperRect.right - (popupRect.width + (2 * buffer)), - left: wrapperRect.left - (popupRect.width + (2 * buffer)), - bottom: window.innerHeight - wrapperRect.bottom - (popupRect.height + (2 * buffer)), - }; - - function hasCrossDimensionOverflow(key) { - if (key === 'left' || key === 'right') { - const domNodeCenterY = wrapperRect.top + (wrapperRect.height / 2); - const tooltipTop = domNodeCenterY - ((popupRect.height / 2) + buffer); - if (tooltipTop <= 0) { - return true; - } - const tooltipBottom = domNodeCenterY + (popupRect.height / 2) + buffer; - if (tooltipBottom >= window.innerHeight) { - return true; - } - } else { - const domNodeCenterX = wrapperRect.left + (wrapperRect.width / 2); - const tooltipLeft = domNodeCenterX - ((popupRect.width / 2) + buffer); - if (tooltipLeft <= 0) { - return true; - } - const tooltipRight = domNodeCenterX + (popupRect.width / 2) + buffer; - if (tooltipRight >= window.innerWidth) { - return true; - } - } - return false; - } - - let calculatedPopoverPosition = requestedPosition; - if (popupOverflow[requestedPosition] <= 0 || hasCrossDimensionOverflow(requestedPosition)) { - // requested position overflows window bounds - // select direction what has the most free space - Object.keys(popupOverflow).forEach((key) => { - if (popupOverflow[key] > popupOverflow[calculatedPopoverPosition] && !hasCrossDimensionOverflow(key)) { - calculatedPopoverPosition = key; - } - }); - } - - return calculatedPopoverPosition; -} diff --git a/src/services/popover/popover_calculate_styles.js b/src/services/popover/popover_calculate_styles.js deleted file mode 100644 index 741d3e2c422..00000000000 --- a/src/services/popover/popover_calculate_styles.js +++ /dev/null @@ -1,19 +0,0 @@ -export function calculatePopoverStyles(wrapperNodeRect, popupNodeRect, position, buffer = 16) { - const styles = {}; - - if (position === 'top') { - styles.top = wrapperNodeRect.top + window.scrollY - (popupNodeRect.height + buffer); - styles.left = wrapperNodeRect.left + (wrapperNodeRect.width / 2) - (popupNodeRect.width / 2); - } else if (position === 'bottom') { - styles.top = wrapperNodeRect.top + window.scrollY + wrapperNodeRect.height + buffer; - styles.left = wrapperNodeRect.left + (wrapperNodeRect.width / 2) - (popupNodeRect.width / 2); - } else if (position === 'right') { - styles.top = wrapperNodeRect.top + window.scrollY - ((popupNodeRect.height - wrapperNodeRect.height) / 2); - styles.left = wrapperNodeRect.left + wrapperNodeRect.width + buffer; - } else if (position === 'left') { - styles.top = wrapperNodeRect.top + window.scrollY - ((popupNodeRect.height - wrapperNodeRect.height) / 2); - styles.left = wrapperNodeRect.left - popupNodeRect.width - buffer; - } - - return styles; -}