diff --git a/web/app/modifiers/tooltip.ts b/web/app/modifiers/tooltip.ts index 40fc0bc8c..ed915f6b1 100644 --- a/web/app/modifiers/tooltip.ts +++ b/web/app/modifiers/tooltip.ts @@ -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. @@ -26,8 +37,8 @@ import htmlElement from "hermes/utils/html-element"; * * * - * 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 @@ -40,6 +51,8 @@ interface TooltipModifierSignature { Positional: [string]; Named: { placement?: Placement; + delay?: number; + _useTestDelay?: boolean; }; }; } @@ -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 @@ -104,6 +120,12 @@ export default class TooltipModifier extends Modifier */ @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 @@ -111,6 +133,24 @@ export default class TooltipModifier extends Modifier */ @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; /** @@ -139,13 +179,25 @@ export default class TooltipModifier extends Modifier @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. @@ -154,6 +206,17 @@ export default class TooltipModifier extends Modifier 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 */ @@ -265,7 +328,9 @@ export default class TooltipModifier extends Modifier this.tooltip, updatePosition ); - } + + this.updateState(TooltipState.Open); + }); /** * A click listener added in the `modify` hook that hides the tooltip @@ -310,7 +375,7 @@ export default class TooltipModifier extends Modifier */ @action onFocusIn() { if (this.reference.matches(":focus-visible")) { - this.showContent(); + this.showContent.perform(); } } @@ -323,15 +388,18 @@ export default class TooltipModifier extends Modifier 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); + } } /** @@ -344,6 +412,8 @@ export default class TooltipModifier extends Modifier named: { placement?: Placement; stayOpenOnClick?: boolean; + delay?: number; + _useTestDelay?: boolean; } ) { this._reference = element; @@ -359,7 +429,18 @@ export default class TooltipModifier extends Modifier 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. @@ -371,7 +452,7 @@ export default class TooltipModifier extends Modifier 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); } diff --git a/web/tests/integration/modifiers/tooltip-test.ts b/web/tests/integration/modifiers/tooltip-test.ts index 5533d4d97..20d2466f9 100644 --- a/web/tests/integration/modifiers/tooltip-test.ts +++ b/web/tests/integration/modifiers/tooltip-test.ts @@ -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 }}
Hover or focus me
@@ -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 }}
@@ -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` +
+ Hover or focus me +
+ `); + + 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"); + }); });