FEATURE: initial implementation of an ember native select

This commit is contained in:
Joffrey JAFFEUX 2017-08-13 14:34:50 +02:00 committed by GitHub
parent 7213e02dee
commit 482924b161
15 changed files with 853 additions and 5 deletions

View File

@ -36,11 +36,10 @@
<h3>{{i18n "admin.customize.theme.color_scheme"}}</h3>
<p>{{i18n "admin.customize.theme.color_scheme_select"}}</p>
<p>{{combo-box content=colorSchemes
nameProperty="name"
value=colorSchemeId
selectionIcon="paint-brush"
valueAttribute="id"}}
<p>{{d-select-box data=colorSchemes
textKey="name"
value=colorSchemeId
icon="paint-brush"}}
{{#if colorSchemeChanged}}
{{d-button action="changeScheme" class="btn-primary btn-small submit-edit" icon="check"}}
{{d-button action="cancelChangeScheme" class="btn-small cancel-edit" icon="times"}}

View File

@ -0,0 +1,7 @@
import SelectBoxComponent from "discourse/components/select-box";
export default SelectBoxComponent.extend({
layoutName: "components/select-box",
classNames: "discourse"
});

View File

@ -0,0 +1,211 @@
import { observes } from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
classNames: "select-box",
classNameBindings: ["expanded:is-expanded"],
attributeBindings: ['componentStyle:style'],
componentStyle: function() {
return Ember.String.htmlSafe(`width: ${this.get("maxWidth")}px`);
}.property("maxWidth"),
expanded: false,
focused: false,
caretUpIcon: "caret-up",
caretDownIcon: "caret-down",
icon: null,
value: null,
noDataText: I18n.t("select_box.no_data"),
lastHoveredId: null,
idKey: "id",
textKey: "text",
iconKey: "icon",
filterable: false,
filter: "",
filterPlaceholder: I18n.t("select_box.filter_placeholder"),
filterIcon: "search",
selectBoxRowComponent: "select-box/select-box-row",
selectBoxFilterComponent: "select-box/select-box-filter",
selectBoxHeaderComponent: "select-box/select-box-header",
selectBoxCollectionComponent: "select-box/select-box-collection",
maxCollectionHeight: 200,
maxWidth: 200,
verticalOffset: 0,
horizontalOffset: 0,
_renderBody: false,
init() {
this._super();
if(!this.get("data")) {
this.set("data", []);
}
this.setProperties({
componentId: this.elementId,
filteredData: [],
selectedData: {}
});
},
@observes("filter")
_filter: function() {
if(_.isEmpty(this.get("filter"))) {
this.set("filteredData", this._remapData(this.get("data")));
} else {
const filtered = _.filter(this.get("data"), (data)=> {
return data[this.get("textKey")].toLowerCase().indexOf(this.get("filter")) > -1;
});
this.set("filteredData", this._remapData(filtered));
}
},
@observes("expanded", "filteredData")
_expand: function() {
if(this.get("expanded")) {
this.setProperties({focused: false, _renderBody: true});
Ember.$(document).on("keydown.select-box", (event) => {
const keyCode = event.keyCode || event.which;
if (keyCode === 9) {
this.set("expanded", false);
}
});
if(_.isUndefined(this.get("lastHoveredId"))) {
this.set("lastHoveredId", this.get("value"));
}
Ember.run.scheduleOnce("afterRender", this, () => {
this.$(".select-box-filter .filter-query").focus();
this.$(".select-box-collection").css("max-height", this.get("maxCollectionHeight"));
this.$().removeClass("is-reversed");
const offsetTop = this.$()[0].getBoundingClientRect().top;
const windowHeight = Ember.$(window).height();
const headerHeight = this.$(".select-box-header").outerHeight();
const filterHeight = this.$(".select-box-filter").outerHeight();
if(windowHeight - (offsetTop + this.get("maxCollectionHeight") + filterHeight + headerHeight) < 0) {
this.$().addClass("is-reversed");
this.$(".select-box-body").css({
left: this.get("horizontalOffset"),
top: "",
bottom: headerHeight + this.get("verticalOffset")
});
} else {
this.$(".select-box-body").css({
left: this.get("horizontalOffset"),
top: headerHeight + this.get("verticalOffset"),
bottom: ""
});
}
this.$(".select-box-wrapper").css({
width: this.get("maxWidth"),
display: "block",
height: headerHeight + this.$(".select-box-body").outerHeight()
});
});
} else {
Ember.$(document).off("keydown.select-box");
this.$(".select-box-wrapper").hide();
};
},
willDestroyElement() {
this._super();
Ember.$(document).off("click.select-box");
Ember.$(document).off("keydown.select-box");
this.$(".select-box-offscreen").off("focusin.select-box");
this.$(".select-box-offscreen").off("focusout.select-box");
},
didReceiveAttrs() {
this._super();
this.set("lastHoveredId", this.get("data")[this.get("idKey")]);
this.set("filteredData", this._remapData(this.get("data")));
this._setSelectedData(this.get("data"));
},
didRender() {
this._super();
this.$(".select-box-body").css('width', this.get("maxWidth"));
this._expand();
},
didInsertElement() {
this._super();
Ember.$(document).on("click.select-box", (event) => {
if(this.get("expanded") && $(event.target).parents(".select-box").attr("id") !== this.$().attr("id")) {
this.setProperties({
expanded: false,
focused: false
});
}
});
this.$(".select-box-offscreen").on("focusin.select-box", () => {
this.set("focused", true);
});
this.$(".select-box-offscreen").on("focusout.select-box", () => {
this.set("focused", false);
});
},
actions: {
onToggle() {
this.toggleProperty("expanded");
},
onFilterChange(filter) {
this.set("filter", filter);
},
onSelectRow(id) {
this.setProperties({
value: id,
expanded: false
});
},
onHoverRow(id) {
this.set("lastHoveredId", id);
}
},
_setSelectedData(data) {
const selectedData = _.find(data, (d)=> {
return d[this.get("idKey")] === this.get("value");
});
if(!_.isUndefined(selectedData)) {
this.set("selectedData", this._normalizeData(selectedData));
}
},
_remapData(data) {
return data.map(d => this._normalizeData(d));
},
_normalizeData(data) {
return {
id: data[this.get("idKey")],
text: data[this.get("textKey")],
icon: data[this.get("iconKey")]
};
},
});

View File

@ -0,0 +1,3 @@
export default Ember.Component.extend({
classNames: "select-box-collection"
});

View File

@ -0,0 +1,3 @@
export default Ember.Component.extend({
classNames: "select-box-filter"
});

View File

@ -0,0 +1,23 @@
export default Ember.Component.extend({
classNames: "select-box-header",
classNameBindings: ["focused:is-focused"],
didReceiveAttrs() {
this._super();
this._setCaretIcon();
},
click() {
this.sendAction("onToggle");
},
_setCaretIcon() {
if(this.get("expanded")) {
this.set("caretIcon", this.get("caretUpIcon"));
} else {
this.set("caretIcon", this.get("caretDownIcon"));
}
}
});

View File

@ -0,0 +1,34 @@
export default Ember.Component.extend({
classNames: "select-box-row",
tagName: "li",
classNameBindings: ["isHighlighted"],
attributeBindings: ["text:title"],
lastHoveredId: null,
mouseEnter() {
this.sendAction("onHover", this.get("data.id"));
},
click() {
this.sendAction("onSelect", this.get("data.id"));
},
didReceiveAttrs() {
this._super();
this.set("isHighlighted", this._isHighlighted());
this.set("text", this.get("data.text"));
},
_isHighlighted() {
if(_.isUndefined(this.get("lastHoveredId"))) {
return this.get("data.id") === this.get("selectedId");
} else {
return this.get("data.id") === this.get("lastHoveredId");
}
},
});

View File

@ -0,0 +1,41 @@
<input
class="select-box-offscreen"
type="text"
aria-haspopup="true"
role="button"
aria-labelledby="select-box-input-{{componentId}}"
/>
{{component selectBoxHeaderComponent
data=selectedData
focused=focused
caretUpIcon=caretUpIcon
caretDownIcon=caretDownIcon
onToggle=(action "onToggle")
icon=icon
expanded=expanded
}}
<div class="select-box-body">
{{#if _renderBody}}
{{#if filterable}}
{{component selectBoxFilterComponent
onFilterChange=(action "onFilterChange")
filterIcon=filterIcon
filterPlaceholder=filterPlaceholder
}}
{{/if}}
{{component selectBoxCollectionComponent
filteredData=filteredData
selectBoxRowComponent=selectBoxRowComponent
lastHoveredId=lastHoveredId
onSelectRow=(action "onSelectRow")
onHoverRow=(action "onHoverRow")
noDataText=noDataText
selectedId=value
}}
{{/if}}
</div>
<div class="select-box-wrapper"></div>

View File

@ -0,0 +1,17 @@
<ul class="collection">
{{#each filteredData as |data|}}
{{component selectBoxRowComponent
data=data
lastHoveredId=lastHoveredId
onSelect=onSelectRow
onHover=onHoverRow
selectedId=selectedId
}}
{{else}}
{{#if noDataText}}
<li class="select-box-row no-data">
{{noDataText}}
</li>
{{/if}}
{{/each}}
</ul>

View File

@ -0,0 +1,14 @@
<div class="wrapper">
{{input
tabindex="-1"
class="filter-query"
placeholder=filterPlaceholder
key-up=onFilterChange
}}
{{#if filterIcon}}
<div class="filter-icon">
{{d-icon filterIcon}}
</div>
{{/if}}
</div>

View File

@ -0,0 +1,15 @@
<div class="wrapper">
{{#if icon}}
<div class="icon">
{{d-icon icon}}
</div>
{{/if}}
<span class="current-selection">
{{data.text}}
</span>
<div class="caret-icon">
{{d-icon caretIcon}}
</div>
</div>

View File

@ -0,0 +1,7 @@
{{#if data.icon}}
{{d-icon data.icon}}
{{/if}}
<p class="text">
{{data.text}}
</p>

View File

@ -0,0 +1,246 @@
.select-box {
border-radius: 3px;
box-sizing: border-box;
display: inline-block;
flex-direction: column;
position: relative;
z-index: 998;
&.is-expanded {
z-index: 999;
.select-box-body {
display: flex;
flex-direction: column;
left: 0;
position: absolute;
top: 0;
}
}
&.is-reversed {
.select-box-body {
bottom: 0;
top: auto;
}
.select-box-wrapper {
bottom: 0;
top: auto;
}
}
.d-icon {
color: dark-light-choose(scale-color($header_primary, $lightness: 50%), $header_primary);
font-size: 14px;
}
.select-box-header {
background: $secondary;
border: 1px solid transparent;
box-sizing: border-box;
cursor: pointer;
height: 32px;
outline: none;
}
.select-box-body {
display: none;
box-sizing: border-box;
}
.select-box-row {
cursor: pointer;
outline: none;
padding: 5px;
height: 28px;
min-height: 28px;
line-height: 28px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.select-box-collection {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
background: $secondary;
overflow-x: hidden;
overflow-y: auto;
border-radius: 0 0 3px 3px;
margin: 0;
padding: 0;
-webkit-overflow-scrolling: touch;
}
.select-box-filter {
border-bottom: 1px solid $primary-low;
background: $secondary;
}
.select-box-wrapper {
position: absolute;
top: 0;
left: 0;
background: none;
display: none;
box-sizing: border-box;
pointer-events: none;
border: 1px solid transparent;
}
}
.select-box .select-box-header {
.wrapper {
height: inherit;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-left: 10px;
padding-right: 10px;
}
.current-selection {
text-align: left;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.icon {
margin-right: 5px;
}
.caret-icon {
margin-left: 5px;
pointer-events: none;
}
}
.select-box .select-box-row {
&.is-highlighted {
background: $highlight-medium;
}
}
.select-box .select-box-collection {
flex: 0 1 auto;
.collection {
padding: 0;
margin: 0;
}
&::-webkit-scrollbar {
-webkit-appearance: none;
width: 10px;
}
&::-webkit-scrollbar-thumb {
cursor: pointer;
border-radius: 5px;
background: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
-webkit-transition: color .2s ease;
transition: color .2s ease;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 0;
}
}
.select-box .select-box-row {
.d-icon {
margin-right: 5px;
}
.text {
margin: 0;
}
&.is-selected {
a {
background: $highlight-medium;
}
}
}
.select-box .select-box-filter {
.wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin: 5px 10px;
}
input, input:focus {
margin: 0;
flex: 1;
outline: none;
border: 0;
box-shadow: none;
width: 100%;
padding: 5px 0;
}
.icon {
margin-right: 5px;
}
}
.select-box .select-box-offscreen, .select-box .select-box-offscreen:focus {
clip: rect(0 0 0 0);
width: 1px;
height: 1px;
border: 0;
margin: 0;
padding: 0;
overflow: hidden;
position: absolute;
outline: 0;
left: 0px;
top: 0px;
}
.select-box.discourse {
.select-box-header {
border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
border-radius: 3px;
&.is-focused {
border: 1px solid $tertiary;
border-radius: 3px;
box-shadow: $tertiary 0px 0px 6px 0px;
}
}
&.is-expanded {
.select-box-header {
border-radius: 3px 3px 0 0;
}
.select-box-body, .collection, {
border-radius: 0 0 3px 3px;
}
.select-box-wrapper {
border: 1px solid $tertiary;
border-radius: 3px;
box-shadow: $tertiary 0px 0px 6px 0px;
}
}
.select-box-row {
margin: 5px;
}
&.is-highlighted .select-box-header {
border: 1px solid $tertiary;
box-shadow: $tertiary 0px 0px 6px 0px;
}
}

View File

@ -1143,6 +1143,10 @@ en:
ctrl: 'Ctrl'
alt: 'Alt'
select_box:
no_data: No data
filter_placeholder: Search...
emoji_picker:
filter_placeholder: Search for emoji
people: People

View File

@ -0,0 +1,224 @@
import componentTest from 'helpers/component-test';
moduleForComponent('select-box', {integration: true});
componentTest('updating the data refreshes the list', {
template: '{{select-box value=1 data=data}}',
beforeEach() {
this.set("data", [{id:1, text:"robin"}]);
},
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-row .text").html().trim(), "robin");
andThen(() => this.set("data", [{id:1, text:"regis"}]));
andThen(() => assert.equal(this.$(".select-box-row .text").html().trim(), "regis"));
});
}
});
componentTest('accepts a value by reference', {
template: '{{select-box value=value data=data}}',
beforeEach() {
this.set("value", 1);
this.set("data", [{id:1, text:"robin"}, {id: 2, text:"regis"}]);
},
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-row.is-highlighted .text").html().trim(), "robin", "it highlights the row corresponding to the value");
click(this.$(".select-box-row[title='robin']"));
andThen(() => assert.equal(this.get("value"), 1, "it mutates the value"));
});
}
});
componentTest('select-box can be filtered', {
template: '{{select-box filterable=true value=1 data=data}}',
beforeEach() {
this.set("data", [{id:1, text:"robin"}, {id: 2, text:"regis"}]);
},
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
andThen(() => assert.equal(this.$(".filter-query").length, 1, "it has a search input"));
andThen(() => {
this.$(".filter-query").val("regis");
this.$(".filter-query").trigger("keyup");
});
andThen(() => assert.equal(this.$(".select-box-row").length, 1, "it filters results"));
andThen(() => {
this.$(".filter-query").val("");
this.$(".filter-query").trigger("keyup");
});
andThen(() => assert.equal(this.$(".select-box-row").length, 2, "it returns to original data when filter is empty"));
});
}
});
componentTest('no default icon', {
template: '{{select-box}}',
test(assert) {
assert.equal(this.$(".select-box-header .icon").length, 0, "it doesnt have an icon if not specified");
}
});
componentTest('customisable icon', {
template: '{{select-box icon="shower"}}',
test(assert) {
assert.equal(this.$(".select-box-header .icon").html().trim(), "<i class=\"fa fa-shower d-icon d-icon-shower\"></i>", "it has a the correct icon");
}
});
componentTest('default search icon', {
template: '{{select-box filterable=true}}',
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-filter .filter-icon").html().trim(), "<i class=\"fa fa-search d-icon d-icon-search\"></i>", "it has a the correct icon");
});
}
});
componentTest('with no search icon', {
template: '{{select-box filterable=true searchIcon=null}}',
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".search-icon").length, 0, "it has no icon");
});
}
});
componentTest('custom search icon', {
template: '{{select-box filterable=true filterIcon="shower"}}',
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-filter .filter-icon").html().trim(), "<i class=\"fa fa-shower d-icon d-icon-shower\"></i>", "it has a the correct icon");
});
}
});
componentTest('not filterable by default', {
template: '{{select-box}}',
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-filter").length, 0);
});
}
});
componentTest('select-box is expandable', {
template: '{{select-box}}',
test(assert) {
click(".select-box-header");
andThen(() => {
assert.equal(this.$(".select-box").hasClass("is-expanded"), true);
});
click(".select-box-header");
andThen(() => {
assert.equal(this.$(".select-box").hasClass("is-expanded"), false);
});
}
});
componentTest('accepts custom id/text keys', {
template: '{{select-box value=value data=data idKey="identifier" textKey="name"}}',
beforeEach() {
this.set("value", 1);
this.set("data", [{identifier:1, name:"robin"}]);
},
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-row.is-highlighted .text").html().trim(), "robin");
});
}
});
componentTest('doesnt render collection content before first expand', {
template: '{{select-box value=1 data=data idKey="identifier" textKey="name"}}',
beforeEach() {
this.set("data", [{identifier:1, name:"robin"}]);
},
test(assert) {
assert.equal(this.$(".select-box-body .collection").length, 0);
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-body .collection").length, 1);
});
}
});
componentTest('persists filter state when expandind/collapsing', {
template: '{{select-box value=1 data=data filterable=true}}',
beforeEach() {
this.set("data", [{id:1, text:"robin"}, {id:2, text:"régis"}]);
},
test(assert) {
click(this.$(".select-box-header"));
andThen(() => {
this.$(".filter-query").val("rob");
this.$(".filter-query").trigger("keyup");
});
andThen(() => {
assert.equal(this.$(".select-box-row").length, 1);
});
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$().hasClass("is-expanded"), false);
});
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-row").length, 1);
});
}
});
componentTest('supports options to limit size', {
template: '{{select-box maxWidth=100 maxCollectionHeight=20 data=data}}',
beforeEach() {
this.set("data", [{id:1, text:"robin"}]);
},
test(assert) {
assert.equal(this.$(".select-box-header").outerWidth(), 100, "it limits the width");
click(this.$(".select-box-header"));
andThen(() => {
assert.equal(this.$(".select-box-body").height(), 20, "it limits the height");
});
}
});