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