From f834df08f9b0f681b931dd27eb073e01b789cbaf Mon Sep 17 00:00:00 2001 From: Jeff Daley Date: Thu, 6 Jul 2023 15:14:34 -0400 Subject: [PATCH 1/5] Add delay to tooltip --- web/app/modifiers/tooltip.ts | 90 +++++++++++++++++-- .../integration/modifiers/tooltip-test.ts | 43 ++++++++- 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/web/app/modifiers/tooltip.ts b/web/app/modifiers/tooltip.ts index 40fc0bc8c..c50191896 100644 --- a/web/app/modifiers/tooltip.ts +++ b/web/app/modifiers/tooltip.ts @@ -17,6 +17,12 @@ 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 { set } from "mockdate"; +import simpleTimeout from "hermes/utils/simple-timeout"; + +const DEFAULT_DELAY = Ember.testing ? 0 : 400; /** * A modifier that attaches a tooltip to a reference element on hover or focus. @@ -40,6 +46,8 @@ interface TooltipModifierSignature { Positional: [string]; Named: { placement?: Placement; + delay?: number; + _isTestingDelay?: boolean; }; }; } @@ -54,7 +62,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 @@ -69,6 +80,12 @@ function cleanup(instance: TooltipModifier) { } } +enum TooltipState { + Opening = "opening", + Open = "open", + Closed = "closed", +} + export default class TooltipModifier extends Modifier { constructor(owner: any, args: ArgsFor) { super(owner, args); @@ -104,6 +121,11 @@ export default class TooltipModifier extends Modifier */ @tracked tooltip: HTMLElement | null = null; + /** + * The state of the tooltip. TODO: Add more + */ + @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,9 @@ export default class TooltipModifier extends Modifier */ @tracked placement: Placement = "top"; + @tracked delay: number = DEFAULT_DELAY; + @tracked _isTestingDelay: boolean = false; + @tracked stayOpenOnClick = false; /** @@ -137,15 +162,34 @@ export default class TooltipModifier extends Modifier return this._arrow; } + get isOpening() { + return this.state === TooltipState.Opening; + } + + get isOpen() { + return this.state === TooltipState.Open; + } + + get isClosed() { + return this.state === TooltipState.Closed; + } + @tracked floatingUICleanup: (() => void) | null = null; + @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 +198,16 @@ export default class TooltipModifier extends Modifier return; } + this.updateState(TooltipState.Opening); + + if (this.delay > 0) { + await timeout(this.delay); + } + + if (this._isTestingDelay) { + await simpleTimeout(10); + } + /** * Create the tooltip and set its attributes */ @@ -265,7 +319,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 +366,7 @@ export default class TooltipModifier extends Modifier */ @action onFocusIn() { if (this.reference.matches(":focus-visible")) { - this.showContent(); + this.showContent.perform(); } } @@ -323,15 +379,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 +403,8 @@ export default class TooltipModifier extends Modifier named: { placement?: Placement; stayOpenOnClick?: boolean; + delay?: number; + _isTestingDelay?: boolean; } ) { this._reference = element; @@ -359,7 +420,18 @@ export default class TooltipModifier extends Modifier this.stayOpenOnClick = named.stayOpenOnClick; } + if (named._isTestingDelay) { + this._isTestingDelay = named._isTestingDelay; + } + + 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 +443,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..deab65940 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,42 @@ 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", "tooltip is closed by default"); + + await triggerEvent(".tip", "mouseenter"); + + assert.equal( + htmlElement(".tip").getAttribute("data-tooltip-state"), + "opening", + "tooltip is in its opening state" + ); + + await waitUntil(() => { + return htmlElement(".tip").getAttribute("data-tooltip-state") === "open"; + }); + + assert.equal( + htmlElement(".tip").getAttribute("data-tooltip-state"), + "open", + "tooltip is open" + ); + + await triggerEvent(".tip", "mouseleave"); + + assert.equal( + htmlElement(".tip").getAttribute("data-tooltip-state"), + "closed", + "tooltip is in its closing state" + ); + }); }); From a909bc0b9c3bf4ff5ad5d1914cd280cd48366d0e Mon Sep 17 00:00:00 2001 From: Jeff Daley Date: Thu, 6 Jul 2023 15:40:01 -0400 Subject: [PATCH 2/5] Reduce default delay --- web/app/modifiers/tooltip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/modifiers/tooltip.ts b/web/app/modifiers/tooltip.ts index c50191896..e4a321f80 100644 --- a/web/app/modifiers/tooltip.ts +++ b/web/app/modifiers/tooltip.ts @@ -22,7 +22,7 @@ import Ember from "ember"; import { set } from "mockdate"; import simpleTimeout from "hermes/utils/simple-timeout"; -const DEFAULT_DELAY = Ember.testing ? 0 : 400; +const DEFAULT_DELAY = Ember.testing ? 0 : 275; /** * A modifier that attaches a tooltip to a reference element on hover or focus. From 49737d91af9d917f469ab91a8fe61b07d4e88ed1 Mon Sep 17 00:00:00 2001 From: Jeff Daley Date: Thu, 6 Jul 2023 15:58:20 -0400 Subject: [PATCH 3/5] Cleanup and documentation --- web/app/modifiers/tooltip.ts | 64 +++++++++++-------- .../integration/modifiers/tooltip-test.ts | 2 +- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/web/app/modifiers/tooltip.ts b/web/app/modifiers/tooltip.ts index e4a321f80..d8d6ea9bb 100644 --- a/web/app/modifiers/tooltip.ts +++ b/web/app/modifiers/tooltip.ts @@ -19,11 +19,16 @@ import { guidFor } from "@ember/object/internals"; import htmlElement from "hermes/utils/html-element"; import { restartableTask, timeout } from "ember-concurrency"; import Ember from "ember"; -import { set } from "mockdate"; 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. * @@ -32,8 +37,8 @@ const DEFAULT_DELAY = Ember.testing ? 0 : 275; * *
* - * 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 @@ -47,7 +52,7 @@ interface TooltipModifierSignature { Named: { placement?: Placement; delay?: number; - _isTestingDelay?: boolean; + _useTestDelay?: boolean; }; }; } @@ -80,12 +85,6 @@ function cleanup(instance: TooltipModifier) { } } -enum TooltipState { - Opening = "opening", - Open = "open", - Closed = "closed", -} - export default class TooltipModifier extends Modifier { constructor(owner: any, args: ArgsFor) { super(owner, args); @@ -122,7 +121,8 @@ export default class TooltipModifier extends Modifier @tracked tooltip: HTMLElement | null = null; /** - * The state of the tooltip. TODO: Add more + * The state of the tooltip as it transitions to and from closed and open. + * Used in tests to assert that intermediary states are rendered. */ @tracked state: TooltipState = TooltipState.Closed; @@ -133,9 +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; - @tracked _isTestingDelay: boolean = false; + /** + * 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; /** @@ -162,20 +177,13 @@ export default class TooltipModifier extends Modifier return this._arrow; } - get isOpening() { - return this.state === TooltipState.Opening; - } - - get isOpen() { - return this.state === TooltipState.Open; - } - - get isClosed() { - return this.state === TooltipState.Closed; - } - @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) { @@ -204,7 +212,7 @@ export default class TooltipModifier extends Modifier await timeout(this.delay); } - if (this._isTestingDelay) { + if (this._useTestDelay) { await simpleTimeout(10); } @@ -404,7 +412,7 @@ export default class TooltipModifier extends Modifier placement?: Placement; stayOpenOnClick?: boolean; delay?: number; - _isTestingDelay?: boolean; + _useTestDelay?: boolean; } ) { this._reference = element; @@ -420,8 +428,8 @@ export default class TooltipModifier extends Modifier this.stayOpenOnClick = named.stayOpenOnClick; } - if (named._isTestingDelay) { - this._isTestingDelay = named._isTestingDelay; + if (named._useTestDelay) { + this._useTestDelay = named._useTestDelay; } if (named.delay !== undefined) { diff --git a/web/tests/integration/modifiers/tooltip-test.ts b/web/tests/integration/modifiers/tooltip-test.ts index deab65940..dd18574f3 100644 --- a/web/tests/integration/modifiers/tooltip-test.ts +++ b/web/tests/integration/modifiers/tooltip-test.ts @@ -116,7 +116,7 @@ module("Integration | Modifier | tooltip", function (hooks) { test("it can open on a delay", async function (assert) { await render(hbs` -
+
Hover or focus me
`); From df3d5727bd8ed3352f5c0744f7d5495a94fca2ad Mon Sep 17 00:00:00 2001 From: Jeff Daley Date: Thu, 6 Jul 2023 16:45:39 -0400 Subject: [PATCH 4/5] Cleanup and documentation --- web/app/modifiers/tooltip.ts | 1 + .../integration/modifiers/tooltip-test.ts | 24 +++++-------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/web/app/modifiers/tooltip.ts b/web/app/modifiers/tooltip.ts index d8d6ea9bb..cd42b750d 100644 --- a/web/app/modifiers/tooltip.ts +++ b/web/app/modifiers/tooltip.ts @@ -212,6 +212,7 @@ export default class TooltipModifier extends Modifier await timeout(this.delay); } + // Used in tests to assert intermediary states if (this._useTestDelay) { await simpleTimeout(10); } diff --git a/web/tests/integration/modifiers/tooltip-test.ts b/web/tests/integration/modifiers/tooltip-test.ts index dd18574f3..20d2466f9 100644 --- a/web/tests/integration/modifiers/tooltip-test.ts +++ b/web/tests/integration/modifiers/tooltip-test.ts @@ -123,32 +123,20 @@ module("Integration | Modifier | tooltip", function (hooks) { let state = htmlElement(".tip").getAttribute("data-tooltip-state"); - assert.equal(state, "closed", "tooltip is closed by default"); + assert.equal(state, "closed"); await triggerEvent(".tip", "mouseenter"); - assert.equal( - htmlElement(".tip").getAttribute("data-tooltip-state"), - "opening", - "tooltip is in its opening state" - ); + const tip = htmlElement(".tip"); + + assert.equal(tip.getAttribute("data-tooltip-state"), "opening"); await waitUntil(() => { - return htmlElement(".tip").getAttribute("data-tooltip-state") === "open"; + return tip.getAttribute("data-tooltip-state") === "open"; }); - assert.equal( - htmlElement(".tip").getAttribute("data-tooltip-state"), - "open", - "tooltip is open" - ); - await triggerEvent(".tip", "mouseleave"); - assert.equal( - htmlElement(".tip").getAttribute("data-tooltip-state"), - "closed", - "tooltip is in its closing state" - ); + assert.equal(tip.getAttribute("data-tooltip-state"), "closed"); }); }); From 8e5c4073a4b5d977b02177aa48c3e5ced6591c5b Mon Sep 17 00:00:00 2001 From: Jeff Daley Date: Fri, 7 Jul 2023 13:19:30 -0400 Subject: [PATCH 5/5] Copy tweak --- web/app/modifiers/tooltip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/modifiers/tooltip.ts b/web/app/modifiers/tooltip.ts index cd42b750d..ed915f6b1 100644 --- a/web/app/modifiers/tooltip.ts +++ b/web/app/modifiers/tooltip.ts @@ -121,7 +121,7 @@ export default class TooltipModifier extends Modifier @tracked tooltip: HTMLElement | null = null; /** - * The state of the tooltip as it transitions to and from closed and open. + * 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;