DEV: FloatKit (#23541)
Second iteration of https://github.com/discourse/discourse/pull/23312 with a fix for embroider not resolving an export file using .gjs extension. --- This PR introduces three new concepts to Discourse codebase through an addon called "FloatKit": - menu - tooltip - toast ## Tooltips ### Component Simple cases can be express with an API similar to DButton: ```hbs <DTooltip @label={{i18n "foo.bar"}} @icon="check" @content="Something" /> ``` More complex cases can use blocks: ```hbs <DTooltip> <:trigger> {{d-icon "check"}} <span>{{i18n "foo.bar"}}</span> </:trigger> <:content> Something </:content> </DTooltip> ``` ### Service You can manually show a tooltip using the `tooltip` service: ```javascript const tooltipInstance = await this.tooltip.show( document.querySelector(".my-span"), options ) // and later manual close or destroy it tooltipInstance.close(); tooltipInstance.destroy(); // you can also just close any open tooltip through the service this.tooltip.close(); ``` The service also allows you to register event listeners on a trigger, it removes the need for you to manage open/close of a tooltip started through the service: ```javascript const tooltipInstance = this.tooltip.register( document.querySelector(".my-span"), options ) // when done you can destroy the instance to remove the listeners tooltipInstance.destroy(); ``` Note that the service also allows you to use a custom component as content which will receive `@data` and `@close` as args: ```javascript const tooltipInstance = await this.tooltip.show( document.querySelector(".my-span"), { component: MyComponent, data: { foo: 1 } } ) ``` ## Menus Menus are very similar to tooltips and provide the same kind of APIs: ### Component ```hbs <DMenu @icon="plus" @label={{i18n "foo.bar"}}> <ul> <li>Foo</li> <li>Bat</li> <li>Baz</li> </ul> </DMenu> ``` They also support blocks: ```hbs <DMenu> <:trigger> {{d-icon "plus"}} <span>{{i18n "foo.bar"}}</span> </:trigger> <:content> <ul> <li>Foo</li> <li>Bat</li> <li>Baz</li> </ul> </:content> </DMenu> ``` ### Service You can manually show a menu using the `menu` service: ```javascript const menuInstance = await this.menu.show( document.querySelector(".my-span"), options ) // and later manual close or destroy it menuInstance.close(); menuInstance.destroy(); // you can also just close any open tooltip through the service this.menu.close(); ``` The service also allows you to register event listeners on a trigger, it removes the need for you to manage open/close of a tooltip started through the service: ```javascript const menuInstance = this.menu.register( document.querySelector(".my-span"), options ) // when done you can destroy the instance to remove the listeners menuInstance.destroy(); ``` Note that the service also allows you to use a custom component as content which will receive `@data` and `@close` as args: ```javascript const menuInstance = await this.menu.show( document.querySelector(".my-span"), { component: MyComponent, data: { foo: 1 } } ) ``` ## Toasts Interacting with toasts is made only through the `toasts` service. A default component is provided (DDefaultToast) and can be used through dedicated service methods: - this.toasts.success({ ... }); - this.toasts.warning({ ... }); - this.toasts.info({ ... }); - this.toasts.error({ ... }); - this.toasts.default({ ... }); ```javascript this.toasts.success({ data: { title: "Foo", message: "Bar", actions: [ { label: "Ok", class: "btn-primary", action: (componentArgs) => { // eslint-disable-next-line no-alert alert("Closing toast:" + componentArgs.data.title); componentArgs.close(); }, } ] }, }); ``` You can also provide your own component: ```javascript this.toasts.show(MyComponent, { autoClose: false, class: "foo", data: { baz: 1 }, }) ``` Co-authored-by: Martin Brennan <mjrbrennan@gmail.com> Co-authored-by: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com> Co-authored-by: David Taylor <david@taylorhq.com> Co-authored-by: Jarek Radosz <jradosz@gmail.com>
This commit is contained in:
parent
b8cc1072cc
commit
0623ac684a
|
@ -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,20 +1,28 @@
|
||||||
<DButton
|
<DButtonTooltip>
|
||||||
class="btn-default bootstrap-mode"
|
<:button>
|
||||||
@label="bootstrap_mode"
|
<DButton
|
||||||
@action={{this.routeToAdminGuide}}
|
class="btn-default bootstrap-mode"
|
||||||
>
|
@label="bootstrap_mode"
|
||||||
{{#if this.showUserTip}}
|
@action={{this.routeToAdminGuide}}
|
||||||
<UserTip @id="admin_guide" @content={{this.userTipContent}} />
|
>
|
||||||
{{else}}
|
{{#if this.showUserTip}}
|
||||||
<DTooltip @theme="user-tip" @arrow={{true}}>
|
<UserTip @id="admin_guide" @content={{this.userTipContent}} />
|
||||||
<div class="user-tip__container">
|
{{/if}}
|
||||||
<div class="user-tip__title">
|
</DButton>
|
||||||
{{i18n "user_tips.admin_guide.title"}}
|
</: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"}}
|
||||||
|
</div>
|
||||||
|
<div class="user-tip__content">
|
||||||
|
{{i18n "user_tips.admin_guide.content_no_url"}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-tip__content">
|
</DTooltip>
|
||||||
{{i18n "user_tips.admin_guide.content_no_url"}}
|
{{/unless}}
|
||||||
</div>
|
</:tooltip>
|
||||||
</div>
|
</DButtonTooltip>
|
||||||
</DTooltip>
|
|
||||||
{{/if}}
|
|
||||||
</DButton>
|
|
|
@ -42,6 +42,7 @@ import {
|
||||||
initUserStatusHtml,
|
initUserStatusHtml,
|
||||||
renderUserStatusHtml,
|
renderUserStatusHtml,
|
||||||
} from "discourse/lib/user-status-on-autocomplete";
|
} 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")`
|
// original string `![image|foo=bar|690x220, 50%|bar=baz](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")`
|
||||||
// group 1 `image|foo=bar`
|
// group 1 `image|foo=bar`
|
||||||
|
@ -223,7 +224,7 @@ export default Component.extend(ComposerUploadUppy, {
|
||||||
categoryId: this.topic?.category_id || this.composer?.categoryId,
|
categoryId: this.topic?.category_id || this.composer?.categoryId,
|
||||||
includeGroups: true,
|
includeGroups: true,
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
initUserStatusHtml(result.users);
|
initUserStatusHtml(getOwner(this), result.users);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,11 +1,22 @@
|
||||||
{{#if this.canCreateTopic}}
|
{{#if this.canCreateTopic}}
|
||||||
<DButton
|
<DButtonTooltip>
|
||||||
@action={{this.action}}
|
<:button>
|
||||||
@icon="plus"
|
<DButton
|
||||||
@disabled={{this.disabled}}
|
@action={{this.action}}
|
||||||
@label={{this.label}}
|
@icon="plus"
|
||||||
id="create-topic"
|
@disabled={{this.disabled}}
|
||||||
class={{this.btnClass}}
|
@label={{this.label}}
|
||||||
/>
|
id="create-topic"
|
||||||
{{yield}}
|
class={{this.btnClass}}
|
||||||
|
/>
|
||||||
|
</:button>
|
||||||
|
<:tooltip>
|
||||||
|
{{#if @disabled}}
|
||||||
|
<DTooltip
|
||||||
|
@icon="info-circle"
|
||||||
|
@content={{i18n "topic.create_disabled_category"}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</:tooltip>
|
||||||
|
</DButtonTooltip>
|
||||||
{{/if}}
|
{{/if}}
|
|
@ -20,6 +20,7 @@
|
||||||
{{did-insert this.setupListeners}}
|
{{did-insert this.setupListeners}}
|
||||||
{{will-destroy this.cleanupListeners}}
|
{{will-destroy this.cleanupListeners}}
|
||||||
{{on "mouseup" this.handleMouseUp}}
|
{{on "mouseup" this.handleMouseUp}}
|
||||||
|
{{trap-tab (hash preventScroll=false)}}
|
||||||
>
|
>
|
||||||
<div class="modal-outer-container">
|
<div class="modal-outer-container">
|
||||||
<div class="modal-middle-container">
|
<div class="modal-middle-container">
|
||||||
|
|
|
@ -22,7 +22,6 @@ export default class DModal extends Component {
|
||||||
this.handleDocumentKeydown
|
this.handleDocumentKeydown
|
||||||
);
|
);
|
||||||
this.wrapperElement = element;
|
this.wrapperElement = element;
|
||||||
this.trapTab();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -94,71 +93,6 @@ export default class DModal extends Component {
|
||||||
this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click();
|
this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click();
|
||||||
event.preventDefault();
|
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
|
@action
|
||||||
|
|
|
@ -68,11 +68,7 @@
|
||||||
@label={{this.createTopicLabel}}
|
@label={{this.createTopicLabel}}
|
||||||
@btnClass={{this.createTopicClass}}
|
@btnClass={{this.createTopicClass}}
|
||||||
@canCreateTopicOnTag={{this.canCreateTopicOnTag}}
|
@canCreateTopicOnTag={{this.canCreateTopicOnTag}}
|
||||||
>
|
/>
|
||||||
{{#if this.createTopicButtonDisabled}}
|
|
||||||
<DTooltip>{{i18n "topic.create_disabled_category"}}</DTooltip>
|
|
||||||
{{/if}}
|
|
||||||
</CreateTopicButton>
|
|
||||||
|
|
||||||
<PluginOutlet
|
<PluginOutlet
|
||||||
@name="after-create-topic-button"
|
@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"
|
@class="email-in"
|
||||||
@value={{this.category.email_in}}
|
@value={{this.category.email_in}}
|
||||||
/>
|
/>
|
||||||
<span>
|
|
||||||
{{d-icon "info-circle"}}
|
|
||||||
<DTooltip>{{i18n "category.email_in_tooltip"}}</DTooltip>
|
|
||||||
</span>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="field email-in-allow-strangers">
|
<section class="field email-in-allow-strangers">
|
||||||
|
|
|
@ -3,11 +3,37 @@ import { tracked } from "@glimmer/tracking";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
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 { translateModKey } from "discourse/lib/utilities";
|
||||||
import I18n from "I18n";
|
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 {
|
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 value = this.args.initialValue;
|
||||||
@tracked isSaving = false;
|
@tracked isSaving = false;
|
||||||
|
|
||||||
|
@ -21,9 +47,7 @@ export default class FastEdit extends Component {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
onKeydown(event) {
|
onKeydown(event) {
|
||||||
if (event.key === "Escape") {
|
if (
|
||||||
this.args.close();
|
|
||||||
} else if (
|
|
||||||
event.key === "Enter" &&
|
event.key === "Enter" &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
!this.disabled
|
!this.disabled
|
||||||
|
@ -34,6 +58,7 @@ export default class FastEdit extends Component {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateValue(event) {
|
updateValue(event) {
|
||||||
|
event.preventDefault();
|
||||||
this.value = event.target.value;
|
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"
|
@placeholderKey="admin.groups.manage.interaction.incoming_email_placeholder"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span>
|
<DTooltip
|
||||||
{{d-icon "info-circle"}}
|
@icon="info-circle"
|
||||||
<DTooltip>{{i18n
|
@content={{i18n "admin.groups.manage.interaction.incoming_email_tooltip"}}
|
||||||
"admin.groups.manage.interaction.incoming_email_tooltip"
|
/>
|
||||||
}}</DTooltip>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<PluginOutlet
|
<PluginOutlet
|
||||||
|
|
|
@ -90,17 +90,20 @@
|
||||||
>
|
>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
{{#if this.transformedModel.sectionType}}
|
{{#if this.transformedModel.sectionType}}
|
||||||
<DTooltip @placement="top-start">
|
<DTooltip
|
||||||
{{i18n "sidebar.sections.custom.always_public"}}
|
@icon="check-square"
|
||||||
</DTooltip>
|
@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}}
|
||||||
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<Input
|
<span>{{i18n "sidebar.sections.custom.public"}}</span>
|
||||||
@type="checkbox"
|
|
||||||
@checked={{this.transformedModel.public}}
|
|
||||||
class="mark-public"
|
|
||||||
disabled={{this.transformedModel.sectionType}}
|
|
||||||
/>
|
|
||||||
{{i18n "sidebar.sections.custom.public"}}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/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">
|
<span class="sidebar-section-header-text">
|
||||||
{{@headerLinkText}}
|
{{@headerLinkText}}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{{#if @indicatePublic}}
|
{{#if @indicatePublic}}
|
||||||
<span class="sidebar-section-header-global-indicator">
|
<DTooltip
|
||||||
{{d-icon "globe"}}
|
@icon="globe"
|
||||||
<DTooltip @placement="top">{{d-icon "shield-alt"}}
|
class="sidebar-section-header-global-indicator"
|
||||||
{{i18n "sidebar.sections.global_section"}}
|
>
|
||||||
</DTooltip>
|
<span
|
||||||
</span>
|
class="sidebar-section-header-global-indicator__content"
|
||||||
|
>{{d-icon "shield-alt"}}{{i18n
|
||||||
|
"sidebar.sections.global_section"
|
||||||
|
}}</span>
|
||||||
|
</DTooltip>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</Sidebar::SectionHeader>
|
</Sidebar::SectionHeader>
|
||||||
|
|
||||||
|
|
|
@ -50,12 +50,15 @@
|
||||||
<div class="summarized-on">
|
<div class="summarized-on">
|
||||||
<p>
|
<p>
|
||||||
{{i18n "summary.summarized_on" date=this.summarizedOn}}
|
{{i18n "summary.summarized_on" date=this.summarizedOn}}
|
||||||
<span>
|
|
||||||
{{d-icon "info-circle"}}
|
<DTooltip @placements={{array "top-end"}}>
|
||||||
<DTooltip @placement="top-end">
|
<:trigger>
|
||||||
|
{{d-icon "info-circle"}}
|
||||||
|
</:trigger>
|
||||||
|
<:content>
|
||||||
{{i18n "summary.model_used" model=this.summarizedBy}}
|
{{i18n "summary.model_used" model=this.summarizedBy}}
|
||||||
</DTooltip>
|
</:content>
|
||||||
</span>
|
</DTooltip>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{{#if this.outdated}}
|
{{#if this.outdated}}
|
||||||
|
|
|
@ -52,7 +52,7 @@ export default class TopicTimeline extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
addUserTip(element) {
|
addUserTip() {
|
||||||
if (!this.currentUser) {
|
if (!this.currentUser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,6 @@ export default class TopicTimeline extends Component {
|
||||||
titleText: I18n.t("user_tips.topic_timeline.title"),
|
titleText: I18n.t("user_tips.topic_timeline.title"),
|
||||||
contentText: I18n.t("user_tips.topic_timeline.content"),
|
contentText: I18n.t("user_tips.topic_timeline.content"),
|
||||||
reference: document.querySelector("div.timeline-scrollarea-wrapper"),
|
reference: document.querySelector("div.timeline-scrollarea-wrapper"),
|
||||||
appendTo: element,
|
|
||||||
placement: "left",
|
placement: "left",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,6 @@
|
||||||
<UserStatusMessage
|
<UserStatusMessage
|
||||||
@status={{@user.status}}
|
@status={{@user.status}}
|
||||||
@showDescription={{@showStatusDescription}}
|
@showDescription={{@showStatusDescription}}
|
||||||
@showTooltip={{@showStatusTooltip}}
|
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<span>
|
<span>
|
||||||
|
|
|
@ -1,25 +1,26 @@
|
||||||
{{#if @status}}
|
{{#if @status}}
|
||||||
<span class={{concat-class "user-status-message" @class}}>
|
<DTooltip
|
||||||
{{emoji @status.emoji skipTitle=true}}
|
@identifier="user-status-message-tooltip"
|
||||||
{{#if @showDescription}}
|
class={{concat-class "user-status-message" @class}}
|
||||||
<span class="user-status-message-description">
|
>
|
||||||
|
<:trigger>
|
||||||
|
{{emoji @status.emoji skipTitle=true}}
|
||||||
|
{{#if @showDescription}}
|
||||||
|
<span class="user-status-message-description">
|
||||||
|
{{@status.description}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</:trigger>
|
||||||
|
<:content>
|
||||||
|
{{emoji @status.emoji skipTitle=true}}
|
||||||
|
<span class="user-status-tooltip-description">
|
||||||
{{@status.description}}
|
{{@status.description}}
|
||||||
</span>
|
</span>
|
||||||
{{/if}}
|
{{#if this.until}}
|
||||||
{{#if this.showTooltip}}
|
<div class="user-status-tooltip-until">
|
||||||
<DTooltip>
|
{{this.until}}
|
||||||
<div class="user-status-message-tooltip">
|
|
||||||
{{emoji @status.emoji skipTitle=true}}
|
|
||||||
<span class="user-status-tooltip-description">
|
|
||||||
{{@status.description}}
|
|
||||||
</span>
|
|
||||||
{{#if this.until}}
|
|
||||||
<div class="user-status-tooltip-until">
|
|
||||||
{{this.until}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
</DTooltip>
|
{{/if}}
|
||||||
{{/if}}
|
</:content>
|
||||||
</span>
|
</DTooltip>
|
||||||
{{/if}}
|
{{/if}}
|
|
@ -1,21 +1,19 @@
|
||||||
import Component from "@ember/component";
|
import Component from "@glimmer/component";
|
||||||
import { computed } from "@ember/object";
|
|
||||||
import { until } from "discourse/lib/formatter";
|
import { until } from "discourse/lib/formatter";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
|
||||||
export default class UserStatusMessage extends Component {
|
export default class UserStatusMessage extends Component {
|
||||||
tagName = "";
|
@service currentUser;
|
||||||
showTooltip = true;
|
|
||||||
|
|
||||||
@computed("status.ends_at")
|
|
||||||
get until() {
|
get until() {
|
||||||
if (!this.status.ends_at) {
|
if (!this.args.status.ends_at) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timezone = this.currentUser
|
const timezone = this.currentUser
|
||||||
? this.currentUser.user_option?.timezone
|
? this.currentUser.user_option?.timezone
|
||||||
: moment.tz.guess();
|
: 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,
|
buttonIcon,
|
||||||
reference:
|
reference:
|
||||||
(selector && element.parentElement.querySelector(selector)) || element,
|
(selector && element.parentElement.querySelector(selector)) || element,
|
||||||
appendTo: element.parentElement,
|
|
||||||
placement,
|
placement,
|
||||||
onDismiss,
|
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 deprecated from "discourse-common/lib/deprecated";
|
||||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
|
||||||
|
|
||||||
export const hideOnEscapePlugin = {
|
export function showPopover() {
|
||||||
name: "hideOnEscape",
|
deprecated("`showPopover` is deprecated. Use tooltip service instead.", {
|
||||||
|
id: "show-popover",
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// legacy, shouldn't be needed with setup
|
export function hidePopover() {
|
||||||
export function hidePopover(event) {
|
deprecated("`hidePopover` is deprecated. Use tooltip service instead.", {
|
||||||
const instance = event.target._tippy;
|
id: "hide-popover",
|
||||||
|
});
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { defaultHomepage } from "discourse/lib/utilities";
|
||||||
import { guidFor } from "@ember/object/internals";
|
import { guidFor } from "@ember/object/internals";
|
||||||
import { withoutPrefix } from "discourse-common/lib/get-url";
|
import { withoutPrefix } from "discourse-common/lib/get-url";
|
||||||
|
|
||||||
let popstateFired = false;
|
let popstateFired = false;
|
||||||
const supportsHistoryState = window.history && "state" in window.history;
|
const supportsHistoryState = window.history && "state" in window.history;
|
||||||
const popstateCallbacks = [];
|
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
|
// based on Semantic Versioning 2.0.0. Please update the changelog at
|
||||||
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
// 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.
|
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
||||||
function canModify(klass, type, resolverName, changes) {
|
function canModify(klass, type, resolverName, changes) {
|
||||||
|
@ -640,6 +641,30 @@ class PluginApi {
|
||||||
addButton(name, callback);
|
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.
|
* Remove existing button below a post with your plugin.
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,22 +1,24 @@
|
||||||
import { UserStatusMessage } from "discourse/lib/user-status-message";
|
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);
|
removeStatus(mention);
|
||||||
if (status) {
|
if (status) {
|
||||||
const userStatusMessage = new UserStatusMessage(status);
|
const userStatusMessage = new UserStatusMessage(owner, status);
|
||||||
userStatusMessages.push(userStatusMessage);
|
userStatusMessages[guidFor(mention)] = userStatusMessage;
|
||||||
mention.appendChild(userStatusMessage.html);
|
mention.appendChild(userStatusMessage.html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function destroyUserStatusOnMentions() {
|
export function destroyUserStatusOnMentions() {
|
||||||
userStatusMessages.forEach((instance) => {
|
Object.values(userStatusMessages).forEach((instance) => {
|
||||||
instance.destroy();
|
instance.destroy();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeStatus(mention) {
|
function removeStatus(mention) {
|
||||||
|
userStatusMessages[guidFor(mention)]?.destroy();
|
||||||
mention.querySelector("span.user-status-message")?.remove();
|
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 { emojiUnescape } from "discourse/lib/text";
|
||||||
import { escapeExpression } from "discourse/lib/utilities";
|
import { escapeExpression } from "discourse/lib/utilities";
|
||||||
import { until } from "discourse/lib/formatter";
|
import { until } from "discourse/lib/formatter";
|
||||||
import User from "discourse/models/user";
|
import User from "discourse/models/user";
|
||||||
|
import { setOwner } from "@ember/application";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
|
||||||
export class UserStatusMessage {
|
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.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() {
|
destroy() {
|
||||||
this.#dTooltip.destroy();
|
this.tooltipInstance.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
#emojiHtml(emojiName) {
|
#emojiHtml(emojiName) {
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { UserStatusMessage } from "discourse/lib/user-status-message";
|
||||||
|
|
||||||
let userStatusMessages = [];
|
let userStatusMessages = [];
|
||||||
|
|
||||||
export function initUserStatusHtml(users) {
|
export function initUserStatusHtml(owner, users) {
|
||||||
(users || []).forEach((user, index) => {
|
(users || []).forEach((user, index) => {
|
||||||
if (user.status) {
|
if (user.status) {
|
||||||
user.index = index;
|
user.index = index;
|
||||||
const userStatusMessage = new UserStatusMessage(user.status, {
|
const userStatusMessage = new UserStatusMessage(owner, user.status, {
|
||||||
showDescription: true,
|
showDescription: true,
|
||||||
});
|
});
|
||||||
user.statusHtml = userStatusMessage.html;
|
user.statusHtml = userStatusMessage.html;
|
||||||
|
|
|
@ -19,6 +19,10 @@ class VirtualElementFromTextRange {
|
||||||
return this.rect;
|
return this.rect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getClientRects() {
|
||||||
|
return this.range.getClientRects();
|
||||||
|
}
|
||||||
|
|
||||||
get clientWidth() {
|
get clientWidth() {
|
||||||
return this.rect.width;
|
return this.rect.width;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ loaderShim("@ember-compat/tracked-built-ins", () =>
|
||||||
importSync("@ember-compat/tracked-built-ins")
|
importSync("@ember-compat/tracked-built-ins")
|
||||||
);
|
);
|
||||||
loaderShim("@popperjs/core", () => importSync("@popperjs/core"));
|
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", () => importSync("@uppy/aws-s3"));
|
||||||
loaderShim("@uppy/aws-s3-multipart", () =>
|
loaderShim("@uppy/aws-s3-multipart", () =>
|
||||||
importSync("@uppy/aws-s3-multipart")
|
importSync("@uppy/aws-s3-multipart")
|
||||||
|
@ -27,6 +28,5 @@ loaderShim("ember-modifier", () => importSync("ember-modifier"));
|
||||||
loaderShim("handlebars", () => importSync("handlebars"));
|
loaderShim("handlebars", () => importSync("handlebars"));
|
||||||
loaderShim("js-yaml", () => importSync("js-yaml"));
|
loaderShim("js-yaml", () => importSync("js-yaml"));
|
||||||
loaderShim("message-bus-client", () => importSync("message-bus-client"));
|
loaderShim("message-bus-client", () => importSync("message-bus-client"));
|
||||||
loaderShim("tippy.js", () => importSync("tippy.js"));
|
|
||||||
loaderShim("virtual-dom", () => importSync("virtual-dom"));
|
loaderShim("virtual-dom", () => importSync("virtual-dom"));
|
||||||
loaderShim("xss", () => importSync("xss"));
|
loaderShim("xss", () => importSync("xss"));
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { inject as service } from "@ember/service";
|
||||||
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||||
import { bind } from "discourse-common/utils/decorators";
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
import discourseLater from "discourse-common/lib/later";
|
import discourseLater from "discourse-common/lib/later";
|
||||||
import { createPopper } from "@popperjs/core";
|
|
||||||
import { headerOffset } from "discourse/lib/offset-calculator";
|
import { headerOffset } from "discourse/lib/offset-calculator";
|
||||||
|
|
||||||
const DEFAULT_SELECTOR = "#main-outlet";
|
const DEFAULT_SELECTOR = "#main-outlet";
|
||||||
|
@ -24,6 +23,7 @@ export function resetCardClickListenerSelector() {
|
||||||
|
|
||||||
export default Mixin.create({
|
export default Mixin.create({
|
||||||
router: service(),
|
router: service(),
|
||||||
|
menu: service(),
|
||||||
|
|
||||||
elementId: null, //click detection added for data-{elementId}
|
elementId: null, //click detection added for data-{elementId}
|
||||||
triggeringLinkClass: null, //the <a> classname where this card should appear
|
triggeringLinkClass: null, //the <a> classname where this card should appear
|
||||||
|
@ -39,7 +39,7 @@ export default Mixin.create({
|
||||||
post: null,
|
post: null,
|
||||||
isDocked: false,
|
isDocked: false,
|
||||||
|
|
||||||
_popperReference: null,
|
_menuInstance: null,
|
||||||
|
|
||||||
_show(username, target, event) {
|
_show(username, target, event) {
|
||||||
// No user card for anon
|
// No user card for anon
|
||||||
|
@ -85,7 +85,6 @@ export default Mixin.create({
|
||||||
this.appEvents.trigger("user-card:show", { username });
|
this.appEvents.trigger("user-card:show", { username });
|
||||||
this._showCallback(username, $(target)).then((user) => {
|
this._showCallback(username, $(target)).then((user) => {
|
||||||
this.appEvents.trigger("user-card:after-show", { 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
|
// 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);
|
return this._show($target.text().replace(/^@/, ""), $target);
|
||||||
},
|
},
|
||||||
|
|
||||||
_positionCard(target, event) {
|
_positionCard(target) {
|
||||||
this._popperReference?.destroy();
|
schedule("afterRender", async () => {
|
||||||
|
|
||||||
schedule("afterRender", () => {
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const avatarOverflowSize = 44;
|
||||||
if (this.site.desktopView) {
|
if (this.site.desktopView) {
|
||||||
const avatarOverflowSize = 44;
|
this._menuInstance = await this.menu.show(target[0], {
|
||||||
this._popperReference = createPopper(target[0], this.element, {
|
content: this.element,
|
||||||
placement: "right",
|
autoUpdate: false,
|
||||||
modifiers: [
|
identifier: "card",
|
||||||
{
|
padding: {
|
||||||
name: "preventOverflow",
|
top: 10 + avatarOverflowSize + headerOffset(),
|
||||||
options: {
|
right: 10,
|
||||||
padding: {
|
bottom: 10,
|
||||||
top: headerOffset() + avatarOverflowSize,
|
left: 10,
|
||||||
right: 10,
|
},
|
||||||
bottom: 10,
|
|
||||||
left: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ name: "eventListeners", enabled: false },
|
|
||||||
{ name: "offset", options: { offset: [10, 10] } },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this._popperReference = createPopper(target[0], this.element, {
|
this._menuInstance = await this.menu.show(target[0], {
|
||||||
modifiers: [
|
content: this.element,
|
||||||
{ name: "eventListeners", enabled: false },
|
strategy: "fixed",
|
||||||
{
|
identifier: "card",
|
||||||
name: "computeStyles",
|
computePosition: (content) => {
|
||||||
enabled: true,
|
content.style.left = "10px";
|
||||||
fn({ state }) {
|
content.style.right = "10px";
|
||||||
// mimics our modal top of the screen positioning
|
content.style.top = 10 + avatarOverflowSize + "px";
|
||||||
state.styles.popper = {
|
},
|
||||||
...state.styles.popper,
|
|
||||||
position: "fixed",
|
|
||||||
left: `${
|
|
||||||
(window.innerWidth - state.rects.popper.width) / 2
|
|
||||||
}px`,
|
|
||||||
top: "10%",
|
|
||||||
transform: "translateY(-10%)",
|
|
||||||
};
|
|
||||||
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -261,11 +238,12 @@ export default Mixin.create({
|
||||||
@bind
|
@bind
|
||||||
_hide() {
|
_hide() {
|
||||||
if (!this.visible) {
|
if (!this.visible) {
|
||||||
$(this.element).css({ left: -9999, top: -9999 });
|
|
||||||
if (this.site.mobileView) {
|
if (this.site.mobileView) {
|
||||||
$(".card-cloak").addClass("hidden");
|
$(".card-cloak").addClass("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._menuInstance?.destroy();
|
||||||
},
|
},
|
||||||
|
|
||||||
_close() {
|
_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 { isTesting } from "discourse-common/config/environment";
|
||||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||||
import I18n from "I18n";
|
import I18n from "I18n";
|
||||||
import { escape } from "pretty-text/sanitizer";
|
import { escape } from "pretty-text/sanitizer";
|
||||||
import tippy from "tippy.js";
|
|
||||||
import isElementInViewport from "discourse/lib/is-element-in-viewport";
|
import isElementInViewport from "discourse/lib/is-element-in-viewport";
|
||||||
import discourseLater from "discourse-common/lib/later";
|
import discourseLater from "discourse-common/lib/later";
|
||||||
import { cancel } from "@ember/runloop";
|
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 {
|
export default class UserTips extends Service {
|
||||||
|
@service tooltip;
|
||||||
|
|
||||||
#instances = new Map();
|
#instances = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -20,7 +24,6 @@ export default class UserTips extends Service {
|
||||||
* @param {string} [options.buttonLabel]
|
* @param {string} [options.buttonLabel]
|
||||||
* @param {string} [options.buttonIcon]
|
* @param {string} [options.buttonIcon]
|
||||||
* @param {string} [options.placement]
|
* @param {string} [options.placement]
|
||||||
* @param {Element} [options.appendTo]
|
|
||||||
* @param {string} [options.content]
|
* @param {string} [options.content]
|
||||||
* @param {string} [options.contentText]
|
* @param {string} [options.contentText]
|
||||||
* @param {string} [options.titleText]
|
* @param {string} [options.titleText]
|
||||||
|
@ -51,42 +54,19 @@ export default class UserTips extends Service {
|
||||||
|
|
||||||
this.#instances.set(
|
this.#instances.set(
|
||||||
options.id,
|
options.id,
|
||||||
tippy(options.reference, {
|
new DTooltipInstance(getOwner(this), options.reference, {
|
||||||
hideOnClick: false,
|
identifier: "user-tip",
|
||||||
trigger: "manual",
|
interactive: true,
|
||||||
theme: "user-tip",
|
closeOnScroll: false,
|
||||||
zIndex: "", // reset z-index to use inherited value from the parent
|
closeOnClickOutside: false,
|
||||||
duration: TIPPY_DELAY,
|
|
||||||
|
|
||||||
arrow: iconHTML("tippy-rounded-arrow"),
|
|
||||||
placement: options.placement,
|
placement: options.placement,
|
||||||
appendTo: options.appendTo,
|
component: UserTipContainer,
|
||||||
|
data: {
|
||||||
interactive: true, // for buttons in content
|
titleText: escape(options.titleText),
|
||||||
allowHTML: true,
|
contentHtml: options.contentHtml || null,
|
||||||
|
contentText: options.contentText ? escape(options.contentText) : null,
|
||||||
content:
|
onDismiss: options.onDismiss,
|
||||||
options.content ||
|
buttonText,
|
||||||
`<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();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -95,7 +75,7 @@ export default class UserTips extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
hideTip(userTipId, force = false) {
|
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
|
// user tip is recreated immediately. This happens when Ember components
|
||||||
// are re-rendered because a parent component has changed
|
// 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.#destroyInstance(this.#instances.get(userTipId));
|
||||||
this.#instances.delete(userTipId);
|
this.#instances.delete(userTipId);
|
||||||
this.showNextTip();
|
this.showNextTip();
|
||||||
}, TIPPY_DELAY);
|
}, DELAY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,7 +107,7 @@ export default class UserTips extends Service {
|
||||||
showNextTip() {
|
showNextTip() {
|
||||||
// Return early if a user tip is already visible and it is in viewport
|
// Return early if a user tip is already visible and it is in viewport
|
||||||
for (const tip of this.#instances.values()) {
|
for (const tip of this.#instances.values()) {
|
||||||
if (tip.state.isVisible && isElementInViewport(tip.reference)) {
|
if (tip.expanded && isElementInViewport(tip.trigger)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -135,8 +115,9 @@ export default class UserTips extends Service {
|
||||||
// Otherwise, try to find a user tip in the viewport
|
// Otherwise, try to find a user tip in the viewport
|
||||||
let visibleTip;
|
let visibleTip;
|
||||||
for (const tip of this.#instances.values()) {
|
for (const tip of this.#instances.values()) {
|
||||||
if (isElementInViewport(tip.reference)) {
|
if (isElementInViewport(tip.trigger)) {
|
||||||
visibleTip = tip;
|
visibleTip = tip;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -177,20 +158,18 @@ export default class UserTips extends Service {
|
||||||
|
|
||||||
#showInstance(instance) {
|
#showInstance(instance) {
|
||||||
if (isTesting()) {
|
if (isTesting()) {
|
||||||
instance.show();
|
this.tooltip.show(instance);
|
||||||
} else if (!instance.showTimer) {
|
} else if (!instance.showTimer) {
|
||||||
instance.showTimer = discourseLater(() => {
|
instance.showTimer = discourseLater(() => {
|
||||||
instance.showTimer = null;
|
instance.showTimer = null;
|
||||||
if (!instance.state.isDestroyed) {
|
this.tooltip.show(instance);
|
||||||
instance.show();
|
}, DELAY);
|
||||||
}
|
|
||||||
}, TIPPY_DELAY);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#hideInstance(instance) {
|
#hideInstance(instance) {
|
||||||
cancel(instance.showTimer);
|
cancel(instance.showTimer);
|
||||||
instance.showTimer = null;
|
instance.showTimer = null;
|
||||||
instance.hide();
|
this.tooltip.close(instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,4 +105,8 @@
|
||||||
{{#if this.showFooterNav}}
|
{{#if this.showFooterNav}}
|
||||||
<FooterNav />
|
<FooterNav />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</DiscourseRoot>
|
</DiscourseRoot>
|
||||||
|
|
||||||
|
<DInlineMenu />
|
||||||
|
<DInlineTooltip />
|
||||||
|
<DToasts />
|
|
@ -593,7 +593,7 @@
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<QuoteButton
|
<PostTextSelection
|
||||||
@quoteState={{this.quoteState}}
|
@quoteState={{this.quoteState}}
|
||||||
@selectText={{action "selectText"}}
|
@selectText={{action "selectText"}}
|
||||||
@editPost={{action "editPost"}}
|
@editPost={{action "editPost"}}
|
||||||
|
|
|
@ -163,7 +163,6 @@ createWidget("header-notifications", {
|
||||||
reference: document
|
reference: document
|
||||||
.querySelector(".d-header .badge-notification")
|
.querySelector(".d-header .badge-notification")
|
||||||
?.parentElement?.querySelector(".avatar"),
|
?.parentElement?.querySelector(".avatar"),
|
||||||
appendTo: document.querySelector(".d-header"),
|
|
||||||
|
|
||||||
placement: "bottom-end",
|
placement: "bottom-end",
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,185 +1,4 @@
|
||||||
import { ButtonClass } from "discourse/widgets/button";
|
|
||||||
import { createWidget } from "discourse/widgets/widget";
|
import { createWidget } from "discourse/widgets/widget";
|
||||||
import { h } from "virtual-dom";
|
|
||||||
|
|
||||||
createWidget(
|
// placeholder for now
|
||||||
"post-admin-menu-button",
|
export default createWidget("post-admin-menu", {});
|
||||||
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");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
destroyUserStatusOnMentions,
|
destroyUserStatusOnMentions,
|
||||||
updateUserStatusOnMention,
|
updateUserStatusOnMention,
|
||||||
} from "discourse/lib/update-user-status-on-mention";
|
} from "discourse/lib/update-user-status-on-mention";
|
||||||
|
import { getOwner } from "discourse-common/lib/get-owner";
|
||||||
|
|
||||||
let _beforeAdoptDecorators = [];
|
let _beforeAdoptDecorators = [];
|
||||||
let _afterAdoptDecorators = [];
|
let _afterAdoptDecorators = [];
|
||||||
|
@ -396,7 +397,7 @@ export default class PostCooked {
|
||||||
const mentions = postElement.querySelectorAll(`a.mention[href="${href}"]`);
|
const mentions = postElement.querySelectorAll(`a.mention[href="${href}"]`);
|
||||||
|
|
||||||
mentions.forEach((mention) => {
|
mentions.forEach((mention) => {
|
||||||
updateUserStatusOnMention(mention, user.status);
|
updateUserStatusOnMention(getOwner(this._post()), mention, user.status);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,9 @@ import {
|
||||||
} from "discourse/models/bookmark";
|
} from "discourse/models/bookmark";
|
||||||
import { isTesting } from "discourse-common/config/environment";
|
import { isTesting } from "discourse-common/config/environment";
|
||||||
import DeleteTopicDisallowedModal from "discourse/components/modal/delete-topic-disallowed";
|
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 LIKE_ACTION = 2;
|
||||||
const VIBRATE_DURATION = 5;
|
const VIBRATE_DURATION = 5;
|
||||||
|
@ -403,11 +406,13 @@ registerButton("admin", (attrs) => {
|
||||||
if (!attrs.canManage && !attrs.canWiki && !attrs.canEditStaffNotes) {
|
if (!attrs.canManage && !attrs.canWiki && !attrs.canEditStaffNotes) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "openAdminMenu",
|
action: "openAdminMenu",
|
||||||
title: "post.controls.admin",
|
title: "post.controls.admin",
|
||||||
className: "show-post-admin-menu",
|
className: "show-post-admin-menu",
|
||||||
icon: "wrench",
|
icon: "wrench",
|
||||||
|
sendActionEvent: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -464,7 +469,7 @@ function _replaceButton(buttons, find, replace) {
|
||||||
|
|
||||||
export default createWidget("post-menu", {
|
export default createWidget("post-menu", {
|
||||||
tagName: "section.post-menu-area.clearfix",
|
tagName: "section.post-menu-area.clearfix",
|
||||||
services: ["modal"],
|
services: ["modal", "menu"],
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
collapseButtons: true,
|
collapseButtons: true,
|
||||||
|
@ -477,27 +482,67 @@ export default createWidget("post-menu", {
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
likedUsers: [],
|
likedUsers: [],
|
||||||
readers: [],
|
readers: [],
|
||||||
adminVisible: false,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
buildKey: (attrs) => `post-menu-${attrs.id}`,
|
buildKey: (attrs) => `post-menu-${attrs.id}`,
|
||||||
|
|
||||||
attachButton(name) {
|
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
|
// If the button is replaced via the plugin API, we need to render the
|
||||||
// replacement rather than a button
|
// replacement rather than a button
|
||||||
if (buttonAtts?.replaced) {
|
if (buttonAttrs?.replaced) {
|
||||||
return this.attach(buttonAtts.name, buttonAtts.attrs);
|
return this.attach(buttonAttrs.name, buttonAttrs.attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buttonAtts) {
|
if (buttonAttrs) {
|
||||||
let button = this.attach(this.settings.buttonType, buttonAtts);
|
let button = this.attach(this.settings.buttonType, buttonAttrs);
|
||||||
if (buttonAtts.before) {
|
if (buttonAttrs.before) {
|
||||||
let before = this.attachButton(buttonAtts.before);
|
let before = this.attachButton(buttonAttrs.before);
|
||||||
return h("div.double-button", [before, button]);
|
return h("div.double-button", [before, button]);
|
||||||
} else if (buttonAtts.addContainer) {
|
} else if (buttonAttrs.addContainer) {
|
||||||
return h("div.double-button", [button]);
|
return h("div.double-button", [button]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -590,18 +635,18 @@ export default createWidget("post-menu", {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldAddButton && builder) {
|
if (shouldAddButton && builder) {
|
||||||
const buttonAtts = builder(
|
const buttonAttrs = builder(
|
||||||
attrs,
|
attrs,
|
||||||
this.state,
|
this.state,
|
||||||
this.siteSettings,
|
this.siteSettings,
|
||||||
this.settings,
|
this.settings,
|
||||||
this.currentUser
|
this.currentUser
|
||||||
);
|
);
|
||||||
if (buttonAtts) {
|
if (buttonAttrs) {
|
||||||
const { position, beforeButton, afterButton } = buttonAtts;
|
const { position, beforeButton, afterButton } = buttonAttrs;
|
||||||
delete buttonAtts.position;
|
delete buttonAttrs.position;
|
||||||
|
|
||||||
let button = this.attach(this.settings.buttonType, buttonAtts);
|
let button = this.attach(this.settings.buttonType, buttonAttrs);
|
||||||
|
|
||||||
const content = [];
|
const content = [];
|
||||||
if (beforeButton) {
|
if (beforeButton) {
|
||||||
|
@ -666,9 +711,6 @@ export default createWidget("post-menu", {
|
||||||
];
|
];
|
||||||
|
|
||||||
postControls.push(h("div.actions", controlsButtons));
|
postControls.push(h("div.actions", controlsButtons));
|
||||||
if (state.adminVisible) {
|
|
||||||
postControls.push(this.attach("post-admin-menu", attrs));
|
|
||||||
}
|
|
||||||
|
|
||||||
const contents = [
|
const contents = [
|
||||||
h(
|
h(
|
||||||
|
@ -728,12 +770,28 @@ export default createWidget("post-menu", {
|
||||||
return contents;
|
return contents;
|
||||||
},
|
},
|
||||||
|
|
||||||
openAdminMenu() {
|
openAdminMenu(event) {
|
||||||
this.state.adminVisible = true;
|
this.menu.show(event.target, {
|
||||||
},
|
identifier: "admin-post-menu",
|
||||||
|
component: AdminPostMenu,
|
||||||
closeAdminMenu() {
|
data: {
|
||||||
this.state.adminVisible = false;
|
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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
showDeleteTopicModal() {
|
showDeleteTopicModal() {
|
||||||
|
|
|
@ -991,13 +991,9 @@ export default createWidget("post", {
|
||||||
|
|
||||||
this.currentUser.showUserTip({
|
this.currentUser.showUserTip({
|
||||||
id: "post_menu",
|
id: "post_menu",
|
||||||
|
|
||||||
titleText: I18n.t("user_tips.post_menu.title"),
|
titleText: I18n.t("user_tips.post_menu.title"),
|
||||||
contentText: I18n.t("user_tips.post_menu.content"),
|
contentText: I18n.t("user_tips.post_menu.content"),
|
||||||
|
|
||||||
reference,
|
reference,
|
||||||
appendTo: reference?.closest(".post-controls"),
|
|
||||||
|
|
||||||
placement: "top",
|
placement: "top",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -40,6 +40,7 @@
|
||||||
"@embroider/core": "^3.2.1",
|
"@embroider/core": "^3.2.1",
|
||||||
"@embroider/macros": "^1.13.1",
|
"@embroider/macros": "^1.13.1",
|
||||||
"@embroider/webpack": "^3.1.5",
|
"@embroider/webpack": "^3.1.5",
|
||||||
|
"@floating-ui/dom": "^1.5.0",
|
||||||
"@glimmer/component": "^1.1.2",
|
"@glimmer/component": "^1.1.2",
|
||||||
"@glimmer/tracking": "^1.1.2",
|
"@glimmer/tracking": "^1.1.2",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
@ -98,10 +99,10 @@
|
||||||
"qunit-dom": "^2.0.0",
|
"qunit-dom": "^2.0.0",
|
||||||
"sass": "^1.66.1",
|
"sass": "^1.66.1",
|
||||||
"select-kit": "1.0.0",
|
"select-kit": "1.0.0",
|
||||||
|
"float-kit": "1.0.0",
|
||||||
"sinon": "^15.2.0",
|
"sinon": "^15.2.0",
|
||||||
"source-map": "^0.7.4",
|
"source-map": "^0.7.4",
|
||||||
"terser": "^5.19.4",
|
"terser": "^5.19.4",
|
||||||
"tippy.js": "^6.3.7",
|
|
||||||
"truth-helpers": "1.0.0",
|
"truth-helpers": "1.0.0",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
"virtual-dom": "^2.1.1",
|
"virtual-dom": "^2.1.1",
|
||||||
|
|
|
@ -28,7 +28,7 @@ acceptance("Page Publishing", function (needs) {
|
||||||
await visit("/t/internationalization-localization/280");
|
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-more-actions");
|
||||||
await click(".topic-post:nth-of-type(1) button.show-post-admin-menu");
|
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");
|
await fillIn(".publish-slug", "bad-slug");
|
||||||
assert.ok(!exists(".valid-slug"));
|
assert.ok(!exists(".valid-slug"));
|
||||||
|
|
|
@ -162,8 +162,8 @@ acceptance("Post inline mentions – user status tooltip", function (needs) {
|
||||||
ends_at: null,
|
ends_at: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function mouseEnter(selector) {
|
async function mouseMove(selector) {
|
||||||
await triggerEvent(query(selector), "mouseenter");
|
await triggerEvent(selector, "mousemove");
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shows user status tooltip", async function (assert) {
|
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"
|
"user status is shown"
|
||||||
);
|
);
|
||||||
|
|
||||||
await mouseEnter(".user-status-message");
|
await mouseMove(".user-status-message");
|
||||||
const statusTooltip = document.querySelector(
|
const statusTooltip = document.querySelector(
|
||||||
".user-status-message-tooltip"
|
".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();
|
.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) {
|
test("shows status tooltip if enabled", async function (assert) {
|
||||||
this.currentUser.name = "Evil Trout";
|
this.currentUser.name = "Evil Trout";
|
||||||
this.currentUser.status = { emoji: "tooth", description: "off to dentist" };
|
this.currentUser.status = { emoji: "tooth", description: "off to dentist" };
|
||||||
|
|
||||||
await render(
|
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(
|
assert
|
||||||
document.querySelector("[data-tippy-root] .user-status-message-tooltip")
|
.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";
|
import { exists, fakeTime, query } from "discourse/tests/helpers/qunit-helpers";
|
||||||
|
|
||||||
async function mouseenter() {
|
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) {
|
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");
|
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) {
|
test("it renders status description if enabled", async function (assert) {
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<UserStatusMessage
|
<UserStatusMessage
|
||||||
|
@ -39,10 +34,9 @@ module("Integration | Component | user-status-message", function (hooks) {
|
||||||
@showDescription=true/>
|
@showDescription=true/>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
assert.equal(
|
assert
|
||||||
query(".user-status-message-description").innerText.trim(),
|
.dom('[data-trigger][data-identifier="user-status-message-tooltip"]')
|
||||||
"off to dentist"
|
.containsText("off to dentist");
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("it shows the until TIME on the tooltip if status will expire today", async function (assert) {
|
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";
|
this.status.ends_at = "2100-02-01T12:30:00.000Z";
|
||||||
|
|
||||||
await render(hbs`<UserStatusMessage @status={{this.status}} />`);
|
await render(
|
||||||
|
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||||
await mouseenter();
|
|
||||||
assert.equal(
|
|
||||||
document
|
|
||||||
.querySelector("[data-tippy-root] .user-status-tooltip-until")
|
|
||||||
.textContent.trim(),
|
|
||||||
"Until: 12:30 PM"
|
|
||||||
);
|
);
|
||||||
|
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) {
|
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";
|
this.status.ends_at = "2100-02-02T12:30:00.000Z";
|
||||||
|
|
||||||
await render(hbs`<UserStatusMessage @status={{this.status}} />`);
|
await render(
|
||||||
|
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||||
await mouseenter();
|
|
||||||
assert.equal(
|
|
||||||
document
|
|
||||||
.querySelector("[data-tippy-root] .user-status-tooltip-until")
|
|
||||||
.textContent.trim(),
|
|
||||||
"Until: Feb 2"
|
|
||||||
);
|
);
|
||||||
|
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) {
|
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;
|
this.status.ends_at = null;
|
||||||
|
|
||||||
await render(hbs`<UserStatusMessage @status={{this.status}} />`);
|
await render(
|
||||||
|
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||||
await mouseenter();
|
|
||||||
assert.notOk(
|
|
||||||
document.querySelector("[data-tippy-root] .user-status-tooltip-until")
|
|
||||||
);
|
);
|
||||||
|
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) {
|
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(
|
await render(
|
||||||
hbs`<UserStatusMessage @status={{this.status}} @showTooltip={{false}} />`
|
hbs`<UserStatusMessage @status={{this.status}} /><DInlineTooltip />`
|
||||||
);
|
);
|
||||||
await mouseenter();
|
await mouseenter();
|
||||||
|
|
||||||
assert.notOk(
|
assert
|
||||||
document.querySelector("[data-tippy-root] .user-status-message-tooltip")
|
.dom('[data-content][data-identifier="user-status-message-tooltip"]')
|
||||||
);
|
.exists();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doesn't blow up with an anonymous user", async function (assert) {
|
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}} />`);
|
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) {
|
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" />`
|
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) {
|
test("show admin menu", async function (assert) {
|
||||||
this.set("args", { canManage: true });
|
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");
|
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");
|
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) {
|
test("permanently delete topic", async function (assert) {
|
||||||
|
@ -578,13 +586,17 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||||
this.set("permanentlyDeletePost", () => (this.deleted = true));
|
this.set("permanentlyDeletePost", () => (this.deleted = true));
|
||||||
|
|
||||||
await render(
|
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-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(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) {
|
test("permanently delete post", async function (assert) {
|
||||||
|
@ -593,12 +605,18 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||||
|
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} />
|
<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} />
|
||||||
|
<DInlineMenu />
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await click(".post-menu-area .show-post-admin-menu");
|
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(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) {
|
test("toggle moderator post", async function (assert) {
|
||||||
|
@ -608,29 +626,18 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||||
|
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<MountWidget @widget="post" @args={{this.args}} @togglePostType={{this.togglePostType}} />
|
<MountWidget @widget="post" @args={{this.args}} @togglePostType={{this.togglePostType}} />
|
||||||
|
<DInlineMenu />
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await click(".post-menu-area .show-post-admin-menu");
|
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(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("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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rebake post", async function (assert) {
|
test("rebake post", async function (assert) {
|
||||||
|
@ -639,27 +646,41 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||||
|
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<MountWidget @widget="post" @args={{this.args}} @rebakePost={{this.rebakePost}} />
|
<MountWidget @widget="post" @args={{this.args}} @rebakePost={{this.rebakePost}} />
|
||||||
|
<DInlineMenu />
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await click(".post-menu-area .show-post-admin-menu");
|
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(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) {
|
test("unhide post", async function (assert) {
|
||||||
|
let unhidden;
|
||||||
this.currentUser.admin = true;
|
this.currentUser.admin = true;
|
||||||
this.set("args", { canManage: true, hidden: true });
|
this.set("args", { canManage: true, hidden: true });
|
||||||
this.set("unhidePost", () => (this.unhidden = true));
|
this.set("unhidePost", () => (unhidden = true));
|
||||||
|
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<MountWidget @widget="post" @args={{this.args}} @unhidePost={{this.unhidePost}} />
|
<MountWidget @widget="post" @args={{this.args}} @unhidePost={{this.unhidePost}} />
|
||||||
|
<DInlineMenu />
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await click(".post-menu-area .show-post-admin-menu");
|
await click(".post-menu-area .show-post-admin-menu");
|
||||||
await click(".post-admin-menu .unhide-post");
|
|
||||||
assert.ok(this.unhidden);
|
await click(
|
||||||
assert.ok(!exists(".post-admin-menu"), "also hides the menu");
|
"[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) {
|
test("change owner", async function (assert) {
|
||||||
|
@ -669,12 +690,17 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||||
|
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<MountWidget @widget="post" @args={{this.args}} @changePostOwner={{this.changePostOwner}} />
|
<MountWidget @widget="post" @args={{this.args}} @changePostOwner={{this.changePostOwner}} />
|
||||||
|
<DInlineMenu />
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await click(".post-menu-area .show-post-admin-menu");
|
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(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) {
|
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