Skip to content

Commit

Permalink
[Tooltip] Use hysteresis with the enterDelay
Browse files Browse the repository at this point in the history
  • Loading branch information
oliviertassinari committed Nov 19, 2019
1 parent 8d5abd0 commit 5fd5c5a
Show file tree
Hide file tree
Showing 2 changed files with 82 additions and 29 deletions.
43 changes: 29 additions & 14 deletions packages/material-ui/src/Tooltip/Tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ export const styles = theme => ({
},
});

let hystersisOpen = false;
let hystersisTimer = null;

export function testReset() {
hystersisOpen = false;
clearTimeout(hystersisTimer);
}

const Tooltip = React.forwardRef(function Tooltip(props, ref) {
const {
arrow = false,
Expand All @@ -160,7 +168,7 @@ const Tooltip = React.forwardRef(function Tooltip(props, ref) {
disableTouchListener = false,
enterDelay = 0,
enterTouchDelay = 700,
id,
id: idProp,
interactive = false,
leaveDelay = 0,
leaveTouchDelay = 1500,
Expand All @@ -176,11 +184,10 @@ const Tooltip = React.forwardRef(function Tooltip(props, ref) {
} = props;
const theme = useTheme();

const [, forceUpdate] = React.useState(0);
const [childNode, setChildNode] = React.useState();
const [arrowRef, setArrowRef] = React.useState(null);
const ignoreNonTouchEvents = React.useRef(false);
const defaultId = React.useRef();

const closeTimer = React.useRef();
const enterTimer = React.useRef();
const leaveTimer = React.useRef();
Expand Down Expand Up @@ -232,19 +239,18 @@ const Tooltip = React.forwardRef(function Tooltip(props, ref) {
}, [isControlled, title, childNode]);
}

const [defaultId, setDefaultId] = React.useState();
const id = idProp || defaultId;
React.useEffect(() => {
if (!open || defaultId) {
return;
}

// Fallback to this default id when possible.
// Use the random value for client-side rendering only.
// We can't use it server-side.
if (!defaultId.current) {
defaultId.current = `mui-tooltip-${Math.round(Math.random() * 1e5)}`;
}

// Rerender with defaultId and childNode.
if (openProp) {
forceUpdate(n => !n);
}
}, [openProp]);
setDefaultId(`mui-tooltip-${Math.round(Math.random() * 1e5)}`);
}, [open, defaultId]);

React.useEffect(() => {
return () => {
Expand All @@ -256,6 +262,9 @@ const Tooltip = React.forwardRef(function Tooltip(props, ref) {
}, []);

const handleOpen = event => {
clearTimeout(hystersisTimer);
hystersisOpen = true;

// The mouseover event will trigger for every nested element in the tooltip.
// We can skip rerendering when the tooltip is already open.
// We are using the mouseover event instead of the mouseenter event to fix a hide/show issue.
Expand Down Expand Up @@ -288,7 +297,7 @@ const Tooltip = React.forwardRef(function Tooltip(props, ref) {

clearTimeout(enterTimer.current);
clearTimeout(leaveTimer.current);
if (enterDelay) {
if (enterDelay && !hystersisOpen) {
event.persist();
enterTimer.current = setTimeout(() => {
handleOpen(event);
Expand Down Expand Up @@ -327,6 +336,12 @@ const Tooltip = React.forwardRef(function Tooltip(props, ref) {
};

const handleClose = event => {
clearTimeout(hystersisTimer);
hystersisTimer = setTimeout(() => {
hystersisOpen = false;
}, 500);
// Use 500 ms per https://github.com/reach/reach-ui/blob/3b5319027d763a3082880be887d7a29aee7d3afc/packages/tooltip/src/index.js#L214

if (!isControlled) {
setOpenState(false);
}
Expand Down Expand Up @@ -417,7 +432,7 @@ const Tooltip = React.forwardRef(function Tooltip(props, ref) {
// We are open to change the tradeoff.
const shouldShowNativeTitle = !open && !disableHoverListener;
const childrenProps = {
'aria-describedby': open ? id || defaultId.current : null,
'aria-describedby': open ? id : null,
title: shouldShowNativeTitle && typeof title === 'string' ? title : null,
...other,
...children.props,
Expand Down
68 changes: 53 additions & 15 deletions packages/material-ui/src/Tooltip/Tooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ import PropTypes from 'prop-types';
import { spy, useFakeTimers } from 'sinon';
import consoleErrorMock from 'test/utils/consoleErrorMock';
import { createMount, getClasses } from '@material-ui/core/test-utils';
import { act, createClientRender, fireEvent } from 'test/utils/createClientRender';
import describeConformance from '../test-utils/describeConformance';
import Popper from '../Popper';
import Tooltip from './Tooltip';
import Tooltip, { testReset } from './Tooltip';
import Input from '../Input';
import createMuiTheme from '../styles/createMuiTheme';

const theme = createMuiTheme();

function focusVisible(wrapper) {
function focusVisibleLegacy(wrapper) {
document.dispatchEvent(new window.Event('keydown'));
wrapper.simulate('focus');
}

function focusVisible(element) {
act(() => {
element.blur();
fireEvent.keyDown(document.activeElement || document.body, { key: 'Tab' });
element.focus();
});
}

function simulatePointerDevice() {
// first focus on a page triggers focus visible until a pointer event
// has been dispatched
Expand All @@ -26,10 +32,14 @@ function simulatePointerDevice() {
describe('<Tooltip />', () => {
let mount;
let classes;
const render = createClientRender({ strict: false });
let clock;
const defaultProps = {
children: <span id="testChild">Hello World</span>,
theme,
children: (
<button id="testChild" type="submit">
Hello World
</button>
),
title: 'Hello World',
};

Expand All @@ -40,16 +50,20 @@ describe('<Tooltip />', () => {
clock = useFakeTimers();
});

beforeEach(() => {
testReset();
});

after(() => {
clock.restore();
mount.cleanUp();
});

describeConformance(<Tooltip {...defaultProps} />, () => ({
classes,
inheritComponent: 'span',
inheritComponent: 'button',
mount,
refInstanceof: window.HTMLSpanElement,
refInstanceof: window.HTMLButtonElement,
skip: [
'componentProp',
// react-transition-group issue
Expand Down Expand Up @@ -193,16 +207,40 @@ describe('<Tooltip />', () => {

describe('prop: delay', () => {
it('should take the enterDelay into account', () => {
const wrapper = mount(<Tooltip enterDelay={111} {...defaultProps} />);
const wrapper = mount(<Tooltip {...defaultProps} enterDelay={111} />);
simulatePointerDevice();
const children = wrapper.find('#testChild');
focusVisible(children);
focusVisibleLegacy(children);
assert.strictEqual(wrapper.find('[role="tooltip"]').exists(), false);
clock.tick(111);
wrapper.update();
assert.strictEqual(wrapper.find('[role="tooltip"]').exists(), true);
});

it('should use hysteresis with the enterDelay', () => {
const { container } = render(
<Tooltip
enterDelay={111}
leaveDelay={5}
TransitionProps={{ timeout: 6 }}
{...defaultProps}
/>,
);
const children = container.querySelector('#testChild');
focusVisible(children);
assert.strictEqual(document.body.querySelectorAll('[role="tooltip"]').length, 0);
clock.tick(111);
assert.strictEqual(document.body.querySelectorAll('[role="tooltip"]').length, 1);
document.activeElement.blur();
clock.tick(5);
clock.tick(6);
assert.strictEqual(document.body.querySelectorAll('[role="tooltip"]').length, 0);

focusVisible(children);
// Bypass `enterDelay` wait, instant display.
assert.strictEqual(document.body.querySelectorAll('[role="tooltip"]').length, 1);
});

it('should take the leaveDelay into account', () => {
const childRef = React.createRef();
const wrapper = mount(
Expand All @@ -212,7 +250,7 @@ describe('<Tooltip />', () => {
);
simulatePointerDevice();
const children = wrapper.find('#testChild');
focusVisible(children);
focusVisibleLegacy(children);
assert.strictEqual(wrapper.find('[role="tooltip"]').exists(), true);
children.simulate('blur');
assert.strictEqual(wrapper.find('[role="tooltip"]').exists(), true);
Expand Down Expand Up @@ -345,7 +383,7 @@ describe('<Tooltip />', () => {
describe('forward', () => {
it('should forward props to the child element', () => {
const wrapper = mount(
<Tooltip className="foo" {...defaultProps}>
<Tooltip {...defaultProps} className="foo">
<h1 className="bar">H1</h1>
</Tooltip>,
);
Expand All @@ -354,7 +392,7 @@ describe('<Tooltip />', () => {

it('should respect the props priority', () => {
const wrapper = mount(
<Tooltip hidden {...defaultProps}>
<Tooltip {...defaultProps} hidden>
<h1 hidden={false}>H1</h1>
</Tooltip>,
);
Expand Down Expand Up @@ -390,7 +428,7 @@ describe('<Tooltip />', () => {

assert.strictEqual(wrapper.find('[role="tooltip"]').exists(), false);

focusVisible(wrapper.find('#target'));
focusVisibleLegacy(wrapper.find('#target'));

assert.strictEqual(wrapper.find('[role="tooltip"]').exists(), true);
});
Expand Down

0 comments on commit 5fd5c5a

Please sign in to comment.