DEV: adds a new dropdown widget usable in any widget (#9297)
diff --git a/app/assets/javascripts/discourse/widgets/widget-dropdown.js b/app/assets/javascripts/discourse/widgets/widget-dropdown.js
new file mode 100644
index 0000000..e882588
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/widget-dropdown.js
@@ -0,0 +1,263 @@
+import { createWidget } from "discourse/widgets/widget";
+import hbs from "discourse/widgets/hbs-compiler";
+
+/*
+
+ widget-dropdown
+
+ Usage
+ -----
+
+ {{attach
+ widget="widget-dropdown"
+ attrs=(hash
+ id=id
+ label=label
+ content=content
+ onChange=onChange
+ options=(hash)
+ )
+ }}
+
+ Mandatory attributes:
+
+ - id: must be unique in the application
+
+ - label or translatedLabel:
+ - label: an i18n key to be translated and displayed on the header
+ - translatedLabel: an already translated label to display on the header
+
+ - onChange: action called when a click happens on a row, content[rowIndex] will be passed as params
+
+ Optional attributes:
+
+ - class: adds css class to the dropdown
+ - content: list of items to display, if undefined or empty dropdown won't display
+ Example content:
+
+ ```
+ [
+ { id: 1, label: "foo.bar" },
+ "separator",
+ { id: 2, translatedLabel: "FooBar" },
+ { id: 3 label: "foo.baz", icon: "times" },
+ { id: 4, html: "<b>foo</b>" }
+ ]
+ ```
+
+ - options: accepts a hash of optional attributes
+ - headerClass: adds css class to the dropdown header
+ - bodyClass: adds css class to the dropdown header
+*/
+
+export const WidgetDropdownHeaderClass = {
+ tagName: "button",
+
+ transform(attrs) {
+ return {
+ label: attrs.translatedLabel ? attrs.translatedLabel : I18n.t(attrs.label)
+ };
+ },
+
+ buildClasses(attrs) {
+ let classes = ["widget-dropdown-header", "btn", "btn-default"];
+ if (attrs.class) {
+ classes = classes.concat(attrs.class.split(" "));
+ }
+ return classes.filter(Boolean).join(" ");
+ },
+
+ click(event) {
+ event.preventDefault();
+
+ this.sendWidgetAction("_onTrigger");
+ },
+
+ template: hbs`
+ {{#if attrs.icon}}
+ {{d-icon attrs.icon}}
+ {{/if}}
+ <span class="label">
+ {{transformed.label}}
+ </span>
+ `
+};
+
+createWidget("widget-dropdown-header", WidgetDropdownHeaderClass);
+
+export const WidgetDropdownItemClass = {
+ tagName: "div",
+
+ transform(attrs) {
+ return {
+ content:
+ attrs.item === "separator"
+ ? "<hr>"
+ : attrs.item.html
+ ? attrs.item.html
+ : attrs.item.translatedLabel
+ ? attrs.item.translatedLabel
+ : I18n.t(attrs.item.label)
+ };
+ },
+
+ buildAttributes(attrs) {
+ return { "data-id": attrs.item.id };
+ },
+
+ buildClasses(attrs) {
+ return [
+ "widget-dropdown-item",
+ attrs.item === "separator" ? "separator" : `item-${attrs.item.id}`
+ ].join(" ");
+ },
+
+ click(event) {
+ event.preventDefault();
+
+ this.sendWidgetAction("_onChange", this.attrs.item);
+ },
+
+ template: hbs`
+ {{#if attrs.item.icon}}
+ {{d-icon attrs.item.icon}}
+ {{/if}}
+ {{{transformed.content}}}
+ `
+};
+
+createWidget("widget-dropdown-item", WidgetDropdownItemClass);
+
+export const WidgetDropdownClass = {
+ tagName: "div",
+
+ init(attrs) {
+ if (!attrs) {
+ throw "A widget-dropdown expects attributes.";
+ }
+
+ if (!attrs.id) {
+ throw "A widget-dropdown expects a unique `id` attribute.";
+ }
+
+ if (!attrs.label && !attrs.translatedLabel) {
+ throw "A widget-dropdown expects at least a `label` or `translatedLabel`";
+ }
+ },
+
+ buildKey: attrs => {
+ return attrs.id;
+ },
+
+ buildAttributes(attrs) {
+ return { id: attrs.id };
+ },
+
+ defaultState() {
+ return {
+ opened: false
+ };
+ },
+
+ buildClasses(attrs) {
+ const classes = ["widget-dropdown"];
+ classes.push(this.state.opened ? "opened" : "closed");
+ return classes.join(" ") + " " + (attrs.class || "");
+ },
+
+ transform(attrs) {
+ const options = attrs.options || {};
+
+ return {
+ options,
+ bodyClass: `widget-dropdown-body ${options.bodyClass || ""}`
+ };
+ },
+
+ clickOutside() {
+ this.state.opened = false;
+ this.scheduleRerender();
+ },
+
+ _onChange(params) {
+ this.state.opened = false;
+ if (this.attrs.onChange) {
+ if (typeof this.attrs.onChange === "string") {
+ this.sendWidgetAction(this.attrs.onChange, params);
+ } else {
+ this.attrs.onChange(params);
+ }
+ }
+ },
+
+ _onTrigger() {
+ if (this.state.opened) {
+ this.state.opened = false;
+ this._closeDropdown(this.attrs.id);
+ } else {
+ this.state.opened = true;
+ this._openDropdown(this.attrs.id);
+ }
+
+ this._popper && this._popper.update();
+ },
+
+ destroy() {
+ if (this._popper) {
+ this._popper.destroy();
+ this._popper = null;
+ }
+ },
+
+ template: hbs`
+ {{#if attrs.content}}
+ {{attach
+ widget="widget-dropdown-header"
+ attrs=(hash
+ icon=attrs.icon
+ label=attrs.label
+ translatedLabel=attrs.translatedLabel
+ class=this.transformed.options.headerClass
+ )
+ }}
+
+ <div class={{transformed.bodyClass}}>
+ {{#each attrs.content as |item|}}
+ {{attach
+ widget="widget-dropdown-item"
+ attrs=(hash item=item)
+ }}
+ {{/each}}
+ </div>
+ {{/if}}
+ `,
+
+ _closeDropdown() {
+ this._popper && this._popper.destroy();
+ },
+
+ _openDropdown(id) {
+ const dropdownHeader = document.querySelector(
+ `#${id} .widget-dropdown-header`
+ );
+ const dropdownBody = document.querySelector(`#${id} .widget-dropdown-body`);
+
+ if (dropdownHeader && dropdownBody) {
+ /* global Popper:true */
+ this._popper = Popper.createPopper(dropdownHeader, dropdownBody, {
+ strategy: "fixed",
+ placement: "bottom-start",
+ modifiers: [
+ {
+ name: "offset",
+ options: {
+ offset: [0, 5]
+ }
+ }
+ ]
+ });
+ }
+ }
+};
+
+export default createWidget("widget-dropdown", WidgetDropdownClass);
diff --git a/app/assets/stylesheets/common/components/widget-dropdown.scss b/app/assets/stylesheets/common/components/widget-dropdown.scss
new file mode 100644
index 0000000..b84b389
--- /dev/null
+++ b/app/assets/stylesheets/common/components/widget-dropdown.scss
@@ -0,0 +1,51 @@
+.widget-dropdown {
+ margin: 1em;
+ display: inline-flex;
+ box-sizing: border-box;
+
+ &.closed {
+ .widget-dropdown-body {
+ display: none;
+ }
+ }
+
+ .widget-dropdown-body {
+ display: flex;
+ flex-direction: column;
+ padding: 0.25em;
+ background: $secondary;
+ margin-top: 5px;
+ z-index: z("dropdown");
+ border: 1px solid $primary-low;
+ max-height: 250px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+
+ .widget-dropdown-item {
+ cursor: pointer;
+ padding: 0.25em;
+ display: flex;
+ flex: 1;
+ align-items: center;
+
+ .d-icon {
+ color: $primary-medium;
+ margin-right: 0.25em;
+ }
+
+ &.separator {
+ padding: 0;
+ background: $primary-low;
+ margin: 0.25em 0;
+ }
+
+ &:hover {
+ background: $tertiary-low;
+ }
+ }
+
+ .widget-dropdown-header {
+ cursor: pointer;
+ }
+}
diff --git a/test/javascripts/widgets/widget-dropdown-test.js b/test/javascripts/widgets/widget-dropdown-test.js
new file mode 100644
index 0000000..511af79
--- /dev/null
+++ b/test/javascripts/widgets/widget-dropdown-test.js
@@ -0,0 +1,298 @@
+import { moduleForWidget, widgetTest } from "helpers/widget-test";
+
+moduleForWidget("widget-dropdown");
+
+const DEFAULT_CONTENT = {
+ content: [
+ { id: 1, label: "foo" },
+ { id: 2, translatedLabel: "FooBar" },
+ "separator",
[... diff too long, it was truncated ...]
GitHub sha: 4f6d722e