Skip to content

Commit

Permalink
feat(tooltip): Add optional prop to auto orientate within parent boun…
Browse files Browse the repository at this point in the history
…ds (#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 <[email protected]>
Co-authored-by: Taylor Jones <[email protected]>
  • Loading branch information
3 people authored Oct 8, 2021
1 parent 3e0231d commit a82d3f4
Show file tree
Hide file tree
Showing 3 changed files with 306 additions and 9 deletions.
98 changes: 98 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Tooltip>)', 0),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
''
),
autoOrientation: boolean('Auto orientation', true),
}),
withoutIcon: () => ({
showIcon: false,
align: select('Tooltip alignment (align)', alignments, 'center'),
Expand Down Expand Up @@ -178,6 +189,93 @@ DefaultBottom.parameters = {
},
};

export const AutoOrientation = () => (
<div
style={{
...containerStyles,
justifyContent: 'unset',
alignItems: 'unset',
flexWrap: 'wrap',
}}>
{/* Top Left */}
<div style={{ flex: '50%' }}>
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
<p id="tooltip-body">
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.
</p>
<div className={`${prefix}--tooltip__footer`}>
<a href="/" className={`${prefix}--link`}>
Learn More
</a>
<Button size="small">Create</Button>
</div>
</Tooltip>
</div>
{/* Top Right */}
<div style={{ flex: '50%', textAlign: 'right' }}>
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
<p id="tooltip-body">
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.
</p>
<div className={`${prefix}--tooltip__footer`}>
<a href="/" className={`${prefix}--link`}>
Learn More
</a>
<Button size="small">Create</Button>
</div>
</Tooltip>
</div>
{/* Bottom Left */}
<div style={{ flex: '50%', marginTop: 'auto' }}>
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
<p id="tooltip-body">
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.
</p>
<div className={`${prefix}--tooltip__footer`}>
<a href="/" className={`${prefix}--link`}>
Learn More
</a>
<Button size="small">Create</Button>
</div>
</Tooltip>
</div>
{/* Bottom Right */}
<div style={{ flex: '50%', textAlign: 'right', marginTop: 'auto' }}>
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
<p id="tooltip-body">
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.
</p>
<div className={`${prefix}--tooltip__footer`}>
<a href="/" className={`${prefix}--link`}>
Learn More
</a>
<Button size="small">Create</Button>
</div>
</Tooltip>
</div>
</div>
);

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 = () => (
<div style={containerStyles}>
<Tooltip {...props.withoutIcon()}>
Expand Down
191 changes: 183 additions & 8 deletions packages/react/src/components/Tooltip/Tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -430,8 +605,6 @@ class Tooltip extends Component {
children,
className,
triggerClassName,
direction,
align,
focusTrap,
triggerText,
showIcon,
Expand All @@ -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
);
Expand Down Expand Up @@ -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}>
<div
className={tooltipClasses}
{...other}
id={this._tooltipId}
data-floating-menu-direction={direction}
data-floating-menu-direction={storedDirection}
onMouseOver={this.handleMouse}
onMouseOut={this.handleMouse}
onFocus={this.handleMouse}
Expand Down
Loading

0 comments on commit a82d3f4

Please sign in to comment.