DEV: FloatKit (#23312)
This PR introduces three new UI elements to Discourse codebase through an addon called "FloatKit": - menu - tooltip - toast 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> ``` You can manually show a tooltip using the `tooltip` service: ```javascript const tooltipInstance = await this.tooltip.show( document.querySelector(".my-span"), options ) // and later manually 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 are very similar to tooltips and provide the same kind of APIs: ```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> ``` You can manually show a menu using the `menu` service: ```javascript const menuInstance = await this.menu.show( document.querySelector(".my-span"), options ) // and later manually 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 } } ) ``` 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:
parent
d9238fb01b
commit
abcdd8d367
|
@ -0,0 +1,241 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { fn } from "@ember/helper";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
import { action } from "@ember/object";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
|
||||
export default class AdminPostMenu extends Component {
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
@service adminPostMenuButtons;
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#if this.currentUser.staff}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="review.moderation_history"
|
||||
@icon="list"
|
||||
class="btn btn-transparent moderation-history"
|
||||
@href={{this.reviewUrl}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and this.currentUser.staff (not @data.transformedPost.isWhisper))}}
|
||||
<li>
|
||||
<DButton
|
||||
@label={{if
|
||||
@data.transformedPost.isModeratorAction
|
||||
"post.controls.revert_to_regular"
|
||||
"post.controls.convert_to_moderator"
|
||||
}}
|
||||
@icon="shield-alt"
|
||||
class={{concatClass
|
||||
"btn btn-transparent toggle-post-type"
|
||||
(if @data.transformedPost.isModeratorAction "btn-success")
|
||||
}}
|
||||
@action={{fn this.topicAction "togglePostType"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if @data.transformedPost.canEditStaffNotes}}
|
||||
<li>
|
||||
<DButton
|
||||
@icon="user-shield"
|
||||
@label={{if
|
||||
@data.transformedPost.notice
|
||||
"post.controls.change_post_notice"
|
||||
"post.controls.add_post_notice"
|
||||
}}
|
||||
title="post.controls.unhide"
|
||||
class={{concatClass
|
||||
"btn btn-transparent"
|
||||
(if @data.transformedPost.notice "change-notice" "add-notice")
|
||||
(if @data.transformedPost.notice "btn-success")
|
||||
}}
|
||||
@action={{fn this.topicAction "changeNotice"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and this.currentUser.staff @data.transformedPost.hidden)}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.unhide"
|
||||
@icon="far-eye"
|
||||
class="btn btn-transparent unhide-post"
|
||||
@action={{fn this.topicAction "unhidePost"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if
|
||||
(or
|
||||
this.currentUser.admin
|
||||
(and
|
||||
this.siteSettings.moderators_change_post_ownership
|
||||
this.currentUser.staff
|
||||
)
|
||||
)
|
||||
}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.change_owner"
|
||||
@icon="user"
|
||||
title="post.controls.lock_post_description"
|
||||
class="btn btn-transparent change-owner"
|
||||
@action={{fn this.topicAction "changePostOwner"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and @data.transformedPost.user_id this.currentUser.staff)}}
|
||||
{{#if this.siteSettings.enable_badges}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.grant_badge"
|
||||
@icon="certificate"
|
||||
class="btn btn-transparent grant-badge"
|
||||
@action={{fn this.topicAction "grantBadge"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if @data.transformedPost.locked}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.unlock_post"
|
||||
@icon="unlock"
|
||||
title="post.controls.unlock_post_description"
|
||||
class={{concatClass
|
||||
"btn btn-transparent unlock-post"
|
||||
(if @data.post.locked "btn-success")
|
||||
}}
|
||||
@action={{fn this.topicAction "unlockPost"}}
|
||||
/>
|
||||
</li>
|
||||
{{else}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.lock_post"
|
||||
@icon="lock"
|
||||
title="post.controls.lock_post_description"
|
||||
class="btn btn-transparent lock-post"
|
||||
@action={{fn this.topicAction "lockPost"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if @data.transformedPost.canPermanentlyDelete}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.permanently_delete"
|
||||
@icon="trash-alt"
|
||||
class="btn btn-transparent permanently-delete"
|
||||
@action={{fn this.topicAction "permanentlyDeletePost"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if (or @data.transformedPost.canManage @data.transformedPost.canWiki)}}
|
||||
{{#if @data.transformedPost.wiki}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.unwiki"
|
||||
@icon="far-edit"
|
||||
class={{concatClass
|
||||
"btn btn-transparent wiki wikied"
|
||||
(if @data.transformedPost.wiki "btn-success")
|
||||
}}
|
||||
@action={{fn this.topicAction "toggleWiki"}}
|
||||
/>
|
||||
</li>
|
||||
{{else}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.wiki"
|
||||
@icon="far-edit"
|
||||
class="btn btn-transparent wiki"
|
||||
@action={{fn this.topicAction "toggleWiki"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if @data.transformedPost.canPublishPage}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.publish_page"
|
||||
@icon="file"
|
||||
class="btn btn-transparent publish-page"
|
||||
@action={{fn this.topicAction "showPagePublish"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#if @data.transformedPost.canManage}}
|
||||
<li>
|
||||
<DButton
|
||||
@label="post.controls.rebake"
|
||||
@icon="sync-alt"
|
||||
class="btn btn-transparent rebuild-html"
|
||||
@action={{fn this.topicAction "rebakePost"}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
{{#each this.extraButtons as |button|}}
|
||||
<li>
|
||||
<DButton
|
||||
@label={{button.label}}
|
||||
@translatedLabel={{button.translatedLabel}}
|
||||
@icon={{button.icon}}
|
||||
class={{concatClass "btn btn-transparent" button.className}}
|
||||
@action={{fn this.extraAction button}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
get reviewUrl() {
|
||||
return `/review?topic_id=${this.args.data.transformedPost.id}&status=all`;
|
||||
}
|
||||
|
||||
get extraButtons() {
|
||||
return this.adminPostMenuButtons.callbacks
|
||||
.map((callback) => {
|
||||
return callback(this.args.data.transformedPost);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@action
|
||||
async topicAction(actionName) {
|
||||
await this.args.close();
|
||||
|
||||
try {
|
||||
await this.args.data[actionName]?.();
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Unknown error while attempting \`${actionName}\`:`, error);
|
||||
}
|
||||
|
||||
await this.args.data.scheduleRerender();
|
||||
}
|
||||
|
||||
@action
|
||||
async extraAction(button) {
|
||||
await this.args.close();
|
||||
await button.action(this.args.data.post);
|
||||
await this.args.data.scheduleRerender();
|
||||
}
|
||||
}
|
|
@ -1,12 +1,19 @@
|
|||
<DButton
|
||||
<DButtonTooltip>
|
||||
<:button>
|
||||
<DButton
|
||||
class="btn-default bootstrap-mode"
|
||||
@label="bootstrap_mode"
|
||||
@action={{this.routeToAdminGuide}}
|
||||
>
|
||||
>
|
||||
{{#if this.showUserTip}}
|
||||
<UserTip @id="admin_guide" @content={{this.userTipContent}} />
|
||||
{{else}}
|
||||
<DTooltip @theme="user-tip" @arrow={{true}}>
|
||||
{{/if}}
|
||||
</DButton>
|
||||
</:button>
|
||||
|
||||
<:tooltip>
|
||||
{{#unless this.showUserTip}}
|
||||
<DTooltip @theme="user-tip" @icon="info-circle" @arrow={{true}}>
|
||||
<div class="user-tip__container">
|
||||
<div class="user-tip__title">
|
||||
{{i18n "user_tips.admin_guide.title"}}
|
||||
|
@ -16,5 +23,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</DTooltip>
|
||||
{{/if}}
|
||||
</DButton>
|
||||
{{/unless}}
|
||||
</:tooltip>
|
||||
</DButtonTooltip>
|
|
@ -42,6 +42,7 @@ import {
|
|||
initUserStatusHtml,
|
||||
renderUserStatusHtml,
|
||||
} from "discourse/lib/user-status-on-autocomplete";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
|
||||
// original string `![image|foo=bar|690x220, 50%|bar=baz](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")`
|
||||
// group 1 `image|foo=bar`
|
||||
|
@ -223,7 +224,7 @@ export default Component.extend(ComposerUploadUppy, {
|
|||
categoryId: this.topic?.category_id || this.composer?.categoryId,
|
||||
includeGroups: true,
|
||||
}).then((result) => {
|
||||
initUserStatusHtml(result.users);
|
||||
initUserStatusHtml(getOwner(this), result.users);
|
||||
return result;
|
||||
});
|
||||
},
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
{{#if this.canCreateTopic}}
|
||||
<DButtonTooltip>
|
||||
<:button>
|
||||
<DButton
|
||||
@action={{this.action}}
|
||||
@icon="plus"
|
||||
|
@ -7,5 +9,14 @@
|
|||
id="create-topic"
|
||||
class={{this.btnClass}}
|
||||
/>
|
||||
{{yield}}
|
||||
</:button>
|
||||
<:tooltip>
|
||||
{{#if @disabled}}
|
||||
<DTooltip
|
||||
@icon="info-circle"
|
||||
@content={{i18n "topic.create_disabled_category"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</:tooltip>
|
||||
</DButtonTooltip>
|
||||
{{/if}}
|
|
@ -20,6 +20,7 @@
|
|||
{{did-insert this.setupListeners}}
|
||||
{{will-destroy this.cleanupListeners}}
|
||||
{{on "mouseup" this.handleMouseUp}}
|
||||
{{trap-tab (hash preventScroll=false)}}
|
||||
>
|
||||
<div class="modal-outer-container">
|
||||
<div class="modal-middle-container">
|
||||
|
|
|
@ -22,7 +22,6 @@ export default class DModal extends Component {
|
|||
this.handleDocumentKeydown
|
||||
);
|
||||
this.wrapperElement = element;
|
||||
this.trapTab();
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -94,71 +93,6 @@ export default class DModal extends Component {
|
|||
this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === "Tab") {
|
||||
this.trapTab(event);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
trapTab(event) {
|
||||
if (this.args.hidden) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const innerContainer = this.wrapperElement.querySelector(
|
||||
".modal-inner-container"
|
||||
);
|
||||
if (!innerContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
let focusableElements =
|
||||
'[autofocus], a, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
if (!event) {
|
||||
// on first trap we don't allow to focus modal-close
|
||||
// and apply manual focus only if we don't have any autofocus element
|
||||
const autofocusedElement = innerContainer.querySelector("[autofocus]");
|
||||
if (
|
||||
!autofocusedElement ||
|
||||
document.activeElement !== autofocusedElement
|
||||
) {
|
||||
// if there's not autofocus, or the activeElement, is not the autofocusable element
|
||||
// attempt to focus the first of the focusable elements or just the modal-body
|
||||
// to make it possible to scroll with arrow down/up
|
||||
(
|
||||
autofocusedElement ||
|
||||
innerContainer.querySelector(
|
||||
focusableElements + ", button:not(.modal-close)"
|
||||
) ||
|
||||
innerContainer.querySelector(".modal-body")
|
||||
)?.focus();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
focusableElements += ", button:enabled";
|
||||
|
||||
const firstFocusableElement =
|
||||
innerContainer.querySelector(focusableElements);
|
||||
const focusableContent = innerContainer.querySelectorAll(focusableElements);
|
||||
const lastFocusableElement = focusableContent[focusableContent.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === firstFocusableElement) {
|
||||
lastFocusableElement?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusableElement) {
|
||||
(
|
||||
innerContainer.querySelector(".modal-close") || firstFocusableElement
|
||||
)?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
@ -68,11 +68,7 @@
|
|||
@label={{this.createTopicLabel}}
|
||||
@btnClass={{this.createTopicClass}}
|
||||
@canCreateTopicOnTag={{this.canCreateTopicOnTag}}
|
||||
>
|
||||
{{#if this.createTopicButtonDisabled}}
|
||||
<DTooltip>{{i18n "topic.create_disabled_category"}}</DTooltip>
|
||||
{{/if}}
|
||||
</CreateTopicButton>
|
||||
/>
|
||||
|
||||
<PluginOutlet
|
||||
@name="after-create-topic-button"
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div
|
||||
{{on "click" (action "close")}}
|
||||
id={{this.componentId}}
|
||||
class="d-popover {{this.class}} {{if this.isExpanded 'is-expanded'}}"
|
||||
>
|
||||
{{yield (hash isExpanded=this.isExpanded)}}
|
||||
</div>
|
|
@ -1,97 +0,0 @@
|
|||
import Component from "@ember/component";
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import tippy from "tippy.js";
|
||||
import { guidFor } from "@ember/object/internals";
|
||||
import { action } from "@ember/object";
|
||||
import { next } from "@ember/runloop";
|
||||
import { hideOnEscapePlugin } from "discourse/lib/d-popover";
|
||||
|
||||
export default class DiscoursePopover extends Component {
|
||||
tagName = "";
|
||||
|
||||
isExpanded = false;
|
||||
|
||||
options = null;
|
||||
|
||||
class = null;
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this._tippyInstance = this._setupTippy();
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this._tippyInstance?.destroy();
|
||||
}
|
||||
|
||||
get componentId() {
|
||||
return guidFor(this);
|
||||
}
|
||||
|
||||
@action
|
||||
close(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._tippyInstance?.hide();
|
||||
}
|
||||
|
||||
_setupTippy() {
|
||||
const baseOptions = {
|
||||
trigger: "click",
|
||||
zIndex: 1400,
|
||||
arrow: iconHTML("tippy-rounded-arrow"),
|
||||
interactive: true,
|
||||
allowHTML: false,
|
||||
appendTo: "parent",
|
||||
hideOnClick: true,
|
||||
plugins: [hideOnEscapePlugin],
|
||||
content:
|
||||
this.options?.content ||
|
||||
document
|
||||
.getElementById(this.componentId)
|
||||
.querySelector(
|
||||
":scope > .d-popover-content, :scope > div, :scope > ul"
|
||||
),
|
||||
onShow: () => {
|
||||
next(() => {
|
||||
if (this.isDestroyed || this.isDestroying) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("isExpanded", true);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
onHide: () => {
|
||||
next(() => {
|
||||
if (this.isDestroyed || this.isDestroying) {
|
||||
return;
|
||||
}
|
||||
this.set("isExpanded", false);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const target = document
|
||||
.getElementById(this.componentId)
|
||||
.querySelector(
|
||||
':scope > .d-popover-trigger, :scope > .btn, :scope > [role="button"]'
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = tippy(target, { ...baseOptions, ...(this.options || {}) });
|
||||
|
||||
return instance?.id ? instance : null;
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import Component from "@glimmer/component";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
export default class DiscourseTooltip extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<div {{didInsert this.initTippy}}>{{yield}}</div>
|
||||
</template>
|
||||
|
||||
@service capabilities;
|
||||
|
||||
#tippyInstance;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
if (this.capabilities.touch) {
|
||||
window.addEventListener("scroll", this.onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
if (this.capabilities.touch) {
|
||||
window.removeEventListener("scroll", this.onScroll);
|
||||
}
|
||||
this.#tippyInstance.destroy();
|
||||
}
|
||||
|
||||
@bind
|
||||
onScroll() {
|
||||
discourseDebounce(() => this.#tippyInstance.hide(), 10);
|
||||
}
|
||||
|
||||
stopPropagation(instance, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@action
|
||||
initTippy(element) {
|
||||
this.#tippyInstance = tippy(element.parentElement, {
|
||||
content: element,
|
||||
interactive: this.args.interactive ?? false,
|
||||
trigger: this.capabilities.touch ? "click" : "mouseenter",
|
||||
theme: this.args.theme || "d-tooltip",
|
||||
arrow: this.args.arrow ? iconHTML("tippy-rounded-arrow") : false,
|
||||
placement: this.args.placement || "bottom-start",
|
||||
onTrigger: this.stopPropagation,
|
||||
onUntrigger: this.stopPropagation,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -333,10 +333,7 @@
|
|||
@class="email-in"
|
||||
@value={{this.category.email_in}}
|
||||
/>
|
||||
<span>
|
||||
{{d-icon "info-circle"}}
|
||||
<DTooltip>{{i18n "category.email_in_tooltip"}}</DTooltip>
|
||||
</span>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="field email-in-allow-strangers">
|
||||
|
|
|
@ -3,11 +3,37 @@ import { tracked } from "@glimmer/tracking";
|
|||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { fixQuotes } from "discourse/components/quote-button";
|
||||
import { fixQuotes } from "discourse/components/post-text-selection";
|
||||
import { translateModKey } from "discourse/lib/utilities";
|
||||
import I18n from "I18n";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { on } from "@ember/modifier";
|
||||
import autoFocus from "discourse/modifiers/auto-focus";
|
||||
|
||||
export default class FastEdit extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
{{! template-lint-disable no-pointer-down-event-binding }}
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div class="fast-edit-container" {{on "keydown" this.onKeydown}}>
|
||||
<textarea
|
||||
{{on "input" this.updateValue}}
|
||||
id="fast-edit-input"
|
||||
{{autoFocus}}
|
||||
>{{@initialValue}}</textarea>
|
||||
|
||||
<DButton
|
||||
class="btn-small btn-primary save-fast-edit"
|
||||
@action={{this.save}}
|
||||
@icon="pencil-alt"
|
||||
@label="composer.save_edit"
|
||||
@translatedTitle={{this.buttonTitle}}
|
||||
@isLoading={{this.isSaving}}
|
||||
@disabled={{this.disabled}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@tracked value = this.args.initialValue;
|
||||
@tracked isSaving = false;
|
||||
|
||||
|
@ -21,9 +47,7 @@ export default class FastEdit extends Component {
|
|||
|
||||
@action
|
||||
onKeydown(event) {
|
||||
if (event.key === "Escape") {
|
||||
this.args.close();
|
||||
} else if (
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!this.disabled
|
||||
|
@ -34,6 +58,7 @@ export default class FastEdit extends Component {
|
|||
|
||||
@action
|
||||
updateValue(event) {
|
||||
event.preventDefault();
|
||||
this.value = event.target.value;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{{! template-lint-disable no-pointer-down-event-binding }}
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div class="fast-edit-container" {{on "keydown" this.onKeydown}}>
|
||||
<textarea
|
||||
{{auto-focus}}
|
||||
{{on "input" this.updateValue}}
|
||||
id="fast-edit-input"
|
||||
>{{@initialValue}}</textarea>
|
||||
|
||||
<DButton
|
||||
class="btn-small btn-primary save-fast-edit"
|
||||
@action={{this.save}}
|
||||
@icon="pencil-alt"
|
||||
@label="composer.save_edit"
|
||||
@translatedTitle={{this.buttonTitle}}
|
||||
@isLoading={{this.isSaving}}
|
||||
@disabled={{this.disabled}}
|
||||
/>
|
||||
</div>
|
|
@ -105,12 +105,10 @@
|
|||
@placeholderKey="admin.groups.manage.interaction.incoming_email_placeholder"
|
||||
/>
|
||||
|
||||
<span>
|
||||
{{d-icon "info-circle"}}
|
||||
<DTooltip>{{i18n
|
||||
"admin.groups.manage.interaction.incoming_email_tooltip"
|
||||
}}</DTooltip>
|
||||
</span>
|
||||
<DTooltip
|
||||
@icon="info-circle"
|
||||
@content={{i18n "admin.groups.manage.interaction.incoming_email_tooltip"}}
|
||||
/>
|
||||
|
||||
<span>
|
||||
<PluginOutlet
|
||||
|
|
|
@ -90,17 +90,20 @@
|
|||
>
|
||||
<label class="checkbox-label">
|
||||
{{#if this.transformedModel.sectionType}}
|
||||
<DTooltip @placement="top-start">
|
||||
{{i18n "sidebar.sections.custom.always_public"}}
|
||||
</DTooltip>
|
||||
{{/if}}
|
||||
<DTooltip
|
||||
@icon="check-square"
|
||||
@content={{i18n "sidebar.sections.custom.always_public"}}
|
||||
class="always-public-tooltip"
|
||||
/>
|
||||
{{else}}
|
||||
<Input
|
||||
@type="checkbox"
|
||||
@checked={{this.transformedModel.public}}
|
||||
class="mark-public"
|
||||
disabled={{this.transformedModel.sectionType}}
|
||||
/>
|
||||
{{i18n "sidebar.sections.custom.public"}}
|
||||
{{/if}}
|
||||
<span>{{i18n "sidebar.sections.custom.public"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -0,0 +1,253 @@
|
|||
import { ajax } from "discourse/lib/ajax";
|
||||
import { postUrl, setCaretPosition } from "discourse/lib/utilities";
|
||||
import Sharing from "discourse/lib/sharing";
|
||||
import { action } from "@ember/object";
|
||||
import { getAbsoluteURL } from "discourse-common/lib/get-url";
|
||||
import { inject as service } from "@ember/service";
|
||||
import FastEditModal from "discourse/components/modal/fast-edit";
|
||||
import Component from "@glimmer/component";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { fn } from "@ember/helper";
|
||||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import FastEdit from "discourse/components/fast-edit";
|
||||
import { on } from "@ember/modifier";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { modifier } from "ember-modifier";
|
||||
|
||||
export function fixQuotes(str) {
|
||||
// u+201c, u+201d = “ ”
|
||||
// u+2018, u+2019 = ‘ ’
|
||||
return str.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
export default class PostTextSelectionToolbar extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
{{! template-lint-disable no-pointer-down-event-binding }}
|
||||
<div
|
||||
{{on "mousedown" this.trapEvents}}
|
||||
{{on "mouseup" this.trapEvents}}
|
||||
class={{concatClass "quote-button" "visible"}}
|
||||
{{this.appEventsListeners}}
|
||||
>
|
||||
<div class="buttons">
|
||||
{{#if this.embedQuoteButton}}
|
||||
<DButton
|
||||
@icon="quote-left"
|
||||
@label="post.quote_reply"
|
||||
@title="post.quote_reply_shortcut"
|
||||
class="btn-flat insert-quote"
|
||||
@action={{@data.insertQuote}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if @data.canEditPost}}
|
||||
<DButton
|
||||
@icon="pencil-alt"
|
||||
@label="post.quote_edit"
|
||||
@title="post.quote_edit_shortcut"
|
||||
class="btn-flat quote-edit-label"
|
||||
{{on "click" this.toggleFastEdit}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.quoteSharingEnabled}}
|
||||
<span class="quote-sharing">
|
||||
{{#if this.quoteSharingShowLabel}}
|
||||
<DButton
|
||||
@icon="share"
|
||||
@label="post.quote_share"
|
||||
class="btn-flat quote-share-label"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<span class="quote-share-buttons">
|
||||
{{#each this.quoteSharingSources as |source|}}
|
||||
<DButton
|
||||
@action={{fn this.share source}}
|
||||
@translatedTitle={{source.title}}
|
||||
@icon={{source.icon}}
|
||||
class="btn-flat"
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
<PluginOutlet
|
||||
@name="quote-share-buttons-after"
|
||||
@connectorTagName="span"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="extra">
|
||||
{{#if this.isFastEditing}}
|
||||
<FastEdit
|
||||
@initialValue={{@data.quoteState.buffer}}
|
||||
@post={{this.post}}
|
||||
@close={{this.closeFastEdit}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<PluginOutlet @name="quote-button-after" @connectorTagName="div" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@service currentUser;
|
||||
@service modal;
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
@service appEvents;
|
||||
|
||||
@tracked isFastEditing = false;
|
||||
|
||||
appEventsListeners = modifier(() => {
|
||||
this.appEvents.on("quote-button:edit", this, "toggleFastEdit");
|
||||
|
||||
return () => {
|
||||
this.appEvents.off("quote-button:edit", this, "toggleFastEdit");
|
||||
};
|
||||
});
|
||||
|
||||
get topic() {
|
||||
return this.args.data.topic;
|
||||
}
|
||||
|
||||
get quoteState() {
|
||||
return this.args.data.quoteState;
|
||||
}
|
||||
|
||||
get post() {
|
||||
return this.topic.postStream.findLoadedPost(
|
||||
this.args.data.quoteState.postId
|
||||
);
|
||||
}
|
||||
|
||||
get quoteSharingEnabled() {
|
||||
return (
|
||||
this.site.desktopView &&
|
||||
this.quoteSharingSources.length > 0 &&
|
||||
!this.topic.invisible &&
|
||||
!this.topic.category?.read_restricted &&
|
||||
(this.siteSettings.share_quote_visibility === "all" ||
|
||||
(this.siteSettings.share_quote_visibility === "anonymous" &&
|
||||
!this.currentUser))
|
||||
);
|
||||
}
|
||||
|
||||
get quoteSharingSources() {
|
||||
return Sharing.activeSources(
|
||||
this.siteSettings.share_quote_buttons,
|
||||
this.siteSettings.login_required || this.topic.isPrivateMessage
|
||||
);
|
||||
}
|
||||
|
||||
get quoteSharingShowLabel() {
|
||||
return this.quoteSharingSources.length > 1;
|
||||
}
|
||||
|
||||
get shareUrl() {
|
||||
return getAbsoluteURL(
|
||||
postUrl(this.topic.slug, this.topic.id, this.post.post_number)
|
||||
);
|
||||
}
|
||||
|
||||
get embedQuoteButton() {
|
||||
const canCreatePost = this.topic.details.can_create_post;
|
||||
const canReplyAsNewTopic = this.topic.details.can_reply_as_new_topic;
|
||||
|
||||
return (
|
||||
(canCreatePost || canReplyAsNewTopic) &&
|
||||
this.currentUser?.get("user_option.enable_quoting")
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
trapEvents(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@action
|
||||
async closeFastEdit() {
|
||||
this.isFastEditing = false;
|
||||
await this.args.data.hideToolbar();
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleFastEdit() {
|
||||
if (this.args.data.supportsFastEdit) {
|
||||
if (this.site.desktopView) {
|
||||
this.isFastEditing = !this.isFastEditing;
|
||||
} else {
|
||||
this.modal.show(FastEditModal, {
|
||||
model: {
|
||||
initialValue: this.args.data.quoteState.buffer,
|
||||
post: this.post,
|
||||
},
|
||||
});
|
||||
this.args.data.hideToolbar();
|
||||
}
|
||||
} else {
|
||||
const result = await ajax(`/posts/${this.post.id}`);
|
||||
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bestIndex = 0;
|
||||
const rows = result.raw.split("\n");
|
||||
|
||||
// selecting even a part of the text of a list item will include
|
||||
// "* " at the beginning of the buffer, we remove it to be able
|
||||
// to find it in row
|
||||
const buffer = fixQuotes(
|
||||
this.args.data.quoteState.buffer.split("\n")[0].replace(/^\* /, "")
|
||||
);
|
||||
|
||||
rows.some((row, index) => {
|
||||
if (row.length && row.includes(buffer)) {
|
||||
bestIndex = index;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
this.args.data.editPost(this.post);
|
||||
|
||||
document
|
||||
.querySelector("#reply-control")
|
||||
?.addEventListener("transitionend", () => {
|
||||
const textarea = document.querySelector(".d-editor-input");
|
||||
if (!textarea || this.isDestroyed || this.isDestroying) {
|
||||
return;
|
||||
}
|
||||
|
||||
// best index brings us to one row before as slice start from 1
|
||||
// we add 1 to be at the beginning of next line, unless we start from top
|
||||
setCaretPosition(
|
||||
textarea,
|
||||
rows.slice(0, bestIndex).join("\n").length + (bestIndex > 0 ? 1 : 0)
|
||||
);
|
||||
|
||||
// ensures we correctly scroll to caret and reloads composer
|
||||
// if we do another selection/edit
|
||||
textarea.blur();
|
||||
textarea.focus();
|
||||
});
|
||||
|
||||
this.args.data.hideToolbar();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
share(source) {
|
||||
Sharing.shareSource(source, {
|
||||
url: this.shareUrl,
|
||||
title: this.topic.title,
|
||||
quote: window.getSelection().toString(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,279 @@
|
|||
import {
|
||||
selectedNode,
|
||||
selectedRange,
|
||||
selectedText,
|
||||
} from "discourse/lib/utilities";
|
||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||
import { action } from "@ember/object";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import toMarkdown from "discourse/lib/to-markdown";
|
||||
import escapeRegExp from "discourse-common/utils/escape-regexp";
|
||||
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
|
||||
import { inject as service } from "@ember/service";
|
||||
import Component from "@glimmer/component";
|
||||
import { modifier } from "ember-modifier";
|
||||
import PostTextSelectionToolbar from "discourse/components/post-text-selection-toolbar";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
|
||||
function getQuoteTitle(element) {
|
||||
const titleEl = element.querySelector(".title");
|
||||
if (!titleEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const titleLink = titleEl.querySelector("a:not(.back)");
|
||||
if (titleLink) {
|
||||
return titleLink.textContent.trim();
|
||||
}
|
||||
|
||||
return titleEl.textContent.trim().replace(/:$/, "");
|
||||
}
|
||||
|
||||
export function fixQuotes(str) {
|
||||
// u+201c, u+201d = “ ”
|
||||
// u+2018, u+2019 = ‘ ’
|
||||
return str.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
export default class PostTextSelection extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<div
|
||||
{{this.documentListeners}}
|
||||
{{this.appEventsListeners}}
|
||||
{{this.runLoopHandlers}}
|
||||
></div>
|
||||
</template>
|
||||
|
||||
@service appEvents;
|
||||
@service capabilities;
|
||||
@service currentUser;
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
@service menu;
|
||||
|
||||
prevSelection;
|
||||
|
||||
runLoopHandlers = modifier(() => {
|
||||
return () => {
|
||||
cancel(this.selectionChangeHandler);
|
||||
cancel(this.holdingMouseDownHandle);
|
||||
};
|
||||
});
|
||||
|
||||
documentListeners = modifier(() => {
|
||||
document.addEventListener("mousedown", this.mousedown, { passive: true });
|
||||
document.addEventListener("mouseup", this.mouseup, { passive: true });
|
||||
document.addEventListener("selectionchange", this.selectionchange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", this.mousedown);
|
||||
document.removeEventListener("mouseup", this.mouseup);
|
||||
document.removeEventListener("selectionchange", this.selectionchange);
|
||||
};
|
||||
});
|
||||
|
||||
appEventsListeners = modifier(() => {
|
||||
this.appEvents.on("quote-button:quote", this, "insertQuote");
|
||||
|
||||
return () => {
|
||||
this.appEvents.off("quote-button:quote", this, "insertQuote");
|
||||
};
|
||||
});
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
|
||||
this.menuInstance?.destroy();
|
||||
cancel(this.selectionChangedHandler);
|
||||
}
|
||||
|
||||
@bind
|
||||
async hideToolbar() {
|
||||
this.args.quoteState.clear();
|
||||
await this.menuInstance?.close();
|
||||
}
|
||||
|
||||
@bind
|
||||
async selectionChanged() {
|
||||
let supportsFastEdit = this.canEditPost;
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure we selected content inside 1 post *only*
|
||||
let postId;
|
||||
for (let r = 0; r < selection.rangeCount; r++) {
|
||||
const range = selection.getRangeAt(r);
|
||||
const selectionStart =
|
||||
range.startContainer.nodeType === Node.ELEMENT_NODE
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement;
|
||||
const ancestor =
|
||||
range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
|
||||
if (!selectionStart.closest(".cooked")) {
|
||||
return await this.hideToolbar();
|
||||
}
|
||||
|
||||
postId ||= ancestor.closest(".boxed, .reply")?.dataset?.postId;
|
||||
|
||||
if (!ancestor.closest(".contents") || !postId) {
|
||||
return await this.hideToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
const _selectedElement =
|
||||
selectedNode().nodeType === Node.ELEMENT_NODE
|
||||
? selectedNode()
|
||||
: selectedNode().parentElement;
|
||||
const _selectedText = selectedText();
|
||||
const cooked =
|
||||
_selectedElement.querySelector(".cooked") ||
|
||||
_selectedElement.closest(".cooked");
|
||||
|
||||
// computing markdown takes a lot of time on long posts
|
||||
// this code attempts to compute it only when we can't fast track
|
||||
let opts = {
|
||||
full:
|
||||
selectedRange().startOffset > 0
|
||||
? false
|
||||
: _selectedText === toMarkdown(cooked.innerHTML),
|
||||
};
|
||||
|
||||
for (
|
||||
let element = _selectedElement;
|
||||
element && element.tagName !== "ARTICLE";
|
||||
element = element.parentElement
|
||||
) {
|
||||
if (element.tagName === "ASIDE" && element.classList.contains("quote")) {
|
||||
opts.username = element.dataset.username || getQuoteTitle(element);
|
||||
opts.post = element.dataset.post;
|
||||
opts.topic = element.dataset.topic;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const quoteState = this.args.quoteState;
|
||||
quoteState.selected(postId, _selectedText, opts);
|
||||
|
||||
if (this.canEditPost) {
|
||||
const regexp = new RegExp(escapeRegExp(quoteState.buffer), "gi");
|
||||
const matches = cooked.innerHTML.match(regexp);
|
||||
const non_ascii_regex = /[^\x00-\x7F]/;
|
||||
|
||||
if (
|
||||
quoteState.buffer.length === 0 ||
|
||||
quoteState.buffer.includes("|") || // tables are too complex
|
||||
quoteState.buffer.match(/\n/g) || // linebreaks are too complex
|
||||
matches?.length > 1 || // duplicates are too complex
|
||||
non_ascii_regex.test(quoteState.buffer) // non-ascii chars break fast-edit
|
||||
) {
|
||||
supportsFastEdit = false;
|
||||
} else if (matches?.length === 1) {
|
||||
supportsFastEdit = true;
|
||||
}
|
||||
}
|
||||
|
||||
// avoid hard loops in quote selection unconditionally
|
||||
// this can happen if you triple click text in firefox
|
||||
if (this.menuInstance?.expanded && this.prevSelection === _selectedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.prevSelection = _selectedText;
|
||||
|
||||
// on Desktop, shows the button at the beginning of the selection
|
||||
// on Mobile, shows the button at the end of the selection
|
||||
const { isIOS, isAndroid, isOpera } = this.capabilities;
|
||||
const showAtEnd = this.site.isMobileDevice || isIOS || isAndroid || isOpera;
|
||||
const options = {
|
||||
component: PostTextSelectionToolbar,
|
||||
inline: true,
|
||||
placement: showAtEnd ? "bottom-start" : "top-start",
|
||||
fallbackPlacements: showAtEnd
|
||||
? ["bottom-end", "top-start"]
|
||||
: ["bottom-start"],
|
||||
offset: showAtEnd ? 25 : 3,
|
||||
trapTab: false,
|
||||
data: {
|
||||
canEditPost: this.canEditPost,
|
||||
editPost: this.args.editPost,
|
||||
supportsFastEdit,
|
||||
topic: this.args.topic,
|
||||
quoteState,
|
||||
insertQuote: this.insertQuote,
|
||||
hideToolbar: this.hideToolbar,
|
||||
},
|
||||
};
|
||||
|
||||
this.menuInstance?.destroy();
|
||||
|
||||
this.menuInstance = await this.menu.show(
|
||||
virtualElementFromTextRange(),
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
onSelectionChanged() {
|
||||
const { isIOS, isWinphone, isAndroid } = this.capabilities;
|
||||
const wait = isIOS || isWinphone || isAndroid ? INPUT_DELAY : 100;
|
||||
this.selectionChangedHandler = discourseDebounce(
|
||||
this,
|
||||
this.selectionChanged,
|
||||
wait
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
async mousedown() {
|
||||
this.isMousedown = true;
|
||||
this.holdingMouseDown = false;
|
||||
this.holdingMouseDownHandler = discourseLater(() => {
|
||||
this.holdingMouseDown = true;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@bind
|
||||
async mouseup() {
|
||||
this.prevSelection = null;
|
||||
this.isMousedown = false;
|
||||
|
||||
if (this.holdingMouseDown) {
|
||||
this.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
selectionchange() {
|
||||
cancel(this.selectionChangeHandler);
|
||||
this.selectionChangeHandler = discourseLater(() => {
|
||||
if (!this.isMousedown) {
|
||||
this.onSelectionChanged();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
get post() {
|
||||
return this.args.topic.postStream.findLoadedPost(
|
||||
this.args.quoteState.postId
|
||||
);
|
||||
}
|
||||
|
||||
get canEditPost() {
|
||||
return this.siteSettings.enable_fast_edit && this.post?.can_edit;
|
||||
}
|
||||
|
||||
@action
|
||||
async insertQuote() {
|
||||
await this.args.selectText();
|
||||
await this.hideToolbar();
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
<div
|
||||
{{did-insert this.didInsert}}
|
||||
class={{concat-class
|
||||
"quote-button"
|
||||
(if this.visible "visible")
|
||||
(if this.displayFastEditInput "fast-editing")
|
||||
(if this.animated "animated")
|
||||
}}
|
||||
>
|
||||
<div class="buttons">
|
||||
{{#if this.embedQuoteButton}}
|
||||
<DButton
|
||||
@action={{this.insertQuote}}
|
||||
@icon="quote-left"
|
||||
@label="post.quote_reply"
|
||||
@title="post.quote_reply_shortcut"
|
||||
class="btn-flat insert-quote"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.canEditPost}}
|
||||
<DButton
|
||||
@icon="pencil-alt"
|
||||
@action={{this.toggleFastEditForm}}
|
||||
@label="post.quote_edit"
|
||||
@title="post.quote_edit_shortcut"
|
||||
class="btn-flat quote-edit-label"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.quoteSharingEnabled}}
|
||||
<span class="quote-sharing">
|
||||
{{#if this.quoteSharingShowLabel}}
|
||||
<DButton
|
||||
@icon="share"
|
||||
@label="post.quote_share"
|
||||
class="btn-flat quote-share-label"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<span class="quote-share-buttons">
|
||||
{{#each this.quoteSharingSources as |source|}}
|
||||
<DButton
|
||||
@action={{fn this.share source}}
|
||||
@translatedTitle={{source.title}}
|
||||
@icon={{source.icon}}
|
||||
class="btn-flat"
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
<PluginOutlet
|
||||
@name="quote-share-buttons-after"
|
||||
@connectorTagName="span"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="extra">
|
||||
{{#if this.displayFastEditInput}}
|
||||
<FastEdit
|
||||
@initialValue={{this.fastEditInitialSelection}}
|
||||
@post={{this.post}}
|
||||
@close={{this.hideButton}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<PluginOutlet @name="quote-button-after" @connectorTagName="div" />
|
||||
</div>
|
||||
</div>
|
|
@ -1,442 +0,0 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import {
|
||||
postUrl,
|
||||
selectedNode,
|
||||
selectedRange,
|
||||
selectedText,
|
||||
setCaretPosition,
|
||||
} from "discourse/lib/utilities";
|
||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||
import Sharing from "discourse/lib/sharing";
|
||||
import { action } from "@ember/object";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { getAbsoluteURL } from "discourse-common/lib/get-url";
|
||||
import { next, schedule } from "@ember/runloop";
|
||||
import toMarkdown from "discourse/lib/to-markdown";
|
||||
import escapeRegExp from "discourse-common/utils/escape-regexp";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
|
||||
import { inject as service } from "@ember/service";
|
||||
import FastEditModal from "discourse/components/modal/fast-edit";
|
||||
|
||||
function getQuoteTitle(element) {
|
||||
const titleEl = element.querySelector(".title");
|
||||
if (!titleEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const titleLink = titleEl.querySelector("a:not(.back)");
|
||||
if (titleLink) {
|
||||
return titleLink.textContent.trim();
|
||||
}
|
||||
|
||||
return titleEl.textContent.trim().replace(/:$/, "");
|
||||
}
|
||||
|
||||
export function fixQuotes(str) {
|
||||
// u+201c, u+201d = “ ”
|
||||
// u+2018, u+2019 = ‘ ’
|
||||
return str.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
export default class QuoteButton extends Component {
|
||||
@service appEvents;
|
||||
@service capabilities;
|
||||
@service currentUser;
|
||||
@service modal;
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
|
||||
@tracked visible = false;
|
||||
@tracked animated = false;
|
||||
@tracked isFastEditable = false;
|
||||
@tracked displayFastEditInput = false;
|
||||
@tracked fastEditInitialSelection;
|
||||
|
||||
isMouseDown = false;
|
||||
reselected = false;
|
||||
prevSelection;
|
||||
element;
|
||||
popper;
|
||||
popperPlacement = "top-start";
|
||||
popperOffset = [0, 3];
|
||||
|
||||
@bind
|
||||
hideButton() {
|
||||
this.args.quoteState.clear();
|
||||
|
||||
this.visible = false;
|
||||
this.animated = false;
|
||||
this.isFastEditable = false;
|
||||
this.displayFastEditInput = false;
|
||||
this.fastEditInitialSelection = null;
|
||||
|
||||
this.teardownSelectionListeners();
|
||||
}
|
||||
|
||||
selectionChanged() {
|
||||
if (this.displayFastEditInput) {
|
||||
this.textRange = virtualElementFromTextRange();
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection.isCollapsed) {
|
||||
if (this.visible) {
|
||||
this.hideButton();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure we selected content inside 1 post *only*
|
||||
let postId;
|
||||
for (let r = 0; r < selection.rangeCount; r++) {
|
||||
const range = selection.getRangeAt(r);
|
||||
const selectionStart =
|
||||
range.startContainer.nodeType === Node.ELEMENT_NODE
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement;
|
||||
const ancestor =
|
||||
range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
|
||||
if (!selectionStart.closest(".cooked")) {
|
||||
return;
|
||||
}
|
||||
|
||||
postId ||= ancestor.closest(".boxed, .reply")?.dataset?.postId;
|
||||
|
||||
if (!ancestor.closest(".contents") || !postId) {
|
||||
if (this.visible) {
|
||||
this.hideButton();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const _selectedElement =
|
||||
selectedNode().nodeType === Node.ELEMENT_NODE
|
||||
? selectedNode()
|
||||
: selectedNode().parentElement;
|
||||
const _selectedText = selectedText();
|
||||
const cooked =
|
||||
_selectedElement.querySelector(".cooked") ||
|
||||
_selectedElement.closest(".cooked");
|
||||
|
||||
// computing markdown takes a lot of time on long posts
|
||||
// this code attempts to compute it only when we can't fast track
|
||||
let opts = {
|
||||
full:
|
||||
selectedRange().startOffset > 0
|
||||
? false
|
||||
: _selectedText === toMarkdown(cooked.innerHTML),
|
||||
};
|
||||
|
||||
for (
|
||||
let element = _selectedElement;
|
||||
element && element.tagName !== "ARTICLE";
|
||||
element = element.parentElement
|
||||
) {
|
||||
if (element.tagName === "ASIDE" && element.classList.contains("quote")) {
|
||||
opts.username = element.dataset.username || getQuoteTitle(element);
|
||||
opts.post = element.dataset.post;
|
||||
opts.topic = element.dataset.topic;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const quoteState = this.args.quoteState;
|
||||
quoteState.selected(postId, _selectedText, opts);
|
||||
this.visible = quoteState.buffer.length > 0;
|
||||
|
||||
if (this.canEditPost) {
|
||||
const regexp = new RegExp(escapeRegExp(quoteState.buffer), "gi");
|
||||
const matches = cooked.innerHTML.match(regexp);
|
||||
const non_ascii_regex = /[^\x00-\x7F]/;
|
||||
|
||||
if (
|
||||
quoteState.buffer.length === 0 ||
|
||||
quoteState.buffer.includes("|") || // tables are too complex
|
||||
quoteState.buffer.match(/\n/g) || // linebreaks are too complex
|
||||
matches?.length > 1 || // duplicates are too complex
|
||||
non_ascii_regex.test(quoteState.buffer) // non-ascii chars break fast-edit
|
||||
) {
|
||||
this.isFastEditable = false;
|
||||
this.fastEditInitialSelection = null;
|
||||
} else if (matches?.length === 1) {
|
||||
this.isFastEditable = true;
|
||||
this.fastEditInitialSelection = quoteState.buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// avoid hard loops in quote selection unconditionally
|
||||
// this can happen if you triple click text in firefox
|
||||
if (this.prevSelection === _selectedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.prevSelection = _selectedText;
|
||||
|
||||
// on Desktop, shows the button at the beginning of the selection
|
||||
// on Mobile, shows the button at the end of the selection
|
||||
const isMobileDevice = this.site.isMobileDevice;
|
||||
const { isIOS, isAndroid, isOpera } = this.capabilities;
|
||||
const showAtEnd = isMobileDevice || isIOS || isAndroid || isOpera;
|
||||
|
||||
if (showAtEnd) {
|
||||
this.popperPlacement = "bottom-start";
|
||||
this.popperOffset = [0, 25];
|
||||
}
|
||||
|
||||
// change the position of the button
|
||||
schedule("afterRender", () => {
|
||||
if (!this.element || this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.textRange = virtualElementFromTextRange();
|
||||
this.setupSelectionListeners();
|
||||
|
||||
this.popper = createPopper(this.textRange, this.element, {
|
||||
placement: this.popperPlacement,
|
||||
modifiers: [
|
||||
{
|
||||
name: "computeStyles",
|
||||
options: {
|
||||
adaptive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: this.popperOffset,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!this.animated) {
|
||||
// We only enable CSS transitions after the initial positioning
|
||||
// otherwise the button can appear to fly in from off-screen
|
||||
next(() => (this.animated = true));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
updateRect() {
|
||||
this.textRange?.updateRect();
|
||||
}
|
||||
|
||||
setupSelectionListeners() {
|
||||
document.body.addEventListener("mouseup", this.updateRect);
|
||||
window.addEventListener("scroll", this.updateRect);
|
||||
document.scrollingElement.addEventListener("scroll", this.updateRect);
|
||||
}
|
||||
|
||||
teardownSelectionListeners() {
|
||||
document.body.removeEventListener("mouseup", this.updateRect);
|
||||
window.removeEventListener("scroll", this.updateRect);
|
||||
document.scrollingElement.removeEventListener("scroll", this.updateRect);
|
||||
}
|
||||
|
||||
@bind
|
||||
onSelectionChanged() {
|
||||
const { isWinphone, isAndroid } = this.capabilities;
|
||||
const wait = isWinphone || isAndroid ? INPUT_DELAY : 25;
|
||||
discourseDebounce(this, this.selectionChanged, wait);
|
||||
}
|
||||
|
||||
@bind
|
||||
mousedown(e) {
|
||||
this.prevSelection = null;
|
||||
this.isMouseDown = true;
|
||||
this.reselected = false;
|
||||
|
||||
// prevents fast-edit input event from triggering mousedown
|
||||
if (e.target.classList.contains("fast-edit-input")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.target.closest(".quote-button, .create, .share, .reply-new")) {
|
||||
this.hideButton();
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
mouseup(e) {
|
||||
// prevents fast-edit input event from triggering mouseup
|
||||
if (e.target.classList.contains("fast-edit-input")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.prevSelection = null;
|
||||
this.isMouseDown = false;
|
||||
this.onSelectionChanged();
|
||||
}
|
||||
|
||||
@bind
|
||||
selectionchange() {
|
||||
if (!this.isMouseDown && !this.reselected) {
|
||||
this.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didInsert(element) {
|
||||
this.element = element;
|
||||
|
||||
document.addEventListener("mousedown", this.mousedown);
|
||||
document.addEventListener("mouseup", this.mouseup);
|
||||
document.addEventListener("selectionchange", this.selectionchange);
|
||||
|
||||
this.appEvents.on("quote-button:quote", this, "insertQuote");
|
||||
this.appEvents.on("quote-button:edit", this, "toggleFastEditForm");
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this.popper?.destroy();
|
||||
|
||||
document.removeEventListener("mousedown", this.mousedown);
|
||||
document.removeEventListener("mouseup", this.mouseup);
|
||||
document.removeEventListener("selectionchange", this.selectionchange);
|
||||
|
||||
this.appEvents.off("quote-button:quote", this, "insertQuote");
|
||||
this.appEvents.off("quote-button:edit", this, "toggleFastEditForm");
|
||||
this.teardownSelectionListeners();
|
||||
}
|
||||
|
||||
get post() {
|
||||
return this.args.topic.postStream.findLoadedPost(
|
||||
this.args.quoteState.postId
|
||||
);
|
||||
}
|
||||
|
||||
get quoteSharingEnabled() {
|
||||
return (
|
||||
this.site.desktopView &&
|
||||
this.quoteSharingSources.length > 0 &&
|
||||
!this.args.topic.invisible &&
|
||||
!this.args.topic.category?.read_restricted &&
|
||||
(this.siteSettings.share_quote_visibility === "all" ||
|
||||
(this.siteSettings.share_quote_visibility === "anonymous" &&
|
||||
!this.currentUser))
|
||||
);
|
||||
}
|
||||
|
||||
get quoteSharingSources() {
|
||||
return Sharing.activeSources(
|
||||
this.siteSettings.share_quote_buttons,
|
||||
this.siteSettings.login_required || this.args.topic.isPrivateMessage
|
||||
);
|
||||
}
|
||||
|
||||
get quoteSharingShowLabel() {
|
||||
return this.quoteSharingSources.length > 1;
|
||||
}
|
||||
|
||||
get shareUrl() {
|
||||
return getAbsoluteURL(
|
||||
postUrl(this.args.topic.slug, this.args.topic.id, this.post.post_number)
|
||||
);
|
||||
}
|
||||
|
||||
get embedQuoteButton() {
|
||||
const canCreatePost = this.args.topic.details.can_create_post;
|
||||
const canReplyAsNewTopic = this.args.topic.details.can_reply_as_new_topic;
|
||||
|
||||
return (
|
||||
(canCreatePost || canReplyAsNewTopic) &&
|
||||
this.currentUser?.get("user_option.enable_quoting")
|
||||
);
|
||||
}
|
||||
|
||||
get canEditPost() {
|
||||
return this.siteSettings.enable_fast_edit && this.post?.can_edit;
|
||||
}
|
||||
|
||||
@action
|
||||
async insertQuote() {
|
||||
await this.args.selectText();
|
||||
this.hideButton();
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleFastEditForm() {
|
||||
if (this.isFastEditable) {
|
||||
if (this.site.desktopView) {
|
||||
this.displayFastEditInput = !this.displayFastEditInput;
|
||||
} else {
|
||||
this.modal.show(FastEditModal, {
|
||||
model: {
|
||||
initialValue: this.fastEditInitialSelection,
|
||||
post: this.post,
|
||||
},
|
||||
});
|
||||
this.hideButton();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ajax(`/posts/${this.post.id}`);
|
||||
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bestIndex = 0;
|
||||
const rows = result.raw.split("\n");
|
||||
|
||||
// selecting even a part of the text of a list item will include
|
||||
// "* " at the beginning of the buffer, we remove it to be able
|
||||
// to find it in row
|
||||
const buffer = fixQuotes(
|
||||
this.args.quoteState.buffer.split("\n")[0].replace(/^\* /, "")
|
||||
);
|
||||
|
||||
rows.some((row, index) => {
|
||||
if (row.length && row.includes(buffer)) {
|
||||
bestIndex = index;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
this.args.editPost(this.post);
|
||||
|
||||
document
|
||||
.querySelector("#reply-control")
|
||||
?.addEventListener("transitionend", () => {
|
||||
const textarea = document.querySelector(".d-editor-input");
|
||||
if (!textarea || this.isDestroyed || this.isDestroying) {
|
||||
return;
|
||||
}
|
||||
|
||||
// best index brings us to one row before as slice start from 1
|
||||
// we add 1 to be at the beginning of next line, unless we start from top
|
||||
setCaretPosition(
|
||||
textarea,
|
||||
rows.slice(0, bestIndex).join("\n").length + (bestIndex > 0 ? 1 : 0)
|
||||
);
|
||||
|
||||
// ensures we correctly scroll to caret and reloads composer
|
||||
// if we do another selection/edit
|
||||
textarea.blur();
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
share(source) {
|
||||
Sharing.shareSource(source, {
|
||||
url: this.shareUrl,
|
||||
title: this.args.topic.title,
|
||||
quote: window.getSelection().toString(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -20,13 +20,18 @@
|
|||
<span class="sidebar-section-header-text">
|
||||
{{@headerLinkText}}
|
||||
</span>
|
||||
|
||||
{{#if @indicatePublic}}
|
||||
<span class="sidebar-section-header-global-indicator">
|
||||
{{d-icon "globe"}}
|
||||
<DTooltip @placement="top">{{d-icon "shield-alt"}}
|
||||
{{i18n "sidebar.sections.global_section"}}
|
||||
<DTooltip
|
||||
@icon="globe"
|
||||
class="sidebar-section-header-global-indicator"
|
||||
>
|
||||
<span
|
||||
class="sidebar-section-header-global-indicator__content"
|
||||
>{{d-icon "shield-alt"}}{{i18n
|
||||
"sidebar.sections.global_section"
|
||||
}}</span>
|
||||
</DTooltip>
|
||||
</span>
|
||||
{{/if}}
|
||||
</Sidebar::SectionHeader>
|
||||
|
||||
|
|
|
@ -50,12 +50,15 @@
|
|||
<div class="summarized-on">
|
||||
<p>
|
||||
{{i18n "summary.summarized_on" date=this.summarizedOn}}
|
||||
<span>
|
||||
|
||||
<DTooltip @placements={{array "top-end"}}>
|
||||
<:trigger>
|
||||
{{d-icon "info-circle"}}
|
||||
<DTooltip @placement="top-end">
|
||||
</:trigger>
|
||||
<:content>
|
||||
{{i18n "summary.model_used" model=this.summarizedBy}}
|
||||
</:content>
|
||||
</DTooltip>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{{#if this.outdated}}
|
||||
|
|
|
@ -52,7 +52,7 @@ export default class TopicTimeline extends Component {
|
|||
}
|
||||
|
||||
@bind
|
||||
addUserTip(element) {
|
||||
addUserTip() {
|
||||
if (!this.currentUser) {
|
||||
return;
|
||||
}
|
||||
|
@ -62,7 +62,6 @@ export default class TopicTimeline extends Component {
|
|||
titleText: I18n.t("user_tips.topic_timeline.title"),
|
||||
contentText: I18n.t("user_tips.topic_timeline.content"),
|
||||
reference: document.querySelector("div.timeline-scrollarea-wrapper"),
|
||||
appendTo: element,
|
||||
placement: "left",
|
||||
});
|
||||
}
|
||||
|
|
|
@ -34,7 +34,6 @@
|
|||
<UserStatusMessage
|
||||
@status={{@user.status}}
|
||||
@showDescription={{@showStatusDescription}}
|
||||
@showTooltip={{@showStatusTooltip}}
|
||||
/>
|
||||
{{/if}}
|
||||
<span>
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
{{#if @status}}
|
||||
<span class={{concat-class "user-status-message" @class}}>
|
||||
<DTooltip
|
||||
@identifier="user-status-message-tooltip"
|
||||
class={{concat-class "user-status-message" @class}}
|
||||
>
|
||||
<:trigger>
|
||||
{{emoji @status.emoji skipTitle=true}}
|
||||
{{#if @showDescription}}
|
||||
<span class="user-status-message-description">
|
||||
{{@status.description}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{#if this.showTooltip}}
|
||||
<DTooltip>
|
||||
<div class="user-status-message-tooltip">
|
||||
</:trigger>
|
||||
<:content>
|
||||
{{emoji @status.emoji skipTitle=true}}
|
||||
<span class="user-status-tooltip-description">
|
||||
{{@status.description}}
|
||||
|
@ -18,8 +21,6 @@
|
|||
{{this.until}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</:content>
|
||||
</DTooltip>
|
||||
{{/if}}
|
||||
</span>
|
||||
{{/if}}
|
|
@ -1,21 +1,19 @@
|
|||
import Component from "@ember/component";
|
||||
import { computed } from "@ember/object";
|
||||
import Component from "@glimmer/component";
|
||||
import { until } from "discourse/lib/formatter";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class UserStatusMessage extends Component {
|
||||
tagName = "";
|
||||
showTooltip = true;
|
||||
@service currentUser;
|
||||
|
||||
@computed("status.ends_at")
|
||||
get until() {
|
||||
if (!this.status.ends_at) {
|
||||
return null;
|
||||
if (!this.args.status.ends_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timezone = this.currentUser
|
||||
? this.currentUser.user_option?.timezone
|
||||
: moment.tz.guess();
|
||||
|
||||
return until(this.status.ends_at, timezone, this.currentUser?.locale);
|
||||
return until(this.args.status.ends_at, timezone, this.currentUser?.locale);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import { htmlSafe } from "@ember/template";
|
||||
import Component from "@glimmer/component";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class UserTipContainer extends Component {
|
||||
<template>
|
||||
<div class="user-tip__container">
|
||||
<div class="user-tip__title">{{@data.titleText}}</div>
|
||||
<div class="user-tip__content">
|
||||
{{#if @data.contentHtml}}
|
||||
{{this.safeHtmlContent}}
|
||||
{{else}}
|
||||
{{@data.contentText}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if @data.onDismiss}}
|
||||
<div class="user-tip__buttons">
|
||||
<DButton
|
||||
class="btn-primary"
|
||||
@translatedLabel={{@data.buttonText}}
|
||||
@action={{this.handleDismiss}}
|
||||
@forwardEvent={{true}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
get safeHtmlContent() {
|
||||
return htmlSafe(this.args.data.contentHtml);
|
||||
}
|
||||
|
||||
@action
|
||||
handleDismiss(_, event) {
|
||||
event.preventDefault();
|
||||
this.args.close();
|
||||
this.args.data.onDismiss();
|
||||
}
|
||||
}
|
|
@ -37,7 +37,6 @@ export default class UserTip extends Component {
|
|||
buttonIcon,
|
||||
reference:
|
||||
(selector && element.parentElement.querySelector(selector)) || element,
|
||||
appendTo: element.parentElement,
|
||||
placement,
|
||||
onDismiss,
|
||||
});
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export default function noop() {
|
||||
return () => {};
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
// https://github.com/emberjs/ember.js/blob/master/packages/@ember/-internals/glimmer/lib/helpers/unique-id.ts
|
||||
export default function uniqueId() {
|
||||
return ([3e7] + -1e3 + -4e3 + -2e3 + -1e11).replace(
|
||||
/[0-3]/g,
|
||||
(a) =>
|
||||
/* eslint-disable no-bitwise */
|
||||
((a * 4) ^ ((Math.random() * 16) >> (a & 2))).toString(16)
|
||||
/* eslint-enable no-bitwise */
|
||||
);
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { showPopover } from "discourse/lib/d-popover";
|
||||
|
||||
export default {
|
||||
initialize() {
|
||||
["click", "mouseover"].forEach((eventType) => {
|
||||
document.addEventListener(eventType, (e) => {
|
||||
if (e.target.dataset.tooltip || e.target.dataset.popover) {
|
||||
showPopover(e, {
|
||||
interactive: false,
|
||||
content: (reference) =>
|
||||
reference.dataset.tooltip || reference.dataset.popover,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
|
@ -1,74 +1,13 @@
|
|||
import tippy from "tippy.js";
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import deprecated from "discourse-common/lib/deprecated";
|
||||
|
||||
export const hideOnEscapePlugin = {
|
||||
name: "hideOnEscape",
|
||||
|
||||
defaultValue: true,
|
||||
|
||||
fn({ hide }) {
|
||||
function onKeyDown(event) {
|
||||
if (event.keyCode === 27) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onShow() {
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
},
|
||||
onHide() {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function isPopoverShown(event) {
|
||||
const instance = event.target._tippy;
|
||||
return instance?.state.isShown;
|
||||
export function showPopover() {
|
||||
deprecated("`showPopover` is deprecated. Use tooltip service instead.", {
|
||||
id: "show-popover",
|
||||
});
|
||||
}
|
||||
|
||||
// legacy, shouldn't be needed with setup
|
||||
export function hidePopover(event) {
|
||||
const instance = event.target._tippy;
|
||||
|
||||
if (instance?.state.isShown) {
|
||||
instance.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// legacy, setup() should be used
|
||||
export function showPopover(event, options = {}) {
|
||||
const instance = event.target._tippy ?? setup(event.target, options);
|
||||
|
||||
if (instance.state.isShown) {
|
||||
instance.hide();
|
||||
} else {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
// target is the element that triggers the display of the popover
|
||||
// options accepts all tippy.js options as defined in their documentation
|
||||
// https://atomiks.github.io/tippyjs/v6/all-props/
|
||||
export default function setup(target, options) {
|
||||
const tippyOptions = Object.assign(
|
||||
{
|
||||
arrow: iconHTML("tippy-rounded-arrow"),
|
||||
content: options.textContent || options.htmlContent,
|
||||
allowHTML: options?.htmlContent?.length,
|
||||
trigger: "mouseenter click",
|
||||
hideOnClick: true,
|
||||
zIndex: 1400,
|
||||
plugins: [hideOnEscapePlugin],
|
||||
touch: ["hold", 500],
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
// legacy support delete tippyOptions.textContent;
|
||||
delete tippyOptions.htmlContent;
|
||||
|
||||
return tippy(target, tippyOptions);
|
||||
export function hidePopover() {
|
||||
deprecated("`hidePopover` is deprecated. Use tooltip service instead.", {
|
||||
id: "hide-popover",
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
import tippy from "tippy.js";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
|
||||
export class DTooltip {
|
||||
#tippyInstance;
|
||||
|
||||
constructor(target, content) {
|
||||
this.#tippyInstance = this.#initTippy(target, content);
|
||||
if (this.#hasTouchCapabilities()) {
|
||||
window.addEventListener("scroll", this.onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.#hasTouchCapabilities()) {
|
||||
window.removeEventListener("scroll", this.onScroll);
|
||||
}
|
||||
this.#tippyInstance.destroy();
|
||||
}
|
||||
|
||||
@bind
|
||||
onScroll() {
|
||||
discourseDebounce(() => this.#tippyInstance.hide(), 10);
|
||||
}
|
||||
|
||||
#initTippy(target, content) {
|
||||
return tippy(target, {
|
||||
interactive: false,
|
||||
content,
|
||||
trigger: this.#hasTouchCapabilities() ? "click" : "mouseenter",
|
||||
theme: "d-tooltip",
|
||||
arrow: false,
|
||||
placement: "bottom-start",
|
||||
onTrigger: this.#stopPropagation,
|
||||
onUntrigger: this.#stopPropagation,
|
||||
});
|
||||
}
|
||||
|
||||
#hasTouchCapabilities() {
|
||||
return navigator.maxTouchPoints > 1 || "ontouchstart" in window;
|
||||
}
|
||||
|
||||
#stopPropagation(instance, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import EmberObject from "@ember/object";
|
|||
import { defaultHomepage } from "discourse/lib/utilities";
|
||||
import { guidFor } from "@ember/object/internals";
|
||||
import { withoutPrefix } from "discourse-common/lib/get-url";
|
||||
|
||||
let popstateFired = false;
|
||||
const supportsHistoryState = window.history && "state" in window.history;
|
||||
const popstateCallbacks = [];
|
||||
|
|
|
@ -134,7 +134,8 @@ import { addBeforeAuthCompleteCallback } from "discourse/instance-initializers/a
|
|||
// based on Semantic Versioning 2.0.0. Please update the changelog at
|
||||
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
||||
export const PLUGIN_API_VERSION = "1.11.0";
|
||||
|
||||
export const PLUGIN_API_VERSION = "1.12.0";
|
||||
|
||||
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
||||
function canModify(klass, type, resolverName, changes) {
|
||||
|
@ -640,6 +641,30 @@ class PluginApi {
|
|||
addButton(name, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new button in the post admin menu.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```
|
||||
* api.addPostAdminMenuButton((name, attrs) => {
|
||||
* return {
|
||||
* action: () => {
|
||||
* alert('You clicked on the coffee button!');
|
||||
* },
|
||||
* icon: 'coffee',
|
||||
* className: 'hot-coffee',
|
||||
* title: 'coffee.title',
|
||||
* };
|
||||
* });
|
||||
* ```
|
||||
**/
|
||||
addPostAdminMenuButton(name, callback) {
|
||||
this.container
|
||||
.lookup("service:admin-post-menu-buttons")
|
||||
.addButton(name, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove existing button below a post with your plugin.
|
||||
*
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
import { UserStatusMessage } from "discourse/lib/user-status-message";
|
||||
import { guidFor } from "@ember/object/internals";
|
||||
|
||||
let userStatusMessages = [];
|
||||
const userStatusMessages = {};
|
||||
|
||||
export function updateUserStatusOnMention(mention, status) {
|
||||
export function updateUserStatusOnMention(owner, mention, status) {
|
||||
removeStatus(mention);
|
||||
if (status) {
|
||||
const userStatusMessage = new UserStatusMessage(status);
|
||||
userStatusMessages.push(userStatusMessage);
|
||||
const userStatusMessage = new UserStatusMessage(owner, status);
|
||||
userStatusMessages[guidFor(mention)] = userStatusMessage;
|
||||
mention.appendChild(userStatusMessage.html);
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyUserStatusOnMentions() {
|
||||
userStatusMessages.forEach((instance) => {
|
||||
Object.values(userStatusMessages).forEach((instance) => {
|
||||
instance.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function removeStatus(mention) {
|
||||
userStatusMessages[guidFor(mention)]?.destroy();
|
||||
mention.querySelector("span.user-status-message")?.remove();
|
||||
}
|
||||
|
|
|
@ -1,19 +1,27 @@
|
|||
import { DTooltip } from "discourse/lib/d-tooltip";
|
||||
import { emojiUnescape } from "discourse/lib/text";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import { until } from "discourse/lib/formatter";
|
||||
import User from "discourse/models/user";
|
||||
import { setOwner } from "@ember/application";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export class UserStatusMessage {
|
||||
#dTooltip;
|
||||
@service tooltip;
|
||||
|
||||
constructor(status, opts) {
|
||||
html = null;
|
||||
content = null;
|
||||
|
||||
constructor(owner, status, opts) {
|
||||
setOwner(this, owner);
|
||||
this.html = this.#statusHtml(status, opts);
|
||||
this.#dTooltip = new DTooltip(this.html, this.#tooltipHtml(status));
|
||||
this.content = this.#tooltipHtml(status);
|
||||
this.tooltipInstance = this.tooltip.register(this.html, {
|
||||
content: this.content,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#dTooltip.destroy();
|
||||
this.tooltipInstance.destroy();
|
||||
}
|
||||
|
||||
#emojiHtml(emojiName) {
|
||||
|
|
|
@ -2,11 +2,11 @@ import { UserStatusMessage } from "discourse/lib/user-status-message";
|
|||
|
||||
let userStatusMessages = [];
|
||||
|
||||
export function initUserStatusHtml(users) {
|
||||
export function initUserStatusHtml(owner, users) {
|
||||
(users || []).forEach((user, index) => {
|
||||
if (user.status) {
|
||||
user.index = index;
|
||||
const userStatusMessage = new UserStatusMessage(user.status, {
|
||||
const userStatusMessage = new UserStatusMessage(owner, user.status, {
|
||||
showDescription: true,
|
||||
});
|
||||
user.statusHtml = userStatusMessage.html;
|
||||
|
|
|
@ -19,6 +19,10 @@ class VirtualElementFromTextRange {
|
|||
return this.rect;
|
||||
}
|
||||
|
||||
getClientRects() {
|
||||
return this.range.getClientRects();
|
||||
}
|
||||
|
||||
get clientWidth() {
|
||||
return this.rect.width;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ loaderShim("@ember-compat/tracked-built-ins", () =>
|
|||
importSync("@ember-compat/tracked-built-ins")
|
||||
);
|
||||
loaderShim("@popperjs/core", () => importSync("@popperjs/core"));
|
||||
loaderShim("@floating-ui/dom", () => importSync("@floating-ui/dom"));
|
||||
loaderShim("@uppy/aws-s3", () => importSync("@uppy/aws-s3"));
|
||||
loaderShim("@uppy/aws-s3-multipart", () =>
|
||||
importSync("@uppy/aws-s3-multipart")
|
||||
|
@ -27,6 +28,5 @@ loaderShim("ember-modifier", () => importSync("ember-modifier"));
|
|||
loaderShim("handlebars", () => importSync("handlebars"));
|
||||
loaderShim("js-yaml", () => importSync("js-yaml"));
|
||||
loaderShim("message-bus-client", () => importSync("message-bus-client"));
|
||||
loaderShim("tippy.js", () => importSync("tippy.js"));
|
||||
loaderShim("virtual-dom", () => importSync("virtual-dom"));
|
||||
loaderShim("xss", () => importSync("xss"));
|
||||
|
|
|
@ -7,7 +7,6 @@ import { inject as service } from "@ember/service";
|
|||
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import { headerOffset } from "discourse/lib/offset-calculator";
|
||||
|
||||
const DEFAULT_SELECTOR = "#main-outlet";
|
||||
|
@ -24,6 +23,7 @@ export function resetCardClickListenerSelector() {
|
|||
|
||||
export default Mixin.create({
|
||||
router: service(),
|
||||
menu: service(),
|
||||
|
||||
elementId: null, //click detection added for data-{elementId}
|
||||
triggeringLinkClass: null, //the <a> classname where this card should appear
|
||||
|
@ -39,7 +39,7 @@ export default Mixin.create({
|
|||
post: null,
|
||||
isDocked: false,
|
||||
|
||||
_popperReference: null,
|
||||
_menuInstance: null,
|
||||
|
||||
_show(username, target, event) {
|
||||
// No user card for anon
|
||||
|
@ -85,7 +85,6 @@ export default Mixin.create({
|
|||
this.appEvents.trigger("user-card:show", { username });
|
||||
this._showCallback(username, $(target)).then((user) => {
|
||||
this.appEvents.trigger("user-card:after-show", { user });
|
||||
this._positionCard($(target), event);
|
||||
});
|
||||
|
||||
// We bind scrolling on mobile after cards are shown to hide them if user scrolls
|
||||
|
@ -189,57 +188,35 @@ export default Mixin.create({
|
|||
return this._show($target.text().replace(/^@/, ""), $target);
|
||||
},
|
||||
|
||||
_positionCard(target, event) {
|
||||
this._popperReference?.destroy();
|
||||
|
||||
schedule("afterRender", () => {
|
||||
_positionCard(target) {
|
||||
schedule("afterRender", async () => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.site.desktopView) {
|
||||
const avatarOverflowSize = 44;
|
||||
this._popperReference = createPopper(target[0], this.element, {
|
||||
placement: "right",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
if (this.site.desktopView) {
|
||||
this._menuInstance = await this.menu.show(target[0], {
|
||||
content: this.element,
|
||||
autoUpdate: false,
|
||||
identifier: "card",
|
||||
padding: {
|
||||
top: headerOffset() + avatarOverflowSize,
|
||||
top: 10 + avatarOverflowSize + headerOffset(),
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "eventListeners", enabled: false },
|
||||
{ name: "offset", options: { offset: [10, 10] } },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
this._popperReference = createPopper(target[0], this.element, {
|
||||
modifiers: [
|
||||
{ name: "eventListeners", enabled: false },
|
||||
{
|
||||
name: "computeStyles",
|
||||
enabled: true,
|
||||
fn({ state }) {
|
||||
// mimics our modal top of the screen positioning
|
||||
state.styles.popper = {
|
||||
...state.styles.popper,
|
||||
position: "fixed",
|
||||
left: `${
|
||||
(window.innerWidth - state.rects.popper.width) / 2
|
||||
}px`,
|
||||
top: "10%",
|
||||
transform: "translateY(-10%)",
|
||||
};
|
||||
|
||||
return state;
|
||||
this._menuInstance = await this.menu.show(target[0], {
|
||||
content: this.element,
|
||||
strategy: "fixed",
|
||||
identifier: "card",
|
||||
computePosition: (content) => {
|
||||
content.style.left = "10px";
|
||||
content.style.right = "10px";
|
||||
content.style.top = 10 + avatarOverflowSize + "px";
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -261,11 +238,12 @@ export default Mixin.create({
|
|||
@bind
|
||||
_hide() {
|
||||
if (!this.visible) {
|
||||
$(this.element).css({ left: -9999, top: -9999 });
|
||||
if (this.site.mobileView) {
|
||||
$(".card-cloak").addClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
this._menuInstance?.destroy();
|
||||
},
|
||||
|
||||
_close() {
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import Modifier from "ember-modifier";
|
||||
import { registerDestructor } from "@ember/destroyable";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
const FOCUSABLE_ELEMENTS =
|
||||
'details:not(.is-disabled) summary, [autofocus], a, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export default class TrapTabModifier extends Modifier {
|
||||
element = null;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
}
|
||||
|
||||
modify(element, [options]) {
|
||||
this.preventScroll = options?.preventScroll ?? true;
|
||||
this.orignalElement = element;
|
||||
this.element = element.querySelector(".modal-inner-container") || element;
|
||||
this.orignalElement.addEventListener("keydown", this.trapTab);
|
||||
|
||||
// on first trap we don't allow to focus modal-close
|
||||
// and apply manual focus only if we don't have any autofocus element
|
||||
const autofocusedElement = this.element.querySelector("[autofocus]");
|
||||
|
||||
if (!autofocusedElement || document.activeElement !== autofocusedElement) {
|
||||
// if there's not autofocus, or the activeElement, is not the autofocusable element
|
||||
// attempt to focus the first of the focusable elements or just the modal-body
|
||||
// to make it possible to scroll with arrow down/up
|
||||
(
|
||||
autofocusedElement ||
|
||||
this.element.querySelector(
|
||||
FOCUSABLE_ELEMENTS + ", button:not(.modal-close)"
|
||||
) ||
|
||||
this.element.querySelector(".modal-body")
|
||||
)?.focus({
|
||||
preventScroll: this.preventScroll,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
trapTab(event) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = FOCUSABLE_ELEMENTS + ", button:enabled";
|
||||
const firstFocusableElement = this.element.querySelector(focusableElements);
|
||||
const focusableContent = this.element.querySelectorAll(focusableElements);
|
||||
|
||||
const lastFocusableElement = focusableContent[focusableContent.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === firstFocusableElement) {
|
||||
lastFocusableElement?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusableElement) {
|
||||
event.preventDefault();
|
||||
|
||||
(
|
||||
this.element.querySelector(".modal-close") || firstFocusableElement
|
||||
)?.focus({ preventScroll: this.preventScroll });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.orignalElement.removeEventListener("keydown", this.trapTab);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import Service from "@ember/service";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
|
||||
export default class AdminPostMenuButtons extends Service {
|
||||
@tracked callbacks = [];
|
||||
|
||||
addButton(callback) {
|
||||
this.callbacks.push(callback);
|
||||
}
|
||||
}
|
|
@ -1,16 +1,20 @@
|
|||
import Service from "@ember/service";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import Service, { inject as service } from "@ember/service";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import I18n from "I18n";
|
||||
import { escape } from "pretty-text/sanitizer";
|
||||
import tippy from "tippy.js";
|
||||
import isElementInViewport from "discourse/lib/is-element-in-viewport";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import DTooltipInstance from "float-kit/lib/d-tooltip-instance";
|
||||
import UserTipContainer from "discourse/components/user-tip-container";
|
||||
|
||||
const TIPPY_DELAY = 500;
|
||||
const DELAY = 500;
|
||||
|
||||
export default class UserTips extends Service {
|
||||
@service tooltip;
|
||||
|
||||
#instances = new Map();
|
||||
|
||||
/**
|
||||
|
@ -20,7 +24,6 @@ export default class UserTips extends Service {
|
|||
* @param {string} [options.buttonLabel]
|
||||
* @param {string} [options.buttonIcon]
|
||||
* @param {string} [options.placement]
|
||||
* @param {Element} [options.appendTo]
|
||||
* @param {string} [options.content]
|
||||
* @param {string} [options.contentText]
|
||||
* @param {string} [options.titleText]
|
||||
|
@ -51,42 +54,19 @@ export default class UserTips extends Service {
|
|||
|
||||
this.#instances.set(
|
||||
options.id,
|
||||
tippy(options.reference, {
|
||||
hideOnClick: false,
|
||||
trigger: "manual",
|
||||
theme: "user-tip",
|
||||
zIndex: "", // reset z-index to use inherited value from the parent
|
||||
duration: TIPPY_DELAY,
|
||||
|
||||
arrow: iconHTML("tippy-rounded-arrow"),
|
||||
new DTooltipInstance(getOwner(this), options.reference, {
|
||||
identifier: "user-tip",
|
||||
interactive: true,
|
||||
closeOnScroll: false,
|
||||
closeOnClickOutside: false,
|
||||
placement: options.placement,
|
||||
appendTo: options.appendTo,
|
||||
|
||||
interactive: true, // for buttons in content
|
||||
allowHTML: true,
|
||||
|
||||
content:
|
||||
options.content ||
|
||||
`<div class='user-tip__container'>
|
||||
<div class='user-tip__title'>${escape(options.titleText)}</div>
|
||||
<div class='user-tip__content'>${
|
||||
options.contentHtml || escape(options.contentText)
|
||||
}</div>
|
||||
<div class='user-tip__buttons'>
|
||||
<button class="btn btn-primary">${buttonText}</button>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
onCreate(tippyInstance) {
|
||||
// Used to set correct z-index property on root tippy element
|
||||
tippyInstance.popper.classList.add("user-tip");
|
||||
|
||||
tippyInstance.popper
|
||||
.querySelector(".btn")
|
||||
.addEventListener("click", (event) => {
|
||||
options.onDismiss?.();
|
||||
event.preventDefault();
|
||||
});
|
||||
component: UserTipContainer,
|
||||
data: {
|
||||
titleText: escape(options.titleText),
|
||||
contentHtml: options.contentHtml || null,
|
||||
contentText: options.contentText ? escape(options.contentText) : null,
|
||||
onDismiss: options.onDismiss,
|
||||
buttonText,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
@ -95,7 +75,7 @@ export default class UserTips extends Service {
|
|||
}
|
||||
|
||||
hideTip(userTipId, force = false) {
|
||||
// Tippy instances are not destroyed immediately because sometimes there
|
||||
// Instances are not destroyed immediately because sometimes their
|
||||
// user tip is recreated immediately. This happens when Ember components
|
||||
// are re-rendered because a parent component has changed
|
||||
|
||||
|
@ -113,7 +93,7 @@ export default class UserTips extends Service {
|
|||
this.#destroyInstance(this.#instances.get(userTipId));
|
||||
this.#instances.delete(userTipId);
|
||||
this.showNextTip();
|
||||
}, TIPPY_DELAY);
|
||||
}, DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,7 +107,7 @@ export default class UserTips extends Service {
|
|||
showNextTip() {
|
||||
// Return early if a user tip is already visible and it is in viewport
|
||||
for (const tip of this.#instances.values()) {
|
||||
if (tip.state.isVisible && isElementInViewport(tip.reference)) {
|
||||
if (tip.expanded && isElementInViewport(tip.trigger)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -135,8 +115,9 @@ export default class UserTips extends Service {
|
|||
// Otherwise, try to find a user tip in the viewport
|
||||
let visibleTip;
|
||||
for (const tip of this.#instances.values()) {
|
||||
if (isElementInViewport(tip.reference)) {
|
||||
if (isElementInViewport(tip.trigger)) {
|
||||
visibleTip = tip;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -177,20 +158,18 @@ export default class UserTips extends Service {
|
|||
|
||||
#showInstance(instance) {
|
||||
if (isTesting()) {
|
||||
instance.show();
|
||||
this.tooltip.show(instance);
|
||||
} else if (!instance.showTimer) {
|
||||
instance.showTimer = discourseLater(() => {
|
||||
instance.showTimer = null;
|
||||
if (!instance.state.isDestroyed) {
|
||||
instance.show();
|
||||
}
|
||||
}, TIPPY_DELAY);
|
||||
this.tooltip.show(instance);
|
||||
}, DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
#hideInstance(instance) {
|
||||
cancel(instance.showTimer);
|
||||
instance.showTimer = null;
|
||||
instance.hide();
|
||||
this.tooltip.close(instance);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -106,3 +106,7 @@
|
|||
<FooterNav />
|
||||
{{/if}}
|
||||
</DiscourseRoot>
|
||||
|
||||
<DInlineMenu />
|
||||
<DInlineTooltip />
|
||||
<DToasts />
|
|
@ -593,7 +593,7 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
<QuoteButton
|
||||
<PostTextSelection
|
||||
@quoteState={{this.quoteState}}
|
||||
@selectText={{action "selectText"}}
|
||||
@editPost={{action "editPost"}}
|
||||
|
|
|
@ -163,7 +163,6 @@ createWidget("header-notifications", {
|
|||
reference: document
|
||||
.querySelector(".d-header .badge-notification")
|
||||
?.parentElement?.querySelector(".avatar"),
|
||||
appendTo: document.querySelector(".d-header"),
|
||||
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
|
|
@ -1,185 +1,4 @@
|
|||
import { ButtonClass } from "discourse/widgets/button";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import { h } from "virtual-dom";
|
||||
|
||||
createWidget(
|
||||
"post-admin-menu-button",
|
||||
Object.assign(ButtonClass, { tagName: "li.btn" })
|
||||
);
|
||||
|
||||
createWidget("post-admin-menu-button", {
|
||||
tagName: "li",
|
||||
|
||||
html(attrs) {
|
||||
return this.attach("button", {
|
||||
className: attrs.className,
|
||||
action: attrs.action,
|
||||
url: attrs.url,
|
||||
icon: attrs.icon,
|
||||
label: attrs.label,
|
||||
secondaryAction: attrs.secondaryAction,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function buildManageButtons(attrs, currentUser, siteSettings) {
|
||||
if (!currentUser) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let contents = [];
|
||||
if (currentUser.staff) {
|
||||
contents.push({
|
||||
icon: "list",
|
||||
className: "popup-menu-button moderation-history",
|
||||
label: "review.moderation_history",
|
||||
url: `/review?topic_id=${attrs.topicId}&status=all`,
|
||||
});
|
||||
}
|
||||
|
||||
if (attrs.canPermanentlyDelete) {
|
||||
contents.push({
|
||||
icon: "trash-alt",
|
||||
className: "popup-menu-button permanently-delete",
|
||||
label: "post.controls.permanently_delete",
|
||||
action: "permanentlyDeletePost",
|
||||
});
|
||||
}
|
||||
|
||||
if (!attrs.isWhisper && currentUser.staff) {
|
||||
const buttonAtts = {
|
||||
action: "togglePostType",
|
||||
icon: "shield-alt",
|
||||
className: "popup-menu-button toggle-post-type",
|
||||
};
|
||||
|
||||
if (attrs.isModeratorAction) {
|
||||
buttonAtts.label = "post.controls.revert_to_regular";
|
||||
} else {
|
||||
buttonAtts.label = "post.controls.convert_to_moderator";
|
||||
}
|
||||
contents.push(buttonAtts);
|
||||
}
|
||||
|
||||
if (attrs.canEditStaffNotes) {
|
||||
contents.push({
|
||||
icon: "user-shield",
|
||||
label: attrs.notice
|
||||
? "post.controls.change_post_notice"
|
||||
: "post.controls.add_post_notice",
|
||||
action: "changeNotice",
|
||||
className: attrs.notice
|
||||
? "popup-menu-button change-notice"
|
||||
: "popup-menu-button add-notice",
|
||||
});
|
||||
}
|
||||
|
||||
if (currentUser.staff && attrs.hidden) {
|
||||
contents.push({
|
||||
icon: "far-eye",
|
||||
label: "post.controls.unhide",
|
||||
action: "unhidePost",
|
||||
className: "popup-menu-button unhide-post",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
currentUser.admin ||
|
||||
(siteSettings.moderators_change_post_ownership && currentUser.staff)
|
||||
) {
|
||||
contents.push({
|
||||
icon: "user",
|
||||
label: "post.controls.change_owner",
|
||||
action: "changePostOwner",
|
||||
className: "popup-menu-button change-owner",
|
||||
});
|
||||
}
|
||||
|
||||
if (attrs.user_id && currentUser.staff) {
|
||||
if (siteSettings.enable_badges) {
|
||||
contents.push({
|
||||
icon: "certificate",
|
||||
label: "post.controls.grant_badge",
|
||||
action: "grantBadge",
|
||||
className: "popup-menu-button grant-badge",
|
||||
});
|
||||
}
|
||||
|
||||
if (attrs.locked) {
|
||||
contents.push({
|
||||
icon: "unlock",
|
||||
label: "post.controls.unlock_post",
|
||||
action: "unlockPost",
|
||||
title: "post.controls.unlock_post_description",
|
||||
className: "popup-menu-button unlock-post",
|
||||
});
|
||||
} else {
|
||||
contents.push({
|
||||
icon: "lock",
|
||||
label: "post.controls.lock_post",
|
||||
action: "lockPost",
|
||||
title: "post.controls.lock_post_description",
|
||||
className: "popup-menu-button lock-post",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs.canManage || attrs.canWiki) {
|
||||
if (attrs.wiki) {
|
||||
contents.push({
|
||||
action: "toggleWiki",
|
||||
label: "post.controls.unwiki",
|
||||
icon: "far-edit",
|
||||
className: "popup-menu-button wiki wikied",
|
||||
});
|
||||
} else {
|
||||
contents.push({
|
||||
action: "toggleWiki",
|
||||
label: "post.controls.wiki",
|
||||
icon: "far-edit",
|
||||
className: "popup-menu-button wiki",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs.canPublishPage) {
|
||||
contents.push({
|
||||
icon: "file",
|
||||
label: "post.controls.publish_page",
|
||||
action: "showPagePublish",
|
||||
className: "popup-menu-button publish-page",
|
||||
});
|
||||
}
|
||||
|
||||
if (attrs.canManage) {
|
||||
contents.push({
|
||||
icon: "sync-alt",
|
||||
label: "post.controls.rebake",
|
||||
action: "rebakePost",
|
||||
className: "popup-menu-button rebuild-html",
|
||||
});
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
export default createWidget("post-admin-menu", {
|
||||
tagName: "div.post-admin-menu.popup-menu",
|
||||
|
||||
html() {
|
||||
const contents = [];
|
||||
|
||||
buildManageButtons(this.attrs, this.currentUser, this.siteSettings).forEach(
|
||||
(b) => {
|
||||
b.secondaryAction = "closeAdminMenu";
|
||||
contents.push(this.attach("post-admin-menu-button", b));
|
||||
}
|
||||
);
|
||||
|
||||
return h("ul", contents);
|
||||
},
|
||||
|
||||
clickOutside() {
|
||||
this.sendWidgetAction("closeAdminMenu");
|
||||
},
|
||||
});
|
||||
// placeholder for now
|
||||
export default createWidget("post-admin-menu", {});
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
destroyUserStatusOnMentions,
|
||||
updateUserStatusOnMention,
|
||||
} from "discourse/lib/update-user-status-on-mention";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
|
||||
let _beforeAdoptDecorators = [];
|
||||
let _afterAdoptDecorators = [];
|
||||
|
@ -396,7 +397,7 @@ export default class PostCooked {
|
|||
const mentions = postElement.querySelectorAll(`a.mention[href="${href}"]`);
|
||||
|
||||
mentions.forEach((mention) => {
|
||||
updateUserStatusOnMention(mention, user.status);
|
||||
updateUserStatusOnMention(getOwner(this._post()), mention, user.status);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@ import {
|
|||
} from "discourse/models/bookmark";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import DeleteTopicDisallowedModal from "discourse/components/modal/delete-topic-disallowed";
|
||||
import RenderGlimmer from "discourse/widgets/render-glimmer";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import AdminPostMenu from "discourse/components/admin-post-menu";
|
||||
|
||||
const LIKE_ACTION = 2;
|
||||
const VIBRATE_DURATION = 5;
|
||||
|
@ -403,11 +406,13 @@ registerButton("admin", (attrs) => {
|
|||
if (!attrs.canManage && !attrs.canWiki && !attrs.canEditStaffNotes) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
action: "openAdminMenu",
|
||||
title: "post.controls.admin",
|
||||
className: "show-post-admin-menu",
|
||||
icon: "wrench",
|
||||
sendActionEvent: true,
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -464,7 +469,7 @@ function _replaceButton(buttons, find, replace) {
|
|||
|
||||
export default createWidget("post-menu", {
|
||||
tagName: "section.post-menu-area.clearfix",
|
||||
services: ["modal"],
|
||||
services: ["modal", "menu"],
|
||||
|
||||
settings: {
|
||||
collapseButtons: true,
|
||||
|
@ -477,27 +482,67 @@ export default createWidget("post-menu", {
|
|||
collapsed: true,
|
||||
likedUsers: [],
|
||||
readers: [],
|
||||
adminVisible: false,
|
||||
};
|
||||
},
|
||||
|
||||
buildKey: (attrs) => `post-menu-${attrs.id}`,
|
||||
|
||||
attachButton(name) {
|
||||
let buttonAtts = buildButton(name, this);
|
||||
let buttonAttrs = buildButton(name, this);
|
||||
|
||||
if (buttonAttrs?.component) {
|
||||
return [
|
||||
new RenderGlimmer(
|
||||
this,
|
||||
buttonAttrs.tagName,
|
||||
hbs`<@data.component
|
||||
@permanentlyDeletePost={{@data.permanentlyDeletePost}}
|
||||
@lockPost={{@data.lockPost}}
|
||||
@unlockPost={{@data.unlockPost}}
|
||||
@grantBadge={{@data.grantBadge}}
|
||||
@rebakePost={{@data.rebakePost}}
|
||||
@toggleWiki={{@data.toggleWiki}}
|
||||
@changePostOwner={{@data.changePostOwner}}
|
||||
@changeNotice={{@data.changeNotice}}
|
||||
@togglePostType={{@data.togglePostType}}
|
||||
@unhidePost={{@data.unhidePost}}
|
||||
@showPagePublish={{@data.showPagePublish}}
|
||||
@post={{@data.post}}
|
||||
@transformedPost={{@data.transformedPost}}
|
||||
@scheduleRerender={{@data.scheduleRerender}}
|
||||
/>`,
|
||||
{
|
||||
component: buttonAttrs.component,
|
||||
transformedPost: this.attrs,
|
||||
post: this.findAncestorModel(),
|
||||
permanentlyDeletePost: () =>
|
||||
this.sendWidgetAction("permanentlyDeletePost"),
|
||||
lockPost: () => this.sendWidgetAction("lockPost"),
|
||||
unlockPost: () => this.sendWidgetAction("unlockPost"),
|
||||
grantBadge: () => this.sendWidgetAction("grantBadge"),
|
||||
rebakePost: () => this.sendWidgetAction("rebakePost"),
|
||||
toggleWiki: () => this.sendWidgetAction("toggleWiki"),
|
||||
changePostOwner: () => this.sendWidgetAction("changePostOwner"),
|
||||
changeNotice: () => this.sendWidgetAction("changeNotice"),
|
||||
togglePostType: () => this.sendWidgetAction("togglePostType"),
|
||||
scheduleRerender: () => this.scheduleRerender(),
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// If the button is replaced via the plugin API, we need to render the
|
||||
// replacement rather than a button
|
||||
if (buttonAtts?.replaced) {
|
||||
return this.attach(buttonAtts.name, buttonAtts.attrs);
|
||||
if (buttonAttrs?.replaced) {
|
||||
return this.attach(buttonAttrs.name, buttonAttrs.attrs);
|
||||
}
|
||||
|
||||
if (buttonAtts) {
|
||||
let button = this.attach(this.settings.buttonType, buttonAtts);
|
||||
if (buttonAtts.before) {
|
||||
let before = this.attachButton(buttonAtts.before);
|
||||
if (buttonAttrs) {
|
||||
let button = this.attach(this.settings.buttonType, buttonAttrs);
|
||||
if (buttonAttrs.before) {
|
||||
let before = this.attachButton(buttonAttrs.before);
|
||||
return h("div.double-button", [before, button]);
|
||||
} else if (buttonAtts.addContainer) {
|
||||
} else if (buttonAttrs.addContainer) {
|
||||
return h("div.double-button", [button]);
|
||||
}
|
||||
|
||||
|
@ -590,18 +635,18 @@ export default createWidget("post-menu", {
|
|||
}
|
||||
|
||||
if (shouldAddButton && builder) {
|
||||
const buttonAtts = builder(
|
||||
const buttonAttrs = builder(
|
||||
attrs,
|
||||
this.state,
|
||||
this.siteSettings,
|
||||
this.settings,
|
||||
this.currentUser
|
||||
);
|
||||
if (buttonAtts) {
|
||||
const { position, beforeButton, afterButton } = buttonAtts;
|
||||
delete buttonAtts.position;
|
||||
if (buttonAttrs) {
|
||||
const { position, beforeButton, afterButton } = buttonAttrs;
|
||||
delete buttonAttrs.position;
|
||||
|
||||
let button = this.attach(this.settings.buttonType, buttonAtts);
|
||||
let button = this.attach(this.settings.buttonType, buttonAttrs);
|
||||
|
||||
const content = [];
|
||||
if (beforeButton) {
|
||||
|
@ -666,9 +711,6 @@ export default createWidget("post-menu", {
|
|||
];
|
||||
|
||||
postControls.push(h("div.actions", controlsButtons));
|
||||
if (state.adminVisible) {
|
||||
postControls.push(this.attach("post-admin-menu", attrs));
|
||||
}
|
||||
|
||||
const contents = [
|
||||
h(
|
||||
|
@ -728,12 +770,28 @@ export default createWidget("post-menu", {
|
|||
return contents;
|
||||
},
|
||||
|
||||
openAdminMenu() {
|
||||
this.state.adminVisible = true;
|
||||
openAdminMenu(event) {
|
||||
this.menu.show(event.target, {
|
||||
identifier: "admin-post-menu",
|
||||
component: AdminPostMenu,
|
||||
data: {
|
||||
scheduleRerender: this.scheduleRerender.bind(this),
|
||||
transformedPost: this.attrs,
|
||||
post: this.findAncestorModel(),
|
||||
permanentlyDeletePost: () =>
|
||||
this.sendWidgetAction("permanentlyDeletePost"),
|
||||
lockPost: () => this.sendWidgetAction("lockPost"),
|
||||
unlockPost: () => this.sendWidgetAction("unlockPost"),
|
||||
grantBadge: () => this.sendWidgetAction("grantBadge"),
|
||||
rebakePost: () => this.sendWidgetAction("rebakePost"),
|
||||
toggleWiki: () => this.sendWidgetAction("toggleWiki"),
|
||||
changePostOwner: () => this.sendWidgetAction("changePostOwner"),
|
||||
changeNotice: () => this.sendWidgetAction("changeNotice"),
|
||||
togglePostType: () => this.sendWidgetAction("togglePostType"),
|
||||
unhidePost: () => this.sendWidgetAction("unhidePost"),
|
||||
showPagePublish: () => this.sendWidgetAction("showPagePublish"),
|
||||
},
|
||||
|
||||
closeAdminMenu() {
|
||||
this.state.adminVisible = false;
|
||||
});
|
||||
},
|
||||
|
||||
showDeleteTopicModal() {
|
||||
|
|
|
@ -991,13 +991,9 @@ export default createWidget("post", {
|
|||
|
||||
this.currentUser.showUserTip({
|
||||
id: "post_menu",
|
||||
|
||||
titleText: I18n.t("user_tips.post_menu.title"),
|
||||
contentText: I18n.t("user_tips.post_menu.content"),
|
||||
|
||||
reference,
|
||||
appendTo: reference?.closest(".post-controls"),
|
||||
|
||||
placement: "top",
|
||||
});
|
||||
},
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
"@embroider/core": "^3.2.1",
|
||||
"@embroider/macros": "^1.13.1",
|
||||
"@embroider/webpack": "^3.1.5",
|
||||
"@floating-ui/dom": "^1.5.0",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
@ -98,10 +99,10 @@
|
|||
"qunit-dom": "^2.0.0",
|
||||
"sass": "^1.66.1",
|
||||
"select-kit": "1.0.0",
|
||||
"float-kit": "1.0.0",
|
||||
"sinon": "^15.2.0",
|
||||
"source-map": "^0.7.4",
|
||||
"terser": "^5.19.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"truth-helpers": "1.0.0",
|
||||
"util": "^0.12.5",
|
||||
"virtual-dom": "^2.1.1",
|
||||
|
|
|
@ -28,7 +28,7 @@ acceptance("Page Publishing", function (needs) {
|
|||
await visit("/t/internationalization-localization/280");
|
||||
await click(".topic-post:nth-of-type(1) button.show-more-actions");
|
||||
await click(".topic-post:nth-of-type(1) button.show-post-admin-menu");
|
||||
await click(".topic-post:nth-of-type(1) .publish-page");
|
||||
await click(".publish-page");
|
||||
|
||||
await fillIn(".publish-slug", "bad-slug");
|
||||
assert.ok(!exists(".valid-slug"));
|
||||
|
|
|
@ -162,8 +162,8 @@ acceptance("Post inline mentions – user status tooltip", function (needs) {
|
|||
ends_at: null,
|
||||
};
|
||||
|
||||
async function mouseEnter(selector) {
|
||||
await triggerEvent(query(selector), "mouseenter");
|
||||
async function mouseMove(selector) {
|
||||
await triggerEvent(selector, "mousemove");
|
||||
}
|
||||
|
||||
test("shows user status tooltip", async function (assert) {
|
||||
|
@ -177,7 +177,7 @@ acceptance("Post inline mentions – user status tooltip", function (needs) {
|
|||
"user status is shown"
|
||||
);
|
||||
|
||||
await mouseEnter(".user-status-message");
|
||||
await mouseMove(".user-status-message");
|
||||
const statusTooltip = document.querySelector(
|
||||
".user-status-message-tooltip"
|
||||
);
|
||||
|
|
|
@ -1,128 +0,0 @@
|
|||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { click, render, triggerKeyEvent } from "@ember/test-helpers";
|
||||
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import {
|
||||
hidePopover,
|
||||
isPopoverShown,
|
||||
showPopover,
|
||||
} from "discourse/lib/d-popover";
|
||||
|
||||
module("Integration | Component | d-popover", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("show/hide popover from lib", async function (assert) {
|
||||
let showCallCount = 0;
|
||||
let hideCallCount = 0;
|
||||
|
||||
this.set("onButtonClick", (_, event) => {
|
||||
if (isPopoverShown(event)) {
|
||||
hidePopover(event);
|
||||
hideCallCount++;
|
||||
} else {
|
||||
// Note: we need to override the default `trigger` and `hideOnClick`
|
||||
// settings in order to completely control showing / hiding the tip
|
||||
// via showPopover / hidePopover. Otherwise tippy's event listeners
|
||||
// will compete with those created in this test (on DButton).
|
||||
showPopover(event, {
|
||||
content: "test",
|
||||
duration: 0,
|
||||
trigger: "manual",
|
||||
hideOnClick: false,
|
||||
});
|
||||
showCallCount++;
|
||||
}
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<DButton
|
||||
@translatedLabel="test"
|
||||
@action={{this.onButtonClick}}
|
||||
@forwardEvent={{true}}
|
||||
/>
|
||||
`);
|
||||
|
||||
assert.notOk(document.querySelector("div[data-tippy-root]"));
|
||||
|
||||
await click(".btn");
|
||||
assert.strictEqual(
|
||||
document.querySelector("div[data-tippy-root]").innerText.trim(),
|
||||
"test"
|
||||
);
|
||||
|
||||
await click(".btn");
|
||||
|
||||
assert.notOk(document.querySelector("div[data-tippy-root]"));
|
||||
|
||||
assert.strictEqual(showCallCount, 1, "showPopover was invoked once");
|
||||
assert.strictEqual(hideCallCount, 1, "hidePopover was invoked once");
|
||||
});
|
||||
|
||||
test("show/hide popover from component", async function (assert) {
|
||||
await render(hbs`
|
||||
<DPopover>
|
||||
<DButton class="trigger" @icon="chevron-down" />
|
||||
<ul>
|
||||
<li class="test">foo</li>
|
||||
<li><DButton class="closer" @icon="times" /></li>
|
||||
</ul>
|
||||
</DPopover>
|
||||
`);
|
||||
|
||||
assert.notOk(exists(".d-popover.is-expanded"));
|
||||
assert.notOk(exists(".test"));
|
||||
|
||||
await click(".trigger");
|
||||
|
||||
assert.ok(exists(".d-popover.is-expanded"));
|
||||
assert.strictEqual(query(".test").innerText.trim(), "foo");
|
||||
|
||||
await click(".closer");
|
||||
assert.notOk(exists(".d-popover.is-expanded"));
|
||||
});
|
||||
|
||||
test("using options with component", async function (assert) {
|
||||
await render(hbs`
|
||||
<DPopover @options={{hash content="bar"}}>
|
||||
<DButton @icon="chevron-down" />
|
||||
</DPopover>
|
||||
`);
|
||||
|
||||
await click(".btn");
|
||||
assert.strictEqual(query(".tippy-content").innerText.trim(), "bar");
|
||||
});
|
||||
|
||||
test("d-popover component accepts a block", async function (assert) {
|
||||
await render(hbs`
|
||||
<DPopover as |state|>
|
||||
<DButton @icon={{if state.isExpanded "chevron-up" "chevron-down"}} />
|
||||
</DPopover>
|
||||
`);
|
||||
|
||||
assert.ok(exists(".d-icon-chevron-down"));
|
||||
|
||||
await click(".btn");
|
||||
assert.ok(exists(".d-icon-chevron-up"));
|
||||
});
|
||||
|
||||
test("d-popover component accepts a class property", async function (assert) {
|
||||
await render(hbs`<DPopover @class="foo"></DPopover>`);
|
||||
|
||||
assert.ok(exists(".d-popover.foo"));
|
||||
});
|
||||
|
||||
test("d-popover component closes on escape key", async function (assert) {
|
||||
await render(hbs`
|
||||
<DPopover as |state|>
|
||||
<DButton @icon={{if state.isExpanded "chevron-up" "chevron-down"}} />
|
||||
</DPopover>
|
||||
`);
|
||||
|
||||
await click(".btn");
|
||||
assert.ok(exists(".d-popover.is-expanded"));
|
||||
|
||||
await triggerKeyEvent(document, "keydown", "Escape");
|
||||
assert.notOk(exists(".d-popover.is-expanded"));
|
||||
});
|
||||
});
|
|
@ -1,47 +0,0 @@
|
|||
import { module, test } from "qunit";
|
||||
import { render, triggerEvent } from "@ember/test-helpers";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import { query } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
async function mouseenter() {
|
||||
await triggerEvent(query("button"), "mouseenter");
|
||||
}
|
||||
|
||||
module("Integration | Component | d-tooltip", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("doesn't show tooltip if it wasn't expanded", async function (assert) {
|
||||
await render(hbs`
|
||||
<button>
|
||||
<DTooltip>
|
||||
Tooltip text
|
||||
</DTooltip>
|
||||
</button>
|
||||
`);
|
||||
assert.notOk(document.querySelector("[data-tippy-root]"));
|
||||
});
|
||||
|
||||
test("it shows tooltip on mouseenter", async function (assert) {
|
||||
await render(hbs`
|
||||
<button>
|
||||
<DTooltip>
|
||||
Tooltip text
|
||||
</DTooltip>
|
||||
</button>
|
||||
`);
|
||||
|
||||
await mouseenter();
|
||||
assert.ok(
|
||||
document.querySelector("[data-tippy-root]"),
|
||||
"the tooltip is added to the page"
|
||||
);
|
||||
assert.equal(
|
||||
document
|
||||
.querySelector("[data-tippy-root] .tippy-content")
|
||||
.textContent.trim(),
|
||||
"Tooltip text",
|
||||
"the tooltip content is correct"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { render } from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
|
||||
module(
|
||||
"Integration | Component | FloatKit | d-button-tooltip",
|
||||
function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("default", async function (assert) {
|
||||
await render(hbs`
|
||||
<DButtonTooltip>
|
||||
<:button>
|
||||
<DButton />
|
||||
</:button>
|
||||
<:tooltip>
|
||||
<DTooltip />
|
||||
</:tooltip>
|
||||
</DButtonTooltip>`);
|
||||
|
||||
assert.dom(".btn").exists();
|
||||
assert.dom("[data-trigger]").exists();
|
||||
});
|
||||
}
|
||||
);
|
|
@ -0,0 +1,86 @@
|
|||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { render } from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import DToastInstance from "float-kit/lib/d-toast-instance";
|
||||
|
||||
module(
|
||||
"Integration | Component | FloatKit | d-default-toast",
|
||||
function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("icon", async function (assert) {
|
||||
this.toast = new DToastInstance(this, { data: { icon: "check" } });
|
||||
|
||||
await render(hbs`<DDefaultToast @data={{this.toast.options.data}} />`);
|
||||
|
||||
assert.dom(".fk-d-default-toast__icon-container .d-icon-check").exists();
|
||||
});
|
||||
|
||||
test("no icon", async function (assert) {
|
||||
this.toast = new DToastInstance(this, {});
|
||||
|
||||
await render(hbs`<DDefaultToast @data={{this.toast.options.data}} />`);
|
||||
|
||||
assert.dom(".fk-d-default-toast__icon-container").doesNotExist();
|
||||
});
|
||||
|
||||
test("title", async function (assert) {
|
||||
this.toast = new DToastInstance(this, { data: { title: "Title" } });
|
||||
|
||||
await render(hbs`<DDefaultToast @data={{this.toast.options.data}} />`);
|
||||
|
||||
assert
|
||||
.dom(".fk-d-default-toast__title")
|
||||
.hasText(this.toast.options.data.title);
|
||||
});
|
||||
|
||||
test("no title", async function (assert) {
|
||||
this.toast = new DToastInstance(this, {});
|
||||
|
||||
await render(hbs`<DDefaultToast @data={{this.toast.options.data}} />`);
|
||||
|
||||
assert.dom(".fk-d-default-toast__title").doesNotExist();
|
||||
});
|
||||
|
||||
test("message", async function (assert) {
|
||||
this.toast = new DToastInstance(this, { data: { message: "Message" } });
|
||||
|
||||
await render(hbs`<DDefaultToast @data={{this.toast.options.data}} />`);
|
||||
|
||||
assert
|
||||
.dom(".fk-d-default-toast__message")
|
||||
.hasText(this.toast.options.data.message);
|
||||
});
|
||||
|
||||
test("no message", async function (assert) {
|
||||
this.toast = new DToastInstance(this, {});
|
||||
|
||||
await render(hbs`<DDefaultToast @data={{this.toast.options.data}} />`);
|
||||
|
||||
assert.dom(".fk-d-default-toast__message").doesNotExist();
|
||||
});
|
||||
|
||||
test("actions", async function (assert) {
|
||||
this.toast = new DToastInstance(this, {
|
||||
data: {
|
||||
actions: [
|
||||
{
|
||||
label: "cancel",
|
||||
icon: "times",
|
||||
class: "btn-danger",
|
||||
action: () => {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await render(hbs`<DDefaultToast @data={{this.toast.options.data}} />`);
|
||||
|
||||
assert
|
||||
.dom(".fk-d-default-toast__actions .btn.btn-danger")
|
||||
.exists()
|
||||
.hasText("cancel");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -0,0 +1,186 @@
|
|||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import {
|
||||
click,
|
||||
find,
|
||||
render,
|
||||
triggerEvent,
|
||||
triggerKeyEvent,
|
||||
} from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import DDefaultToast from "float-kit/components/d-default-toast";
|
||||
|
||||
module("Integration | Component | FloatKit | d-menu", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
async function open() {
|
||||
await triggerEvent(".fk-d-menu__trigger", "click");
|
||||
}
|
||||
|
||||
test("@label", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").containsText("label");
|
||||
});
|
||||
|
||||
test("@icon", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @icon="check" />`);
|
||||
|
||||
assert.dom(".fk-d-menu__trigger .d-icon-check").exists();
|
||||
});
|
||||
|
||||
test("@content", async function (assert) {
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}} @label="label" @content="content" />`
|
||||
);
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu").hasText("content");
|
||||
});
|
||||
|
||||
test("-expanded class", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").doesNotHaveClass("-expanded");
|
||||
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").hasClass("-expanded");
|
||||
});
|
||||
|
||||
test("trigger id attribute", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").hasAttribute("id");
|
||||
});
|
||||
|
||||
test("@identifier", async function (assert) {
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}} @label="label" @identifier="tip" />`
|
||||
);
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").hasAttribute("data-identifier", "tip");
|
||||
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu").hasAttribute("data-identifier", "tip");
|
||||
});
|
||||
|
||||
test("aria-expanded attribute", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").hasAttribute("aria-expanded", "false");
|
||||
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").hasAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("<:trigger>", async function (assert) {
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}}><:trigger>label</:trigger></DMenu />`
|
||||
);
|
||||
|
||||
assert.dom(".fk-d-menu__trigger").containsText("label");
|
||||
});
|
||||
|
||||
test("<:content>", async function (assert) {
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}}><:content>content</:content></DMenu />`
|
||||
);
|
||||
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu").containsText("content");
|
||||
});
|
||||
|
||||
test("content role attribute", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @label="label" />`);
|
||||
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu").hasAttribute("role", "dialog");
|
||||
});
|
||||
|
||||
test("@component", async function (assert) {
|
||||
this.component = DDefaultToast;
|
||||
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}} @label="test" @component={{this.component}} @data={{hash message="content"}}/>`
|
||||
);
|
||||
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu").containsText("content");
|
||||
|
||||
await click(".fk-d-menu .btn");
|
||||
|
||||
assert.dom(".fk-d-menu").doesNotExist();
|
||||
});
|
||||
|
||||
test("content aria-labelledby attribute", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @label="label" />`);
|
||||
|
||||
await open();
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector(".fk-d-menu__trigger").id,
|
||||
document.querySelector(".fk-d-menu").getAttribute("aria-labelledby")
|
||||
);
|
||||
});
|
||||
|
||||
test("@closeOnEscape", async function (assert) {
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}} @label="label" @closeOnEscape={{true}} />`
|
||||
);
|
||||
await open();
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Escape");
|
||||
|
||||
assert.dom(".fk-d-menu").doesNotExist();
|
||||
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}} @label="label" @closeOnEscape={{false}} />`
|
||||
);
|
||||
await open();
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Escape");
|
||||
|
||||
assert.dom(".fk-d-menu").exists();
|
||||
});
|
||||
|
||||
test("@closeOnClickOutside", async function (assert) {
|
||||
await render(
|
||||
hbs`<span class="test">test</span><DMenu @inline={{true}} @label="label" @closeOnClickOutside={{true}} />`
|
||||
);
|
||||
await open();
|
||||
await click(".test");
|
||||
|
||||
assert.dom(".fk-d-menu").doesNotExist();
|
||||
|
||||
await render(
|
||||
hbs`<span class="test">test</span><DMenu @inline={{true}} @label="label" @closeOnClickOutside={{false}} />`
|
||||
);
|
||||
await open();
|
||||
await click(".test");
|
||||
|
||||
assert.dom(".fk-d-menu").exists();
|
||||
});
|
||||
|
||||
test("@maxWidth", async function (assert) {
|
||||
await render(
|
||||
hbs`<DMenu @inline={{true}} @label="label" @maxWidth={{20}} />`
|
||||
);
|
||||
await open();
|
||||
|
||||
assert.ok(
|
||||
find(".fk-d-menu").getAttribute("style").includes("max-width: 20px;")
|
||||
);
|
||||
});
|
||||
|
||||
test("applies position", async function (assert) {
|
||||
await render(hbs`<DMenu @inline={{true}} @label="label" />`);
|
||||
await open();
|
||||
|
||||
assert.dom(".fk-d-menu").hasAttribute("style", /left: /);
|
||||
assert.ok(find(".fk-d-menu").getAttribute("style").includes("top: "));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,192 @@
|
|||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import {
|
||||
click,
|
||||
find,
|
||||
render,
|
||||
triggerEvent,
|
||||
triggerKeyEvent,
|
||||
} from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import DDefaultToast from "float-kit/components/d-default-toast";
|
||||
|
||||
module("Integration | Component | FloatKit | d-tooltip", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
async function hover() {
|
||||
await triggerEvent(".fk-d-tooltip__trigger", "mousemove");
|
||||
}
|
||||
|
||||
test("@label", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-tooltip__label").hasText("label");
|
||||
});
|
||||
|
||||
test("@icon", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @icon="check" />`);
|
||||
|
||||
assert.dom(".fk-d-tooltip__icon .d-icon-check").exists();
|
||||
});
|
||||
|
||||
test("@content", async function (assert) {
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}} @label="label" @content="content" />`
|
||||
);
|
||||
await hover();
|
||||
|
||||
assert.dom(".fk-d-tooltip").hasText("content");
|
||||
});
|
||||
|
||||
test("-expanded class", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").doesNotHaveClass("-expanded");
|
||||
|
||||
await hover();
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").hasClass("-expanded");
|
||||
});
|
||||
|
||||
test("trigger role attribute", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").hasAttribute("role", "button");
|
||||
});
|
||||
|
||||
test("trigger id attribute", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").hasAttribute("id");
|
||||
});
|
||||
|
||||
test("@identifier", async function (assert) {
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}} @label="label" @identifier="tip" />`
|
||||
);
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").hasAttribute("data-identifier", "tip");
|
||||
|
||||
await hover();
|
||||
|
||||
assert.dom(".fk-d-tooltip").hasAttribute("data-identifier", "tip");
|
||||
});
|
||||
|
||||
test("aria-expanded attribute", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").hasAttribute("aria-expanded", "false");
|
||||
|
||||
await hover();
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").hasAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("<:trigger>", async function (assert) {
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}}><:trigger>label</:trigger></DTooltip />`
|
||||
);
|
||||
|
||||
assert.dom(".fk-d-tooltip__trigger").hasText("label");
|
||||
});
|
||||
|
||||
test("<:content>", async function (assert) {
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}}><:content>content</:content></DTooltip />`
|
||||
);
|
||||
|
||||
await hover();
|
||||
|
||||
assert.dom(".fk-d-tooltip").hasText("content");
|
||||
});
|
||||
|
||||
test("content role attribute", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
|
||||
|
||||
await hover();
|
||||
|
||||
assert.dom(".fk-d-tooltip").hasAttribute("role", "tooltip");
|
||||
});
|
||||
|
||||
test("@component", async function (assert) {
|
||||
this.component = DDefaultToast;
|
||||
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}} @label="test" @component={{this.component}} @data={{hash message="content"}} />`
|
||||
);
|
||||
|
||||
await hover();
|
||||
|
||||
assert.dom(".fk-d-tooltip").containsText("content");
|
||||
|
||||
await click(".fk-d-tooltip .btn");
|
||||
|
||||
assert.dom(".fk-d-tooltip").doesNotExist();
|
||||
});
|
||||
|
||||
test("content aria-labelledby attribute", async function (assert) {
|
||||
await render(hbs`<DTooltip @inline={{true}} @label="label" />`);
|
||||
|
||||
await hover();
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector(".fk-d-tooltip__trigger").id,
|
||||
document.querySelector(".fk-d-tooltip").getAttribute("aria-labelledby")
|
||||
);
|
||||
});
|
||||
|
||||
test("@closeOnEscape", async function (assert) {
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}} @label="label" @closeOnEscape={{true}} />`
|
||||
);
|
||||
await hover();
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Escape");
|
||||
|
||||
assert.dom(".fk-d-tooltip").doesNotExist();
|
||||
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}} @label="label" @closeOnEscape={{false}} />`
|
||||
);
|
||||
await hover();
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Escape");
|
||||
|
||||
assert.dom(".fk-d-tooltip").exists();
|
||||
});
|
||||
|
||||
test("@closeOnClickOutside", async function (assert) {
|
||||
await render(
|
||||
hbs`<span class="test">test</span><DTooltip @inline={{true}} @label="label" @closeOnClickOutside={{true}} />`
|
||||
);
|
||||
await hover();
|
||||
await click(".test");
|
||||
|
||||
assert.dom(".fk-d-tooltip").doesNotExist();
|
||||
|
||||
await render(
|
||||
hbs`<span class="test">test</span><DTooltip @inline={{true}} @label="label" @closeOnClickOutside={{false}} />`
|
||||
);
|
||||
await hover();
|
||||
await click(".test");
|
||||
|
||||
assert.dom(".fk-d-tooltip").exists();
|
||||
});
|
||||
|
||||
test("@maxWidth", async function (assert) {
|
||||
await render(
|
||||
hbs`<DTooltip @inline={{true}} @label="label" @maxWidth={{20}} />`
|
||||
);
|
||||
await hover();
|
||||
|
||||
assert.ok(
|
||||
find(".fk-d-tooltip").getAttribute("style").includes("max-width: 20px;")
|
||||
);
|
||||
});
|
||||
|
||||
test("applies position", async function (assert) {
|
||||
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: "));
|
||||
});
|
||||
});
|
|
@ -118,31 +118,17 @@ module("Integration | Component | user-info", function (hooks) {
|
|||
.exists();
|
||||
});
|
||||
|
||||
test("doesn't show status tooltip by default", async function (assert) {
|
||||
this.currentUser.name = "Evil Trout";
|
||||
this.currentUser.status = { emoji: "tooth", description: "off to dentist" };
|
||||
|
||||
await render(
|
||||
hbs`<UserInfo @user={{this.currentUser}} @showStatus={{true}} />`
|
||||
);
|
||||
await triggerEvent(query(".user-status-message"), "mouseenter");
|
||||
|
||||
assert.notOk(
|
||||
document.querySelector("[data-tippy-root] .user-status-message-tooltip")
|
||||
);
|
||||
});
|
||||
|
||||
test("shows status tooltip if enabled", async function (assert) {
|
||||
this.currentUser.name = "Evil Trout";
|
||||
this.currentUser.status = { emoji: "tooth", description: "off to dentist" };
|
||||
|
||||
await render(
|
||||
hbs`<UserInfo @user={{this.currentUser}} @showStatus={{true}} @showStatusTooltip={{true}} />`
|
||||
hbs`<UserInfo @user={{this.currentUser}} @showStatus={{true}} /><DInlineTooltip />`
|
||||
);
|
||||
await triggerEvent(query(".user-status-message"), "mouseenter");
|
||||
await triggerEvent(query(".user-status-message"), "mousemove");
|
||||
|
||||
assert.ok(
|
||||
document.querySelector("[data-tippy-root] .user-status-message-tooltip")
|
||||
);
|
||||
assert
|
||||
.dom("[data-content][data-identifier='user-status-message-tooltip']")
|
||||
.exists();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ import { hbs } from "ember-cli-htmlbars";
|
|||
import { exists, fakeTime, query } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
async function mouseenter() {
|
||||
await triggerEvent(query(".user-status-message"), "mouseenter");
|
||||
await triggerEvent(query(".user-status-message"), "mousemove");
|
||||
}
|
||||
|
||||
module("Integration | Component | user-status-message", function (hooks) {
|
||||
|
@ -27,11 +27,6 @@ module("Integration | Component | user-status-message", function (hooks) {
|
|||
assert.ok(exists("img.emoji[alt='tooth']"), "the status emoji is shown");
|
||||
});
|
||||
|
||||
test("it doesn't render status description by default", async function (assert) {
|
||||
await render(hbs`<UserStatusMessage @status={{this.status}} />`);
|
||||
assert.notOk(exists(".user-status-message-description"));
|
||||
});
|
||||
|
||||
test("it renders status description if enabled", async function (assert) {
|
||||
await render(hbs`
|
||||
<UserStatusMessage
|
||||
|
@ -39,10 +34,9 @@ module("Integration | Component | user-status-message", function (hooks) {
|
|||
@showDescription=true/>
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
query(".user-status-message-description").innerText.trim(),
|
||||
"off to dentist"
|
||||
);
|
||||
assert
|
||||
.dom('[data-trigger][data-identifier="user-status-message-tooltip"]')
|
||||
.containsText("off to dentist");
|
||||
});
|
||||
|
||||
test("it shows the until TIME on the tooltip if status will expire today", async function (assert) {
|
||||
|
@ -53,15 +47,14 @@ 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}} />`);
|
||||
|
||||
await mouseenter();
|
||||
assert.equal(
|
||||
document
|
||||
.querySelector("[data-tippy-root] .user-status-tooltip-until")
|
||||
.textContent.trim(),
|
||||
"Until: 12:30 PM"
|
||||
await render(
|
||||
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||
);
|
||||
await mouseenter();
|
||||
|
||||
assert
|
||||
.dom('[data-content][data-identifier="user-status-message-tooltip"]')
|
||||
.containsText("Until: 12:30 PM");
|
||||
});
|
||||
|
||||
test("it shows the until DATE on the tooltip if status will expire tomorrow", async function (assert) {
|
||||
|
@ -72,15 +65,14 @@ 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}} />`);
|
||||
|
||||
await mouseenter();
|
||||
assert.equal(
|
||||
document
|
||||
.querySelector("[data-tippy-root] .user-status-tooltip-until")
|
||||
.textContent.trim(),
|
||||
"Until: Feb 2"
|
||||
await render(
|
||||
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||
);
|
||||
await mouseenter();
|
||||
|
||||
assert
|
||||
.dom('[data-content][data-identifier="user-status-message-tooltip"]')
|
||||
.containsText("Until: Feb 2");
|
||||
});
|
||||
|
||||
test("it doesn't show until datetime on the tooltip if status doesn't have expiration date", async function (assert) {
|
||||
|
@ -91,32 +83,27 @@ module("Integration | Component | user-status-message", function (hooks) {
|
|||
);
|
||||
this.status.ends_at = null;
|
||||
|
||||
await render(hbs`<UserStatusMessage @status={{this.status}} />`);
|
||||
|
||||
await mouseenter();
|
||||
assert.notOk(
|
||||
document.querySelector("[data-tippy-root] .user-status-tooltip-until")
|
||||
await render(
|
||||
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||
);
|
||||
await mouseenter();
|
||||
|
||||
assert
|
||||
.dom(
|
||||
'[data-content][data-identifier="user-status-message-tooltip"] .user-status-tooltip-until'
|
||||
)
|
||||
.doesNotExist();
|
||||
});
|
||||
|
||||
test("it shows tooltip by default", async function (assert) {
|
||||
await render(hbs`<UserStatusMessage @status={{this.status}} />`);
|
||||
await mouseenter();
|
||||
|
||||
assert.ok(
|
||||
document.querySelector("[data-tippy-root] .user-status-message-tooltip")
|
||||
);
|
||||
});
|
||||
|
||||
test("it doesn't show tooltip if disabled", async function (assert) {
|
||||
await render(
|
||||
hbs`<UserStatusMessage @status={{this.status}} @showTooltip={{false}} />`
|
||||
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||
);
|
||||
await mouseenter();
|
||||
|
||||
assert.notOk(
|
||||
document.querySelector("[data-tippy-root] .user-status-message-tooltip")
|
||||
);
|
||||
assert
|
||||
.dom('[data-content][data-identifier="user-status-message-tooltip"]')
|
||||
.exists();
|
||||
});
|
||||
|
||||
test("doesn't blow up with an anonymous user", async function (assert) {
|
||||
|
@ -125,7 +112,9 @@ module("Integration | Component | user-status-message", function (hooks) {
|
|||
|
||||
await render(hbs`<UserStatusMessage @status={{this.status}} />`);
|
||||
|
||||
assert.dom(".user-status-message").exists();
|
||||
assert
|
||||
.dom('[data-trigger][data-identifier="user-status-message-tooltip"]')
|
||||
.exists();
|
||||
});
|
||||
|
||||
test("accepts a custom css class", async function (assert) {
|
||||
|
@ -135,6 +124,8 @@ module("Integration | Component | user-status-message", function (hooks) {
|
|||
hbs`<UserStatusMessage @status={{this.status}} @class="foo" />`
|
||||
);
|
||||
|
||||
assert.dom(".user-status-message.foo").exists();
|
||||
assert
|
||||
.dom('[data-trigger][data-identifier="user-status-message-tooltip"].foo')
|
||||
.exists();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -564,13 +564,21 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||
test("show admin menu", async function (assert) {
|
||||
this.set("args", { canManage: true });
|
||||
|
||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
||||
await render(
|
||||
hbs`<MountWidget @widget="post" @args={{this.args}} /><DInlineMenu />`
|
||||
);
|
||||
|
||||
assert.ok(!exists(".post-admin-menu"));
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist();
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
assert.strictEqual(count(".post-admin-menu"), 1, "it shows the popup");
|
||||
|
||||
assert.dom("[data-content][data-identifier='admin-post-menu']").exists();
|
||||
|
||||
await click(".post-menu-area");
|
||||
assert.ok(!exists(".post-admin-menu"), "clicking outside clears the popup");
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist("clicking outside clears the popup");
|
||||
});
|
||||
|
||||
test("permanently delete topic", async function (assert) {
|
||||
|
@ -578,13 +586,17 @@ 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}} />`
|
||||
hbs`<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} /><DInlineMenu />`
|
||||
);
|
||||
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
await click(".post-admin-menu .permanently-delete");
|
||||
await click(
|
||||
"[data-content][data-identifier='admin-post-menu'] .permanently-delete"
|
||||
);
|
||||
assert.ok(this.deleted);
|
||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist("also hides the menu");
|
||||
});
|
||||
|
||||
test("permanently delete post", async function (assert) {
|
||||
|
@ -593,12 +605,18 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||
|
||||
await render(hbs`
|
||||
<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} />
|
||||
<DInlineMenu />
|
||||
`);
|
||||
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
await click(".post-admin-menu .permanently-delete");
|
||||
|
||||
await click(
|
||||
"[data-content][data-identifier='admin-post-menu'] .permanently-delete"
|
||||
);
|
||||
assert.ok(this.deleted);
|
||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist("also hides the menu");
|
||||
});
|
||||
|
||||
test("toggle moderator post", async function (assert) {
|
||||
|
@ -608,29 +626,18 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||
|
||||
await render(hbs`
|
||||
<MountWidget @widget="post" @args={{this.args}} @togglePostType={{this.togglePostType}} />
|
||||
<DInlineMenu />
|
||||
`);
|
||||
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
await click(".post-admin-menu .toggle-post-type");
|
||||
await click(
|
||||
"[data-content][data-identifier='admin-post-menu'] .toggle-post-type"
|
||||
);
|
||||
|
||||
assert.ok(this.toggled);
|
||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||
});
|
||||
|
||||
test("toggle moderator post", async function (assert) {
|
||||
this.currentUser.set("moderator", true);
|
||||
this.set("args", { canManage: true });
|
||||
this.set("togglePostType", () => (this.toggled = true));
|
||||
|
||||
await render(hbs`
|
||||
<MountWidget @widget="post" @args={{this.args}} @togglePostType={{this.togglePostType}} />
|
||||
`);
|
||||
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
await click(".post-admin-menu .toggle-post-type");
|
||||
|
||||
assert.ok(this.toggled);
|
||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist("also hides the menu");
|
||||
});
|
||||
|
||||
test("rebake post", async function (assert) {
|
||||
|
@ -639,27 +646,41 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||
|
||||
await render(hbs`
|
||||
<MountWidget @widget="post" @args={{this.args}} @rebakePost={{this.rebakePost}} />
|
||||
<DInlineMenu />
|
||||
`);
|
||||
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
await click(".post-admin-menu .rebuild-html");
|
||||
await click(
|
||||
"[data-content][data-identifier='admin-post-menu'] .rebuild-html"
|
||||
);
|
||||
assert.ok(this.baked);
|
||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist("also hides the menu");
|
||||
});
|
||||
|
||||
test("unhide post", async function (assert) {
|
||||
let unhidden;
|
||||
this.currentUser.admin = true;
|
||||
this.set("args", { canManage: true, hidden: true });
|
||||
this.set("unhidePost", () => (this.unhidden = true));
|
||||
this.set("unhidePost", () => (unhidden = true));
|
||||
|
||||
await render(hbs`
|
||||
<MountWidget @widget="post" @args={{this.args}} @unhidePost={{this.unhidePost}} />
|
||||
<DInlineMenu />
|
||||
`);
|
||||
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
await click(".post-admin-menu .unhide-post");
|
||||
assert.ok(this.unhidden);
|
||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||
|
||||
await click(
|
||||
"[data-content][data-identifier='admin-post-menu'] .unhide-post"
|
||||
);
|
||||
|
||||
assert.ok(unhidden);
|
||||
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist("also hides the menu");
|
||||
});
|
||||
|
||||
test("change owner", async function (assert) {
|
||||
|
@ -669,12 +690,17 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||
|
||||
await render(hbs`
|
||||
<MountWidget @widget="post" @args={{this.args}} @changePostOwner={{this.changePostOwner}} />
|
||||
<DInlineMenu />
|
||||
`);
|
||||
|
||||
await click(".post-menu-area .show-post-admin-menu");
|
||||
await click(".post-admin-menu .change-owner");
|
||||
await click(
|
||||
"[data-content][data-identifier='admin-post-menu'] .change-owner"
|
||||
);
|
||||
assert.ok(this.owned);
|
||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
||||
assert
|
||||
.dom("[data-content][data-identifier='admin-post-menu']")
|
||||
.doesNotExist("also hides the menu");
|
||||
});
|
||||
|
||||
test("reply", async function (assert) {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
engine-strict = true
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-button-tooltip";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-default-toast";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-inline-menu";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-inline-tooltip";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-menu";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-popover";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-toasts";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/components/d-tooltip";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/lib/d-menu-instance";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/lib/d-tooltip-instance";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/services/menu";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "float-kit/services/toasts";
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue