mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
DEV: FloatKit (#23541)
Second iteration of https://github.com/discourse/discourse/pull/23312 with a fix for embroider not resolving an export file using .gjs extension. --- This PR introduces three new concepts to Discourse codebase through an addon called "FloatKit": - menu - tooltip - toast ## Tooltips ### Component Simple cases can be express with an API similar to DButton: ```hbs <DTooltip @label={{i18n "foo.bar"}} @icon="check" @content="Something" /> ``` More complex cases can use blocks: ```hbs <DTooltip> <:trigger> {{d-icon "check"}} <span>{{i18n "foo.bar"}}</span> </:trigger> <:content> Something </:content> </DTooltip> ``` ### Service You can manually show a tooltip using the `tooltip` service: ```javascript const tooltipInstance = await this.tooltip.show( document.querySelector(".my-span"), options ) // and later manual close or destroy it tooltipInstance.close(); tooltipInstance.destroy(); // you can also just close any open tooltip through the service this.tooltip.close(); ``` The service also allows you to register event listeners on a trigger, it removes the need for you to manage open/close of a tooltip started through the service: ```javascript const tooltipInstance = this.tooltip.register( document.querySelector(".my-span"), options ) // when done you can destroy the instance to remove the listeners tooltipInstance.destroy(); ``` Note that the service also allows you to use a custom component as content which will receive `@data` and `@close` as args: ```javascript const tooltipInstance = await this.tooltip.show( document.querySelector(".my-span"), { component: MyComponent, data: { foo: 1 } } ) ``` ## Menus Menus are very similar to tooltips and provide the same kind of APIs: ### Component ```hbs <DMenu @icon="plus" @label={{i18n "foo.bar"}}> <ul> <li>Foo</li> <li>Bat</li> <li>Baz</li> </ul> </DMenu> ``` They also support blocks: ```hbs <DMenu> <:trigger> {{d-icon "plus"}} <span>{{i18n "foo.bar"}}</span> </:trigger> <:content> <ul> <li>Foo</li> <li>Bat</li> <li>Baz</li> </ul> </:content> </DMenu> ``` ### Service You can manually show a menu using the `menu` service: ```javascript const menuInstance = await this.menu.show( document.querySelector(".my-span"), options ) // and later manual close or destroy it menuInstance.close(); menuInstance.destroy(); // you can also just close any open tooltip through the service this.menu.close(); ``` The service also allows you to register event listeners on a trigger, it removes the need for you to manage open/close of a tooltip started through the service: ```javascript const menuInstance = this.menu.register( document.querySelector(".my-span"), options ) // when done you can destroy the instance to remove the listeners menuInstance.destroy(); ``` Note that the service also allows you to use a custom component as content which will receive `@data` and `@close` as args: ```javascript const menuInstance = await this.menu.show( document.querySelector(".my-span"), { component: MyComponent, data: { foo: 1 } } ) ``` ## Toasts Interacting with toasts is made only through the `toasts` service. A default component is provided (DDefaultToast) and can be used through dedicated service methods: - this.toasts.success({ ... }); - this.toasts.warning({ ... }); - this.toasts.info({ ... }); - this.toasts.error({ ... }); - this.toasts.default({ ... }); ```javascript this.toasts.success({ data: { title: "Foo", message: "Bar", actions: [ { label: "Ok", class: "btn-primary", action: (componentArgs) => { // eslint-disable-next-line no-alert alert("Closing toast:" + componentArgs.data.title); componentArgs.close(); }, } ] }, }); ``` You can also provide your own component: ```javascript this.toasts.show(MyComponent, { autoClose: false, class: "foo", data: { baz: 1 }, }) ``` Co-authored-by: Martin Brennan <mjrbrennan@gmail.com> Co-authored-by: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com> Co-authored-by: David Taylor <david@taylorhq.com> Co-authored-by: Jarek Radosz <jradosz@gmail.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
const DButtonTooltip = <template>
|
||||
<div class="fk-d-button-tooltip" ...attributes>
|
||||
{{yield to="button"}}
|
||||
{{yield to="tooltip"}}
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default DButtonTooltip;
|
||||
@@ -0,0 +1,56 @@
|
||||
import DButton from "discourse/components/d-button";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import { concat, fn, hash } from "@ember/helper";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
|
||||
const DDefaultToast = <template>
|
||||
<div
|
||||
class={{concatClass
|
||||
"fk-d-default-toast"
|
||||
(concat "-" (or @data.theme "default"))
|
||||
}}
|
||||
...attributes
|
||||
>
|
||||
{{#if @data.icon}}
|
||||
<div class="fk-d-default-toast__icon-container">
|
||||
{{icon @data.icon}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="fk-d-default-toast__main-container">
|
||||
<div class="fk-d-default-toast__texts">
|
||||
{{#if @data.title}}
|
||||
<div class="fk-d-default-toast__title">
|
||||
{{@data.title}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if @data.message}}
|
||||
<div class="fk-d-default-toast__message">
|
||||
{{@data.message}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if @data.actions}}
|
||||
<div class="fk-d-default-toast__actions">
|
||||
{{#each @data.actions as |toastAction|}}
|
||||
{{#if toastAction.action}}
|
||||
<DButton
|
||||
@icon={{toastAction.icon}}
|
||||
@translatedLabel={{toastAction.label}}
|
||||
@action={{fn toastAction.action (hash data=@data close=@close)}}
|
||||
class={{toastAction.class}}
|
||||
tabindex="0"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="fk-d-default-toast__close-container">
|
||||
<DButton class="btn-flat" @icon="times" @action={{@close}} />
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default DDefaultToast;
|
||||
@@ -0,0 +1,85 @@
|
||||
import Component from "@glimmer/component";
|
||||
import FloatKitApplyFloatingUi from "float-kit/modifiers/apply-floating-ui";
|
||||
import FloatKitCloseOnEscape from "float-kit/modifiers/close-on-escape";
|
||||
import FloatKitCloseOnClickOutside from "float-kit/modifiers/close-on-click-outside";
|
||||
import { modifier } from "ember-modifier";
|
||||
import { getScrollParent } from "float-kit/lib/get-scroll-parent";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { concat } from "@ember/helper";
|
||||
import TrapTab from "discourse/modifiers/trap-tab";
|
||||
import DFloatPortal from "float-kit/components/d-float-portal";
|
||||
|
||||
export default class DFloatBody extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<DFloatPortal
|
||||
@inline={{@inline}}
|
||||
@portalOutletElement={{@portalOutletElement}}
|
||||
>
|
||||
<div
|
||||
class={{concatClass
|
||||
@mainClass
|
||||
(if this.options.animated "-animated")
|
||||
(if @instance.expanded "-expanded")
|
||||
}}
|
||||
data-identifier={{this.options.identifier}}
|
||||
data-content
|
||||
aria-labelledby={{@instance.id}}
|
||||
aria-expanded={{if @instance.expanded "true" "false"}}
|
||||
role={{@role}}
|
||||
{{FloatKitApplyFloatingUi this.trigger this.options @instance}}
|
||||
{{(if @trapTab (modifier TrapTab))}}
|
||||
{{(if
|
||||
this.supportsCloseOnClickOutside
|
||||
(modifier FloatKitCloseOnClickOutside this.trigger @instance.close)
|
||||
)}}
|
||||
{{(if
|
||||
this.supportsCloseOnEscape
|
||||
(modifier FloatKitCloseOnEscape @instance.close)
|
||||
)}}
|
||||
{{(if this.supportsCloseOnScroll (modifier this.closeOnScroll))}}
|
||||
style={{htmlSafe (concat "max-width: " this.options.maxWidth "px")}}
|
||||
...attributes
|
||||
>
|
||||
<div class={{@innerClass}}>
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
</DFloatPortal>
|
||||
</template>
|
||||
|
||||
closeOnScroll = modifier(() => {
|
||||
const firstScrollParent = getScrollParent(this.trigger);
|
||||
|
||||
const handler = () => {
|
||||
this.args.instance.close();
|
||||
};
|
||||
|
||||
firstScrollParent.addEventListener("scroll", handler, { passive: true });
|
||||
|
||||
return () => {
|
||||
firstScrollParent.removeEventListener("scroll", handler);
|
||||
};
|
||||
});
|
||||
|
||||
get supportsCloseOnClickOutside() {
|
||||
return this.args.instance.expanded && this.options.closeOnClickOutside;
|
||||
}
|
||||
|
||||
get supportsCloseOnEscape() {
|
||||
return this.args.instance.expanded && this.options.closeOnEscape;
|
||||
}
|
||||
|
||||
get supportsCloseOnScroll() {
|
||||
return this.args.instance.expanded && this.options.closeOnScroll;
|
||||
}
|
||||
|
||||
get trigger() {
|
||||
return this.args.instance.trigger;
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.args.instance.options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
|
||||
export default class DFloatPortal extends Component {
|
||||
<template>
|
||||
{{#if this.inline}}
|
||||
{{yield}}
|
||||
{{else}}
|
||||
{{#in-element @portalOutletElement}}
|
||||
{{yield}}
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
get inline() {
|
||||
return this.args.inline ?? isTesting();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import DFloatBody from "float-kit/components/d-float-body";
|
||||
|
||||
const DInlineFloat = <template>
|
||||
{{#if @instance.expanded}}
|
||||
<DFloatBody
|
||||
@instance={{@instance}}
|
||||
@trapTab={{@trapTab}}
|
||||
@mainClass={{@mainClass}}
|
||||
@innerClass={{@innerClass}}
|
||||
@role={{@role}}
|
||||
@portalOutletElement={{@portalOutletElement}}
|
||||
@inline={{@inline}}
|
||||
>
|
||||
{{#if @instance.options.component}}
|
||||
<@instance.options.component
|
||||
@data={{@instance.options.data}}
|
||||
@close={{@instance.close}}
|
||||
/>
|
||||
{{else}}
|
||||
{{@instance.options.content}}
|
||||
{{/if}}
|
||||
</DFloatBody>
|
||||
{{/if}}
|
||||
</template>;
|
||||
|
||||
export default DInlineFloat;
|
||||
@@ -0,0 +1,27 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DInlineFloat from "float-kit/components/d-inline-float";
|
||||
import { MENU } from "float-kit/lib/constants";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
|
||||
export default class DInlineMenu extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<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>
|
||||
|
||||
@service menu;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DInlineFloat from "float-kit/components/d-inline-float";
|
||||
import { TOOLTIP } from "float-kit/lib/constants";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
|
||||
export default class DInlineTooltip extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<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>
|
||||
|
||||
@service tooltip;
|
||||
}
|
||||
104
app/assets/javascripts/float-kit/addon/components/d-menu.gjs
Normal file
104
app/assets/javascripts/float-kit/addon/components/d-menu.gjs
Normal file
@@ -0,0 +1,104 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { modifier } from "ember-modifier";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DFloatBody from "float-kit/components/d-float-body";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import DMenuInstance from "float-kit/lib/d-menu-instance";
|
||||
|
||||
export default class DMenu extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<DButton
|
||||
class={{concatClass
|
||||
"fk-d-menu__trigger"
|
||||
(if this.menuInstance.expanded "-expanded")
|
||||
}}
|
||||
id={{this.menuInstance.id}}
|
||||
data-identifier={{this.options.identifier}}
|
||||
data-trigger
|
||||
@icon={{@icon}}
|
||||
@translatedAriaLabel={{@ariaLabel}}
|
||||
@translatedLabel={{@label}}
|
||||
@translatedTitle={{@title}}
|
||||
@disabled={{@disabled}}
|
||||
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
|
||||
{{this.registerTrigger}}
|
||||
...attributes
|
||||
>
|
||||
{{#if (has-block "trigger")}}
|
||||
{{yield this.componentArgs to="trigger"}}
|
||||
{{/if}}
|
||||
</DButton>
|
||||
|
||||
{{#if this.menuInstance.expanded}}
|
||||
<DFloatBody
|
||||
@instance={{this.menuInstance}}
|
||||
@trapTab={{this.options.trapTab}}
|
||||
@mainClass="fk-d-menu"
|
||||
@innerClass="fk-d-menu__inner-content"
|
||||
@role="dialog"
|
||||
@inline={{this.options.inline}}
|
||||
@portalOutletElement={{this.menu.portalOutletElement}}
|
||||
>
|
||||
{{#if (has-block)}}
|
||||
{{yield this.componentArgs}}
|
||||
{{else if (has-block "content")}}
|
||||
{{yield this.componentArgs to="content"}}
|
||||
{{else if this.options.component}}
|
||||
<this.options.component
|
||||
@data={{this.options.data}}
|
||||
@close={{this.menuInstance.close}}
|
||||
/>
|
||||
{{else if this.options.content}}
|
||||
{{this.options.content}}
|
||||
{{/if}}
|
||||
</DFloatBody>
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
@service menu;
|
||||
|
||||
@tracked menuInstance = null;
|
||||
|
||||
registerTrigger = modifier((element) => {
|
||||
const options = {
|
||||
...this.args,
|
||||
...{
|
||||
autoUpdate: true,
|
||||
listeners: true,
|
||||
beforeTrigger: () => {
|
||||
this.menu.close();
|
||||
},
|
||||
},
|
||||
};
|
||||
const instance = new DMenuInstance(getOwner(this), element, options);
|
||||
|
||||
this.menuInstance = instance;
|
||||
|
||||
return () => {
|
||||
instance.destroy();
|
||||
|
||||
if (this.isDestroying) {
|
||||
this.menuInstance = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
get menuId() {
|
||||
return `d-menu-${this.menuInstance.id}`;
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.menuInstance?.options ?? {};
|
||||
}
|
||||
|
||||
get componentArgs() {
|
||||
return {
|
||||
close: this.menu.close,
|
||||
data: this.options.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
|
||||
import deprecated from "discourse-common/lib/deprecated";
|
||||
|
||||
export default class DPopover extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<div style="display:inline-flex;" {{this.registerDTooltip}}>
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@service tooltip;
|
||||
|
||||
registerDTooltip = modifier((element) => {
|
||||
deprecated(
|
||||
"`<DPopover />` is deprecated. Use `<DTooltip />` or the `tooltip` service instead.",
|
||||
{ id: "discourse.d-popover" }
|
||||
);
|
||||
|
||||
const trigger = element.children[0];
|
||||
const content = element.children[1];
|
||||
|
||||
if (!trigger || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = this.tooltip.register(trigger, {
|
||||
content,
|
||||
});
|
||||
|
||||
content.remove();
|
||||
|
||||
return () => {
|
||||
instance.destroy();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { on } from "@ember/modifier";
|
||||
|
||||
export default class DToasts extends Component {
|
||||
<template>
|
||||
<div class="fk-d-toasts">
|
||||
{{#each this.toasts.activeToasts as |toast|}}
|
||||
<div
|
||||
role={{if toast.options.autoClose "status" "log"}}
|
||||
key={{toast.id}}
|
||||
class={{concatClass "fk-d-toast" toast.options.class}}
|
||||
{{(if toast.options.autoClose (modifier toast.registerAutoClose))}}
|
||||
{{on "mouseenter" toast.cancelAutoClose}}
|
||||
>
|
||||
<toast.options.component
|
||||
@data={{toast.options.data}}
|
||||
@close={{toast.close}}
|
||||
/>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@service toasts;
|
||||
}
|
||||
109
app/assets/javascripts/float-kit/addon/components/d-tooltip.gjs
Normal file
109
app/assets/javascripts/float-kit/addon/components/d-tooltip.gjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { modifier } from "ember-modifier";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DFloatBody from "float-kit/components/d-float-body";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import DTooltipInstance from "float-kit/lib/d-tooltip-instance";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
|
||||
export default class DTooltip extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<span
|
||||
class={{concatClass
|
||||
"fk-d-tooltip__trigger"
|
||||
(if this.tooltipInstance.expanded "-expanded")
|
||||
}}
|
||||
role="button"
|
||||
id={{this.tooltipInstance.id}}
|
||||
data-identifier={{this.options.identifier}}
|
||||
data-trigger
|
||||
aria-expanded={{if this.tooltipInstance.expanded "true" "false"}}
|
||||
{{this.registerTrigger}}
|
||||
...attributes
|
||||
>
|
||||
<div class="fk-d-tooltip__trigger-container">
|
||||
{{#if (has-block "trigger")}}
|
||||
<div>
|
||||
{{yield this.componentArgs to="trigger"}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if @icon}}
|
||||
<span class="fk-d-tooltip__icon">
|
||||
{{~icon @icon~}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{#if @label}}
|
||||
<span class="fk-d-tooltip__label">{{@label}}</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{{#if this.tooltipInstance.expanded}}
|
||||
<DFloatBody
|
||||
@instance={{this.tooltipInstance}}
|
||||
@trapTab={{and this.options.interactive this.options.trapTab}}
|
||||
@mainClass="fk-d-tooltip"
|
||||
@innerClass="fk-d-tooltip__inner-content"
|
||||
@role="tooltip"
|
||||
@inline={{this.options.inline}}
|
||||
@portalOutletElement={{this.tooltip.portalOutletElement}}
|
||||
>
|
||||
{{#if (has-block)}}
|
||||
{{yield this.componentArgs}}
|
||||
{{else if (has-block "content")}}
|
||||
{{yield this.componentArgs to="content"}}
|
||||
{{else if this.options.component}}
|
||||
<this.options.component
|
||||
@data={{this.options.data}}
|
||||
@close={{this.tooltipInstance.close}}
|
||||
/>
|
||||
{{else if this.options.content}}
|
||||
{{this.options.content}}
|
||||
{{/if}}
|
||||
</DFloatBody>
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
@service tooltip;
|
||||
|
||||
@tracked tooltipInstance = null;
|
||||
|
||||
registerTrigger = modifier((element) => {
|
||||
const options = {
|
||||
...this.args,
|
||||
...{
|
||||
listeners: true,
|
||||
beforeTrigger: () => {
|
||||
this.tooltip.close();
|
||||
},
|
||||
},
|
||||
};
|
||||
const instance = new DTooltipInstance(getOwner(this), element, options);
|
||||
|
||||
this.tooltipInstance = instance;
|
||||
|
||||
return () => {
|
||||
instance.destroy();
|
||||
|
||||
if (this.isDestroying) {
|
||||
this.tooltipInstance = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
get options() {
|
||||
return this.tooltipInstance?.options;
|
||||
}
|
||||
|
||||
get componentArgs() {
|
||||
return {
|
||||
close: this.tooltip.close,
|
||||
data: this.options.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
76
app/assets/javascripts/float-kit/addon/lib/constants.js
Normal file
76
app/assets/javascripts/float-kit/addon/lib/constants.js
Normal file
@@ -0,0 +1,76 @@
|
||||
export const FLOAT_UI_PLACEMENTS = [
|
||||
"top",
|
||||
"top-start",
|
||||
"top-end",
|
||||
"right",
|
||||
"right-start",
|
||||
"right-end",
|
||||
"bottom",
|
||||
"bottom-start",
|
||||
"bottom-end",
|
||||
"left",
|
||||
"left-start",
|
||||
"left-end",
|
||||
];
|
||||
|
||||
export const TOOLTIP = {
|
||||
options: {
|
||||
animated: true,
|
||||
arrow: true,
|
||||
beforeTrigger: null,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEscape: true,
|
||||
closeOnScroll: true,
|
||||
component: null,
|
||||
content: null,
|
||||
identifier: null,
|
||||
interactive: false,
|
||||
listeners: false,
|
||||
maxWidth: 350,
|
||||
data: null,
|
||||
offset: 10,
|
||||
triggers: ["hover", "click"],
|
||||
untriggers: ["hover", "click"],
|
||||
placement: "top",
|
||||
fallbackPlacements: FLOAT_UI_PLACEMENTS,
|
||||
autoUpdate: true,
|
||||
trapTab: true,
|
||||
},
|
||||
portalOutletId: "d-tooltip-portal-outlet",
|
||||
};
|
||||
|
||||
export const MENU = {
|
||||
options: {
|
||||
animated: true,
|
||||
arrow: false,
|
||||
beforeTrigger: null,
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: true,
|
||||
closeOnScroll: false,
|
||||
component: null,
|
||||
content: null,
|
||||
identifier: null,
|
||||
interactive: true,
|
||||
listeners: false,
|
||||
maxWidth: 400,
|
||||
data: null,
|
||||
offset: 10,
|
||||
triggers: ["click"],
|
||||
untriggers: ["click"],
|
||||
placement: "bottom",
|
||||
fallbackPlacements: FLOAT_UI_PLACEMENTS,
|
||||
autoUpdate: true,
|
||||
trapTab: true,
|
||||
},
|
||||
portalOutletId: "d-menu-portal-outlet",
|
||||
};
|
||||
|
||||
import DDefaultToast from "float-kit/components/d-default-toast";
|
||||
|
||||
export const TOAST = {
|
||||
options: {
|
||||
autoClose: true,
|
||||
duration: 10000,
|
||||
component: DDefaultToast,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
import { setOwner } from "@ember/application";
|
||||
import { MENU } from "float-kit/lib/constants";
|
||||
import { guidFor } from "@ember/object/internals";
|
||||
import FloatKitInstance from "float-kit/lib/float-kit-instance";
|
||||
|
||||
export default class DMenuInstance extends FloatKitInstance {
|
||||
@service menu;
|
||||
|
||||
constructor(owner, trigger, options = {}) {
|
||||
super(...arguments);
|
||||
|
||||
setOwner(this, owner);
|
||||
this.options = { ...MENU.options, ...options };
|
||||
this.id = trigger.id || guidFor(trigger);
|
||||
this.trigger = trigger;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
@action
|
||||
onMouseMove(event) {
|
||||
if (this.trigger.contains(event.target) && this.expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onClick(event) {
|
||||
if (this.expanded && this.untriggers.includes("click")) {
|
||||
this.onUntrigger(event);
|
||||
return;
|
||||
}
|
||||
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onMouseLeave(event) {
|
||||
if (this.untriggers.includes("hover")) {
|
||||
this.onUntrigger(event);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async onTrigger() {
|
||||
this.options.beforeTrigger?.(this);
|
||||
await this.show();
|
||||
}
|
||||
|
||||
@action
|
||||
async onUntrigger() {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
@action
|
||||
async destroy() {
|
||||
await this.close();
|
||||
this.tearDownListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { setOwner } from "@ember/application";
|
||||
import { TOAST } from "float-kit/lib/constants";
|
||||
import uniqueId from "discourse/helpers/unique-id";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { cancel } from "@ember/runloop";
|
||||
|
||||
const CSS_TRANSITION_DELAY_MS = 500;
|
||||
const TRANSITION_CLASS = "-fade-out";
|
||||
|
||||
export default class DToastInstance {
|
||||
@service toasts;
|
||||
|
||||
options = null;
|
||||
id = uniqueId();
|
||||
autoCloseHandler = null;
|
||||
|
||||
registerAutoClose = modifier((element) => {
|
||||
let innerHandler;
|
||||
|
||||
this.autoCloseHandler = discourseLater(() => {
|
||||
element.classList.add(TRANSITION_CLASS);
|
||||
|
||||
innerHandler = discourseLater(() => {
|
||||
this.close();
|
||||
}, CSS_TRANSITION_DELAY_MS);
|
||||
}, this.options.duration || TOAST.options.duration);
|
||||
|
||||
return () => {
|
||||
cancel(innerHandler);
|
||||
cancel(this.autoCloseHandler);
|
||||
};
|
||||
});
|
||||
|
||||
constructor(owner, options = {}) {
|
||||
setOwner(this, owner);
|
||||
this.options = { ...TOAST.options, ...options };
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.toasts.close(this);
|
||||
}
|
||||
|
||||
@action
|
||||
cancelAutoClose() {
|
||||
cancel(this.autoCloseHandler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
import { setOwner } from "@ember/application";
|
||||
import { TOOLTIP } from "float-kit/lib/constants";
|
||||
import { guidFor } from "@ember/object/internals";
|
||||
import FloatKitInstance from "float-kit/lib/float-kit-instance";
|
||||
|
||||
export default class DTooltipInstance extends FloatKitInstance {
|
||||
@service tooltip;
|
||||
|
||||
constructor(owner, trigger, options = {}) {
|
||||
super(...arguments);
|
||||
|
||||
setOwner(this, owner);
|
||||
this.options = { ...TOOLTIP.options, ...options };
|
||||
this.id = trigger.id || guidFor(trigger);
|
||||
this.trigger = trigger;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
@action
|
||||
onMouseMove(event) {
|
||||
if (this.trigger.contains(event.target) && this.expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onClick(event) {
|
||||
if (this.expanded && this.untriggers.includes("click")) {
|
||||
this.onUntrigger(event);
|
||||
return;
|
||||
}
|
||||
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onMouseLeave(event) {
|
||||
if (this.untriggers.includes("hover")) {
|
||||
this.onUntrigger(event);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async onTrigger() {
|
||||
this.options.beforeTrigger?.(this);
|
||||
await this.show();
|
||||
}
|
||||
|
||||
@action
|
||||
async onUntrigger() {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
@action
|
||||
async destroy() {
|
||||
await this.close();
|
||||
this.tearDownListeners();
|
||||
}
|
||||
}
|
||||
198
app/assets/javascripts/float-kit/addon/lib/float-kit-instance.js
Normal file
198
app/assets/javascripts/float-kit/addon/lib/float-kit-instance.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import { action } from "@ember/object";
|
||||
import { cancel, next } from "@ember/runloop";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { makeArray } from "discourse-common/lib/helpers";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
const TOUCH_OPTIONS = { passive: true, capture: true };
|
||||
|
||||
function cancelEvent(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
export default class FloatKitInstance {
|
||||
@tracked expanded = false;
|
||||
@tracked id = null;
|
||||
|
||||
trigger = null;
|
||||
content = null;
|
||||
|
||||
@action
|
||||
async show() {
|
||||
this.expanded = true;
|
||||
|
||||
await new Promise((resolve) => next(resolve));
|
||||
}
|
||||
|
||||
@action
|
||||
async close() {
|
||||
this.expanded = false;
|
||||
|
||||
await new Promise((resolve) => next(resolve));
|
||||
}
|
||||
|
||||
@action
|
||||
onFocus(event) {
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onBlur(event) {
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onFocusIn(event) {
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onFocusOut(event) {
|
||||
this.onTrigger(event);
|
||||
}
|
||||
|
||||
@action
|
||||
onTouchStart(event) {
|
||||
if (event.touches.length > 1) {
|
||||
this.onTouchCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
this.trigger.addEventListener(
|
||||
"touchmove",
|
||||
this.onTouchCancel,
|
||||
TOUCH_OPTIONS
|
||||
);
|
||||
this.trigger.addEventListener(
|
||||
"touchcancel",
|
||||
this.onTouchCancel,
|
||||
TOUCH_OPTIONS
|
||||
);
|
||||
this.trigger.addEventListener(
|
||||
"touchend",
|
||||
this.onTouchCancel,
|
||||
TOUCH_OPTIONS
|
||||
);
|
||||
this.touchTimeout = discourseLater(() => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trigger.addEventListener("touchend", cancelEvent, {
|
||||
once: true,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
this.onTrigger(event);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@bind
|
||||
onTouchCancel() {
|
||||
cancel(this.touchTimeout);
|
||||
|
||||
this.trigger.removeEventListener("touchmove", this.onTouchCancel);
|
||||
this.trigger.removeEventListener("touchend", this.onTouchCancel);
|
||||
this.trigger.removeEventListener("touchcancel", this.onTouchCancel);
|
||||
}
|
||||
|
||||
tearDownListeners() {
|
||||
if (!this.options.listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
makeArray(this.triggers)
|
||||
.filter(Boolean)
|
||||
.forEach((trigger) => {
|
||||
switch (trigger) {
|
||||
case "hold":
|
||||
this.trigger.addEventListener("touchstart", this.onTouchStart);
|
||||
break;
|
||||
case "focus":
|
||||
this.trigger.removeEventListener("focus", this.onFocus);
|
||||
this.trigger.removeEventListener("blur", this.onBlur);
|
||||
break;
|
||||
case "focusin":
|
||||
this.trigger.removeEventListener("focusin", this.onFocusIn);
|
||||
this.trigger.removeEventListener("focusout", this.onFocusOut);
|
||||
break;
|
||||
case "hover":
|
||||
this.trigger.removeEventListener("mousemove", this.onMouseMove);
|
||||
if (!this.options.interactive) {
|
||||
this.trigger.removeEventListener("mouseleave", this.onMouseLeave);
|
||||
}
|
||||
|
||||
break;
|
||||
case "click":
|
||||
this.trigger.removeEventListener("click", this.onClick);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
cancel(this.touchTimeout);
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
if (!this.options.listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
makeArray(this.triggers)
|
||||
.filter(Boolean)
|
||||
.forEach((trigger) => {
|
||||
switch (trigger) {
|
||||
case "hold":
|
||||
this.trigger.addEventListener(
|
||||
"touchstart",
|
||||
this.onTouchStart,
|
||||
TOUCH_OPTIONS
|
||||
);
|
||||
break;
|
||||
case "focus":
|
||||
this.trigger.addEventListener("focus", this.onFocus, {
|
||||
passive: true,
|
||||
});
|
||||
this.trigger.addEventListener("blur", this.onBlur, {
|
||||
passive: true,
|
||||
});
|
||||
break;
|
||||
case "focusin":
|
||||
this.trigger.addEventListener("focusin", this.onFocusIn, {
|
||||
passive: true,
|
||||
});
|
||||
this.trigger.addEventListener("focusout", this.onFocusOut, {
|
||||
passive: true,
|
||||
});
|
||||
break;
|
||||
case "hover":
|
||||
this.trigger.addEventListener("mousemove", this.onMouseMove, {
|
||||
passive: true,
|
||||
});
|
||||
if (!this.options.interactive) {
|
||||
this.trigger.addEventListener("mouseleave", this.onMouseLeave, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
case "click":
|
||||
this.trigger.addEventListener("click", this.onClick, {
|
||||
passive: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get triggers() {
|
||||
return this.options.triggers ?? ["click"];
|
||||
}
|
||||
|
||||
get untriggers() {
|
||||
return this.options.untriggers ?? ["click"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function getScrollParent(node) {
|
||||
const isElement = node instanceof HTMLElement;
|
||||
const overflowY = isElement && window.getComputedStyle(node).overflowY;
|
||||
const isScrollable = overflowY !== "visible" && overflowY !== "hidden";
|
||||
|
||||
if (!node || node === document.documentElement) {
|
||||
return null;
|
||||
} else if (isScrollable && node.scrollHeight >= node.clientHeight) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return getScrollParent(node.parentNode) || window;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
arrow,
|
||||
computePosition,
|
||||
flip,
|
||||
inline,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import { FLOAT_UI_PLACEMENTS } from "float-kit/lib/constants";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { headerOffset } from "discourse/lib/offset-calculator";
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import domFromString from "discourse-common/lib/dom-from-string";
|
||||
|
||||
export async function updatePosition(trigger, content, options) {
|
||||
let padding = 0;
|
||||
if (!isTesting()) {
|
||||
padding = options.padding || {
|
||||
top: headerOffset(),
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
};
|
||||
}
|
||||
|
||||
const flipOptions = {
|
||||
fallbackPlacements: options.fallbackPlacements ?? FLOAT_UI_PLACEMENTS,
|
||||
padding,
|
||||
};
|
||||
|
||||
const middleware = [
|
||||
offset(options.offset ? parseInt(options.offset, 10) : 10),
|
||||
];
|
||||
|
||||
if (options.inline) {
|
||||
middleware.push(inline());
|
||||
}
|
||||
|
||||
middleware.push(flip(flipOptions));
|
||||
middleware.push(shift({ padding }));
|
||||
|
||||
let arrowElement;
|
||||
if (options.arrow) {
|
||||
arrowElement = content.querySelector(".arrow");
|
||||
|
||||
if (!arrowElement) {
|
||||
arrowElement = domFromString(
|
||||
iconHTML("tippy-rounded-arrow", { class: "arrow" })
|
||||
)[0];
|
||||
content.appendChild(arrowElement);
|
||||
}
|
||||
|
||||
middleware.push(arrow({ element: arrowElement }));
|
||||
}
|
||||
|
||||
content.dataset.strategy = options.strategy || "absolute";
|
||||
|
||||
const { x, y, placement, middlewareData } = await computePosition(
|
||||
trigger,
|
||||
content,
|
||||
{
|
||||
placement: options.placement,
|
||||
strategy: options.strategy || "absolute",
|
||||
middleware,
|
||||
}
|
||||
);
|
||||
|
||||
if (options.computePosition) {
|
||||
options.computePosition(content, {
|
||||
x,
|
||||
y,
|
||||
placement,
|
||||
middlewareData,
|
||||
arrowElement,
|
||||
});
|
||||
} else {
|
||||
content.dataset.placement = placement;
|
||||
Object.assign(content.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
|
||||
if (middlewareData.arrow && arrowElement) {
|
||||
const arrowX = middlewareData.arrow.x;
|
||||
const arrowY = middlewareData.arrow.y;
|
||||
|
||||
Object.assign(arrowElement.style, {
|
||||
left: arrowX != null ? `${arrowX}px` : "",
|
||||
top: arrowY != null ? `${arrowY}px` : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Modifier from "ember-modifier";
|
||||
import { registerDestructor } from "@ember/destroyable";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import { autoUpdate } from "@floating-ui/dom";
|
||||
import { updatePosition } from "float-kit/lib/update-position";
|
||||
|
||||
export default class FloatKitApplyFloatingUi extends Modifier {
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.teardown());
|
||||
}
|
||||
|
||||
modify(element, [trigger, options, instance]) {
|
||||
instance.content = element;
|
||||
this.instance = instance;
|
||||
this.options = options ?? {};
|
||||
|
||||
if (this.options.autoUpdate) {
|
||||
this.cleanup = autoUpdate(trigger, element, this.update);
|
||||
} else {
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
async update() {
|
||||
await updatePosition(
|
||||
this.instance.trigger,
|
||||
this.instance.content,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
|
||||
teardown() {
|
||||
this.cleanup?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Modifier from "ember-modifier";
|
||||
import { registerDestructor } from "@ember/destroyable";
|
||||
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("click", 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("click", this.check);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Modifier from "ember-modifier";
|
||||
import { registerDestructor } from "@ember/destroyable";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class FloatKitCloseOnEscape extends Modifier {
|
||||
@service menu;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
}
|
||||
|
||||
modify(element, [closeFn]) {
|
||||
this.closeFn = closeFn;
|
||||
this.element = element;
|
||||
|
||||
document.addEventListener("keydown", this.check);
|
||||
}
|
||||
|
||||
@bind
|
||||
check(event) {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.closeFn();
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
document.removeEventListener("keydown", this.check);
|
||||
}
|
||||
}
|
||||
120
app/assets/javascripts/float-kit/addon/services/menu.js
Normal file
120
app/assets/javascripts/float-kit/addon/services/menu.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import Service from "@ember/service";
|
||||
import { getOwner } from "@ember/application";
|
||||
import { action } from "@ember/object";
|
||||
import DMenuInstance from "float-kit/lib/d-menu-instance";
|
||||
import { guidFor } from "@ember/object/internals";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { updatePosition } from "float-kit/lib/update-position";
|
||||
|
||||
export default class Menu extends Service {
|
||||
@tracked activeMenu;
|
||||
@tracked portalOutletElement;
|
||||
|
||||
/**
|
||||
* Render a menu
|
||||
*
|
||||
* @param {Element | DMenuInstance}
|
||||
* - trigger - the element that triggered the menu, can also be an object implementing `getBoundingClientRect`
|
||||
* - menu - an instance of a menu
|
||||
* @param {Object} [options] - options
|
||||
* @param {String | Element | Component} [options.content] - Specifies the content of the menu
|
||||
* @param {Integer} [options.maxWidth] - Specifies the maximum width of the content
|
||||
* @param {Object} [options.data] - An object which will be passed as the `@data` argument when content is a `Component`
|
||||
* @param {Boolean} [options.arrow] - Determines if the menu has an arrow
|
||||
* @param {Boolean} [options.offset] - Displaces the content from its reference trigger in pixels
|
||||
* @param {String} [options.identifier] - Add a data-identifier attribute to the trigger and the content
|
||||
* @param {Boolean} [options.inline] - Improves positioning for trigger that spans over multiple lines
|
||||
*
|
||||
* @returns {Promise<DMenuInstance>}
|
||||
*/
|
||||
@action
|
||||
async show() {
|
||||
let instance;
|
||||
|
||||
if (arguments[0] instanceof DMenuInstance) {
|
||||
instance = arguments[0];
|
||||
|
||||
if (this.activeMenu === instance && this.activeMenu.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 = new DMenuInstance(getOwner(this), trigger, arguments[1]);
|
||||
}
|
||||
|
||||
await this.replace(instance);
|
||||
instance.expanded = true;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces any active menu-
|
||||
*/
|
||||
@action
|
||||
async replace(menu) {
|
||||
await this.activeMenu?.close();
|
||||
this.activeMenu = menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the active menu
|
||||
* @param {DMenuInstance} [menu] - the menu to close, if not provider will close any active menu
|
||||
*/
|
||||
@action
|
||||
async close(menu) {
|
||||
if (this.activeMenu && menu && this.activeMenu.id !== menu.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.activeMenu?.close();
|
||||
this.activeMenu = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event listeners on a trigger to show a menu
|
||||
*
|
||||
* @param {Element} trigger - the element that triggered the menu, can also be an object implementing `getBoundingClientRect`
|
||||
* @param {Object} [options] - @see `show`
|
||||
*
|
||||
* @returns {DMenuInstance} An instance of the menu
|
||||
*/
|
||||
@action
|
||||
register(trigger, options = {}) {
|
||||
return new DMenuInstance(getOwner(this), trigger, {
|
||||
...options,
|
||||
listeners: true,
|
||||
beforeTrigger: async (menu) => {
|
||||
await this.replace(menu);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
registerPortalOutletElement(element) {
|
||||
this.portalOutletElement = element;
|
||||
}
|
||||
}
|
||||
113
app/assets/javascripts/float-kit/addon/services/toasts.js
Normal file
113
app/assets/javascripts/float-kit/addon/services/toasts.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import Service from "@ember/service";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||
import { action } from "@ember/object";
|
||||
import DDefaultToast from "float-kit/components/d-default-toast";
|
||||
import DToastInstance from "float-kit/lib/d-toast-instance";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
|
||||
export default class Toasts extends Service {
|
||||
@tracked activeToasts = new TrackedArray();
|
||||
|
||||
/**
|
||||
* Render a toast
|
||||
*
|
||||
* @param {Object} [options] - options passed to the toast component as `@toast` argument
|
||||
* @param {String} [options.duration] - The duration (ms) of the toast, will be closed after this time
|
||||
* @param {ComponentClass} [options.component] - A component to render, will use `DDefaultToast` if not provided
|
||||
* @param {String} [options.class] - A class added to the d-toast element
|
||||
* @param {Object} [options.data] - An object which will be passed as the `@data` argument to the component
|
||||
*
|
||||
* @returns {DToastInstance} - a toast instance
|
||||
*/
|
||||
@action
|
||||
show(options = {}) {
|
||||
const instance = new DToastInstance(getOwner(this), options);
|
||||
this.activeToasts.push(instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a DDefaultToast toast with the default theme
|
||||
*
|
||||
* @param {Object} [options] - @see show
|
||||
*
|
||||
* @returns {DToastInstance} - a toast instance
|
||||
*/
|
||||
@action
|
||||
default(options = {}) {
|
||||
options.data.theme = "default";
|
||||
|
||||
return this.show({ ...options, component: DDefaultToast });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a DDefaultToast toast with the success theme
|
||||
*
|
||||
* @param {Object} [options] - @see show
|
||||
*
|
||||
* @returns {DToastInstance} - a toast instance
|
||||
*/
|
||||
@action
|
||||
success(options = {}) {
|
||||
options.data.theme = "success";
|
||||
options.data.icon = "check";
|
||||
|
||||
return this.show({ ...options, component: DDefaultToast });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a DDefaultToast toast with the error theme
|
||||
*
|
||||
* @param {Object} [options] - @see show
|
||||
*
|
||||
* @returns {DToastInstance} - a toast instance
|
||||
*/
|
||||
@action
|
||||
error(options = {}) {
|
||||
options.data.theme = "error";
|
||||
options.data.icon = "exclamation-triangle";
|
||||
|
||||
return this.show({ ...options, component: DDefaultToast });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a DDefaultToast toast with the warning theme
|
||||
*
|
||||
* @param {Object} [options] - @see show
|
||||
*
|
||||
* @returns {DToastInstance} - a toast instance
|
||||
*/
|
||||
@action
|
||||
warning(options = {}) {
|
||||
options.data.theme = "warning";
|
||||
options.data.icon = "exclamation-circle";
|
||||
|
||||
return this.show({ ...options, component: DDefaultToast });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a DDefaultToast toast with the info theme
|
||||
*
|
||||
* @param {Object} [options] - @see show
|
||||
*
|
||||
* @returns {DToastInstance} - a toast instance
|
||||
*/
|
||||
@action
|
||||
info(options = {}) {
|
||||
options.data.theme = "info";
|
||||
options.data.icon = "info-circle";
|
||||
|
||||
return this.show({ ...options, component: DDefaultToast });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a toast. Any object containg a valid `id` property can be used as a toast parameter.
|
||||
*/
|
||||
@action
|
||||
close(toast) {
|
||||
this.activeToasts = new TrackedArray(
|
||||
this.activeToasts.filter((activeToast) => activeToast.id !== toast.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
120
app/assets/javascripts/float-kit/addon/services/tooltip.js
Normal file
120
app/assets/javascripts/float-kit/addon/services/tooltip.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import Service from "@ember/service";
|
||||
import { getOwner } from "@ember/application";
|
||||
import { action } from "@ember/object";
|
||||
import DTooltipInstance from "float-kit/lib/d-tooltip-instance";
|
||||
import { guidFor } from "@ember/object/internals";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { updatePosition } from "float-kit/lib/update-position";
|
||||
|
||||
export default class Tooltip extends Service {
|
||||
@tracked activeTooltip;
|
||||
@tracked portalOutletElement;
|
||||
|
||||
/**
|
||||
* Render a tooltip
|
||||
*
|
||||
* @param {Element | DTooltipInstance}
|
||||
* - trigger - the element that triggered the tooltip, can also be an object implementing `getBoundingClientRect`
|
||||
* - tooltip - an instance of a tooltip
|
||||
* @param {Object} [options] - options, if trigger given as first argument
|
||||
* @param {String | Element | Component} [options.content] - Specifies the content of the tooltip
|
||||
* @param {Integer} [options.maxWidth] - Specifies the maximum width of the content
|
||||
* @param {Object} [options.data] - An object which will be passed as the `@data` argument when content is a `Component`
|
||||
* @param {Boolean} [options.arrow] - Determines if the tooltip has an arrow
|
||||
* @param {Boolean} [options.offset] - Displaces the content from its reference trigger in pixels
|
||||
* @param {String} [options.identifier] - Add a data-identifier attribute to the trigger and the content
|
||||
* @param {Boolean} [options.inline] - Improves positioning for trigger that spans over multiple lines
|
||||
*
|
||||
* @returns {Promise<DTooltipInstance>}
|
||||
*/
|
||||
@action
|
||||
async show() {
|
||||
let instance;
|
||||
|
||||
if (arguments[0] instanceof DTooltipInstance) {
|
||||
instance = arguments[0];
|
||||
|
||||
if (this.activeTooltip === instance && this.activeTooltip.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 = new DTooltipInstance(getOwner(this), trigger, arguments[1]);
|
||||
}
|
||||
|
||||
await this.replace(instance);
|
||||
instance.expanded = true;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces any active tooltip
|
||||
*/
|
||||
@action
|
||||
async replace(tooltip) {
|
||||
await this.activeTooltip?.close();
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the active tooltip
|
||||
* @param {DTooltipInstance} [tooltip] - the tooltip to close, if not provider will close any active tooltip
|
||||
*/
|
||||
@action
|
||||
async close(tooltip) {
|
||||
if (this.activeTooltip && tooltip && this.activeTooltip.id !== tooltip.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.activeTooltip?.close();
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event listeners on a trigger to show a tooltip
|
||||
*
|
||||
* @param {Element} trigger - the element that triggered the tooltip, can also be an object implementing `getBoundingClientRect`
|
||||
* @param {Object} [options] - @see `show`
|
||||
*
|
||||
* @returns {DTooltipInstance} An instance of the tooltip
|
||||
*/
|
||||
@action
|
||||
register(trigger, options = {}) {
|
||||
return new DTooltipInstance(getOwner(this), trigger, {
|
||||
...options,
|
||||
listeners: true,
|
||||
beforeTrigger: async (tooltip) => {
|
||||
await this.replace(tooltip);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
registerPortalOutletElement(element) {
|
||||
this.portalOutletElement = element;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user