Skip to content

Commit a4967b2

Browse files
asudohjoshblack
authored andcommitted
fix(FloatingMenu): lazy evaluate trigger button position (#5881)
This change switches the position calculation logic for the trigger buttons of `<OverflowMenu>` and `<Tooltip>` from eager-evaluation on mount to lazy-evaluation on rendering `<FloatingMenu>`. This change relegates such logic from `<OverflowMenu>` and `<Tooltip>` to `<FloatingMenu>` to do that. Such change has two merits: 1. Avoids stale trigger position data, which happened when `<Tooltip>` is opened programmatically 2. Reduces `getBoundingClientRect()` calls when application is initialized Also, swiches `<FloatingMenu>`'s prop for the DOM element ref of trigger button, as well as the corresponding internal properties of `<OverflowMenu>`/`<Tooltip>` from raw element to React ref. Fixes #5305.
1 parent 005f597 commit a4967b2

File tree

4 files changed

+55
-145
lines changed

4 files changed

+55
-145
lines changed

packages/react/src/components/OverflowMenu/OverflowMenu.js

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import FloatingMenu, {
1515
DIRECTION_TOP,
1616
DIRECTION_BOTTOM,
1717
} from '../../internal/FloatingMenu';
18-
import OptimizedResize from '../../internal/OptimizedResize';
1918
import { OverflowMenuVertical16 } from '@carbon/icons-react';
2019
import { keys, matches as keyCodeMatches } from '../../internal/keyboard';
2120
import mergeRefs from '../../tools/mergeRefs';
@@ -240,29 +239,17 @@ class OverflowMenu extends Component {
240239
*/
241240
_hBlurTimeout;
242241

243-
shouldComponentUpdate(nextProps, nextState) {
244-
if (nextState.open && !this.state.open) {
245-
requestAnimationFrame(() => {
246-
this.getMenuPosition();
247-
});
248-
return false; // Let `.getMenuPosition()` cause render
249-
}
250-
251-
return true;
252-
}
253-
254-
componentDidMount() {
255-
requestAnimationFrame(() => {
256-
this.getMenuPosition();
257-
});
258-
this.hResize = OptimizedResize.add(() => {
259-
this.getMenuPosition();
260-
});
261-
}
242+
/**
243+
* The element ref of the tooltip's trigger button.
244+
* @type {React.RefObject<Element>}
245+
* @private
246+
*/
247+
_triggerRef = React.createRef();
262248

263249
getPrimaryFocusableElement = () => {
264-
if (this.menuEl) {
265-
const primaryFocusPropEl = this.menuEl.querySelector(
250+
const { current: triggerEl } = this._triggerRef;
251+
if (triggerEl) {
252+
const primaryFocusPropEl = triggerEl.querySelector(
266253
'[data-floating-menu-primary-focus]'
267254
);
268255
if (primaryFocusPropEl) {
@@ -301,16 +288,8 @@ class OverflowMenu extends Component {
301288
clearTimeout(this._hBlurTimeout);
302289
this._hBlurTimeout = undefined;
303290
}
304-
this.hResize.release();
305291
}
306292

307-
getMenuPosition = () => {
308-
if (this.menuEl) {
309-
const menuPosition = this.menuEl.getBoundingClientRect();
310-
this.setState({ menuPosition });
311-
}
312-
};
313-
314293
handleClick = evt => {
315294
if (!this._menuBody || !this._menuBody.contains(evt.target)) {
316295
this.setState({ open: !this.state.open });
@@ -365,13 +344,10 @@ class OverflowMenu extends Component {
365344
});
366345
};
367346

368-
bindMenuEl = menuEl => {
369-
this.menuEl = menuEl;
370-
};
371-
372347
focusMenuEl = () => {
373-
if (this.menuEl) {
374-
this.menuEl.focus();
348+
const { current: triggerEl } = this._triggerRef;
349+
if (triggerEl) {
350+
triggerEl.focus();
375351
}
376352
};
377353

@@ -428,9 +404,10 @@ class OverflowMenu extends Component {
428404
focusinEventName,
429405
event => {
430406
const { target } = event;
407+
const { current: triggerEl } = this._triggerRef;
431408
if (
432409
!menuBody.contains(target) &&
433-
this.menuEl &&
410+
triggerEl &&
434411
!target.matches(
435412
`.${prefix}--overflow-menu,.${prefix}--overflow-menu-options`
436413
)
@@ -448,8 +425,9 @@ class OverflowMenu extends Component {
448425
* @returns {Element} The DOM element where the floating menu is placed in.
449426
*/
450427
_getTarget = () => {
428+
const { current: triggerEl } = this._triggerRef;
451429
return (
452-
(this.menuEl && this.menuEl.closest('[data-floating-menu-container]')) ||
430+
(triggerEl && triggerEl.closest('[data-floating-menu-container]')) ||
453431
document.body
454432
);
455433
};
@@ -525,11 +503,10 @@ class OverflowMenu extends Component {
525503

526504
const wrappedMenuBody = (
527505
<FloatingMenu
528-
menuPosition={this.state.menuPosition}
506+
triggerRef={this._triggerRef}
529507
menuDirection={direction}
530508
menuOffset={flipped ? menuOffsetFlip : menuOffset}
531509
menuRef={this._bindMenuBody}
532-
menuEl={this.menuEl}
533510
flipped={this.props.flipped}
534511
target={this._getTarget}
535512
onPlace={this._handlePlace}>
@@ -559,7 +536,7 @@ class OverflowMenu extends Component {
559536
aria-label={ariaLabel}
560537
id={id}
561538
tabIndex={tabIndex}
562-
ref={mergeRefs(ref, this.bindMenuEl)}>
539+
ref={mergeRefs(this._triggerRef, ref)}>
563540
<IconElement {...iconProps}>
564541
{iconDescription && <title>{iconDescription}</title>}
565542
</IconElement>

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

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -198,54 +198,4 @@ describe('Tooltip', () => {
198198
expect(rootWrapper.find('Tooltip').instance().state.open).toEqual(false);
199199
});
200200
});
201-
202-
describe('getTriggerPosition', () => {
203-
it('sets triggerPosition when triggerEl is set', () => {
204-
const rootWrapper = mount(<Tooltip triggerText="Tooltip" />);
205-
// Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component
206-
rootWrapper
207-
.find('Tooltip')
208-
.instance()
209-
.setState({
210-
triggerPosition: { left: 0, top: 0, right: 0, bottom: 0 },
211-
});
212-
rootWrapper.update();
213-
rootWrapper
214-
.find('Tooltip')
215-
.instance()
216-
.getTriggerPosition();
217-
// Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component
218-
expect(rootWrapper.find('Tooltip').state.triggerPosition).not.toEqual({
219-
left: 0,
220-
top: 0,
221-
right: 0,
222-
bottom: 0,
223-
});
224-
});
225-
it('does not set triggerPosition when triggerEl is not set', () => {
226-
const rootWrapper = mount(<Tooltip triggerText="Tooltip" />);
227-
// Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component
228-
rootWrapper
229-
.find('Tooltip')
230-
.instance()
231-
.setState({
232-
triggerPosition: { left: 0, top: 0, right: 0, bottom: 0 },
233-
});
234-
rootWrapper.update();
235-
delete rootWrapper.find('Tooltip').instance().triggerEl;
236-
rootWrapper
237-
.find('Tooltip')
238-
.instance()
239-
.getTriggerPosition();
240-
// Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component
241-
expect(
242-
rootWrapper.find('Tooltip').instance().state.triggerPosition
243-
).toEqual({
244-
left: 0,
245-
top: 0,
246-
right: 0,
247-
bottom: 0,
248-
});
249-
});
250-
});
251201
});

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

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,17 @@ class Tooltip extends Component {
210210
*/
211211
_tooltipEl = null;
212212

213+
/**
214+
* The element ref of the tooltip's trigger button.
215+
* @type {React.RefObject<Element>}
216+
* @private
217+
*/
218+
_triggerRef = React.createRef();
219+
213220
componentDidMount() {
214221
if (!this._debouncedHandleFocus) {
215222
this._debouncedHandleFocus = debounce(this._handleFocus, 200);
216223
}
217-
requestAnimationFrame(() => {
218-
this.getTriggerPosition();
219-
});
220224

221225
document.addEventListener('keydown', this.handleEscKeyPress, false);
222226
}
@@ -251,13 +255,6 @@ class Tooltip extends Component {
251255
});
252256
};
253257

254-
getTriggerPosition = () => {
255-
if (this.triggerEl) {
256-
const triggerPosition = this.triggerEl.getBoundingClientRect();
257-
this.setState({ triggerPosition });
258-
}
259-
};
260-
261258
/**
262259
* Handles `focus`/`blur` event.
263260
* @param {string} state `over` to show the tooltip, `out` to hide the tooltip.
@@ -266,15 +263,15 @@ class Tooltip extends Component {
266263
_handleFocus = (state, evt) => {
267264
const { relatedTarget } = evt;
268265
if (state === 'over') {
269-
this.getTriggerPosition();
270266
this._handleUserInputOpenClose(evt, { open: true });
271267
} else {
272268
// Note: SVGElement in IE11 does not have `.contains()`
269+
const { current: triggerEl } = this._triggerRef;
273270
const shouldPreventClose =
274271
relatedTarget &&
275-
((this.triggerEl &&
276-
this.triggerEl.contains &&
277-
this.triggerEl.contains(relatedTarget)) ||
272+
((triggerEl &&
273+
triggerEl.contains &&
274+
triggerEl.contains(relatedTarget)) ||
278275
(this._tooltipEl && this._tooltipEl.contains(relatedTarget)));
279276
if (!shouldPreventClose) {
280277
this._handleUserInputOpenClose(evt, { open: false });
@@ -292,10 +289,13 @@ class Tooltip extends Component {
292289
/**
293290
* @returns {Element} The DOM element where the floating menu is placed in.
294291
*/
295-
_getTarget = () =>
296-
(this.triggerEl &&
297-
this.triggerEl.closest('[data-floating-menu-container]')) ||
298-
document.body;
292+
_getTarget = () => {
293+
const { current: triggerEl } = this._triggerRef;
294+
return (
295+
(triggerEl && triggerEl.closest('[data-floating-menu-container]')) ||
296+
document.body
297+
);
298+
};
299299

300300
handleMouse = evt => {
301301
evt.persist();
@@ -312,9 +312,6 @@ class Tooltip extends Component {
312312
const shouldOpen = this.isControlled
313313
? !this.props.open
314314
: !this.state.open;
315-
if (shouldOpen) {
316-
this.getTriggerPosition();
317-
}
318315
this._handleUserInputOpenClose(evt, { open: shouldOpen });
319316
} else if (
320317
state &&
@@ -348,9 +345,6 @@ class Tooltip extends Component {
348345
const shouldOpen = this.isControlled
349346
? !this.props.open
350347
: !this.state.open;
351-
if (shouldOpen) {
352-
this.getTriggerPosition();
353-
}
354348
this._handleUserInputOpenClose(event, { open: shouldOpen });
355349
}
356350
};
@@ -403,10 +397,7 @@ class Tooltip extends Component {
403397
triggerClassName
404398
);
405399

406-
const refProp = mergeRefs(ref, node => {
407-
this.triggerEl = node;
408-
});
409-
400+
const refProp = mergeRefs(this._triggerRef, ref);
410401
const iconProperties = { name: iconName, role: null, description: null };
411402

412403
const properties = {
@@ -457,7 +448,7 @@ class Tooltip extends Component {
457448
{open && (
458449
<FloatingMenu
459450
target={this._getTarget}
460-
menuPosition={this.state.triggerPosition}
451+
triggerRef={this._triggerRef}
461452
menuDirection={direction}
462453
menuOffset={menuOffset}
463454
menuRef={node => {

packages/react/src/internal/FloatingMenu.js

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import React from 'react';
1111
import ReactDOM from 'react-dom';
1212
import window from 'window-or-global';
1313
import { settings } from 'carbon-components';
14+
import OptimizedResize from './OptimizedResize';
1415

1516
const { prefix } = settings;
1617

@@ -84,7 +85,7 @@ const hasChangeInOffset = (oldMenuOffset = {}, menuOffset = {}) => {
8485
*/
8586
const getFloatingPosition = ({
8687
menuSize,
87-
refPosition,
88+
refPosition = {},
8889
offset = {},
8990
direction = DIRECTION_BOTTOM,
9091
scrollX = 0,
@@ -153,16 +154,6 @@ class FloatingMenu extends React.Component {
153154
*/
154155
target: PropTypes.func,
155156

156-
/**
157-
* The position in the viewport of the trigger button.
158-
*/
159-
menuPosition: PropTypes.shape({
160-
top: PropTypes.number,
161-
right: PropTypes.number,
162-
bottom: PropTypes.number,
163-
left: PropTypes.number,
164-
}),
165-
166157
/**
167158
* Where to put the tooltip, relative to the trigger button.
168159
*/
@@ -201,7 +192,6 @@ class FloatingMenu extends React.Component {
201192
};
202193

203194
static defaultProps = {
204-
menuPosition: {},
205195
menuOffset: {},
206196
menuDirection: DIRECTION_BOTTOM,
207197
};
@@ -239,7 +229,6 @@ class FloatingMenu extends React.Component {
239229
* Calculates the position in the viewport of floating menu,
240230
* once this component is mounted or updated upon change in the following props:
241231
*
242-
* * `menuPosition` (The position in the viewport of the trigger button)
243232
* * `menuOffset` (The adjustment that should be applied to the calculated floating menu's position)
244233
* * `menuDirection` (Where the floating menu menu should be placed relative to the trigger button)
245234
*
@@ -256,30 +245,23 @@ class FloatingMenu extends React.Component {
256245
}
257246

258247
const {
259-
menuPosition: oldRefPosition = {},
260248
menuOffset: oldMenuOffset = {},
261249
menuDirection: oldMenuDirection,
262250
} = prevProps;
263-
const {
264-
menuPosition: refPosition = {},
265-
menuOffset = {},
266-
menuDirection,
267-
} = this.props;
251+
const { menuOffset = {}, menuDirection } = this.props;
268252

269253
if (
270-
oldRefPosition.top !== refPosition.top ||
271-
oldRefPosition.right !== refPosition.right ||
272-
oldRefPosition.bottom !== refPosition.bottom ||
273-
oldRefPosition.left !== refPosition.left ||
274254
hasChangeInOffset(oldMenuOffset, menuOffset) ||
275255
oldMenuDirection !== menuDirection
276256
) {
257+
const { flipped, triggerRef } = this.props;
258+
const { current: triggerEl } = triggerRef;
277259
const menuSize = menuBody.getBoundingClientRect();
278-
const { menuEl, flipped } = this.props;
260+
const refPosition = triggerEl && triggerEl.getBoundingClientRect();
279261
const offset =
280262
typeof menuOffset !== 'function'
281263
? menuOffset
282-
: menuOffset(menuBody, menuDirection, menuEl, flipped);
264+
: menuOffset(menuBody, menuDirection, triggerEl, flipped);
283265
// Skips if either in the following condition:
284266
// a) Menu body has `display:none`
285267
// b) `menuOffset` as a callback returns `undefined` (The callback saw that it couldn't calculate the value)
@@ -302,6 +284,16 @@ class FloatingMenu extends React.Component {
302284
}
303285
};
304286

287+
componentWillUnmount() {
288+
this.hResize.release();
289+
}
290+
291+
componentDidMount() {
292+
this.hResize = OptimizedResize.add(() => {
293+
this._updateMenuSize();
294+
});
295+
}
296+
305297
componentDidUpdate(prevProps) {
306298
this._updateMenuSize(prevProps);
307299
const { onPlace } = this.props;

0 commit comments

Comments
 (0)