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:
Joffrey JAFFEUX 2023-09-12 15:50:26 +02:00 committed by GitHub
parent b8cc1072cc
commit 0623ac684a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
166 changed files with 5360 additions and 2489 deletions

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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;
}); });
}, },

View File

@ -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}}

View File

@ -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">

View File

@ -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

View File

@ -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"

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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,
});
}
}

View File

@ -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">

View File

@ -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;
} }

View File

@ -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>

View File

@ -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

View File

@ -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}}

View File

@ -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(),
});
}
}

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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(),
});
}
}

View File

@ -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>

View File

@ -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}}

View File

@ -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",
}); });
} }

View File

@ -34,7 +34,6 @@
<UserStatusMessage <UserStatusMessage
@status={{@user.status}} @status={{@user.status}}
@showDescription={{@showStatusDescription}} @showDescription={{@showStatusDescription}}
@showTooltip={{@showStatusTooltip}}
/> />
{{/if}} {{/if}}
<span> <span>

View File

@ -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}}

View File

@ -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);
} }
} }

View File

@ -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();
}
}

View File

@ -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,
}); });

View File

@ -0,0 +1,3 @@
export default function noop() {
return () => {};
}

View File

@ -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 */
);
}

View File

@ -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,
});
}
});
});
},
};

View File

@ -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);
} }

View File

@ -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();
}
}

View File

@ -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 = [];

View File

@ -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.
* *

View File

@ -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();
} }

View File

@ -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) {

View File

@ -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;

View File

@ -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;
} }

View File

@ -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"));

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
} }
} }

View File

@ -105,4 +105,8 @@
{{#if this.showFooterNav}} {{#if this.showFooterNav}}
<FooterNav /> <FooterNav />
{{/if}} {{/if}}
</DiscourseRoot> </DiscourseRoot>
<DInlineMenu />
<DInlineTooltip />
<DToasts />

View File

@ -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"}}

View File

@ -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",
}); });

View File

@ -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");
},
});

View File

@ -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);
}); });
} }

View File

@ -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() {

View File

@ -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",
}); });
}, },

View File

@ -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",

View File

@ -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"));

View File

@ -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"
); );

View File

@ -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"));
});
});

View File

@ -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"
);
});
});

View File

@ -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();
});
}
);

View File

@ -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");
});
}
);

View File

@ -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: "));
});
});

View File

@ -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: "));
});
});

View File

@ -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();
}); });
}); });

View File

@ -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();
}); });
}); });

View File

@ -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) {

View File

@ -0,0 +1 @@
engine-strict = true

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,104 @@
import Component from "@glimmer/component";
import { modifier } from "ember-modifier";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DFloatBody from "float-kit/components/d-float-body";
import concatClass from "discourse/helpers/concat-class";
import { getOwner } from "discourse-common/lib/get-owner";
import DMenuInstance from "float-kit/lib/d-menu-instance";
export default class DMenu extends Component {
<template>
{{! template-lint-disable modifier-name-case }}
<DButton
class={{concatClass
"fk-d-menu__trigger"
(if this.menuInstance.expanded "-expanded")
}}
id={{this.menuInstance.id}}
data-identifier={{this.options.identifier}}
data-trigger
@icon={{@icon}}
@translatedAriaLabel={{@ariaLabel}}
@translatedLabel={{@label}}
@translatedTitle={{@title}}
@disabled={{@disabled}}
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{this.registerTrigger}}
...attributes
>
{{#if (has-block "trigger")}}
{{yield this.componentArgs to="trigger"}}
{{/if}}
</DButton>
{{#if this.menuInstance.expanded}}
<DFloatBody
@instance={{this.menuInstance}}
@trapTab={{this.options.trapTab}}
@mainClass="fk-d-menu"
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{this.options.inline}}
@portalOutletElement={{this.menu.portalOutletElement}}
>
{{#if (has-block)}}
{{yield this.componentArgs}}
{{else if (has-block "content")}}
{{yield this.componentArgs to="content"}}
{{else if this.options.component}}
<this.options.component
@data={{this.options.data}}
@close={{this.menuInstance.close}}
/>
{{else if this.options.content}}
{{this.options.content}}
{{/if}}
</DFloatBody>
{{/if}}
</template>
@service menu;
@tracked menuInstance = null;
registerTrigger = modifier((element) => {
const options = {
...this.args,
...{
autoUpdate: true,
listeners: true,
beforeTrigger: () => {
this.menu.close();
},
},
};
const instance = new DMenuInstance(getOwner(this), element, options);
this.menuInstance = instance;
return () => {
instance.destroy();
if (this.isDestroying) {
this.menuInstance = null;
}
};
});
get menuId() {
return `d-menu-${this.menuInstance.id}`;
}
get options() {
return this.menuInstance?.options ?? {};
}
get componentArgs() {
return {
close: this.menu.close,
data: this.options.data,
};
}
}

View File

@ -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();
};
});
}

View File

@ -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;
}

View File

@ -0,0 +1,109 @@
import Component from "@glimmer/component";
import { modifier } from "ember-modifier";
import { tracked } from "@glimmer/tracking";
import icon from "discourse-common/helpers/d-icon";
import { inject as service } from "@ember/service";
import DFloatBody from "float-kit/components/d-float-body";
import concatClass from "discourse/helpers/concat-class";
import DTooltipInstance from "float-kit/lib/d-tooltip-instance";
import { getOwner } from "discourse-common/lib/get-owner";
import and from "truth-helpers/helpers/and";
export default class DTooltip extends Component {
<template>
{{! template-lint-disable modifier-name-case }}
<span
class={{concatClass
"fk-d-tooltip__trigger"
(if this.tooltipInstance.expanded "-expanded")
}}
role="button"
id={{this.tooltipInstance.id}}
data-identifier={{this.options.identifier}}
data-trigger
aria-expanded={{if this.tooltipInstance.expanded "true" "false"}}
{{this.registerTrigger}}
...attributes
>
<div class="fk-d-tooltip__trigger-container">
{{#if (has-block "trigger")}}
<div>
{{yield this.componentArgs to="trigger"}}
</div>
{{else}}
{{#if @icon}}
<span class="fk-d-tooltip__icon">
{{~icon @icon~}}
</span>
{{/if}}
{{#if @label}}
<span class="fk-d-tooltip__label">{{@label}}</span>
{{/if}}
{{/if}}
</div>
</span>
{{#if this.tooltipInstance.expanded}}
<DFloatBody
@instance={{this.tooltipInstance}}
@trapTab={{and this.options.interactive this.options.trapTab}}
@mainClass="fk-d-tooltip"
@innerClass="fk-d-tooltip__inner-content"
@role="tooltip"
@inline={{this.options.inline}}
@portalOutletElement={{this.tooltip.portalOutletElement}}
>
{{#if (has-block)}}
{{yield this.componentArgs}}
{{else if (has-block "content")}}
{{yield this.componentArgs to="content"}}
{{else if this.options.component}}
<this.options.component
@data={{this.options.data}}
@close={{this.tooltipInstance.close}}
/>
{{else if this.options.content}}
{{this.options.content}}
{{/if}}
</DFloatBody>
{{/if}}
</template>
@service tooltip;
@tracked tooltipInstance = null;
registerTrigger = modifier((element) => {
const options = {
...this.args,
...{
listeners: true,
beforeTrigger: () => {
this.tooltip.close();
},
},
};
const instance = new DTooltipInstance(getOwner(this), element, options);
this.tooltipInstance = instance;
return () => {
instance.destroy();
if (this.isDestroying) {
this.tooltipInstance = null;
}
};
});
get options() {
return this.tooltipInstance?.options;
}
get componentArgs() {
return {
close: this.tooltip.close,
data: this.options.data,
};
}
}

View File

@ -0,0 +1,76 @@
export const FLOAT_UI_PLACEMENTS = [
"top",
"top-start",
"top-end",
"right",
"right-start",
"right-end",
"bottom",
"bottom-start",
"bottom-end",
"left",
"left-start",
"left-end",
];
export const TOOLTIP = {
options: {
animated: true,
arrow: true,
beforeTrigger: null,
closeOnClickOutside: true,
closeOnEscape: true,
closeOnScroll: true,
component: null,
content: null,
identifier: null,
interactive: false,
listeners: false,
maxWidth: 350,
data: null,
offset: 10,
triggers: ["hover", "click"],
untriggers: ["hover", "click"],
placement: "top",
fallbackPlacements: FLOAT_UI_PLACEMENTS,
autoUpdate: true,
trapTab: true,
},
portalOutletId: "d-tooltip-portal-outlet",
};
export const MENU = {
options: {
animated: true,
arrow: false,
beforeTrigger: null,
closeOnEscape: true,
closeOnClickOutside: true,
closeOnScroll: false,
component: null,
content: null,
identifier: null,
interactive: true,
listeners: false,
maxWidth: 400,
data: null,
offset: 10,
triggers: ["click"],
untriggers: ["click"],
placement: "bottom",
fallbackPlacements: FLOAT_UI_PLACEMENTS,
autoUpdate: true,
trapTab: true,
},
portalOutletId: "d-menu-portal-outlet",
};
import DDefaultToast from "float-kit/components/d-default-toast";
export const TOAST = {
options: {
autoClose: true,
duration: 10000,
component: DDefaultToast,
},
};

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,198 @@
import { action } from "@ember/object";
import { cancel, next } from "@ember/runloop";
import { tracked } from "@glimmer/tracking";
import discourseLater from "discourse-common/lib/later";
import { makeArray } from "discourse-common/lib/helpers";
import { bind } from "discourse-common/utils/decorators";
const TOUCH_OPTIONS = { passive: true, capture: true };
function cancelEvent(event) {
event.preventDefault();
event.stopImmediatePropagation();
}
export default class FloatKitInstance {
@tracked expanded = false;
@tracked id = null;
trigger = null;
content = null;
@action
async show() {
this.expanded = true;
await new Promise((resolve) => next(resolve));
}
@action
async close() {
this.expanded = false;
await new Promise((resolve) => next(resolve));
}
@action
onFocus(event) {
this.onTrigger(event);
}
@action
onBlur(event) {
this.onTrigger(event);
}
@action
onFocusIn(event) {
this.onTrigger(event);
}
@action
onFocusOut(event) {
this.onTrigger(event);
}
@action
onTouchStart(event) {
if (event.touches.length > 1) {
this.onTouchCancel();
return;
}
event.stopPropagation();
this.trigger.addEventListener(
"touchmove",
this.onTouchCancel,
TOUCH_OPTIONS
);
this.trigger.addEventListener(
"touchcancel",
this.onTouchCancel,
TOUCH_OPTIONS
);
this.trigger.addEventListener(
"touchend",
this.onTouchCancel,
TOUCH_OPTIONS
);
this.touchTimeout = discourseLater(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.trigger.addEventListener("touchend", cancelEvent, {
once: true,
capture: true,
});
this.onTrigger(event);
}, 500);
}
@bind
onTouchCancel() {
cancel(this.touchTimeout);
this.trigger.removeEventListener("touchmove", this.onTouchCancel);
this.trigger.removeEventListener("touchend", this.onTouchCancel);
this.trigger.removeEventListener("touchcancel", this.onTouchCancel);
}
tearDownListeners() {
if (!this.options.listeners) {
return;
}
makeArray(this.triggers)
.filter(Boolean)
.forEach((trigger) => {
switch (trigger) {
case "hold":
this.trigger.addEventListener("touchstart", this.onTouchStart);
break;
case "focus":
this.trigger.removeEventListener("focus", this.onFocus);
this.trigger.removeEventListener("blur", this.onBlur);
break;
case "focusin":
this.trigger.removeEventListener("focusin", this.onFocusIn);
this.trigger.removeEventListener("focusout", this.onFocusOut);
break;
case "hover":
this.trigger.removeEventListener("mousemove", this.onMouseMove);
if (!this.options.interactive) {
this.trigger.removeEventListener("mouseleave", this.onMouseLeave);
}
break;
case "click":
this.trigger.removeEventListener("click", this.onClick);
break;
}
});
cancel(this.touchTimeout);
}
setupListeners() {
if (!this.options.listeners) {
return;
}
makeArray(this.triggers)
.filter(Boolean)
.forEach((trigger) => {
switch (trigger) {
case "hold":
this.trigger.addEventListener(
"touchstart",
this.onTouchStart,
TOUCH_OPTIONS
);
break;
case "focus":
this.trigger.addEventListener("focus", this.onFocus, {
passive: true,
});
this.trigger.addEventListener("blur", this.onBlur, {
passive: true,
});
break;
case "focusin":
this.trigger.addEventListener("focusin", this.onFocusIn, {
passive: true,
});
this.trigger.addEventListener("focusout", this.onFocusOut, {
passive: true,
});
break;
case "hover":
this.trigger.addEventListener("mousemove", this.onMouseMove, {
passive: true,
});
if (!this.options.interactive) {
this.trigger.addEventListener("mouseleave", this.onMouseLeave, {
passive: true,
});
}
break;
case "click":
this.trigger.addEventListener("click", this.onClick, {
passive: true,
});
break;
}
});
}
get triggers() {
return this.options.triggers ?? ["click"];
}
get untriggers() {
return this.options.untriggers ?? ["click"];
}
}

View File

@ -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;
}

View File

@ -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` : "",
});
}
}
}

View File

@ -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?.();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,120 @@
import Service from "@ember/service";
import { getOwner } from "@ember/application";
import { action } from "@ember/object";
import DMenuInstance from "float-kit/lib/d-menu-instance";
import { guidFor } from "@ember/object/internals";
import { tracked } from "@glimmer/tracking";
import { updatePosition } from "float-kit/lib/update-position";
export default class Menu extends Service {
@tracked activeMenu;
@tracked portalOutletElement;
/**
* Render a menu
*
* @param {Element | DMenuInstance}
* - trigger - the element that triggered the menu, can also be an object implementing `getBoundingClientRect`
* - menu - an instance of a menu
* @param {Object} [options] - options
* @param {String | Element | Component} [options.content] - Specifies the content of the menu
* @param {Integer} [options.maxWidth] - Specifies the maximum width of the content
* @param {Object} [options.data] - An object which will be passed as the `@data` argument when content is a `Component`
* @param {Boolean} [options.arrow] - Determines if the menu has an arrow
* @param {Boolean} [options.offset] - Displaces the content from its reference trigger in pixels
* @param {String} [options.identifier] - Add a data-identifier attribute to the trigger and the content
* @param {Boolean} [options.inline] - Improves positioning for trigger that spans over multiple lines
*
* @returns {Promise<DMenuInstance>}
*/
@action
async show() {
let instance;
if (arguments[0] instanceof DMenuInstance) {
instance = arguments[0];
if (this.activeMenu === instance && this.activeMenu.expanded) {
return;
}
} else {
const trigger = arguments[0];
if (
this.activeMenu &&
this.activeMenu.id ===
(trigger?.id?.length ? trigger.id : guidFor(trigger)) &&
this.activeMenu.expanded
) {
this.activeMenu?.close();
return;
}
instance = new DMenuInstance(getOwner(this), trigger, arguments[1]);
}
await this.replace(instance);
instance.expanded = true;
return instance;
}
/**
* Replaces any active menu-
*/
@action
async replace(menu) {
await this.activeMenu?.close();
this.activeMenu = menu;
}
/**
* Closes the active menu
* @param {DMenuInstance} [menu] - the menu to close, if not provider will close any active menu
*/
@action
async close(menu) {
if (this.activeMenu && menu && this.activeMenu.id !== menu.id) {
return;
}
await this.activeMenu?.close();
this.activeMenu = null;
}
/**
* Update the menu position
* @param {DMenuInstance} [menu] - the menu to update, if not provider will update any active menu
*/
@action
async update(menu) {
const instance = menu || this.activeMenu;
if (!instance) {
return;
}
await updatePosition(instance.trigger, instance.content, instance.options);
await instance.show();
}
/**
* Register event listeners on a trigger to show a menu
*
* @param {Element} trigger - the element that triggered the menu, can also be an object implementing `getBoundingClientRect`
* @param {Object} [options] - @see `show`
*
* @returns {DMenuInstance} An instance of the menu
*/
@action
register(trigger, options = {}) {
return new DMenuInstance(getOwner(this), trigger, {
...options,
listeners: true,
beforeTrigger: async (menu) => {
await this.replace(menu);
},
});
}
@action
registerPortalOutletElement(element) {
this.portalOutletElement = element;
}
}

View File

@ -0,0 +1,113 @@
import Service from "@ember/service";
import { tracked } from "@glimmer/tracking";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { action } from "@ember/object";
import DDefaultToast from "float-kit/components/d-default-toast";
import DToastInstance from "float-kit/lib/d-toast-instance";
import { getOwner } from "discourse-common/lib/get-owner";
export default class Toasts extends Service {
@tracked activeToasts = new TrackedArray();
/**
* Render a toast
*
* @param {Object} [options] - options passed to the toast component as `@toast` argument
* @param {String} [options.duration] - The duration (ms) of the toast, will be closed after this time
* @param {ComponentClass} [options.component] - A component to render, will use `DDefaultToast` if not provided
* @param {String} [options.class] - A class added to the d-toast element
* @param {Object} [options.data] - An object which will be passed as the `@data` argument to the component
*
* @returns {DToastInstance} - a toast instance
*/
@action
show(options = {}) {
const instance = new DToastInstance(getOwner(this), options);
this.activeToasts.push(instance);
return instance;
}
/**
* Render a DDefaultToast toast with the default theme
*
* @param {Object} [options] - @see show
*
* @returns {DToastInstance} - a toast instance
*/
@action
default(options = {}) {
options.data.theme = "default";
return this.show({ ...options, component: DDefaultToast });
}
/**
* Render a DDefaultToast toast with the success theme
*
* @param {Object} [options] - @see show
*
* @returns {DToastInstance} - a toast instance
*/
@action
success(options = {}) {
options.data.theme = "success";
options.data.icon = "check";
return this.show({ ...options, component: DDefaultToast });
}
/**
* Render a DDefaultToast toast with the error theme
*
* @param {Object} [options] - @see show
*
* @returns {DToastInstance} - a toast instance
*/
@action
error(options = {}) {
options.data.theme = "error";
options.data.icon = "exclamation-triangle";
return this.show({ ...options, component: DDefaultToast });
}
/**
* Render a DDefaultToast toast with the warning theme
*
* @param {Object} [options] - @see show
*
* @returns {DToastInstance} - a toast instance
*/
@action
warning(options = {}) {
options.data.theme = "warning";
options.data.icon = "exclamation-circle";
return this.show({ ...options, component: DDefaultToast });
}
/**
* Render a DDefaultToast toast with the info theme
*
* @param {Object} [options] - @see show
*
* @returns {DToastInstance} - a toast instance
*/
@action
info(options = {}) {
options.data.theme = "info";
options.data.icon = "info-circle";
return this.show({ ...options, component: DDefaultToast });
}
/**
* Close a toast. Any object containg a valid `id` property can be used as a toast parameter.
*/
@action
close(toast) {
this.activeToasts = new TrackedArray(
this.activeToasts.filter((activeToast) => activeToast.id !== toast.id)
);
}
}

View File

@ -0,0 +1,120 @@
import Service from "@ember/service";
import { getOwner } from "@ember/application";
import { action } from "@ember/object";
import DTooltipInstance from "float-kit/lib/d-tooltip-instance";
import { guidFor } from "@ember/object/internals";
import { tracked } from "@glimmer/tracking";
import { updatePosition } from "float-kit/lib/update-position";
export default class Tooltip extends Service {
@tracked activeTooltip;
@tracked portalOutletElement;
/**
* Render a tooltip
*
* @param {Element | DTooltipInstance}
* - trigger - the element that triggered the tooltip, can also be an object implementing `getBoundingClientRect`
* - tooltip - an instance of a tooltip
* @param {Object} [options] - options, if trigger given as first argument
* @param {String | Element | Component} [options.content] - Specifies the content of the tooltip
* @param {Integer} [options.maxWidth] - Specifies the maximum width of the content
* @param {Object} [options.data] - An object which will be passed as the `@data` argument when content is a `Component`
* @param {Boolean} [options.arrow] - Determines if the tooltip has an arrow
* @param {Boolean} [options.offset] - Displaces the content from its reference trigger in pixels
* @param {String} [options.identifier] - Add a data-identifier attribute to the trigger and the content
* @param {Boolean} [options.inline] - Improves positioning for trigger that spans over multiple lines
*
* @returns {Promise<DTooltipInstance>}
*/
@action
async show() {
let instance;
if (arguments[0] instanceof DTooltipInstance) {
instance = arguments[0];
if (this.activeTooltip === instance && this.activeTooltip.expanded) {
return;
}
} else {
const trigger = arguments[0];
if (
this.activeTooltip &&
this.activeTooltip.id ===
(trigger?.id?.length ? trigger.id : guidFor(trigger)) &&
this.activeTooltip.expanded
) {
this.activeTooltip?.close();
return;
}
instance = new DTooltipInstance(getOwner(this), trigger, arguments[1]);
}
await this.replace(instance);
instance.expanded = true;
return instance;
}
/**
* Replaces any active tooltip
*/
@action
async replace(tooltip) {
await this.activeTooltip?.close();
this.activeTooltip = tooltip;
}
/**
* Closes the active tooltip
* @param {DTooltipInstance} [tooltip] - the tooltip to close, if not provider will close any active tooltip
*/
@action
async close(tooltip) {
if (this.activeTooltip && tooltip && this.activeTooltip.id !== tooltip.id) {
return;
}
await this.activeTooltip?.close();
this.activeTooltip = null;
}
/**
* Update the tooltip position
* @param {DTooltipInstance} [tooltip] - the tooltip to update, if not provider will update any active tooltip
*/
@action
async update(tooltip) {
const instance = tooltip || this.activeTooltip;
if (!instance) {
return;
}
await updatePosition(instance.trigger, instance.content, instance.options);
await instance.show();
}
/**
* Register event listeners on a trigger to show a tooltip
*
* @param {Element} trigger - the element that triggered the tooltip, can also be an object implementing `getBoundingClientRect`
* @param {Object} [options] - @see `show`
*
* @returns {DTooltipInstance} An instance of the tooltip
*/
@action
register(trigger, options = {}) {
return new DTooltipInstance(getOwner(this), trigger, {
...options,
listeners: true,
beforeTrigger: async (tooltip) => {
await this.replace(tooltip);
},
});
}
@action
registerPortalOutletElement(element) {
this.portalOutletElement = element;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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