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 00000000000..e882588d671
--- /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: "foo" }
+ ]
+ ```
+
+ - 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}}
+
+ {{transformed.label}}
+
+ `
+};
+
+createWidget("widget-dropdown-header", WidgetDropdownHeaderClass);
+
+export const WidgetDropdownItemClass = {
+ tagName: "div",
+
+ transform(attrs) {
+ return {
+ content:
+ attrs.item === "separator"
+ ? "
"
+ : 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
+ )
+ }}
+
+
+ {{#each attrs.content as |item|}}
+ {{attach
+ widget="widget-dropdown-item"
+ attrs=(hash item=item)
+ }}
+ {{/each}}
+
+ {{/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 00000000000..b84b389bf98
--- /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 00000000000..511af7964fc
--- /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",
+ { id: 3, translatedLabel: "With icon", icon: "times" },
+ { id: 4, html: "baz" }
+ ],
+ label: "foo"
+};
+
+async function clickRowById(id) {
+ await click(`#my-dropdown .widget-dropdown-item.item-${id}`);
+}
+
+function rowById(id) {
+ return find(`#my-dropdown .widget-dropdown-item.item-${id}`)[0];
+}
+
+async function toggle() {
+ await click("#my-dropdown .widget-dropdown-header");
+}
+
+function headerLabel() {
+ return find(
+ "#my-dropdown .widget-dropdown-header .label"
+ )[0].innerText.trim();
+}
+
+function header() {
+ return find("#my-dropdown .widget-dropdown-header")[0];
+}
+
+function body() {
+ return find("#my-dropdown .widget-dropdown-body")[0];
+}
+
+const TEMPLATE = `
+ {{mount-widget
+ widget="widget-dropdown"
+ args=(hash
+ id="my-dropdown"
+ icon=icon
+ label=label
+ class=class
+ translatedLabel=translatedLabel
+ content=content
+ options=options
+ )
+}}`;
+
+widgetTest("dropdown id", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ test(assert) {
+ assert.ok(exists("#my-dropdown"));
+ }
+});
+
+widgetTest("label", {
+ template: TEMPLATE,
+
+ _translations: I18n.translations,
+
+ beforeEach() {
+ I18n.translations = { en: { js: { foo: "FooBaz" } } };
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ afterEach() {
+ I18n.translations = this._translations;
+ },
+
+ test(assert) {
+ assert.equal(headerLabel(), "FooBaz");
+ }
+});
+
+widgetTest("translatedLabel", {
+ template: TEMPLATE,
+
+ _translations: I18n.translations,
+
+ beforeEach() {
+ I18n.translations = { en: { js: { foo: "FooBaz" } } };
+ this.setProperties(DEFAULT_CONTENT);
+ this.set("translatedLabel", "BazFoo");
+ },
+
+ afterEach() {
+ I18n.translations = this._translations;
+ },
+
+ test(assert) {
+ assert.equal(headerLabel(), this.translatedLabel);
+ }
+});
+
+widgetTest("content", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ test(assert) {
+ assert.equal(rowById(1).dataset.id, 1, "it creates rows");
+ assert.equal(rowById(2).dataset.id, 2, "it creates rows");
+ assert.equal(rowById(3).dataset.id, 3, "it creates rows");
+ }
+});
+
+widgetTest("onChange action", {
+ template: `
+
+ {{mount-widget
+ widget="widget-dropdown"
+ args=(hash
+ id="my-dropdown"
+ label=label
+ content=content
+ onChange=(action "onChange")
+ )
+ }}
+ `,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+
+ this.on(
+ "onChange",
+ item => (this._element.querySelector("#test").innerText = item.id)
+ );
+ },
+
+ async test(assert) {
+ await clickRowById(2);
+ assert.equal(find("#test").text(), 2, "it calls the onChange actions");
+ }
+});
+
+widgetTest("can be opened and closed", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ async test(assert) {
+ assert.ok(exists("#my-dropdown.closed"));
+ await toggle();
+ assert.ok(exists("#my-dropdown.opened"));
+ await toggle();
+ assert.ok(exists("#my-dropdown.closed"));
+ }
+});
+
+widgetTest("icon", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ this.set("icon", "times");
+ },
+
+ test(assert) {
+ assert.ok(exists(header().querySelector(".d-icon-times")));
+ }
+});
+
+widgetTest("class", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ this.set("class", "activated");
+ },
+
+ test(assert) {
+ assert.ok(exists("#my-dropdown.activated"));
+ }
+});
+
+widgetTest("content with translatedLabel", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ test(assert) {
+ assert.equal(rowById(2).innerText.trim(), "FooBar");
+ }
+});
+
+widgetTest("content with label", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ I18n.translations = { en: { js: { foo: "FooBaz" } } };
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ test(assert) {
+ assert.equal(rowById(1).innerText.trim(), "FooBaz");
+ }
+});
+
+widgetTest("content with icon", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ test(assert) {
+ assert.ok(exists(rowById(3).querySelector(".d-icon-times")));
+ }
+});
+
+widgetTest("content with html", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ test(assert) {
+ assert.equal(rowById(4).innerHTML.trim(), "baz");
+ }
+});
+
+widgetTest("separator", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ },
+
+ test(assert) {
+ assert.ok(
+ find(
+ "#my-dropdown .widget-dropdown-item:nth-child(3)"
+ )[0].classList.contains("separator")
+ );
+ }
+});
+
+widgetTest("hides widget if no content", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties({ content: null, label: "foo" });
+ },
+
+ test(assert) {
+ assert.notOk(exists("#my-dropdown .widget-dropdown-header"));
+ assert.notOk(exists("#my-dropdown .widget-dropdown-body"));
+ }
+});
+
+widgetTest("headerClass option", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ this.set("options", { headerClass: "btn-small and-text" });
+ },
+
+ test(assert) {
+ assert.ok(header().classList.contains("widget-dropdown-header"));
+ assert.ok(header().classList.contains("btn-small"));
+ assert.ok(header().classList.contains("and-text"));
+ }
+});
+
+widgetTest("bodyClass option", {
+ template: TEMPLATE,
+
+ beforeEach() {
+ this.setProperties(DEFAULT_CONTENT);
+ this.set("options", { bodyClass: "gigantic and-yet-small" });
+ },
+
+ test(assert) {
+ assert.ok(body().classList.contains("widget-dropdown-body"));
+ assert.ok(body().classList.contains("gigantic"));
+ assert.ok(body().classList.contains("and-yet-small"));
+ }
+});