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

This commit is contained in:
Joffrey JAFFEUX 2020-03-31 09:13:16 +02:00 committed by GitHub
parent 9bbaaea1e8
commit 4f6d722e45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 612 additions and 0 deletions

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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: "<b>baz</b>" }
],
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: `
<div id="test"></div>
{{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(), "<span><b>baz</b></span>");
}
});
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"));
}
});