diff --git a/README.md b/README.md
index d534784..cb090ea 100644
--- a/README.md
+++ b/README.md
@@ -167,6 +167,12 @@ When this option is enabled, the following arguments are become irrelevant: `@po
Optional. By default, tooltips will be positioned next to the element that caused them to display. When this argument is specified, the tooltip will still show when mousing over the `@element`, but will be positioned next to a _different_ element.
+#### `@followMouse`
+
+Note: You must be opted in to `@usePopover` to use `@followMouse`
+
+Optional. Instead of a statically positioned tooltip, make the tooltip follow the position of the mouse when it is inside of the tooltipper, anchored to the axis the tooltip is positioned on. (ie. if the tooltip on the right hand side of the tooltipper, then it will only move on the Y axis)
+
### API
#### `isLoading`
diff --git a/demo-app/app.gts b/demo-app/app.gts
index 78f0009..22d77ca 100644
--- a/demo-app/app.gts
+++ b/demo-app/app.gts
@@ -22,6 +22,7 @@ Router.map(function () {
this.route('tether');
this.route('use-click');
this.route('use-focus');
+ this.route('follow-mouse');
});
export class App extends EmberApp {
diff --git a/demo-app/styles/app.css b/demo-app/styles/app.css
index 52ca190..08a5b1a 100644
--- a/demo-app/styles/app.css
+++ b/demo-app/styles/app.css
@@ -9,3 +9,4 @@
@import 'tables.css';
@import 'util.css';
@import 'tether.css';
+@import 'follow-mouse.css';
diff --git a/demo-app/styles/follow-mouse.css b/demo-app/styles/follow-mouse.css
new file mode 100644
index 0000000..25b0a14
--- /dev/null
+++ b/demo-app/styles/follow-mouse.css
@@ -0,0 +1,38 @@
+.follow-mouse-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 100px;
+}
+
+.follow-mouse-tooltipper {
+ width: 200px;
+ height: 100px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.follow-mouse-tooltip {
+ position: fixed;
+ position-try:
+ flip-inline,
+ flip-block,
+ flip-inline flip-block;
+}
+
+.follow-mouse-tooltip-top {
+ position-area: top;
+}
+
+.follow-mouse-tooltip-bottom {
+ position-area: bottom;
+}
+
+.follow-mouse-tooltip-left {
+ position-area: left;
+}
+
+.follow-mouse-tooltip-right {
+ position-area: right;
+}
diff --git a/demo-app/templates/application.gjs b/demo-app/templates/application.gjs
index 44c80aa..e55a6ec 100644
--- a/demo-app/templates/application.gjs
+++ b/demo-app/templates/application.gjs
@@ -88,6 +88,12 @@ import '../styles/app.css';
Use Focus
+
+ |
+
+
+ Follow mouse
+
{{outlet}}
diff --git a/demo-app/templates/follow-mouse.gjs b/demo-app/templates/follow-mouse.gjs
new file mode 100644
index 0000000..44d8704
--- /dev/null
+++ b/demo-app/templates/follow-mouse.gjs
@@ -0,0 +1,54 @@
+import Tooltip from '#src/components/tooltip';
+
+
+
+ Move the tooltip with the mouse, anchored to the axis matching the edge it
+ is on
+
+
+
+
+ Top
+
+ Hello World
+
+
+
+
+ Bottom
+
+ Hello World
+
+
+
+
+ Left
+
+ Hello World
+
+
+
+
+ Right
+
+ Hello World
+
+
+
+
diff --git a/src/components/tooltip.gjs b/src/components/tooltip.gjs
index ff25b68..6da393c 100644
--- a/src/components/tooltip.gjs
+++ b/src/components/tooltip.gjs
@@ -12,6 +12,7 @@ import { waitFor } from '@ember/test-waiters';
import { waitForAnimation } from '@zestia/animation-utils';
import autoPosition from '../utils/auto-position.js';
import getPositionArea from '../utils/position-area.js';
+import getSide from '../utils/get-side.js';
import Component from '@glimmer/component';
const { max } = Math;
@@ -27,6 +28,7 @@ export default class TooltipComponent extends Component {
@tracked tooltipCoords = [0, 0];
@tracked tooltipElement;
@tracked tooltipPosition;
+ @tracked tooltipSide;
hideTimer;
isOverTooltipElement;
@@ -51,9 +53,17 @@ export default class TooltipComponent extends Component {
}
get tooltipStyle() {
- const [x, y] = this.tooltipCoords;
+ if (!this.args.usePopover) {
+ const [x, y] = this.tooltipCoords;
- return htmlSafe(`top: ${y}px; left: ${x}px`);
+ return htmlSafe(`top: ${y}px; left: ${x}px`);
+ }
+
+ if (this.shouldFollowMouse) {
+ return htmlSafe(`translate: var(--offset-x, 0px) var(--offset-y, 0px)`);
+ }
+
+ return '';
}
get hideDelay() {
@@ -173,6 +183,14 @@ export default class TooltipComponent extends Component {
return getPosition(this.positionElement, window, this.columns, this.rows);
}
+ get shouldFollowMouse() {
+ if (this.args.useClick) {
+ return false;
+ }
+
+ return this.args.followMouse && this.args.usePopover;
+ }
+
handleMouseEnterTooltipperElement = () => {
this.#hideFocusOnlyTooltip();
this.isOverTooltipperElement = true;
@@ -185,6 +203,18 @@ export default class TooltipComponent extends Component {
this.#scheduleHideTooltip();
};
+ handleMouseMoveTooltipperElement = (event) => {
+ if (!this.tooltipElement) {
+ return;
+ }
+
+ if (this.tooltipElement.contains(event.target)) {
+ return;
+ }
+
+ this.#followMouse(event);
+ };
+
handleClickTooltipperElement = (event) => {
event.stopPropagation();
this.tooltipService.hideAllTooltips();
@@ -234,6 +264,26 @@ export default class TooltipComponent extends Component {
this.#showTooltip();
};
+ #followMouse() {
+ const tooltipperRect = this.tooltipperElement.getBoundingClientRect();
+
+ let offsetX = 0;
+ let offsetY = 0;
+
+ if (this.tooltipSide === 'top' || this.tooltipSide === 'bottom') {
+ offsetX =
+ event.clientX - (tooltipperRect.left + tooltipperRect.width / 2);
+ }
+
+ if (this.tooltipSide === 'left' || this.tooltipSide === 'right') {
+ offsetY =
+ event.clientY - (tooltipperRect.top + tooltipperRect.height / 2);
+ }
+
+ this.tooltipElement.style.setProperty(`--offset-x`, `${offsetX}px`);
+ this.tooltipElement.style.setProperty(`--offset-y`, `${offsetY}px`);
+ }
+
async #load() {
const start = Date.now();
@@ -433,6 +483,10 @@ export default class TooltipComponent extends Component {
return autoPosition(this.referencePosition);
}
+ #getTooltipSide() {
+ return getSide(this.tooltipperElement, this.tooltipElement);
+ }
+
#tether() {
if (!this.positionElement) {
return;
@@ -440,6 +494,7 @@ export default class TooltipComponent extends Component {
this.tooltipCoords = this.#getTooltipCoords();
this.tooltipPosition = this.#getTooltipPosition();
+ this.tooltipSide = this.#getTooltipSide();
this.tetherID = requestAnimationFrame(this.#tether.bind(this));
}
@@ -511,6 +566,10 @@ export default class TooltipComponent extends Component {
this.#add(el, 'focus', this.handleFocusTooltipperElement);
this.#add(el, 'blur', this.handleBlurTooltipperElement);
}
+
+ if (this.shouldFollowMouse) {
+ this.#add(el, 'mousemove', this.handleMouseMoveTooltipperElement);
+ }
}
return () => {
@@ -527,6 +586,10 @@ export default class TooltipComponent extends Component {
this.#rem(el, 'focus', this.handleFocusTooltipperElement);
this.#rem(el, 'blur', this.handleBlurTooltipperElement);
}
+
+ if (this.shouldFollowMouse) {
+ this.#rem(el, 'mousemove', this.handleMouseMoveTooltipperElement);
+ }
}
};
});
@@ -600,9 +663,10 @@ export default class TooltipComponent extends Component {
class="tooltip"
data-showing="{{this.shouldShowTooltip}}"
data-position={{this.tooltipPosition}}
+ data-side={{this.tooltipSide}}
data-sticky="{{this.isSticky}}"
id={{this.id}}
- style={{unless @usePopover this.tooltipStyle}}
+ style={{this.tooltipStyle}}
role="tooltip"
aria-live="polite"
popover={{if @usePopover "manual"}}
diff --git a/src/utils/get-side.js b/src/utils/get-side.js
new file mode 100644
index 0000000..29854ca
--- /dev/null
+++ b/src/utils/get-side.js
@@ -0,0 +1,34 @@
+export default function getSide(anchor, popover) {
+ const anchorRect = anchor.getBoundingClientRect();
+ const popoverRect = popover.getBoundingClientRect();
+
+ const popoverEdge = {
+ top: Math.ceil(popoverRect.top),
+ bottom: Math.ceil(popoverRect.bottom),
+ left: Math.ceil(popoverRect.left),
+ right: Math.ceil(popoverRect.right)
+ };
+
+ const anchorEdge = {
+ top: Math.ceil(anchorRect.top),
+ bottom: Math.ceil(anchorRect.bottom),
+ left: Math.ceil(anchorRect.left),
+ right: Math.ceil(anchorRect.right)
+ };
+
+ if (popoverEdge.bottom <= anchorEdge.top) {
+ return 'top';
+ }
+
+ if (popoverEdge.top >= anchorEdge.bottom) {
+ return 'bottom';
+ }
+
+ if (popoverEdge.right <= anchorEdge.left) {
+ return 'left';
+ }
+
+ if (popoverEdge.left >= anchorEdge.right) {
+ return 'right';
+ }
+}
diff --git a/tests/integration/follow-mouse-test.gjs b/tests/integration/follow-mouse-test.gjs
new file mode 100644
index 0000000..57c6e60
--- /dev/null
+++ b/tests/integration/follow-mouse-test.gjs
@@ -0,0 +1,374 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, click, triggerEvent, find } from '@ember/test-helpers';
+import Tooltip from '#src/components/tooltip';
+
+const moveMouseInsideTooltipper = async (tooltipper, x, y) => {
+ const tooltipperRect = tooltipper.getBoundingClientRect();
+
+ const tooltipperCenterX = tooltipperRect.left + tooltipperRect.width / 2;
+ const tooltipperCenterY = tooltipperRect.top + tooltipperRect.height / 2;
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperCenterX + x,
+ clientY: tooltipperCenterY + y
+ });
+};
+
+module('tooltip | follow mouse', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('@followMouse does not work if @useClick is enabled', async function (assert) {
+ assert.expect(2);
+
+ await render(
+
+
+
+
+
+ );
+
+ await click('.tooltipper');
+
+ const tooltip = find('.tooltip');
+
+ assert.ok(tooltip, 'it renders the tooltip');
+
+ const prevPos = tooltip.getBoundingClientRect();
+
+ await triggerEvent('.tooltipper', 'mousemove', { clientX: 10, clientY: 0 });
+
+ assert.deepEqual(
+ prevPos,
+ tooltip.getBoundingClientRect(),
+ 'it does not move the tooltip when the mouse moves'
+ );
+ });
+
+ test('@followMouse does nothing if @usePopover is not enabled', async function (assert) {
+ assert.expect(2);
+
+ await render(
+
+
+
+
+
+ );
+
+ const tooltipper = find('.tooltipper');
+ const tooltipperRect = tooltipper.getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mouseenter');
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left,
+ clientY: tooltipperRect.top
+ });
+
+ const tooltip = find('.tooltip');
+
+ assert.ok(tooltip, 'it renders the tooltip');
+
+ const prevPos = tooltip.getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left + 5,
+ clientY: tooltipperRect.top + 5
+ });
+
+ assert.deepEqual(
+ prevPos,
+ tooltip.getBoundingClientRect(),
+ 'it does not move the tooltip when the mouse moves'
+ );
+ });
+
+ test('@followMouse moves tooltip along the axis it is positioned (bottom)', async function (assert) {
+ await render(
+
+ {{! template-lint-disable no-forbidden-elements }}
+
+
+ Popover target
+
+ Tooltip
+
+
+
+ );
+
+ const tooltipper = find('.tooltipper');
+ const tooltipperRect = tooltipper.getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mouseenter');
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left,
+ clientY: tooltipperRect.top
+ });
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'bottom');
+
+ const prevRect = find('.tooltip').getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left + 5,
+ clientY: tooltipperRect.top + 5
+ });
+
+ const currentRect = find('.tooltip').getBoundingClientRect();
+
+ assert.true(
+ currentRect.x > prevRect.x,
+ 'tooltip has moved right on X axis'
+ );
+
+ assert.strictEqual(
+ currentRect.y,
+ prevRect.y,
+ 'tooltip has not moved at all on the Y axis'
+ );
+ });
+
+ test('@followMouse moves tooltip along the axis it is positioned (top)', async function (assert) {
+ await render(
+
+ {{! template-lint-disable no-forbidden-elements }}
+
+
+ Popover target
+
+ Tooltip
+
+
+
+ );
+
+ const tooltipper = find('.tooltipper');
+ const tooltipperRect = tooltipper.getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mouseenter');
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left,
+ clientY: tooltipperRect.top
+ });
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'top');
+
+ const prevRect = find('.tooltip').getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left + 5,
+ clientY: tooltipperRect.top + 5
+ });
+
+ const currentRect = find('.tooltip').getBoundingClientRect();
+
+ assert.true(
+ currentRect.x > prevRect.x,
+ 'tooltip has moved right on X axis'
+ );
+
+ assert.strictEqual(
+ currentRect.y,
+ prevRect.y,
+ 'tooltip has not moved at all on the Y axis'
+ );
+ });
+
+ test('@followMouse moves tooltip along the axis it is positioned (left)', async function (assert) {
+ await render(
+
+ {{! template-lint-disable no-forbidden-elements }}
+
+
+ Popover target
+
+ Tooltip
+
+
+
+ );
+
+ const tooltipper = find('.tooltipper');
+ const tooltipperRect = tooltipper.getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mouseenter');
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left,
+ clientY: tooltipperRect.top
+ });
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'left');
+
+ const prevRect = find('.tooltip').getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left + 5,
+ clientY: tooltipperRect.top + 5
+ });
+
+ const currentRect = find('.tooltip').getBoundingClientRect();
+
+ assert.true(
+ currentRect.y > prevRect.y,
+ 'tooltip has moved down on the Y axis'
+ );
+
+ assert.strictEqual(
+ currentRect.x,
+ prevRect.x,
+ 'tooltip has not moved at all on the X axis'
+ );
+ });
+
+ test('@followMouse moves tooltip along the axis it is positioned (right)', async function (assert) {
+ await render(
+
+ {{! template-lint-disable no-forbidden-elements }}
+
+
+ Popover target
+
+ Tooltip
+
+
+
+ );
+
+ const tooltipper = find('.tooltipper');
+ const tooltipperRect = tooltipper.getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mouseenter');
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left,
+ clientY: tooltipperRect.top
+ });
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'right');
+
+ const prevRect = find('.tooltip').getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left + 5,
+ clientY: tooltipperRect.top + 5
+ });
+
+ const currentRect = find('.tooltip').getBoundingClientRect();
+
+ assert.true(
+ currentRect.y > prevRect.y,
+ 'tooltip has moved down on the Y axis'
+ );
+
+ assert.strictEqual(
+ currentRect.x,
+ prevRect.x,
+ 'tooltip has not moved at all on the X axis'
+ );
+ });
+
+ test('it does not move the tooltip if cursor is inside of the tooltip whilst moving', async function (assert) {
+ await render(
+
+ {{! template-lint-disable no-forbidden-elements }}
+
+
+ Popover target
+
+ Tooltip
+
+
+
+ );
+
+ const tooltipper = find('.tooltipper');
+ const tooltipperRect = tooltipper.getBoundingClientRect();
+
+ await triggerEvent(tooltipper, 'mouseenter');
+
+ await triggerEvent(tooltipper, 'mousemove', {
+ clientX: tooltipperRect.left,
+ clientY: tooltipperRect.top
+ });
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'right');
+
+ const tooltip = find('.tooltip');
+
+ const tooltipRect = tooltip.getBoundingClientRect();
+
+ // Note event trigger with the tooltip as the target, not the tooltipper. To simulate the mouse being inside of the tooltip
+ await triggerEvent(tooltip, 'mousemove', {
+ clientX: tooltipperRect.left + 5,
+ clientY: tooltipperRect.top + 5
+ });
+
+ const tooltipRectAfterMove = tooltip.getBoundingClientRect();
+
+ assert.strictEqual(
+ tooltipRect.y,
+ tooltipRectAfterMove.y,
+ 'tooltip has not moved at all on the y despite mouse movement, as we block this from happening if mousemove event target is inside of/or the tooltip itself'
+ );
+ });
+});
diff --git a/tests/integration/side-test.gjs b/tests/integration/side-test.gjs
new file mode 100644
index 0000000..df65c84
--- /dev/null
+++ b/tests/integration/side-test.gjs
@@ -0,0 +1,62 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, waitFor } from '@ember/test-helpers';
+import Tooltip from '#src/components/tooltip';
+import { trackedObject } from '@ember/reactive/collections';
+
+module('tooltip | Side detection', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it applies a data-side attribute', async function (assert) {
+ assert.expect(4);
+
+ const state = trackedObject({ class: 'top' });
+
+ await render(
+
+ {{! template-lint-disable no-forbidden-elements }}
+
+
+
+
+
+
+ );
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'top');
+
+ state.class = 'bottom';
+
+ await waitFor(".tooltip[data-side='bottom']");
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'bottom');
+
+ state.class = 'left';
+
+ await waitFor(".tooltip[data-side='left']");
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'left');
+
+ state.class = 'right';
+
+ await waitFor(".tooltip[data-side='right']");
+
+ assert.dom('.tooltip').hasAttribute('data-side', 'right');
+ });
+});
diff --git a/tests/integration/use-popover-test.gjs b/tests/integration/use-popover-test.gjs
index 55effef..5a95e55 100644
--- a/tests/integration/use-popover-test.gjs
+++ b/tests/integration/use-popover-test.gjs
@@ -56,7 +56,7 @@ module('tooltip | use popover', function (hooks) {
assert
.dom(tooltip)
.hasAttribute('popover', 'manual')
- .doesNotHaveAttribute('style')
+ .hasAttribute('style', '')
.hasAttribute('data-position', 'top center', '(flipped)');
});
});