mirror of
https://github.com/discourse/discourse.git
synced 2025-06-02 15:54:43 +08:00
DEV: FloatKit (#23541)
Second iteration of https://github.com/discourse/discourse/pull/23312 with a fix for embroider not resolving an export file using .gjs extension. --- This PR introduces three new concepts to Discourse codebase through an addon called "FloatKit": - menu - tooltip - toast ## Tooltips ### Component Simple cases can be express with an API similar to DButton: ```hbs <DTooltip @label={{i18n "foo.bar"}} @icon="check" @content="Something" /> ``` More complex cases can use blocks: ```hbs <DTooltip> <:trigger> {{d-icon "check"}} <span>{{i18n "foo.bar"}}</span> </:trigger> <:content> Something </:content> </DTooltip> ``` ### Service You can manually show a tooltip using the `tooltip` service: ```javascript const tooltipInstance = await this.tooltip.show( document.querySelector(".my-span"), options ) // and later manual close or destroy it tooltipInstance.close(); tooltipInstance.destroy(); // you can also just close any open tooltip through the service this.tooltip.close(); ``` The service also allows you to register event listeners on a trigger, it removes the need for you to manage open/close of a tooltip started through the service: ```javascript const tooltipInstance = this.tooltip.register( document.querySelector(".my-span"), options ) // when done you can destroy the instance to remove the listeners tooltipInstance.destroy(); ``` Note that the service also allows you to use a custom component as content which will receive `@data` and `@close` as args: ```javascript const tooltipInstance = await this.tooltip.show( document.querySelector(".my-span"), { component: MyComponent, data: { foo: 1 } } ) ``` ## Menus Menus are very similar to tooltips and provide the same kind of APIs: ### Component ```hbs <DMenu @icon="plus" @label={{i18n "foo.bar"}}> <ul> <li>Foo</li> <li>Bat</li> <li>Baz</li> </ul> </DMenu> ``` They also support blocks: ```hbs <DMenu> <:trigger> {{d-icon "plus"}} <span>{{i18n "foo.bar"}}</span> </:trigger> <:content> <ul> <li>Foo</li> <li>Bat</li> <li>Baz</li> </ul> </:content> </DMenu> ``` ### Service You can manually show a menu using the `menu` service: ```javascript const menuInstance = await this.menu.show( document.querySelector(".my-span"), options ) // and later manual close or destroy it menuInstance.close(); menuInstance.destroy(); // you can also just close any open tooltip through the service this.menu.close(); ``` The service also allows you to register event listeners on a trigger, it removes the need for you to manage open/close of a tooltip started through the service: ```javascript const menuInstance = this.menu.register( document.querySelector(".my-span"), options ) // when done you can destroy the instance to remove the listeners menuInstance.destroy(); ``` Note that the service also allows you to use a custom component as content which will receive `@data` and `@close` as args: ```javascript const menuInstance = await this.menu.show( document.querySelector(".my-span"), { component: MyComponent, data: { foo: 1 } } ) ``` ## Toasts Interacting with toasts is made only through the `toasts` service. A default component is provided (DDefaultToast) and can be used through dedicated service methods: - this.toasts.success({ ... }); - this.toasts.warning({ ... }); - this.toasts.info({ ... }); - this.toasts.error({ ... }); - this.toasts.default({ ... }); ```javascript this.toasts.success({ data: { title: "Foo", message: "Bar", actions: [ { label: "Ok", class: "btn-primary", action: (componentArgs) => { // eslint-disable-next-line no-alert alert("Closing toast:" + componentArgs.data.title); componentArgs.close(); }, } ] }, }); ``` You can also provide your own component: ```javascript this.toasts.show(MyComponent, { autoClose: false, class: "foo", data: { baz: 1 }, }) ``` Co-authored-by: Martin Brennan <mjrbrennan@gmail.com> Co-authored-by: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com> Co-authored-by: David Taylor <david@taylorhq.com> Co-authored-by: Jarek Radosz <jradosz@gmail.com>
This commit is contained in:
@ -1,30 +1,24 @@
|
||||
{{#if @buttons.length}}
|
||||
<Chat::Composer::Button
|
||||
{{on "click" this.toggleExpand}}
|
||||
@icon="plus"
|
||||
title={{i18n "chat.composer.toggle_toolbar"}}
|
||||
disabled={{@isDisabled}}
|
||||
{{did-insert this.setupTrigger}}
|
||||
<DMenu
|
||||
class={{concat-class
|
||||
"chat-composer-dropdown__trigger-btn"
|
||||
"btn-flat"
|
||||
(if @hasActivePanel "has-active-panel")
|
||||
}}
|
||||
aria-expanded={{if this.isExpanded "true" "false"}}
|
||||
aria-controls={{this.ariaControls}}
|
||||
@title={{i18n "chat.composer.toggle_toolbar"}}
|
||||
@icon="plus"
|
||||
@disabled={{@isDisabled}}
|
||||
@arrow={{true}}
|
||||
@placements={{array "top" "bottom"}}
|
||||
...attributes
|
||||
/>
|
||||
{{#if this.isExpanded}}
|
||||
<ul
|
||||
id="chat-composer-dropdown__list"
|
||||
class="chat-composer-dropdown__list"
|
||||
{{did-insert this.setupPanel}}
|
||||
{{will-destroy this.teardownPanel}}
|
||||
>
|
||||
as |menu|
|
||||
>
|
||||
<ul class="chat-composer-dropdown__list">
|
||||
{{#each @buttons as |button|}}
|
||||
<li class={{concat-class "chat-composer-dropdown__item" button.id}}>
|
||||
<DButton
|
||||
@icon={{button.icon}}
|
||||
@action={{fn this.onButtonClick button}}
|
||||
@action={{fn this.onButtonClick button menu.close}}
|
||||
@label={{button.label}}
|
||||
class={{concat-class
|
||||
"chat-composer-dropdown__action-btn"
|
||||
@ -34,5 +28,5 @@
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</DMenu>
|
||||
{{/if}}
|
@ -1,68 +1,10 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import tippy from "tippy.js";
|
||||
import { action } from "@ember/object";
|
||||
import { hideOnEscapePlugin } from "discourse/lib/d-popover";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
|
||||
export default class ChatComposerDropdown extends Component {
|
||||
@tracked isExpanded = false;
|
||||
@tracked tippyInstance = null;
|
||||
|
||||
trigger = null;
|
||||
|
||||
@action
|
||||
setupTrigger(element) {
|
||||
this.trigger = element;
|
||||
}
|
||||
|
||||
get ariaControls() {
|
||||
return this.tippyInstance?.popper?.id;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleExpand() {
|
||||
if (this.args.hasActivePanel) {
|
||||
this.args.onCloseActivePanel?.();
|
||||
} else {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onButtonClick(button) {
|
||||
this.tippyInstance.hide();
|
||||
onButtonClick(button, closeFn) {
|
||||
closeFn();
|
||||
button.action();
|
||||
}
|
||||
|
||||
@action
|
||||
setupPanel(element) {
|
||||
this.tippyInstance = tippy(this.trigger, {
|
||||
theme: "chat-composer-dropdown",
|
||||
trigger: "click",
|
||||
zIndex: 1400,
|
||||
arrow: iconHTML("tippy-rounded-arrow"),
|
||||
interactive: true,
|
||||
allowHTML: false,
|
||||
appendTo: "parent",
|
||||
hideOnClick: true,
|
||||
plugins: [hideOnEscapePlugin],
|
||||
content: element,
|
||||
onShow: () => {
|
||||
this.isExpanded = true;
|
||||
return true;
|
||||
},
|
||||
onHide: () => {
|
||||
this.isExpanded = false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.tippyInstance.show();
|
||||
}
|
||||
|
||||
@action
|
||||
teardownPanel() {
|
||||
this.tippyInstance?.destroy();
|
||||
}
|
||||
}
|
||||
|
@ -35,13 +35,6 @@
|
||||
<ChatComposerDropdown
|
||||
@buttons={{this.dropdownButtons}}
|
||||
@isDisabled={{this.disabled}}
|
||||
@hasActivePanel={{eq
|
||||
this.chatEmojiPickerManager.picker.context
|
||||
this.context
|
||||
}}
|
||||
@onCloseActivePanel={{this.chatEmojiPickerManager.close}}
|
||||
{{on "focus" (fn this.computeIsFocused true)}}
|
||||
{{on "blur" (fn this.computeIsFocused false)}}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
@ -425,7 +425,7 @@ export default class ChatComposer extends Component {
|
||||
user.cssClasses = "is-online";
|
||||
}
|
||||
});
|
||||
initUserStatusHtml(result.users);
|
||||
initUserStatusHtml(getOwner(this), result.users);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
@ -0,0 +1,104 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { emojiUnescape, emojiUrlFor } from "discourse/lib/text";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { cached, tracked } from "@glimmer/tracking";
|
||||
import { getReactionText } from "discourse/plugins/chat/discourse/lib/get-reaction-text";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { modifier } from "ember-modifier";
|
||||
import { on } from "@ember/modifier";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
|
||||
export default class ChatMessageReaction extends Component {
|
||||
<template>
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
{{#if (and @reaction this.emojiUrl)}}
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class={{concatClass
|
||||
"chat-message-reaction"
|
||||
(if @reaction.reacted "reacted")
|
||||
(if this.isActive "-active")
|
||||
}}
|
||||
data-emoji-name={{@reaction.emoji}}
|
||||
title={{this.emojiString}}
|
||||
{{on "click" this.handleClick passive=true}}
|
||||
{{this.registerTooltip}}
|
||||
>
|
||||
<img
|
||||
loading="lazy"
|
||||
class="emoji"
|
||||
width="20"
|
||||
height="20"
|
||||
alt={{this.emojiString}}
|
||||
src={{this.emojiUrl}}
|
||||
/>
|
||||
|
||||
{{#if (and this.showCount @reaction.count)}}
|
||||
<span class="count">{{@reaction.count}}</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
@service capabilities;
|
||||
@service currentUser;
|
||||
@service tooltip;
|
||||
@service site;
|
||||
|
||||
@tracked isActive = false;
|
||||
|
||||
registerTooltip = modifier((element) => {
|
||||
if (!this.popoverContent?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = this.tooltip.register(element, {
|
||||
content: htmlSafe(this.popoverContent),
|
||||
identifier: "chat-message-reaction-tooltip",
|
||||
animated: false,
|
||||
placement: "top",
|
||||
fallbackPlacements: ["bottom"],
|
||||
triggers: this.site.mobileView ? ["hold"] : ["hover"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
instance?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
get showCount() {
|
||||
return this.args.showCount ?? true;
|
||||
}
|
||||
|
||||
get emojiString() {
|
||||
return `:${this.args.reaction.emoji}:`;
|
||||
}
|
||||
|
||||
get emojiUrl() {
|
||||
return emojiUrlFor(this.args.reaction.emoji);
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
this.args.onReaction?.(
|
||||
this.args.reaction.emoji,
|
||||
this.args.reaction.reacted ? "remove" : "add"
|
||||
);
|
||||
|
||||
this.tooltip.close();
|
||||
}
|
||||
|
||||
@cached
|
||||
get popoverContent() {
|
||||
if (!this.args.reaction.count || !this.args.reaction.users?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return emojiUnescape(getReactionText(this.args.reaction, this.currentUser));
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
{{#if (and @reaction this.emojiUrl)}}
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class={{concat-class
|
||||
"chat-message-reaction"
|
||||
(if @reaction.reacted "reacted")
|
||||
(if this.isActive "-active")
|
||||
}}
|
||||
data-emoji-name={{@reaction.emoji}}
|
||||
data-tippy-content={{this.popoverContent}}
|
||||
title={{this.emojiString}}
|
||||
{{did-insert this.setup}}
|
||||
{{will-destroy this.teardown}}
|
||||
{{did-update this.refreshTooltip this.popoverContent}}
|
||||
>
|
||||
<img
|
||||
loading="lazy"
|
||||
class="emoji"
|
||||
width="20"
|
||||
height="20"
|
||||
alt={{this.emojiString}}
|
||||
src={{this.emojiUrl}}
|
||||
/>
|
||||
|
||||
{{#if (and this.showCount @reaction.count)}}
|
||||
<span class="count">{{@reaction.count}}</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
{{/if}}
|
@ -1,156 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { emojiUnescape, emojiUrlFor } from "discourse/lib/text";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import setupPopover from "discourse/lib/d-popover";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { getReactionText } from "discourse/plugins/chat/discourse/lib/get-reaction-text";
|
||||
|
||||
export default class ChatMessageReaction extends Component {
|
||||
@service capabilities;
|
||||
@service currentUser;
|
||||
|
||||
@tracked isActive = false;
|
||||
|
||||
get showCount() {
|
||||
return this.args.showCount ?? true;
|
||||
}
|
||||
|
||||
@action
|
||||
setup(element) {
|
||||
this.setupListeners(element);
|
||||
this.setupTooltip(element);
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
cancel(this.longPressHandler);
|
||||
this.teardownTooltip();
|
||||
}
|
||||
|
||||
@action
|
||||
setupListeners(element) {
|
||||
this.element = element;
|
||||
|
||||
if (this.capabilities.touch) {
|
||||
this.element.addEventListener("touchstart", this.onTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
this.element.addEventListener("touchmove", this.cancelTouch, {
|
||||
passive: true,
|
||||
});
|
||||
this.element.addEventListener("touchend", this.onTouchEnd);
|
||||
this.element.addEventListener("touchCancel", this.cancelTouch);
|
||||
}
|
||||
|
||||
this.element.addEventListener("click", this.handleClick, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
teardownListeners() {
|
||||
if (this.capabilities.touch) {
|
||||
this.element.removeEventListener("touchstart", this.onTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
this.element.removeEventListener("touchmove", this.cancelTouch, {
|
||||
passive: true,
|
||||
});
|
||||
this.element.removeEventListener("touchend", this.onTouchEnd);
|
||||
this.element.removeEventListener("touchCancel", this.cancelTouch);
|
||||
}
|
||||
|
||||
this.element.removeEventListener("click", this.handleClick, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onTouchStart(event) {
|
||||
event.stopPropagation();
|
||||
this.isActive = true;
|
||||
|
||||
this.longPressHandler = discourseLater(() => {
|
||||
this.touching = false;
|
||||
}, 400);
|
||||
|
||||
this.touching = true;
|
||||
}
|
||||
|
||||
@action
|
||||
cancelTouch() {
|
||||
cancel(this.longPressHandler);
|
||||
this._tippyInstance?.hide();
|
||||
this.touching = false;
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onTouchEnd(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.touching) {
|
||||
this.handleClick(event);
|
||||
}
|
||||
|
||||
cancel(this.longPressHandler);
|
||||
this._tippyInstance?.hide();
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
@action
|
||||
setupTooltip(element) {
|
||||
this._tippyInstance = setupPopover(element, {
|
||||
trigger: "mouseenter",
|
||||
interactive: false,
|
||||
allowHTML: true,
|
||||
offset: [0, 10],
|
||||
onShow(instance) {
|
||||
if (instance.props.content === "") {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
teardownTooltip() {
|
||||
this._tippyInstance?.destroy();
|
||||
}
|
||||
|
||||
@action
|
||||
refreshTooltip() {
|
||||
this._tippyInstance?.setContent(this.popoverContent || "");
|
||||
}
|
||||
|
||||
get emojiString() {
|
||||
return `:${this.args.reaction.emoji}:`;
|
||||
}
|
||||
|
||||
get emojiUrl() {
|
||||
return emojiUrlFor(this.args.reaction.emoji);
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
this.args.onReaction?.(
|
||||
this.args.reaction.emoji,
|
||||
this.args.reaction.reacted ? "remove" : "add"
|
||||
);
|
||||
|
||||
this._tippyInstance?.clearDelayTimeouts();
|
||||
}
|
||||
|
||||
get popoverContent() {
|
||||
if (!this.args.reaction.count || !this.args.reaction.users?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return emojiUnescape(getReactionText(this.args.reaction, this.currentUser));
|
||||
}
|
||||
}
|
@ -34,7 +34,6 @@ import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import ChatOnLongPress from "discourse/plugins/chat/discourse/modifiers/chat/on-long-press";
|
||||
|
||||
let _chatMessageDecorators = [];
|
||||
let _tippyInstances = [];
|
||||
|
||||
export function addChatMessageDecorator(decorator) {
|
||||
_chatMessageDecorators.push(decorator);
|
||||
@ -297,13 +296,6 @@ export default class ChatMessage extends Component {
|
||||
this.#teardownMentionedUsers();
|
||||
}
|
||||
|
||||
#destroyTippyInstances() {
|
||||
_tippyInstances.forEach((instance) => {
|
||||
instance.destroy();
|
||||
});
|
||||
_tippyInstances = [];
|
||||
}
|
||||
|
||||
@action
|
||||
refreshStatusOnMentions() {
|
||||
schedule("afterRender", () => {
|
||||
@ -314,7 +306,7 @@ export default class ChatMessage extends Component {
|
||||
);
|
||||
|
||||
mentions.forEach((mention) => {
|
||||
updateUserStatusOnMention(mention, user.status, _tippyInstances);
|
||||
updateUserStatusOnMention(getOwner(this), mention, user.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -596,6 +588,5 @@ export default class ChatMessage extends Component {
|
||||
user.stopTrackingStatus();
|
||||
user.off("status-changed", this, "refreshStatusOnMentions");
|
||||
});
|
||||
this.#destroyTippyInstances();
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
import { helper } from "@ember/component/helper";
|
||||
|
||||
export default helper(function noop() {
|
||||
return () => {};
|
||||
});
|
Reference in New Issue
Block a user