DEV: allows for multiple menus/tooltips (#26823)

menus and tooltips are now appended to their own portals. The service are the only responsible for managing the instances, prior to this commit, services could manage one instance, but the DMenu and DTooltip components could also take over which could cause unexpected states.

This change also allows nested menus/tooltips.

Other notable changes:

- few months ago core copied the CloseOnClickOutside modifier of float-kit without removing the float-kit one, this commit now only use the core one.
- the close function is now trully async
- the close function accepts an instance or an identifier as parameter
This commit is contained in:
Joffrey JAFFEUX 2024-05-07 23:48:44 +02:00 committed by GitHub
parent 95302cc7ed
commit fe16633a0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 544 additions and 435 deletions

View File

@ -80,7 +80,7 @@ export default class PostTextSelection extends Component {
super.willDestroy(...arguments);
cancel(this.debouncedSelectionChanged);
this.menuInstance?.destroy();
this.menuInstance?.close();
}
@bind

View File

@ -41,7 +41,7 @@ export default class UserTip extends Component {
buttonText = `${iconHTML(this.args.buttonIcon)} ${buttonText}`;
}
instance = new DTooltipInstance(getOwner(this), trigger || element, {
instance = new DTooltipInstance(getOwner(this), {
identifier: "user-tip",
interactive: true,
closeOnScroll: false,
@ -60,6 +60,8 @@ export default class UserTip extends Component {
showSkipButton: this.args.showSkipButton,
},
});
instance.trigger = trigger || element;
instance.detachedTrigger = true;
this.tooltip.show(instance);

View File

@ -22,6 +22,10 @@ export class UserStatusMessage {
}
destroy() {
if (this.tooltip.isDestroyed) {
return;
}
this.tooltipInstance.destroy();
}

View File

@ -8,9 +8,13 @@ export default class CloseOnClickOutside extends Modifier {
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, [closeFn, { targetSelector, secondaryTargetSelector }]) {
modify(
element,
[closeFn, { targetSelector, secondaryTargetSelector, target }]
) {
this.closeFn = closeFn;
this.element = element;
this.target = target;
this.targetSelector = targetSelector;
this.secondaryTargetSelector = secondaryTargetSelector;
@ -25,8 +29,10 @@ export default class CloseOnClickOutside extends Modifier {
return;
}
const target = this.target ?? document.querySelector(this.targetSelector);
if (
document.querySelector(this.targetSelector)?.contains(event.target) ||
target?.contains(event.target) ||
(this.secondaryTargetSelector &&
document
.querySelector(this.secondaryTargetSelector)

View File

@ -126,6 +126,6 @@
{{/if}}
</DiscourseRoot>
<DInlineMenu />
<DInlineTooltip />
<DMenus />
<DTooltips />
<DToasts />

View File

@ -1,3 +1,4 @@
import { getOwner } from "@ember/application";
import {
click,
find,
@ -255,4 +256,13 @@ module("Integration | Component | FloatKit | d-menu", function (hooks) {
assert.dom(document.activeElement).hasClass("my-button");
});
test("a menu can be closed by identifier", async function (assert) {
await render(hbs`<DMenu @inline={{true}} @identifier="test">test</DMenu>`);
await open();
await getOwner(this).lookup("service:menu").close("test");
assert.dom(".fk-d-menu__content.test-content").doesNotExist();
});
});

View File

@ -1,3 +1,4 @@
import { getOwner } from "@ember/application";
import {
click,
find,
@ -40,7 +41,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
);
await hover();
assert.dom(".fk-d-tooltip").hasText("content");
assert.dom(".fk-d-tooltip__content").hasText("content");
});
test("@onRegisterApi", async function (assert) {
@ -107,7 +108,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
assert.dom(".fk-d-tooltip").hasAttribute("data-identifier", "tip");
assert.dom(".fk-d-tooltip__content").hasAttribute("data-identifier", "tip");
});
test("aria-expanded attribute", async function (assert) {
@ -135,7 +136,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
assert.dom(".fk-d-tooltip").hasText("content");
assert.dom(".fk-d-tooltip__content").hasText("content");
});
test("content role attribute", async function (assert) {
@ -143,7 +144,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
assert.dom(".fk-d-tooltip").hasAttribute("role", "tooltip");
assert.dom(".fk-d-tooltip__content").hasAttribute("role", "tooltip");
});
test("@component", async function (assert) {
@ -155,11 +156,11 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
assert.dom(".fk-d-tooltip").containsText("content");
assert.dom(".fk-d-tooltip__content").containsText("content");
await click(".fk-d-tooltip .btn");
await click(".fk-d-tooltip__content .btn");
assert.dom(".fk-d-tooltip").doesNotExist();
assert.dom(".fk-d-tooltip__content").doesNotExist();
});
test("content aria-labelledby attribute", async function (assert) {
@ -169,7 +170,9 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
assert.strictEqual(
document.querySelector(".fk-d-tooltip__trigger").id,
document.querySelector(".fk-d-tooltip").getAttribute("aria-labelledby")
document
.querySelector(".fk-d-tooltip__content")
.getAttribute("aria-labelledby")
);
});
@ -180,7 +183,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
await close();
assert.dom(".fk-d-tooltip").doesNotExist();
assert.dom(".fk-d-tooltip__content").doesNotExist();
await render(
hbs`<DTooltip @inline={{true}} @label="label" @closeOnEscape={{false}} />`
@ -188,7 +191,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
await close();
assert.dom(".fk-d-tooltip").exists();
assert.dom(".fk-d-tooltip__content").exists();
});
test("@closeOnClickOutside", async function (assert) {
@ -198,7 +201,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
await triggerEvent(".test", "pointerdown");
assert.dom(".fk-d-tooltip").doesNotExist();
assert.dom(".fk-d-tooltip__content").doesNotExist();
await render(
hbs`<span class="test">test</span><DTooltip @inline={{true}} @label="label" @closeOnClickOutside={{false}} />`
@ -206,7 +209,7 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
await triggerEvent(".test", "pointerdown");
assert.dom(".fk-d-tooltip").exists();
assert.dom(".fk-d-tooltip__content").exists();
});
test("@maxWidth", async function (assert) {
@ -216,15 +219,32 @@ module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
await hover();
assert.ok(
find(".fk-d-tooltip").getAttribute("style").includes("max-width: 20px;")
find(".fk-d-tooltip__content")
.getAttribute("style")
.includes("max-width: 20px;")
);
});
test("applies position", async function (assert) {
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
await hover();
assert.ok(find(".fk-d-tooltip").getAttribute("style").includes("left: "));
assert.ok(find(".fk-d-tooltip").getAttribute("style").includes("top: "));
assert.ok(
find(".fk-d-tooltip__content").getAttribute("style").includes("left: ")
);
assert.ok(
find(".fk-d-tooltip__content").getAttribute("style").includes("top: ")
);
});
test("a tooltip can be closed by identifier", async function (assert) {
await render(
hbs`<DTooltip @inline={{true}} @label="label" @identifier="test">test</DTooltip>`
);
await open();
await getOwner(this).lookup("service:tooltip").close("test");
assert.dom(".fk-d-tooltip__content.test-content").doesNotExist();
});
});

View File

@ -125,7 +125,7 @@ module("Integration | Component | user-info", function (hooks) {
this.currentUser.status = { emoji: "tooth", description: "off to dentist" };
await render(
hbs`<UserInfo @user={{this.currentUser}} @showStatus={{true}} /><DInlineTooltip />`
hbs`<UserInfo @user={{this.currentUser}} @showStatus={{true}} /><DTooltips />`
);
await triggerEvent(query(".user-status-message"), "mousemove");

View File

@ -48,7 +48,7 @@ module("Integration | Component | user-status-message", function (hooks) {
this.status.ends_at = "2100-02-01T12:30:00.000Z";
await render(
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
hbs`<UserStatusMessage @status={{this.status}} /><DTooltips />`
);
await mouseenter();
@ -66,7 +66,7 @@ module("Integration | Component | user-status-message", function (hooks) {
this.status.ends_at = "2100-02-02T12:30:00.000Z";
await render(
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
hbs`<UserStatusMessage @status={{this.status}} /><DTooltips />`
);
await mouseenter();
@ -84,7 +84,7 @@ module("Integration | Component | user-status-message", function (hooks) {
this.status.ends_at = null;
await render(
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
hbs`<UserStatusMessage @status={{this.status}} /><DTooltips />`
);
await mouseenter();
@ -97,7 +97,7 @@ module("Integration | Component | user-status-message", function (hooks) {
test("it shows tooltip by default", async function (assert) {
await render(
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
hbs`<UserStatusMessage @status={{this.status}} /><DTooltips />`
);
await mouseenter();

View File

@ -576,7 +576,7 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { canManage: true });
await render(
hbs`<MountWidget @widget="post" @args={{this.args}} /><DInlineMenu />`
hbs`<MountWidget @widget="post" @args={{this.args}} /><DMenus />`
);
assert
@ -597,7 +597,7 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("permanentlyDeletePost", () => (this.deleted = true));
await render(
hbs`<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} /><DInlineMenu />`
hbs`<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} /><DMenus />`
);
await click(".post-menu-area .show-post-admin-menu");
@ -616,7 +616,7 @@ module("Integration | Component | Widget | post", function (hooks) {
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} />
<DInlineMenu />
<DMenus />
`);
await click(".post-menu-area .show-post-admin-menu");
@ -637,7 +637,7 @@ module("Integration | Component | Widget | post", function (hooks) {
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @togglePostType={{this.togglePostType}} />
<DInlineMenu />
<DMenus />
`);
await click(".post-menu-area .show-post-admin-menu");
@ -657,7 +657,7 @@ module("Integration | Component | Widget | post", function (hooks) {
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @rebakePost={{this.rebakePost}} />
<DInlineMenu />
<DMenus />
`);
await click(".post-menu-area .show-post-admin-menu");
@ -678,7 +678,7 @@ module("Integration | Component | Widget | post", function (hooks) {
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @unhidePost={{this.unhidePost}} />
<DInlineMenu />
<DMenus />
`);
await click(".post-menu-area .show-post-admin-menu");
@ -701,7 +701,7 @@ module("Integration | Component | Widget | post", function (hooks) {
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @changePostOwner={{this.changePostOwner}} />
<DInlineMenu />
<DMenus />
`);
await click(".post-menu-area .show-post-admin-menu");

View File

@ -1,13 +1,13 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { concat, hash } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import { modifier as modifierFn } from "ember-modifier";
import concatClass from "discourse/helpers/concat-class";
import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
import TrapTab from "discourse/modifiers/trap-tab";
import DFloatPortal from "float-kit/components/d-float-portal";
import { getScrollParent } from "float-kit/lib/get-scroll-parent";
import FloatKitApplyFloatingUi from "float-kit/modifiers/apply-floating-ui";
import FloatKitCloseOnClickOutside from "float-kit/modifiers/close-on-click-outside";
import FloatKitCloseOnEscape from "float-kit/modifiers/close-on-escape";
export default class DFloatBody extends Component {
@ -38,7 +38,11 @@ export default class DFloatBody extends Component {
}
get trigger() {
return this.args.instance.trigger;
return this.args.instance?.trigger;
}
get content() {
return this.args.instance?.content;
}
get options() {
@ -48,7 +52,7 @@ export default class DFloatBody extends Component {
<template>
<DFloatPortal
@inline={{@inline}}
@portalOutletElement={{@portalOutletElement}}
@portalOutletElement={{@instance.portalOutletElement}}
>
<div
class={{concatClass
@ -66,7 +70,9 @@ export default class DFloatBody extends Component {
{{(if @trapTab (modifier TrapTab autofocus=this.options.autofocus))}}
{{(if
this.supportsCloseOnClickOutside
(modifier FloatKitCloseOnClickOutside this.trigger @instance.close)
(modifier
closeOnClickOutside @instance.close (hash target=this.content)
)
)}}
{{(if
this.supportsCloseOnEscape

View File

@ -10,7 +10,7 @@ export default class DFloatPortal extends Component {
{{#if this.inline}}
{{yield}}
{{else}}
{{#in-element @portalOutletElement}}
{{#in-element @portalOutletElement insertBefore=null}}
{{yield}}
{{/in-element}}
{{/if}}

View File

@ -0,0 +1,14 @@
import DInlineFloat from "float-kit/components/d-inline-float";
const DHeadlessMenu = <template>
<DInlineFloat
@instance={{@menu}}
@trapTab={{@menu.options.trapTab}}
@mainClass="fk-d-menu"
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{@inline}}
/>
</template>;
export default DHeadlessMenu;

View File

@ -0,0 +1,15 @@
import { and } from "truth-helpers";
import DInlineFloat from "float-kit/components/d-inline-float";
const DHeadlessTooltip = <template>
<DInlineFloat
@instance={{@tooltip}}
@trapTab={{and @tooltip.options.interactive @tooltip.options.trapTab}}
@mainClass="fk-d-tooltip__content"
@innerClass="fk-d-tooltip__inner-content"
@role="tooltip"
@inline={{@inline}}
/>
</template>;
export default DHeadlessTooltip;

View File

@ -32,7 +32,7 @@ export default class DInlineFloat extends Component {
@mainClass={{@mainClass}}
@innerClass={{@innerClass}}
@role={{@role}}
@portalOutletElement={{@portalOutletElement}}
@portalOutletElement={{@instance.portalOutletElement}}
@inline={{@inline}}
>
{{#if @instance.options.component}}

View File

@ -1,26 +0,0 @@
import Component from "@glimmer/component";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import DInlineFloat from "float-kit/components/d-inline-float";
import { MENU } from "float-kit/lib/constants";
export default class DInlineMenu extends Component {
@service menu;
<template>
<div
id={{MENU.portalOutletId}}
{{didInsert this.menu.registerPortalOutletElement}}
></div>
<DInlineFloat
@instance={{this.menu.activeMenu}}
@portalOutletElement={{this.menu.portalOutletElement}}
@trapTab={{this.menu.activeMenu.options.trapTab}}
@mainClass="fk-d-menu"
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{@inline}}
/>
</template>
}

View File

@ -1,30 +0,0 @@
import Component from "@glimmer/component";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { and } from "truth-helpers";
import DInlineFloat from "float-kit/components/d-inline-float";
import { TOOLTIP } from "float-kit/lib/constants";
export default class DInlineTooltip extends Component {
@service tooltip;
<template>
<div
id={{TOOLTIP.portalOutletId}}
{{didInsert this.tooltip.registerPortalOutletElement}}
></div>
<DInlineFloat
@instance={{this.tooltip.activeTooltip}}
@portalOutletElement={{this.tooltip.portalOutletElement}}
@trapTab={{and
this.tooltip.activeTooltip.options.interactive
this.tooltip.activeTooltip.options.trapTab
}}
@mainClass="fk-d-tooltip"
@innerClass="fk-d-tooltip__inner-content"
@role="tooltip"
@inline={{@inline}}
/>
</template>
}

View File

@ -1,8 +1,9 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { cached } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import { concat } from "@ember/helper";
import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { modifier } from "ember-modifier";
import { and } from "truth-helpers";
@ -18,33 +19,25 @@ export default class DMenu extends Component {
@service menu;
@service site;
@tracked menuInstance = null;
registerTrigger = modifier((element) => {
if (!this.menuInstance.trigger) {
next(() => {
this.menuInstance.trigger = element;
this.options.onRegisterApi?.(this.menuInstance);
});
}
});
registerTrigger = modifier((element, [properties]) => {
const options = {
...properties,
@cached
get menuInstance() {
return new DMenuInstance(getOwner(this), {
...this.allowedProperties(),
...{
autoUpdate: true,
listeners: true,
beforeTrigger: () => {
this.menu.close();
},
},
};
const instance = new DMenuInstance(getOwner(this), element, options);
this.menuInstance = instance;
this.options.onRegisterApi?.(this.menuInstance);
return () => {
instance.destroy();
if (this.isDestroying) {
this.menuInstance = null;
}
};
});
});
}
get menuId() {
return `d-menu-${this.menuInstance.id}`;
@ -88,7 +81,7 @@ export default class DMenu extends Component {
@translatedTitle={{@title}}
@disabled={{@disabled}}
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{this.registerTrigger (this.allowedProperties)}}
{{this.registerTrigger}}
...attributes
>
{{#if (has-block "trigger")}}
@ -126,12 +119,12 @@ export default class DMenu extends Component {
@trapTab={{this.options.trapTab}}
@mainClass={{concatClass
"fk-d-menu"
"fk-d-menu__content"
(concat this.options.identifier "-content")
}}
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{this.options.inline}}
@portalOutletElement={{this.menu.portalOutletElement}}
>
{{#if (has-block)}}
{{yield this.componentArgs}}

View File

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import DHeadlessMenu from "float-kit/components/d-headless-menu";
export default class DMenus extends Component {
@service menu;
<template>
<div id="d-menu-portals"></div>
{{#each this.menu.registeredMenus key="id" as |menu|}}
{{#if menu.detachedTrigger}}
<DHeadlessMenu @menu={{menu}} />
{{/if}}
{{/each}}
</template>
}

View File

@ -1,7 +1,9 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { cached } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import { concat } from "@ember/helper";
import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import { and } from "truth-helpers";
@ -15,34 +17,23 @@ export default class DTooltip extends Component {
@service tooltip;
@service internalTooltip;
@tracked tooltipInstance = null;
registerTrigger = modifier((element, [properties]) => {
const options = {
...properties,
...{
listeners: true,
beforeTrigger: (instance) => {
this.internalTooltip.activeTooltip?.close?.();
this.internalTooltip.activeTooltip = instance;
},
},
};
const instance = new DTooltipInstance(getOwner(this), element, options);
this.tooltipInstance = instance;
this.options.onRegisterApi?.(instance);
return () => {
instance.destroy();
if (this.isDestroying) {
this.tooltipInstance = null;
}
};
registerTrigger = modifier((element) => {
if (!this.tooltipInstance?.trigger) {
next(() => {
this.tooltipInstance.trigger = element;
this.options.onRegisterApi?.(this.tooltipInstance);
});
}
});
@cached
get tooltipInstance() {
return new DTooltipInstance(getOwner(this), {
...this.allowedProperties(),
...{ autoUpdate: true, listeners: true },
});
}
get options() {
return this.tooltipInstance?.options;
}
@ -98,11 +89,13 @@ export default class DTooltip extends Component {
<DFloatBody
@instance={{this.tooltipInstance}}
@trapTab={{and this.options.interactive this.options.trapTab}}
@mainClass="fk-d-tooltip"
@mainClass={{concatClass
"fk-d-tooltip__content"
(concat this.options.identifier "-content")
}}
@innerClass="fk-d-tooltip__inner-content"
@role="tooltip"
@inline={{this.options.inline}}
@portalOutletElement={{this.tooltip.portalOutletElement}}
>
{{#if (has-block)}}
{{yield this.componentArgs}}

View File

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import DHeadlessTooltip from "float-kit/components/d-headless-tooltip";
export default class DTooltips extends Component {
@service tooltip;
<template>
<div id="d-tooltip-portals"></div>
{{#each this.tooltip.registeredTooltips key="id" as |tooltip|}}
{{#if tooltip.detachedTrigger}}
<DHeadlessTooltip @tooltip={{tooltip}} />
{{/if}}
{{/each}}
</template>
}

View File

@ -1,4 +1,5 @@
import { setOwner } from "@ember/application";
import { tracked } from "@glimmer/tracking";
import { getOwner, setOwner } from "@ember/application";
import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import { service } from "@ember/service";
@ -10,54 +11,97 @@ export default class DMenuInstance extends FloatKitInstance {
@service site;
@service modal;
constructor(owner, trigger, options = {}) {
/**
* Indicates whether the menu is expanded or not.
* @property {boolean} expanded - Tracks the state of menu expansion, initially set to false.
*/
@tracked expanded = false;
/**
* Specifies whether the trigger for opening/closing the menu is detached from the menu itself.
* This is the case when a menu is trigger programmaticaly instead of through the <DMenu /> component.
* @property {boolean} detachedTrigger - Tracks whether the trigger is detached, initially set to false.
*/
@tracked detachedTrigger = false;
/**
* Configuration options for the DMenuInstance.
* @property {Object} options - Options object that configures the menu behavior and display.
*/
@tracked options;
@tracked _trigger;
constructor(owner, options = {}) {
super(...arguments);
setOwner(this, owner);
this.options = { ...MENU.options, ...options };
this.id = trigger.id || guidFor(trigger);
this.trigger = trigger;
}
get portalOutletElement() {
return document.getElementById("d-menu-portals");
}
get trigger() {
return this._trigger;
}
set trigger(element) {
this._trigger = element;
this.id = element.id || guidFor(element);
this.setupListeners();
}
@action
close() {
async close() {
if (getOwner(this).isDestroying) {
return;
}
await super.close(...arguments);
if (this.site.mobileView && this.options.modalForMobile) {
this.modal.close();
await this.modal.close();
}
super.close(...arguments);
await this.menu.close(this);
}
@action
onMouseMove(event) {
if (this.trigger.contains(event.target) && this.expanded) {
async show() {
await super.show(...arguments);
await this.menu.show(this);
}
@action
async onMouseMove(event) {
if (this.expanded && this.trigger.contains(event.target)) {
return;
}
this.onTrigger(event);
await this.onTrigger(event);
}
@action
onClick(event) {
async onClick(event) {
if (this.expanded && this.untriggers.includes("click")) {
this.onUntrigger(event);
return;
return await this.onUntrigger(event);
}
this.onTrigger(event);
await this.onTrigger(event);
}
@action
onMouseLeave(event) {
async onMouseLeave(event) {
if (this.untriggers.includes("hover")) {
this.onUntrigger(event);
await this.onUntrigger(event);
}
}
@action
async onTrigger() {
this.options.beforeTrigger?.(this);
await this.options.beforeTrigger?.(this);
await this.show();
}
@ -67,8 +111,7 @@ export default class DMenuInstance extends FloatKitInstance {
}
@action
async destroy() {
await this.close();
destroy() {
this.tearDownListeners();
}
}

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import { setOwner } from "@ember/application";
import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
@ -8,45 +9,89 @@ import FloatKitInstance from "float-kit/lib/float-kit-instance";
export default class DTooltipInstance extends FloatKitInstance {
@service tooltip;
constructor(owner, trigger, options = {}) {
/**
* Indicates whether the tooltip is expanded or not.
* @property {boolean} expanded - Tracks the state of tooltip expansion, initially set to false.
*/
@tracked expanded = false;
/**
* Specifies whether the trigger for opening/closing the tooltip is detached from the tooltip itself.
* This is the case when a tooltip is trigger programmaticaly instead of through the <DTooltip /> component.
* @property {boolean} detachedTrigger - Tracks whether the trigger is detached, initially set to false.
*/
@tracked detachedTrigger = false;
/**
* Configuration options for the DTooltipInstance.
* @property {Object} options - Options object that configures the tooltip behavior and display.
*/
@tracked options;
@tracked _trigger;
constructor(owner, options = {}) {
super(...arguments);
setOwner(this, owner);
this.options = { ...TOOLTIP.options, ...options };
this.id = trigger.id || guidFor(trigger);
this.trigger = trigger;
}
get trigger() {
return this._trigger;
}
set trigger(element) {
this._trigger = element;
this.id = element.id || guidFor(element);
this.setupListeners();
}
@action
onMouseMove(event) {
if (this.trigger.contains(event.target) && this.expanded) {
return;
}
this.onTrigger(event);
get portalOutletElement() {
return document.getElementById("d-tooltip-portals");
}
@action
onClick(event) {
async show() {
await this.tooltip.show(this);
await super.show(...arguments);
}
@action
async close() {
await this.tooltip.close(this);
await super.close(...arguments);
}
@action
async onMouseMove(event) {
if (this.expanded && this.trigger.contains(event.target)) {
return;
}
await this.onTrigger(event);
}
@action
async onClick(event) {
if (this.expanded && this.untriggers.includes("click")) {
this.onUntrigger(event);
return;
return await this.onUntrigger(event);
}
this.onTrigger(event);
await this.onTrigger(event);
}
@action
onMouseLeave(event) {
async onMouseLeave(event) {
if (this.untriggers.includes("hover")) {
this.onUntrigger(event);
await this.onUntrigger(event);
}
}
@action
async onTrigger() {
this.options.beforeTrigger?.(this);
await this.options.beforeTrigger?.(this);
await this.show();
}
@ -57,7 +102,6 @@ export default class DTooltipInstance extends FloatKitInstance {
@action
destroy() {
this.close();
this.tearDownListeners();
}
}

View File

@ -1,6 +1,6 @@
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { cancel, next } from "@ember/runloop";
import { cancel } from "@ember/runloop";
import { makeArray } from "discourse-common/lib/helpers";
import discourseLater from "discourse-common/lib/later";
import { bind } from "discourse-common/utils/decorators";
@ -13,48 +13,44 @@ function cancelEvent(event) {
}
export default class FloatKitInstance {
@tracked expanded = false;
@tracked id = null;
trigger = null;
content = null;
@action
show() {
this.expanded = true;
next(() => {
this.options.onShow?.();
});
async show() {
await this.options.onShow?.();
}
@action
close() {
this.expanded = false;
next(() => {
this.options.onClose?.();
});
async close() {
await this.options.onClose?.();
}
@action
onFocus(event) {
this.onTrigger(event);
async onFocus(event) {
await this.onTrigger(event);
}
@action
onBlur(event) {
this.onTrigger(event);
async onBlur(event) {
await this.onTrigger(event);
}
@action
onFocusIn(event) {
this.onTrigger(event);
async onFocusIn(event) {
await this.onTrigger(event);
}
@action
onFocusOut(event) {
this.onTrigger(event);
async onFocusOut(event) {
await this.onTrigger(event);
}
@action
trapPointerDown(event) {
// this is done to avoid trigger on click outside when you click on your own trigger
// given trigger and content are not in the same div, we can't just check if target is
// inside the menu
event.stopPropagation();
}
@action
@ -105,7 +101,11 @@ export default class FloatKitInstance {
}
tearDownListeners() {
if (!this.options.listeners) {
if (typeof this.trigger.addEventListener === "function") {
this.trigger.removeEventListener("pointerdown", this.trapPointerDown);
}
if (!this.options?.listeners) {
return;
}
@ -141,7 +141,11 @@ export default class FloatKitInstance {
}
setupListeners() {
if (!this.options.listeners) {
if (typeof this.trigger.addEventListener === "function") {
this.trigger.addEventListener("pointerdown", this.trapPointerDown);
}
if (!this.options?.listeners) {
return;
}

View File

@ -1,40 +0,0 @@
import { registerDestructor } from "@ember/destroyable";
import Modifier from "ember-modifier";
import { bind } from "discourse-common/utils/decorators";
export default class FloatKitCloseOnClickOutside extends Modifier {
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, [trigger, closeFn]) {
this.closeFn = closeFn;
this.trigger = trigger;
this.element = element;
document.addEventListener("pointerdown", this.check, {
passive: true,
});
}
@bind
check(event) {
if (this.element.contains(event.target)) {
return;
}
if (
this.trigger instanceof HTMLElement &&
this.trigger.contains(event.target)
) {
return;
}
this.closeFn();
}
cleanup() {
document.removeEventListener("pointerdown", this.check);
}
}

View File

@ -1,14 +1,12 @@
import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import { schedule } from "@ember/runloop";
import Service from "@ember/service";
import DMenuInstance from "float-kit/lib/d-menu-instance";
import { updatePosition } from "float-kit/lib/update-position";
export default class Menu extends Service {
@tracked activeMenu;
@tracked portalOutletElement;
@tracked registeredMenus = [];
/**
* Render a menu
@ -34,36 +32,49 @@ export default class Menu extends Service {
if (arguments[0] instanceof DMenuInstance) {
instance = arguments[0];
if (this.activeMenu === instance && this.activeMenu.expanded) {
if (instance.expanded) {
return;
}
} else {
const trigger = arguments[0];
if (
this.activeMenu &&
this.activeMenu.id ===
(trigger?.id?.length ? trigger.id : guidFor(trigger)) &&
this.activeMenu.expanded
) {
this.activeMenu?.close();
return;
}
instance = this.registeredMenus.find(
(registeredMenu) => registeredMenu.trigger === arguments[0]
);
instance = new DMenuInstance(getOwner(this), trigger, arguments[1]);
if (!instance) {
instance = new DMenuInstance(getOwner(this), arguments[1]);
instance.trigger = arguments[0];
instance.detachedTrigger = true;
}
}
await this.replace(instance);
instance.expanded = true;
return instance;
}
if (instance.options.identifier) {
for (const menu of this.registeredMenus) {
if (
menu.options.identifier === instance.options.identifier &&
menu !== instance
) {
await this.close(menu);
}
}
}
/**
* Replaces any active menu-
*/
@action
async replace(menu) {
await this.activeMenu?.close();
this.activeMenu = menu;
if (instance.expanded) {
return await this.close(instance);
}
await new Promise((resolve) => {
if (!this.registeredMenus.includes(instance)) {
this.registeredMenus = this.registeredMenus.concat(instance);
}
instance.expanded = true;
schedule("afterRender", () => {
resolve();
});
});
return instance;
}
/**
@ -72,26 +83,27 @@ export default class Menu extends Service {
*/
@action
async close(menu) {
if (this.activeMenu && menu && this.activeMenu.id !== menu.id) {
if (typeof menu === "string") {
menu = this.registeredMenus.find(
(registeredMenu) => registeredMenu.options.identifier === menu
);
}
if (!menu) {
return;
}
await this.activeMenu?.close();
this.activeMenu = null;
}
await new Promise((resolve) => {
menu.expanded = false;
/**
* Update the menu position
* @param {DMenuInstance} [menu] - the menu to update, if not provider will update any active menu
*/
@action
async update(menu) {
const instance = menu || this.activeMenu;
if (!instance) {
return;
}
await updatePosition(instance.trigger, instance.content, instance.options);
await instance.show();
this.registeredMenus = this.registeredMenus.filter(
(registeredMenu) => menu.id !== registeredMenu.id
);
schedule("afterRender", () => {
resolve();
});
});
}
/**
@ -104,17 +116,12 @@ export default class Menu extends Service {
*/
@action
register(trigger, options = {}) {
return new DMenuInstance(getOwner(this), trigger, {
const instance = new DMenuInstance(getOwner(this), {
...options,
listeners: true,
beforeTrigger: async (menu) => {
await this.replace(menu);
},
});
}
@action
registerPortalOutletElement(element) {
this.portalOutletElement = element;
instance.trigger = trigger;
instance.detachedTrigger = true;
return instance;
}
}

View File

@ -1,14 +1,12 @@
import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import { schedule } from "@ember/runloop";
import Service from "@ember/service";
import DTooltipInstance from "float-kit/lib/d-tooltip-instance";
import { updatePosition } from "float-kit/lib/update-position";
export default class Tooltip extends Service {
@tracked activeTooltip;
@tracked portalOutletElement;
@tracked registeredTooltips = [];
/**
* Render a tooltip
@ -34,36 +32,48 @@ export default class Tooltip extends Service {
if (arguments[0] instanceof DTooltipInstance) {
instance = arguments[0];
if (this.activeTooltip === instance && this.activeTooltip.expanded) {
if (instance.expanded) {
return;
}
} else {
const trigger = arguments[0];
if (
this.activeTooltip &&
this.activeTooltip.id ===
(trigger?.id?.length ? trigger.id : guidFor(trigger)) &&
this.activeTooltip.expanded
) {
this.activeTooltip?.close();
return;
instance = this.registeredTooltips.find(
(registeredTooltips) => registeredTooltips.trigger === arguments[0]
);
if (!instance) {
instance = new DTooltipInstance(getOwner(this), arguments[1]);
instance.trigger = arguments[0];
instance.detachedTrigger = true;
}
instance = new DTooltipInstance(getOwner(this), trigger, arguments[1]);
}
await this.replace(instance);
instance.expanded = true;
return instance;
}
if (instance.options.identifier) {
for (const tooltip of this.registeredTooltips) {
if (
tooltip.options.identifier === instance.options.identifier &&
tooltip !== instance
) {
await this.close(tooltip);
}
}
}
/**
* Replaces any active tooltip
*/
@action
async replace(tooltip) {
await this.activeTooltip?.close();
this.activeTooltip = tooltip;
if (instance.expanded) {
return await this.close(instance);
}
await new Promise((resolve) => {
if (!this.registeredTooltips.includes(instance)) {
this.registeredTooltips = this.registeredTooltips.concat(instance);
}
instance.expanded = true;
schedule("afterRender", () => {
resolve();
});
});
return instance;
}
/**
@ -72,26 +82,27 @@ export default class Tooltip extends Service {
*/
@action
async close(tooltip) {
if (this.activeTooltip && tooltip && this.activeTooltip.id !== tooltip.id) {
if (typeof tooltip === "string") {
tooltip = this.registeredTooltips.find(
(registeredTooltip) => registeredTooltip.options.identifier === tooltip
);
}
if (!tooltip) {
return;
}
await this.activeTooltip?.close();
this.activeTooltip = null;
}
tooltip.expanded = false;
/**
* Update the tooltip position
* @param {DTooltipInstance} [tooltip] - the tooltip to update, if not provider will update any active tooltip
*/
@action
async update(tooltip) {
const instance = tooltip || this.activeTooltip;
if (!instance) {
return;
}
await updatePosition(instance.trigger, instance.content, instance.options);
await instance.show();
await new Promise((resolve) => {
this.registeredTooltips = this.registeredTooltips.filter(
(registeredTooltips) => tooltip.id !== registeredTooltips.id
);
schedule("afterRender", () => {
resolve();
});
});
}
/**
@ -104,17 +115,12 @@ export default class Tooltip extends Service {
*/
@action
register(trigger, options = {}) {
return new DTooltipInstance(getOwner(this), trigger, {
const instance = new DTooltipInstance(getOwner(this), {
...options,
listeners: true,
beforeTrigger: async (tooltip) => {
await this.replace(tooltip);
},
});
}
@action
registerPortalOutletElement(element) {
this.portalOutletElement = element;
instance.trigger = trigger;
instance.detachedTrigger = true;
return instance;
}
}

View File

@ -0,0 +1 @@
export { default } from "float-kit/components/d-headless-menu";

View File

@ -0,0 +1 @@
export { default } from "float-kit/components/d-headless-tooltip";

View File

@ -1 +0,0 @@
export { default } from "float-kit/components/d-inline-menu";

View File

@ -1 +0,0 @@
export { default } from "float-kit/components/d-inline-tooltip";

View File

@ -0,0 +1 @@
export { default } from "float-kit/components/d-menus";

View File

@ -0,0 +1 @@
export { default } from "float-kit/components/d-tooltips";

View File

@ -9,17 +9,6 @@
}
.fk-d-tooltip {
background-color: var(--secondary);
border-radius: var(--d-border-radius);
border: 1px solid var(--primary-low);
box-shadow: var(--shadow-menu-panel);
z-index: z("max");
width: max-content;
position: absolute;
top: 0;
display: flex !important;
padding: 0;
&__trigger {
display: inline-flex;
cursor: pointer;
@ -33,26 +22,6 @@
}
}
&.-animated {
animation: d-tooltip-opening 0.15s ease-in;
&[data-placement^="bottom"] {
transform-origin: top center;
}
&[data-placement^="top"] {
transform-origin: bottom center;
}
&[data-placement^="right"] {
transform-origin: center left;
}
&[data-placement^="left"] {
transform-origin: center right;
}
}
&__inner-content {
display: flex;
overflow: hidden;
@ -61,41 +30,74 @@
align-items: center;
}
.arrow {
&__content {
background-color: var(--secondary);
border-radius: var(--d-border-radius);
border: 1px solid var(--primary-low);
box-shadow: var(--shadow-menu-panel);
z-index: z("max");
width: max-content;
position: absolute;
}
top: 0;
display: flex !important;
padding: 0;
&[data-placement^="top"] {
.arrow {
bottom: -10px;
rotate: 180deg;
&.-animated {
animation: d-tooltip-opening 0.15s ease-in;
&[data-placement^="bottom"] {
transform-origin: top center;
}
&[data-placement^="top"] {
transform-origin: bottom center;
}
&[data-placement^="right"] {
transform-origin: center left;
}
&[data-placement^="left"] {
transform-origin: center right;
}
}
}
&[data-placement^="top-start"] {
.arrow {
margin-left: 10px;
z-index: z("max");
position: absolute;
}
}
&[data-placement^="bottom"] {
.arrow {
top: -10px;
&[data-placement^="top"] {
.arrow {
bottom: -10px;
rotate: 180deg;
}
}
}
&[data-placement^="right"] {
.arrow {
rotate: -90deg;
left: -10px;
&[data-placement^="top-start"] {
.arrow {
margin-left: 10px;
}
}
}
&[data-placement^="left"] {
.arrow {
rotate: 90deg;
right: -10px;
&[data-placement^="bottom"] {
.arrow {
top: -10px;
}
}
&[data-placement^="right"] {
.arrow {
rotate: -90deg;
left: -10px;
}
}
&[data-placement^="left"] {
.arrow {
rotate: 90deg;
right: -10px;
}
}
}
}

View File

@ -61,8 +61,6 @@ export default class ChatMessageReaction extends Component {
this.args.reaction.emoji,
this.args.reaction.reacted ? "remove" : "add"
);
this.tooltip.close();
}
@cached

View File

@ -158,9 +158,7 @@ module(
});
test("it shows status tooltip", async function (assert) {
await render(
hbs`<ChatChannel @channel={{this.channel}} /><DInlineTooltip />`
);
await render(hbs`<ChatChannel @channel={{this.channel}} /><DTooltips />`);
await triggerEvent(statusSelector(mentionedUser.username), "mousemove");
assert.equal(

View File

@ -361,7 +361,7 @@ export default {
},
]);
return tooltip.close();
return tooltip.close("local-date");
}
if (!event?.target?.classList?.contains("discourse-local-date")) {
@ -370,6 +370,7 @@ export default {
const siteSettings = this.container.lookup("service:site-settings");
return tooltip.show(event.target, {
identifier: "local-date",
content: htmlSafe(buildHtmlPreview(event.target, siteSettings)),
});
},

View File

@ -14,7 +14,7 @@ describe "Homepage", type: :system do
sign_in user
visit "/"
expect(page).to have_no_css(".fk-d-tooltip .user-tip__title")
expect(page).to have_no_css(".fk-d-tooltip__content .user-tip__title")
end
it "does not show the boostrapping tip to an admin user" do
@ -22,7 +22,7 @@ describe "Homepage", type: :system do
sign_in admin
visit "/"
expect(page).to have_no_css(".fk-d-tooltip .user-tip__title")
expect(page).to have_no_css(".fk-d-tooltip__content .user-tip__title")
end
end
@ -35,20 +35,23 @@ describe "Homepage", type: :system do
visit "/"
expect(page).to have_css(".fk-d-tooltip .user-tip__title", text: "Your first notification!")
expect(page).to have_css(
".fk-d-tooltip__content .user-tip__title",
text: "Your first notification!",
)
find(".d-header #current-user").click
find(".d-header").click
# Clicking outside element dismisses the tip
expect(page).to have_no_css(
".fk-d-tooltip .user-tip__title",
".fk-d-tooltip__content .user-tip__title",
text: "Your first notification!",
)
page.refresh
expect(page).to have_no_css(
".fk-d-tooltip .user-tip__title",
".fk-d-tooltip__content .user-tip__title",
text: "Your first notification!",
)
end
@ -57,25 +60,25 @@ describe "Homepage", type: :system do
sign_in user
visit "/"
find(".fk-d-tooltip .user-tip__buttons .btn-primary").click
expect(page).to have_no_css(".fk-d-tooltip .user-tip__title")
find(".fk-d-tooltip__content .user-tip__buttons .btn-primary").click
expect(page).to have_no_css(".fk-d-tooltip__content .user-tip__title")
discovery.topic_list.visit_topic(topics[0])
expect(page).to have_css(".fk-d-tooltip .user-tip__title", text: "Topic timeline")
expect(page).to have_css(".fk-d-tooltip__content .user-tip__title", text: "Topic timeline")
find(".fk-d-tooltip .user-tip__buttons .btn-primary").click
expect(page).to have_css(".fk-d-tooltip .user-tip__title", text: "Keep reading!")
find(".fk-d-tooltip__content .user-tip__buttons .btn-primary").click
expect(page).to have_css(".fk-d-tooltip__content .user-tip__title", text: "Keep reading!")
end
it "can skip all tips" do
sign_in user
visit "/"
find(".fk-d-tooltip .user-tip__buttons .btn", text: "Skip tips").click
expect(page).to have_no_css(".fk-d-tooltip .user-tip__title")
find(".fk-d-tooltip__content .user-tip__buttons .btn", text: "Skip tips").click
expect(page).to have_no_css(".fk-d-tooltip__content .user-tip__title")
discovery.topic_list.visit_topic(topics[0])
expect(page).to have_no_css(".fk-d-tooltip .user-tip__title")
expect(page).to have_no_css(".fk-d-tooltip__content .user-tip__title")
end
end
end