Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tooltip): Add optional prop to auto orientate within parent bounds #9556

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a6701a1
feat(tooltip): add test story
Harry-Liversedge Aug 27, 2021
7276dad
feat(tooltip): add ability to update orientation
Harry-Liversedge Aug 27, 2021
8a07275
feat(tooltip): implement updates for direction
Harry-Liversedge Aug 27, 2021
eaca3f7
feat(tooltip): update orientation logic change
TuxedoFish Aug 28, 2021
6896c9e
feat(tooltip): add prop to control behaviour
TuxedoFish Aug 28, 2021
0eda331
feat(tooltip): add remaining orientation checks
TuxedoFish Aug 28, 2021
32e6e16
feat(tooltip): update naming
TuxedoFish Aug 28, 2021
6658156
feat(tooltip): move logic into tooltip
TuxedoFish Aug 28, 2021
ee0cd5c
Merge remote-tracking branch 'upstream/main' into tooltip-within-pare…
TuxedoFish Aug 28, 2021
36a11ea
feat(tooltip): fix tests
TuxedoFish Aug 28, 2021
f43aafc
feat(tooltip): remove console statements
TuxedoFish Aug 30, 2021
0ea8840
feat(tooltip): standarize comments in functions
TuxedoFish Aug 30, 2021
207d676
Merge remote-tracking branch 'upstream/main' into tooltip-within-pare…
TuxedoFish Aug 31, 2021
9c3ec12
feat(tooltip): add tooltips to all corners for clarity
TuxedoFish Sep 1, 2021
4aaa7eb
Merge remote-tracking branch 'upstream/main' into tooltip-within-pare…
TuxedoFish Sep 1, 2021
eaa466a
Merge remote-tracking branch 'upstream/main' into tooltip-within-pare…
TuxedoFish Sep 3, 2021
8893763
Merge remote-tracking branch 'upstream/main' into tooltip-within-pare…
TuxedoFish Sep 5, 2021
4205b48
Merge remote-tracking branch 'upstream/main' into tooltip-within-pare…
TuxedoFish Oct 6, 2021
debe2a3
Merge branch 'main' into tooltip-within-parent-bounds
tay1orjones Oct 8, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 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,41 @@ DefaultBottom.parameters = {
},
};

export const AutoOrientation = () => (
<div
style={{
...containerStyles,
justifyContent: 'flex-start',
alignItems: 'flex-start',
}}>
<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>
);

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
26 changes: 25 additions & 1 deletion packages/react/src/internal/FloatingMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,17 @@ class FloatingMenu extends React.Component {
current: PropTypes.any,
}),
]),

/**
* Optional function to change orientation of tooltip based on parent
*/
updateOrientation: PropTypes.func,
};

static defaultProps = {
menuOffset: {},
menuDirection: DIRECTION_BOTTOM,
updateOrientation: null,
};

// `true` if the menu body is mounted and calculation of the position is in progress.
Expand Down Expand Up @@ -281,14 +287,32 @@ class FloatingMenu extends React.Component {
hasChangeInOffset(oldMenuOffset, menuOffset) ||
oldMenuDirection !== menuDirection
) {
const { flipped, triggerRef } = this.props;
const { flipped, triggerRef, updateOrientation } = this.props;

const { current: triggerEl } = triggerRef;
const menuSize = menuBody.getBoundingClientRect();
const refPosition = triggerEl && triggerEl.getBoundingClientRect();
const offset =
typeof menuOffset !== 'function'
? menuOffset
: menuOffset(menuBody, menuDirection, triggerEl, flipped);

// Optional function to allow parent component to check
// if the orientation needs to be changed based on params
if (updateOrientation) {
updateOrientation({
menuSize,
refPosition,
direction: menuDirection,
offset,
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
container: {
rect: this.props.target().getBoundingClientRect(),
position: getComputedStyle(this.props.target()).position,
},
});
}
// Skips if either in the following condition:
// a) Menu body has `display:none`
// b) `menuOffset` as a callback returns `undefined` (The callback saw that it couldn't calculate the value)
Expand Down