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

Add delay option to tooltip #254

Merged
merged 5 commits into from
Jul 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 92 additions & 11 deletions web/app/modifiers/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ import {
import { FOCUSABLE } from "hermes/components/editable-field";
import { guidFor } from "@ember/object/internals";
import htmlElement from "hermes/utils/html-element";
import { restartableTask, timeout } from "ember-concurrency";
import Ember from "ember";
import simpleTimeout from "hermes/utils/simple-timeout";

const DEFAULT_DELAY = Ember.testing ? 0 : 275;

enum TooltipState {
Opening = "opening",
Open = "open",
Closed = "closed",
}

/**
* A modifier that attaches a tooltip to a reference element on hover or focus.
Expand All @@ -26,8 +37,8 @@ import htmlElement from "hermes/utils/html-element";
* <FlightIcon @name="arrow-left" />
* </div>
*
* Takes text and an optional named `placement` argument:
* {{tooltip "Go back" placement="left-end"}}
* Takes text and optional arguments:
* {{tooltip "Go back" placement="left-end" delay=0}}
*
* TODO:
* - Add `renderInPlace` argument
Expand All @@ -40,6 +51,8 @@ interface TooltipModifierSignature {
Positional: [string];
Named: {
placement?: Placement;
delay?: number;
_useTestDelay?: boolean;
};
};
}
Expand All @@ -54,7 +67,10 @@ function cleanup(instance: TooltipModifier) {
instance.reference.removeEventListener("click", instance.handleClick);
instance.reference.removeEventListener("focusin", instance.onFocusIn);
instance.reference.removeEventListener("focusout", instance.maybeHideContent);
instance.reference.removeEventListener("mouseenter", instance.showContent);
instance.reference.removeEventListener(
"mouseenter",
instance.showContent.perform
);
instance.reference.removeEventListener(
"mouseleave",
instance.maybeHideContent
Expand Down Expand Up @@ -104,13 +120,37 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
*/
@tracked tooltip: HTMLElement | null = null;

/**
* The state of the tooltip as it transitions between closed and open.
* Used in tests to assert that intermediary states are rendered.
*/
@tracked state: TooltipState = TooltipState.Closed;

/**
* The placement of the tooltip relative to the reference element.
* Defaults to `top` but can be overridden by invoking the modifier
* with a `placement` argument.
*/
@tracked placement: Placement = "top";

/**
* The delay before the tooltip is shown.
* Can be overridden with a `delay` argument.
*/
@tracked delay: number = DEFAULT_DELAY;

/**
* Whether to use a delay in the testing environment.
* Triggers a short `simpleTimeout` on open so we can test
* the content's intermediary states.
*/
@tracked _useTestDelay: boolean = false;

/**
* Whether the content should stay open on click.
* Used in components like `CopyURLButton` where we want to show
* a "success" message without closing and reopening the tooltip.
*/
@tracked stayOpenOnClick = false;

/**
Expand Down Expand Up @@ -139,13 +179,25 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>

@tracked floatingUICleanup: (() => void) | null = null;

/**
* The action that runs when the content's [visibility] state changes.
* Updates the `data-tooltip-state` attribute on the reference
* so we can test intermediary states.
*/
@action updateState(state: TooltipState) {
this.state = state;
if (this.reference) {
this.reference.setAttribute("data-tooltip-state", this.state);
}
}

/**
* The action that runs on mouseenter and focusin.
* Creates the tooltip element and adds it to the DOM,
* positioned relative to the reference element, as
* calculated by the `floating-ui` positioning library.
*/
@action showContent() {
showContent = restartableTask(async () => {
/**
* Do nothing if the tooltip exists, e.g., if the user
* hovers a reference that's already focused.
Expand All @@ -154,6 +206,17 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
return;
}

this.updateState(TooltipState.Opening);

if (this.delay > 0) {
await timeout(this.delay);
}

// Used in tests to assert intermediary states
if (this._useTestDelay) {
await simpleTimeout(10);
}

/**
* Create the tooltip and set its attributes
*/
Expand Down Expand Up @@ -265,7 +328,9 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
this.tooltip,
updatePosition
);
}

this.updateState(TooltipState.Open);
});

/**
* A click listener added in the `modify` hook that hides the tooltip
Expand Down Expand Up @@ -310,7 +375,7 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
*/
@action onFocusIn() {
if (this.reference.matches(":focus-visible")) {
this.showContent();
this.showContent.perform();
}
}

Expand All @@ -323,15 +388,18 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
if (this.reference.matches(":focus-visible")) {
return;
}
if (this.tooltip) {
if (this.tooltip || this.showContent.isRunning) {
this.showContent.cancelAll();
this.hideContent();
}
}

@action hideContent() {
assert("tooltip expected", this.tooltip);
this.tooltip.remove();
this.tooltip = null;
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
this.updateState(TooltipState.Closed);
}
}

/**
Expand All @@ -344,6 +412,8 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
named: {
placement?: Placement;
stayOpenOnClick?: boolean;
delay?: number;
_useTestDelay?: boolean;
}
) {
this._reference = element;
Expand All @@ -359,7 +429,18 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
this.stayOpenOnClick = named.stayOpenOnClick;
}

if (named._useTestDelay) {
this._useTestDelay = named._useTestDelay;
}

if (named.delay !== undefined) {
this.delay = named.delay;
} else {
this.delay = DEFAULT_DELAY;
}

this._reference.setAttribute("aria-describedby", `tooltip-${this.id}`);
this._reference.setAttribute("data-tooltip-state", this.state);

/**
* If the reference isn't inherently focusable, make it focusable.
Expand All @@ -371,7 +452,7 @@ export default class TooltipModifier extends Modifier<TooltipModifierSignature>
document.addEventListener("keydown", this.handleKeydown);
this._reference.addEventListener("click", this.handleClick);
this._reference.addEventListener("focusin", this.onFocusIn);
this._reference.addEventListener("mouseenter", this.showContent);
this._reference.addEventListener("mouseenter", this.showContent.perform);
this._reference.addEventListener("focusout", this.maybeHideContent);
this._reference.addEventListener("mouseleave", this.maybeHideContent);
}
Expand Down
31 changes: 28 additions & 3 deletions web/tests/integration/modifiers/tooltip-test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "ember-qunit";
import { render, triggerEvent } from "@ember/test-helpers";
import { render, triggerEvent, waitUntil } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import htmlElement from "hermes/utils/html-element";
import { wait } from "ember-animated/.";

module("Integration | Modifier | tooltip", function (hooks) {
setupRenderingTest(hooks);

test("it renders", async function (assert) {
await render(hbs`
{{! @glint-nocheck: not typesafe yet }}
<div data-test-div {{tooltip "more information"}}>
Hover or focus me
</div>
Expand Down Expand Up @@ -76,7 +76,6 @@ module("Integration | Modifier | tooltip", function (hooks) {

test("it takes a placement argument", async function (assert) {
await render(hbs`
{{! @glint-nocheck: not typesafe yet }}
<div class="w-full h-full grid place-items-center">
<div>
<div data-test-one {{tooltip "more information"}}>
Expand Down Expand Up @@ -114,4 +113,30 @@ module("Integration | Modifier | tooltip", function (hooks) {
"tooltip can be custom placed"
);
});

test("it can open on a delay", async function (assert) {
await render(hbs`
<div class="tip" {{tooltip "more information" _useTestDelay=true}}>
Hover or focus me
</div>
`);

let state = htmlElement(".tip").getAttribute("data-tooltip-state");

assert.equal(state, "closed");

await triggerEvent(".tip", "mouseenter");

const tip = htmlElement(".tip");

assert.equal(tip.getAttribute("data-tooltip-state"), "opening");

await waitUntil(() => {
return tip.getAttribute("data-tooltip-state") === "open";
});

await triggerEvent(".tip", "mouseleave");

assert.equal(tip.getAttribute("data-tooltip-state"), "closed");
});
});