From a82d3f4c9da7a5ef55b78f17bb1b4ce191b35e0e Mon Sep 17 00:00:00 2001 From: Harry Liversedge Date: Fri, 8 Oct 2021 18:15:06 +0100 Subject: [PATCH] feat(tooltip): Add optional prop to auto orientate within parent bounds (#9556) * feat(tooltip): add test story * feat(tooltip): add ability to update orientation * feat(tooltip): implement updates for direction * feat(tooltip): update orientation logic change * feat(tooltip): add prop to control behaviour * feat(tooltip): add remaining orientation checks * feat(tooltip): update naming * feat(tooltip): move logic into tooltip * feat(tooltip): fix tests * feat(tooltip): remove console statements * feat(tooltip): standarize comments in functions * feat(tooltip): add tooltips to all corners for clarity Co-authored-by: Harry-Liversedge Co-authored-by: Taylor Jones --- .../src/components/Tooltip/Tooltip-story.js | 98 +++++++++ .../react/src/components/Tooltip/Tooltip.js | 191 +++++++++++++++++- packages/react/src/internal/FloatingMenu.js | 26 ++- 3 files changed, 306 insertions(+), 9 deletions(-) diff --git a/packages/react/src/components/Tooltip/Tooltip-story.js b/packages/react/src/components/Tooltip/Tooltip-story.js index 03e25e764ca9..f0c26ecf9134 100644 --- a/packages/react/src/components/Tooltip/Tooltip-story.js +++ b/packages/react/src/components/Tooltip/Tooltip-story.js @@ -44,6 +44,17 @@ const props = { '' ), }), + autoOrientation: () => ({ + align: select('Tooltip alignment (align)', alignments, 'center'), + direction: select('Tooltip direction (direction)', directions, 'bottom'), + triggerText: text('Trigger text (triggerText)', 'Test'), + tabIndex: number('Tab index (tabIndex in )', 0), + selectorPrimaryFocus: text( + 'Primary focus element selector (selectorPrimaryFocus)', + '' + ), + autoOrientation: boolean('Auto orientation', true), + }), withoutIcon: () => ({ showIcon: false, align: select('Tooltip alignment (align)', alignments, 'center'), @@ -178,6 +189,93 @@ DefaultBottom.parameters = { }, }; +export const AutoOrientation = () => ( +
+ {/* Top Left */} +
+ +

+ This is some tooltip text. This box shows the maximum amount of text + that should appear inside. If more room is needed please use a modal + instead. +

+
+ + Learn More + + +
+
+
+ {/* Top Right */} +
+ +

+ This is some tooltip text. This box shows the maximum amount of text + that should appear inside. If more room is needed please use a modal + instead. +

+
+ + Learn More + + +
+
+
+ {/* Bottom Left */} +
+ +

+ This is some tooltip text. This box shows the maximum amount of text + that should appear inside. If more room is needed please use a modal + instead. +

+
+ + Learn More + + +
+
+
+ {/* Bottom Right */} +
+ +

+ This is some tooltip text. This box shows the maximum amount of text + that should appear inside. If more room is needed please use a modal + instead. +

+
+ + Learn More + + +
+
+
+
+); + +AutoOrientation.storyName = 'auto orientation'; + +AutoOrientation.parameters = { + info: { + text: ` + Interactive tooltip should be used if there are actions a user can take in the tooltip (e.g. a link or a button). + For more regular use case, e.g. giving the user more text information about something, use definition tooltip or icon tooltip. + By default, the tooltip will render above the element. The example below shows the default scenario. + `, + }, +}; + export const NoIcon = () => (
diff --git a/packages/react/src/components/Tooltip/Tooltip.js b/packages/react/src/components/Tooltip/Tooltip.js index 751f13f6bb87..0ec8ba2924d5 100644 --- a/packages/react/src/components/Tooltip/Tooltip.js +++ b/packages/react/src/components/Tooltip/Tooltip.js @@ -83,7 +83,11 @@ class Tooltip extends Component { return; } const open = useControlledStateWithValue ? props.defaultOpen : props.open; - this.state = { open }; + this.state = { + open, + storedDirection: props.direction, + storedAlign: props.align, + }; } static propTypes = { @@ -93,6 +97,11 @@ class Tooltip extends Component { */ align: PropTypes.oneOf(['start', 'center', 'end']), + /** + * Whether or not to re-orientate the tooltip if it goes outside, + * of the bounds of the parent. + */ + autoOrientation: PropTypes.bool, /** * Contents to put into the tooltip. */ @@ -263,6 +272,172 @@ class Tooltip extends Component { document.addEventListener('keydown', this.handleEscKeyPress, false); } + componentDidUpdate(prevProps, prevState) { + if (prevProps.direction != this.props.direction) { + this.setState({ storedDirection: this.props.direction }); + } + if (prevProps.align != this.props.align) { + this.setState({ storedAlign: this.props.align }); + } + if (prevState.open && !this.state.open) { + // Reset orientation when closing + this.setState({ + storedDirection: this.props.direction, + storedAlign: this.props.align, + }); + } + } + + updateOrientation = (params) => { + if (this.props.autoOrientation) { + const newOrientation = this.getBestDirection(params); + const { direction, align } = newOrientation; + + if (direction !== this.state.storedDirection) { + this.setState({ open: false }, () => { + this.setState({ open: true, storedDirection: direction }); + }); + } + + if (align === 'original') { + this.setState({ storedAlign: this.props.align }); + } else { + this.setState({ storedAlign: align }); + } + } + }; + + getBestDirection = ({ + menuSize, + refPosition = {}, + offset = {}, + direction = DIRECTION_BOTTOM, + scrollX: pageXOffset = 0, + scrollY: pageYOffset = 0, + container, + }) => { + const { + left: refLeft = 0, + top: refTop = 0, + right: refRight = 0, + bottom: refBottom = 0, + } = refPosition; + const scrollX = container.position !== 'static' ? 0 : pageXOffset; + const scrollY = container.position !== 'static' ? 0 : pageYOffset; + const relativeDiff = { + top: container.position !== 'static' ? container.rect.top : 0, + left: container.position !== 'static' ? container.rect.left : 0, + }; + const { width, height } = menuSize; + const { top = 0, left = 0 } = offset; + const refCenterHorizontal = (refLeft + refRight) / 2; + const refCenterVertical = (refTop + refBottom) / 2; + + // Calculate whether a new direction is needed to stay in parent. + // It will switch the current direction to the opposite i.e. + // If the direction="top" and the top boundary is overflowed + // then it switches the direction to "bottom". + const newDirection = () => { + switch (direction) { + case DIRECTION_LEFT: + return refLeft - width + scrollX - left - relativeDiff.left < 0 + ? DIRECTION_RIGHT + : direction; + case DIRECTION_TOP: + return refTop - height + scrollY - top - relativeDiff.top < 0 + ? DIRECTION_BOTTOM + : direction; + case DIRECTION_RIGHT: + return refRight + scrollX + left - relativeDiff.left + width > + container.rect.width + ? DIRECTION_LEFT + : direction; + case DIRECTION_BOTTOM: + return refBottom + scrollY + top - relativeDiff.top + height > + container.rect.height + ? DIRECTION_TOP + : direction; + default: + // If there is a new direction then ignore the logic above + return direction; + } + }; + + // Calculate whether a new alignment is needed to stay in parent + // If the direction is left or right this involves checking the + // overflow in the vertical direction. If the direction is top or + // bottom, this involves checking overflow in the horizontal direction. + // "original" is used to signify no change. + const newAlignment = () => { + switch (direction) { + case DIRECTION_LEFT: + case DIRECTION_RIGHT: + if ( + refCenterVertical - + height / 2 + + scrollY + + top - + 9 - + relativeDiff.top < + 0 + ) { + // If goes above the top boundary + return 'start'; + } else if ( + refCenterVertical - + height / 2 + + scrollY + + top - + 9 - + relativeDiff.top + + height > + container.rect.height + ) { + // If goes below the bottom boundary + return 'end'; + } else { + // No need to change alignment + return 'original'; + } + case DIRECTION_TOP: + case DIRECTION_BOTTOM: + if ( + refCenterHorizontal - + width / 2 + + scrollX + + left - + relativeDiff.left < + 0 + ) { + // If goes below the left boundary + return 'start'; + } else if ( + refCenterHorizontal - + width / 2 + + scrollX + + left - + relativeDiff.left + + width > + container.rect.width + ) { + // If it goes over the right boundary + return 'end'; + } else { + // No need to change alignment + return 'original'; + } + default: + // No need to change alignment + return 'original'; + } + }; + + return { + direction: newDirection(), + align: newAlignment(), + }; + }; + componentWillUnmount() { if (this._debouncedHandleFocus) { this._debouncedHandleFocus.cancel(); @@ -430,8 +605,6 @@ class Tooltip extends Component { children, className, triggerClassName, - direction, - align, focusTrap, triggerText, showIcon, @@ -447,13 +620,14 @@ class Tooltip extends Component { } = this.props; const { open } = this.isControlled ? this.props : this.state; + const { storedDirection, storedAlign } = this.state; const tooltipClasses = classNames( `${prefix}--tooltip`, { [`${prefix}--tooltip--shown`]: open, - [`${prefix}--tooltip--${direction}`]: direction, - [`${prefix}--tooltip--align-${align}`]: align, + [`${prefix}--tooltip--${storedDirection}`]: storedDirection, + [`${prefix}--tooltip--align-${storedAlign}`]: storedAlign, }, className ); @@ -523,16 +697,17 @@ class Tooltip extends Component { selectorPrimaryFocus={this.props.selectorPrimaryFocus} target={this._getTarget} triggerRef={this._triggerRef} - menuDirection={direction} + menuDirection={storedDirection} menuOffset={menuOffset} menuRef={(node) => { this._tooltipEl = node; - }}> + }} + updateOrientation={this.updateOrientation}>