From 4b558638c845f3bc5dcf8b318e096cb8ae4c853c Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Sun, 10 Sep 2017 19:12:03 +0200 Subject: [PATCH] FEATURE: improves keyboard handling of select-box - arrow keys - escape key --- .../components/category-select-box.js.es6 | 6 + .../discourse/components/select-box.js.es6 | 115 +++++++++++++++--- .../select-box/select-box-row.js.es6 | 9 +- .../templates/components/select-box.hbs | 3 + .../select-box/select-box-collection.hbs | 3 + .../common/components/select-box.scss | 14 +-- .../components/select-box-test.js.es6 | 87 ++++++++++++- 7 files changed, 206 insertions(+), 31 deletions(-) diff --git a/app/assets/javascripts/discourse/components/category-select-box.js.es6 b/app/assets/javascripts/discourse/components/category-select-box.js.es6 index 185dfe673f4..e725a7cb1d1 100644 --- a/app/assets/javascripts/discourse/components/category-select-box.js.es6 +++ b/app/assets/javascripts/discourse/components/category-select-box.js.es6 @@ -26,6 +26,12 @@ export default SelectBoxComponent.extend({ this.set("content", this.get("categories")); this._scopeCategories(); } + + if (Ember.isNone(this.get("value"))) { + if (this.siteSettings.allow_uncategorized_topics && this.get("allowUncategorized") !== false) { + this.set("value", Category.findUncategorized().id); + } + } }, filterFunction: function(content) { diff --git a/app/assets/javascripts/discourse/components/select-box.js.es6 b/app/assets/javascripts/discourse/components/select-box.js.es6 index a7d73fe6a81..07915ea3871 100644 --- a/app/assets/javascripts/discourse/components/select-box.js.es6 +++ b/app/assets/javascripts/discourse/components/select-box.js.es6 @@ -23,6 +23,7 @@ export default Ember.Component.extend({ clearable: false, value: null, + highlightedValue: null, selectedContent: null, noContentLabel: I18n.t("select_box.no_content"), clearSelectionLabel: null, @@ -68,7 +69,15 @@ export default Ember.Component.extend({ shouldHighlightRow: function() { return (rowComponent) => { const id = this._castInteger(rowComponent.get(`content.${this.get("idKey")}`)); - return id === this.get("value"); + return id === this.get("highlightedValue"); + }; + }, + + @computed("value", "idKey") + shouldSelectRow(value, idKey) { + return (rowComponent) => { + const id = this._castInteger(rowComponent.get(`content.${idKey}`)); + return id === value; }; }, @@ -150,7 +159,7 @@ export default Ember.Component.extend({ @on("willDestroyElement") _removeDocumentListeners: function() { - $(document).off("click.select-box", "keydown.select-box"); + $(document).off("click.select-box"); $(window).off("resize.select-box"); }, @@ -195,8 +204,45 @@ export default Ember.Component.extend({ } }, + keyDown(event) { + const keyCode = event.keyCode || event.which; + + if (this.get("expanded")) { + if (keyCode === 9) { + this.set("expanded", false); + } + + if (keyCode === 27) { + this.set("expanded", false); + event.stopPropagation(); + } + + if (keyCode === 13 && Ember.isPresent(this.get("highlightedValue"))) { + event.preventDefault(); + this.setProperties({ + value: this._castInteger(this.get("highlightedValue")), + expanded: false + }); + } + + if (keyCode === 38) { + event.preventDefault(); + const self = this; + Ember.run.throttle(self, this._handleUpArrow, 50); + } + + if (keyCode === 40) { + event.preventDefault(); + const self = this; + Ember.run.throttle(self, this._handleDownArrow, 50); + } + } + }, + @on("didRender") _setupDocumentListeners: function() { + $(document).off("click.select-box"); + $(document) .on("click.select-box", (event) => { if (this.isDestroying || this.isDestroyed) { return; } @@ -207,13 +253,6 @@ export default Ember.Component.extend({ if (!$target.closest($element).length) { this.set("expanded", false); } - }) - .on("keydown.select-box", (event) => { - const keyCode = event.keyCode || event.which; - - if (this.get("expanded") && keyCode === 9) { - this.set("expanded", false); - } }); $(window).on("resize.select-box", () => this.set("expanded", false) ); @@ -233,17 +272,12 @@ export default Ember.Component.extend({ const keyCode = event.keyCode || event.which; if (keyCode === 13 || keyCode === 40) { - this.setProperties({expanded: true, focused: false}); - return false; - } - - if (keyCode === 27) { - this.$(".select-box-offscreen").blur(); - return false; + this.setProperties({ expanded: true, focused: false }); + event.stopPropagation(); } if (keyCode >= 65 && keyCode <= 90) { - this.setProperties({expanded: true, focused: false}); + this.setProperties({ expanded: true, focused: false }); Ember.run.schedule("afterRender", () => { this.$(".filter-query").focus().val(String.fromCharCode(keyCode)); }); @@ -254,7 +288,7 @@ export default Ember.Component.extend({ @observes("expanded") _expandedChanged: function() { if (this.get("expanded")) { - this.setProperties({ focused: false, renderBody: true }); + this.setProperties({ highlightedValue: null, renderBody: true, focused: false }); if (this.get("filterable")) { Ember.run.schedule("afterRender", () => this.$(".filter-query").focus()); @@ -313,6 +347,11 @@ export default Ember.Component.extend({ this.set("filter", filter); }, + onHoverRow(content) { + const id = this._castInteger(content[this.get("idKey")]); + this.set("highlightedValue", id); + }, + onSelectRow(content) { this.setProperties({ value: this._castInteger(content[this.get("idKey")]), @@ -369,5 +408,45 @@ export default Ember.Component.extend({ }); this.get("scrollableParent").off("scroll.select-box"); + }, + + _handleDownArrow() { + this._handleArrow("down"); + }, + + _handleUpArrow() { + this._handleArrow("up"); + }, + + _handleArrow(direction) { + const content = this.get("filteredContent"); + const idKey = this.get("idKey"); + const selectedContent = content.findBy(idKey, this.get("highlightedValue")); + const currentIndex = content.indexOf(selectedContent); + + if (direction === "down") { + if (currentIndex < 0) { + this.set("highlightedValue", this._castInteger(content[0][idKey])); + } else if(currentIndex + 1 < content.length) { + this.set("highlightedValue", this._castInteger(content[currentIndex + 1][idKey])); + } + } else { + if (currentIndex <= 0) { + this.set("highlightedValue", this._castInteger(content[0][idKey])); + } else if(currentIndex - 1 < content.length) { + this.set("highlightedValue", this._castInteger(content[currentIndex - 1][idKey])); + } + } + + Ember.run.schedule("afterRender", () => { + const $highlightedRow = this.$(".select-box-row.is-highlighted"); + + if ($highlightedRow.length === 0) { return; } + + const $collection = this.$(".select-box-collection"); + const rowOffset = $highlightedRow.offset(); + const bodyOffset = $collection.offset(); + $collection.scrollTop(rowOffset.top - bodyOffset.top); + }); } }); diff --git a/app/assets/javascripts/discourse/components/select-box/select-box-row.js.es6 b/app/assets/javascripts/discourse/components/select-box/select-box-row.js.es6 index a976769ac01..e4ebb84e53a 100644 --- a/app/assets/javascripts/discourse/components/select-box/select-box-row.js.es6 +++ b/app/assets/javascripts/discourse/components/select-box/select-box-row.js.es6 @@ -9,7 +9,7 @@ export default Ember.Component.extend({ attributeBindings: ["title"], - classNameBindings: ["isHighlighted:is-highlighted"], + classNameBindings: ["isHighlighted:is-highlighted", "isSelected:is-selected"], @computed("titleForRow") title(titleForRow) { @@ -21,11 +21,16 @@ export default Ember.Component.extend({ return templateForRow(this); }, - @computed("shouldHighlightRow", "value") + @computed("shouldHighlightRow", "highlightedValue") isHighlighted(shouldHighlightRow) { return shouldHighlightRow(this); }, + @computed("shouldSelectRow", "value") + isSelected(shouldSelectRow) { + return shouldSelectRow(this); + }, + mouseEnter() { this.sendAction("onHover", this.get("content")); }, diff --git a/app/assets/javascripts/discourse/templates/components/select-box.hbs b/app/assets/javascripts/discourse/templates/components/select-box.hbs index fd8e9bc171c..e596b1682fc 100644 --- a/app/assets/javascripts/discourse/templates/components/select-box.hbs +++ b/app/assets/javascripts/discourse/templates/components/select-box.hbs @@ -35,10 +35,13 @@ selectBoxRowComponent=selectBoxRowComponent templateForRow=templateForRow shouldHighlightRow=shouldHighlightRow + shouldSelectRow=shouldSelectRow titleForRow=titleForRow onSelectRow=(action "onSelectRow") + onHoverRow=(action "onHoverRow") onClearSelection=(action "onClearSelection") noContentLabel=noContentLabel + highlightedValue=highlightedValue value=value }} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/select-box/select-box-collection.hbs b/app/assets/javascripts/discourse/templates/components/select-box/select-box-collection.hbs index 9cc47c1586c..3d77b5050e5 100644 --- a/app/assets/javascripts/discourse/templates/components/select-box/select-box-collection.hbs +++ b/app/assets/javascripts/discourse/templates/components/select-box/select-box-collection.hbs @@ -11,7 +11,10 @@ templateForRow=templateForRow titleForRow=titleForRow shouldHighlightRow=shouldHighlightRow + shouldSelectRow=shouldSelectRow + highlightedValue=highlightedValue onSelect=onSelectRow + onHover=onHoverRow value=value }} {{else}} diff --git a/app/assets/stylesheets/common/components/select-box.scss b/app/assets/stylesheets/common/components/select-box.scss index b60bc206b5a..49404876eb8 100644 --- a/app/assets/stylesheets/common/components/select-box.scss +++ b/app/assets/stylesheets/common/components/select-box.scss @@ -161,11 +161,15 @@ } &.is-highlighted { + background: $tertiary-low; + } + + &.is-selected { background: $highlight-medium; } - &:hover { - background: $highlight-medium; + &.is-selected.is-highlighted { + background: $tertiary-low; } } @@ -194,12 +198,8 @@ padding: 0; margin: 0; - &:hover .select-box-row.is-highlighted { - background: none; - } - &:hover .select-box-row.is-highlighted:hover { - background: $highlight-medium; + background: $tertiary-low; } } diff --git a/test/javascripts/components/select-box-test.js.es6 b/test/javascripts/components/select-box-test.js.es6 index 9028cb82e79..3aec9521568 100644 --- a/test/javascripts/components/select-box-test.js.es6 +++ b/test/javascripts/components/select-box-test.js.es6 @@ -33,7 +33,7 @@ componentTest('accepts a value by reference', { andThen(() => { assert.equal( - find(".select-box-row.is-highlighted .text").html().trim(), "robin", + find(".select-box-row.is-selected .text").html().trim(), "robin", "it highlights the row corresponding to the value" ); }); @@ -168,7 +168,7 @@ componentTest('accepts custom id/text keys', { click(".select-box-header"); andThen(() => { - assert.equal(find(".select-box-row.is-highlighted .text").html().trim(), "robin"); + assert.equal(find(".select-box-row.is-selected .text").html().trim(), "robin"); }); } }); @@ -268,7 +268,7 @@ componentTest('supports converting select value to integer', { click(".select-box-header"); andThen(() => { - assert.equal(find(".select-box-row.is-highlighted .text").text(), "régis"); + assert.equal(find(".select-box-row.is-selected .text").text(), "régis"); }); andThen(() => { @@ -277,7 +277,7 @@ componentTest('supports converting select value to integer', { }); andThen(() => { - assert.equal(find(".select-box-row.is-highlighted .text").text(), "jeff", "it works with dynamic content"); + assert.equal(find(".select-box-row.is-selected .text").text(), "jeff", "it works with dynamic content"); }); } }); @@ -339,3 +339,82 @@ componentTest('supports custom row title', { }); } }); + +componentTest('supports keyboard events', { + template: '{{select-box content=content}}', + + beforeEach() { + this.set("content", [{ id: 1, text: "robin" }, { id: 2, text: "regis" }]); + }, + + test(assert) { + const arrowDownEvent = () => { + const event = jQuery.Event("keydown"); + event.keyCode = 40; + find(".select-box").trigger(event); + }; + + const arrowUpEvent = () => { + const event = jQuery.Event("keydown"); + event.keyCode = 38; + find(".select-box").trigger(event); + }; + + const escapeEvent = () => { + const event = jQuery.Event("keydown"); + event.keyCode = 27; + find(".select-box").trigger(event); + }; + + const enterEvent = () => { + const event = jQuery.Event("keydown"); + event.keyCode = 13; + find(".select-box").trigger(event); + }; + + click(".select-box-header"); + + andThen(() => arrowDownEvent() ); + + andThen(() => { + assert.equal(find(".select-box-row.is-highlighted").attr("title"), "robin", "it highlights the first row"); + }); + + andThen(() => arrowDownEvent() ); + + andThen(() => { + assert.equal(find(".select-box-row.is-highlighted").attr("title"), "regis", "it highlights the next row"); + }); + + andThen(() => arrowDownEvent() ); + + andThen(() => { + assert.equal(find(".select-box-row.is-highlighted").attr("title"), "regis", "it keeps highlighting the last row when reaching the end"); + }); + + andThen(() => arrowUpEvent() ); + + andThen(() => { + assert.equal(find(".select-box-row.is-highlighted").attr("title"), "robin", "it highlights the previous row"); + }); + + andThen(() => enterEvent() ); + + andThen(() => { + assert.equal(find(".select-box-row.is-selected").attr("title"), "robin", "it selects the row when pressing enter"); + assert.equal(find(".select-box").hasClass("is-expanded"), false, "it collapses the select box when selecting a row"); + }); + + click(".select-box-header"); + + andThen(() => { + assert.equal(find(".select-box").hasClass("is-expanded"), true); + }); + + andThen(() => escapeEvent() ); + + andThen(() => { + assert.equal(find(".select-box").hasClass("is-expanded"), false, "it collapses the select box"); + }); + } +});