Skip to content

Commit

Permalink
Adjust the tooltip position taking into account all window's edges (…
Browse files Browse the repository at this point in the history
…Merge PR #1467)

Fixes #883
  • Loading branch information
julienw committed Nov 22, 2018
2 parents 36dab5c + 397077e commit 22e88ec
Show file tree
Hide file tree
Showing 8 changed files with 1,606 additions and 618 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
"test-build-coverage": "jest --coverage --coverageReporters=html",
"test-serve-coverage": "ws -d coverage/ -p 4343",
"test-coverage": "run-s test-build-coverage test-serve-coverage"

},
"author": "Markus Stange <mstange@themasta.com>",
"license": "MPL-2.0",
Expand Down Expand Up @@ -120,6 +119,7 @@
"prettier": "^1.10.2",
"raw-loader": "^0.5.1",
"react-test-renderer": "^16.4.2",
"react-testing-library": "^5.2.3",
"rimraf": "^2.6.2",
"sinon": "^4.3.0",
"style-loader": "^0.23.0",
Expand Down
48 changes: 37 additions & 11 deletions src/components/shared/Tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { CssPixels } from '../../types/units';
import { ensureExists } from '../../utils/flow';
require('./Tooltip.css');

const MOUSE_OFFSET = 21;
export const MOUSE_OFFSET = 11;

type Props = {
mouseX: CssPixels,
Expand Down Expand Up @@ -82,25 +82,51 @@ export default class Tooltip extends React.PureComponent<Props, State> {
const { children, mouseX, mouseY } = this.props;
const { interiorElement } = this.state;

const offsetX = interiorElement
? Math.max(0, mouseX + interiorElement.offsetWidth - window.innerWidth)
: 0;
// By default, position the tooltip below and at the right of the mouse cursor.
let top = mouseY + MOUSE_OFFSET;
let left = mouseX + MOUSE_OFFSET;

let offsetY = 0;
if (interiorElement) {
// Let's check the vertical position.
if (
mouseY + interiorElement.offsetHeight + MOUSE_OFFSET >
mouseY + MOUSE_OFFSET + interiorElement.offsetHeight >=
window.innerHeight
) {
offsetY = interiorElement.offsetHeight + MOUSE_OFFSET;
} else {
offsetY = -MOUSE_OFFSET;
// The tooltip doesn't fit below the mouse cursor (which is our
// default strategy). Therefore we try to position it either above the
// mouse cursor or finally aligned with the window's top edge.
if (mouseY - MOUSE_OFFSET - interiorElement.offsetHeight > 0) {
// We position the tooltip above the mouse cursor if it fits there.
top = mouseY - interiorElement.offsetHeight - MOUSE_OFFSET;
} else {
// Otherwise we align the tooltip with the window's top edge.
top = 0;
}
}

// Now let's check the horizontal position.
if (
mouseX + MOUSE_OFFSET + interiorElement.offsetWidth >=
window.innerWidth
) {
// The tooltip doesn't fit at the right of the mouse cursor (which is
// our default strategy). Therefore we try to position it either at the
// left of the mouse cursor or finally aligned with the window's left
// edge.
if (mouseX - MOUSE_OFFSET - interiorElement.offsetWidth > 0) {
// We position the tooltip at the left of the mouse cursor if it fits
// there.
left = mouseX - interiorElement.offsetWidth - MOUSE_OFFSET;
} else {
// Otherwise, align the tooltip with the window's left edge.
left = 0;
}
}
}

const style = {
left: mouseX - offsetX,
top: mouseY - offsetY,
left,
top,
};

return ReactDOM.createPortal(
Expand Down
141 changes: 141 additions & 0 deletions src/test/components/Tooltip.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// @flow

import React from 'react';
import { render, cleanup } from 'react-testing-library';

import {
addRootOverlayElement,
removeRootOverlayElement,
} from '../fixtures/utils';

import { ensureExists } from '../../utils/flow';

import Tooltip, { MOUSE_OFFSET } from '../../components/shared/Tooltip';

describe('shared/Tooltip', () => {
beforeEach(addRootOverlayElement);
afterEach(removeRootOverlayElement);
afterEach(cleanup);

it('is rendered appropriately', () => {
const { getTooltip } = setup({
box: { width: 500, height: 200 },
mouse: { x: 0, y: 0 },
});

expect(getTooltip()).toMatchSnapshot();
});

describe('positioning', () => {
it('is rendered at the default location if there is some space', () => {
const { rerender, getTooltipStyle } = setup({
box: { width: 500, height: 200 },
mouse: { x: 0, y: 0 },
});

expect(getTooltipStyle()).toEqual({
left: `${MOUSE_OFFSET}px`,
top: `${MOUSE_OFFSET}px`,
});

const mouseX = 50;
const mouseY = 70;
rerender({ x: mouseX, y: mouseY });

expect(getTooltipStyle()).toEqual({
left: `${mouseX + MOUSE_OFFSET}px`,
top: `${mouseY + MOUSE_OFFSET}px`,
});
});

it('is rendered at the left and top of the cursor if the space is missing at the right and below', () => {
const mouseX = 600;
const mouseY = 500;
const tooltipWidth = 500;
const tooltipHeight = 300;
const { getTooltipStyle } = setup({
box: { width: tooltipWidth, height: tooltipHeight },
mouse: { x: mouseX, y: mouseY },
});

const expectedLeft = mouseX - MOUSE_OFFSET - tooltipWidth;
const expectedTop = mouseY - MOUSE_OFFSET - tooltipHeight;
expect(getTooltipStyle()).toEqual({
left: `${expectedLeft}px`,
top: `${expectedTop}px`,
});
});

it('is rendered at the left and top of the window if the space is missing elsewhere', () => {
const { getTooltipStyle } = setup({
box: { width: 700, height: 500 },
mouse: { x: 500, y: 300 },
});

const expectedLeft = 0;
const expectedTop = 0;
expect(getTooltipStyle()).toEqual({
left: `${expectedLeft}px`,
top: `${expectedTop}px`,
});
});
});
});

type Size = {| width: number, height: number |};
type Position = {| x: number, y: number |};
type Setup = {|
box: Size,
mouse: Position,
|};

function setup({ box, mouse }: Setup) {
// Note we don't mock the window size and rely on the default in JSDom that is
// 1024x768. It wouldn't be so easy to mock, because given it's a simple value
// in `window` we can't use Jest's `spyOn` on it and rely on Jest's easy mock
// restore.
jest
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockImplementation(() => box.width);
jest
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(() => box.height);

const { rerender } = render(
<Tooltip mouseX={mouse.x} mouseY={mouse.y}>
<p>Lorem ipsum</p>
</Tooltip>
);

function getTooltip() {
return ensureExists(document.querySelector('.tooltip'));
}

function getTooltipStyle() {
const tooltip = getTooltip();
const style = tooltip.style;
const result = {};
for (let i = 0; i < style.length; i++) {
const prop = style.item(i);
const value = style.getPropertyValue(prop);
result[prop] = value;
}
return result;
}

return {
rerender: (mouse: Position) => {
rerender(
<Tooltip mouseX={mouse.x} mouseY={mouse.y}>
<p>Lorem ipsum</p>
</Tooltip>
);
},
getTooltip,
getTooltipStyle,
};
}
12 changes: 12 additions & 0 deletions src/test/components/__snapshots__/Tooltip.test.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`shared/Tooltip is rendered appropriately 1`] = `
<div
class="tooltip"
style="left: 11px; top: 11px;"
>
<p>
Lorem ipsum
</p>
</div>
`;

0 comments on commit 22e88ec

Please sign in to comment.