DEV: adds a new dropdown widget usable in any widget (#9297)

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

This commit appears in #9297 which was approved by eviltrout. It was merged by jjaffeux.