Skip to content

Commit a82d3f4

Browse files
TuxedoFishHarry-Liversedgetay1orjones
authored
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 <Harry.Liversedge@ibm.com> Co-authored-by: Taylor Jones <tay1orjones@users.noreply.github.com>
1 parent 3e0231d commit a82d3f4

3 files changed

Lines changed: 306 additions & 9 deletions

File tree

packages/react/src/components/Tooltip/Tooltip-story.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ const props = {
4444
''
4545
),
4646
}),
47+
autoOrientation: () => ({
48+
align: select('Tooltip alignment (align)', alignments, 'center'),
49+
direction: select('Tooltip direction (direction)', directions, 'bottom'),
50+
triggerText: text('Trigger text (triggerText)', 'Test'),
51+
tabIndex: number('Tab index (tabIndex in <Tooltip>)', 0),
52+
selectorPrimaryFocus: text(
53+
'Primary focus element selector (selectorPrimaryFocus)',
54+
''
55+
),
56+
autoOrientation: boolean('Auto orientation', true),
57+
}),
4758
withoutIcon: () => ({
4859
showIcon: false,
4960
align: select('Tooltip alignment (align)', alignments, 'center'),
@@ -178,6 +189,93 @@ DefaultBottom.parameters = {
178189
},
179190
};
180191

192+
export const AutoOrientation = () => (
193+
<div
194+
style={{
195+
...containerStyles,
196+
justifyContent: 'unset',
197+
alignItems: 'unset',
198+
flexWrap: 'wrap',
199+
}}>
200+
{/* Top Left */}
201+
<div style={{ flex: '50%' }}>
202+
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
203+
<p id="tooltip-body">
204+
This is some tooltip text. This box shows the maximum amount of text
205+
that should appear inside. If more room is needed please use a modal
206+
instead.
207+
</p>
208+
<div className={`${prefix}--tooltip__footer`}>
209+
<a href="/" className={`${prefix}--link`}>
210+
Learn More
211+
</a>
212+
<Button size="small">Create</Button>
213+
</div>
214+
</Tooltip>
215+
</div>
216+
{/* Top Right */}
217+
<div style={{ flex: '50%', textAlign: 'right' }}>
218+
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
219+
<p id="tooltip-body">
220+
This is some tooltip text. This box shows the maximum amount of text
221+
that should appear inside. If more room is needed please use a modal
222+
instead.
223+
</p>
224+
<div className={`${prefix}--tooltip__footer`}>
225+
<a href="/" className={`${prefix}--link`}>
226+
Learn More
227+
</a>
228+
<Button size="small">Create</Button>
229+
</div>
230+
</Tooltip>
231+
</div>
232+
{/* Bottom Left */}
233+
<div style={{ flex: '50%', marginTop: 'auto' }}>
234+
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
235+
<p id="tooltip-body">
236+
This is some tooltip text. This box shows the maximum amount of text
237+
that should appear inside. If more room is needed please use a modal
238+
instead.
239+
</p>
240+
<div className={`${prefix}--tooltip__footer`}>
241+
<a href="/" className={`${prefix}--link`}>
242+
Learn More
243+
</a>
244+
<Button size="small">Create</Button>
245+
</div>
246+
</Tooltip>
247+
</div>
248+
{/* Bottom Right */}
249+
<div style={{ flex: '50%', textAlign: 'right', marginTop: 'auto' }}>
250+
<Tooltip {...props.autoOrientation()} tooltipBodyId="tooltip-body">
251+
<p id="tooltip-body">
252+
This is some tooltip text. This box shows the maximum amount of text
253+
that should appear inside. If more room is needed please use a modal
254+
instead.
255+
</p>
256+
<div className={`${prefix}--tooltip__footer`}>
257+
<a href="/" className={`${prefix}--link`}>
258+
Learn More
259+
</a>
260+
<Button size="small">Create</Button>
261+
</div>
262+
</Tooltip>
263+
</div>
264+
</div>
265+
);
266+
267+
AutoOrientation.storyName = 'auto orientation';
268+
269+
AutoOrientation.parameters = {
270+
info: {
271+
text: `
272+
Interactive tooltip should be used if there are actions a user can take in the tooltip (e.g. a link or a button).
273+
For more regular use case, e.g. giving the user more text information about something, use definition tooltip or icon tooltip.
274+
By default, the tooltip will render above the element. The example below shows the default scenario.
275+
`,
276+
},
277+
};
278+
181279
export const NoIcon = () => (
182280
<div style={containerStyles}>
183281
<Tooltip {...props.withoutIcon()}>

packages/react/src/components/Tooltip/Tooltip.js

Lines changed: 183 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ class Tooltip extends Component {
8383
return;
8484
}
8585
const open = useControlledStateWithValue ? props.defaultOpen : props.open;
86-
this.state = { open };
86+
this.state = {
87+
open,
88+
storedDirection: props.direction,
89+
storedAlign: props.align,
90+
};
8791
}
8892

8993
static propTypes = {
@@ -93,6 +97,11 @@ class Tooltip extends Component {
9397
*/
9498
align: PropTypes.oneOf(['start', 'center', 'end']),
9599

100+
/**
101+
* Whether or not to re-orientate the tooltip if it goes outside,
102+
* of the bounds of the parent.
103+
*/
104+
autoOrientation: PropTypes.bool,
96105
/**
97106
* Contents to put into the tooltip.
98107
*/
@@ -263,6 +272,172 @@ class Tooltip extends Component {
263272
document.addEventListener('keydown', this.handleEscKeyPress, false);
264273
}
265274

275+
componentDidUpdate(prevProps, prevState) {
276+
if (prevProps.direction != this.props.direction) {
277+
this.setState({ storedDirection: this.props.direction });
278+
}
279+
if (prevProps.align != this.props.align) {
280+
this.setState({ storedAlign: this.props.align });
281+
}
282+
if (prevState.open && !this.state.open) {
283+
// Reset orientation when closing
284+
this.setState({
285+
storedDirection: this.props.direction,
286+
storedAlign: this.props.align,
287+
});
288+
}
289+
}
290+
291+
updateOrientation = (params) => {
292+
if (this.props.autoOrientation) {
293+
const newOrientation = this.getBestDirection(params);
294+
const { direction, align } = newOrientation;
295+
296+
if (direction !== this.state.storedDirection) {
297+
this.setState({ open: false }, () => {
298+
this.setState({ open: true, storedDirection: direction });
299+
});
300+
}
301+
302+
if (align === 'original') {
303+
this.setState({ storedAlign: this.props.align });
304+
} else {
305+
this.setState({ storedAlign: align });
306+
}
307+
}
308+
};
309+
310+
getBestDirection = ({
311+
menuSize,
312+
refPosition = {},
313+
offset = {},
314+
direction = DIRECTION_BOTTOM,
315+
scrollX: pageXOffset = 0,
316+
scrollY: pageYOffset = 0,
317+
container,
318+
}) => {
319+
const {
320+
left: refLeft = 0,
321+
top: refTop = 0,
322+
right: refRight = 0,
323+
bottom: refBottom = 0,
324+
} = refPosition;
325+
const scrollX = container.position !== 'static' ? 0 : pageXOffset;
326+
const scrollY = container.position !== 'static' ? 0 : pageYOffset;
327+
const relativeDiff = {
328+
top: container.position !== 'static' ? container.rect.top : 0,
329+
left: container.position !== 'static' ? container.rect.left : 0,
330+
};
331+
const { width, height } = menuSize;
332+
const { top = 0, left = 0 } = offset;
333+
const refCenterHorizontal = (refLeft + refRight) / 2;
334+
const refCenterVertical = (refTop + refBottom) / 2;
335+
336+
// Calculate whether a new direction is needed to stay in parent.
337+
// It will switch the current direction to the opposite i.e.
338+
// If the direction="top" and the top boundary is overflowed
339+
// then it switches the direction to "bottom".
340+
const newDirection = () => {
341+
switch (direction) {
342+
case DIRECTION_LEFT:
343+
return refLeft - width + scrollX - left - relativeDiff.left < 0
344+
? DIRECTION_RIGHT
345+
: direction;
346+
case DIRECTION_TOP:
347+
return refTop - height + scrollY - top - relativeDiff.top < 0
348+
? DIRECTION_BOTTOM
349+
: direction;
350+
case DIRECTION_RIGHT:
351+
return refRight + scrollX + left - relativeDiff.left + width >
352+
container.rect.width
353+
? DIRECTION_LEFT
354+
: direction;
355+
case DIRECTION_BOTTOM:
356+
return refBottom + scrollY + top - relativeDiff.top + height >
357+
container.rect.height
358+
? DIRECTION_TOP
359+
: direction;
360+
default:
361+
// If there is a new direction then ignore the logic above
362+
return direction;
363+
}
364+
};
365+
366+
// Calculate whether a new alignment is needed to stay in parent
367+
// If the direction is left or right this involves checking the
368+
// overflow in the vertical direction. If the direction is top or
369+
// bottom, this involves checking overflow in the horizontal direction.
370+
// "original" is used to signify no change.
371+
const newAlignment = () => {
372+
switch (direction) {
373+
case DIRECTION_LEFT:
374+
case DIRECTION_RIGHT:
375+
if (
376+
refCenterVertical -
377+
height / 2 +
378+
scrollY +
379+
top -
380+
9 -
381+
relativeDiff.top <
382+
0
383+
) {
384+
// If goes above the top boundary
385+
return 'start';
386+
} else if (
387+
refCenterVertical -
388+
height / 2 +
389+
scrollY +
390+
top -
391+
9 -
392+
relativeDiff.top +
393+
height >
394+
container.rect.height
395+
) {
396+
// If goes below the bottom boundary
397+
return 'end';
398+
} else {
399+
// No need to change alignment
400+
return 'original';
401+
}
402+
case DIRECTION_TOP:
403+
case DIRECTION_BOTTOM:
404+
if (
405+
refCenterHorizontal -
406+
width / 2 +
407+
scrollX +
408+
left -
409+
relativeDiff.left <
410+
0
411+
) {
412+
// If goes below the left boundary
413+
return 'start';
414+
} else if (
415+
refCenterHorizontal -
416+
width / 2 +
417+
scrollX +
418+
left -
419+
relativeDiff.left +
420+
width >
421+
container.rect.width
422+
) {
423+
// If it goes over the right boundary
424+
return 'end';
425+
} else {
426+
// No need to change alignment
427+
return 'original';
428+
}
429+
default:
430+
// No need to change alignment
431+
return 'original';
432+
}
433+
};
434+
435+
return {
436+
direction: newDirection(),
437+
align: newAlignment(),
438+
};
439+
};
440+
266441
componentWillUnmount() {
267442
if (this._debouncedHandleFocus) {
268443
this._debouncedHandleFocus.cancel();
@@ -430,8 +605,6 @@ class Tooltip extends Component {
430605
children,
431606
className,
432607
triggerClassName,
433-
direction,
434-
align,
435608
focusTrap,
436609
triggerText,
437610
showIcon,
@@ -447,13 +620,14 @@ class Tooltip extends Component {
447620
} = this.props;
448621

449622
const { open } = this.isControlled ? this.props : this.state;
623+
const { storedDirection, storedAlign } = this.state;
450624

451625
const tooltipClasses = classNames(
452626
`${prefix}--tooltip`,
453627
{
454628
[`${prefix}--tooltip--shown`]: open,
455-
[`${prefix}--tooltip--${direction}`]: direction,
456-
[`${prefix}--tooltip--align-${align}`]: align,
629+
[`${prefix}--tooltip--${storedDirection}`]: storedDirection,
630+
[`${prefix}--tooltip--align-${storedAlign}`]: storedAlign,
457631
},
458632
className
459633
);
@@ -523,16 +697,17 @@ class Tooltip extends Component {
523697
selectorPrimaryFocus={this.props.selectorPrimaryFocus}
524698
target={this._getTarget}
525699
triggerRef={this._triggerRef}
526-
menuDirection={direction}
700+
menuDirection={storedDirection}
527701
menuOffset={menuOffset}
528702
menuRef={(node) => {
529703
this._tooltipEl = node;
530-
}}>
704+
}}
705+
updateOrientation={this.updateOrientation}>
531706
<div
532707
className={tooltipClasses}
533708
{...other}
534709
id={this._tooltipId}
535-
data-floating-menu-direction={direction}
710+
data-floating-menu-direction={storedDirection}
536711
onMouseOver={this.handleMouse}
537712
onMouseOut={this.handleMouse}
538713
onFocus={this.handleMouse}

0 commit comments

Comments
 (0)