diff --git a/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/components/search-autocomplete.scss b/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/components/search-autocomplete.scss index d7bae3de27..5a6032f1df 100644 --- a/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/components/search-autocomplete.scss +++ b/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/components/search-autocomplete.scss @@ -2,8 +2,6 @@ .search-autocomplete { position: $search-autocomplete-position; - display: $search-autocomplete-display; - width: $search-autocomplete-width; z-index: $search-autocomplete-z-index; &__content { @@ -15,16 +13,21 @@ } &__item { + margin: $search-autocomplete-item-offset; + padding: $search-autocomplete-item-inner-offset; border-bottom: $search-autocomplete-item-border-bottom; + + &:last-child { + border-bottom-width: 0; + } + + &[aria-selected='true'] { + box-shadow: $search-autocomplete-selected-box-shadow; + } } &__highlight { background: $search-autocomplete-highlight-background; - text-decoration: $search-autocomplete-highlight-text-decoration; - } - - &__footer { - padding: $search-autocomplete-footer-inner-offset; } &__submit { @@ -40,8 +43,6 @@ .search-autocomplete-product { text-decoration: $search-autocomplete-product-text-decoration; display: $search-autocomplete-product-display; - margin: $search-autocomplete-product-offset; - padding: $search-autocomplete-product-inner-offset; &:hover { text-decoration: $search-autocomplete-product-hover-text-decoration; diff --git a/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/variables/search-autocomplete-config.scss b/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/variables/search-autocomplete-config.scss index 675f8655bf..f6bcbe3fca 100644 --- a/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/variables/search-autocomplete-config.scss +++ b/src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/variables/search-autocomplete-config.scss @@ -1,9 +1,8 @@ /* @theme: blank; */ $search-autocomplete-position: absolute !default; -$search-autocomplete-display: block !default; -$search-autocomplete-width: 100% !default; $search-autocomplete-z-index: z('dropdown') + 1 !default; +$search-autocomplete-selected-box-shadow: $focus-visible-style !default; $search-autocomplete-content-min-width: 385px !default; $search-autocomplete-content-display: block !default; @@ -12,11 +11,10 @@ $search-autocomplete-content-float: none !default; $search-autocomplete-content-position: static !default; $search-autocomplete-item-border-bottom: 1px solid get-color('additional', 'light') !default; +$search-autocomplete-item-offset: 0 -#{$offset-y-m + $offset-y-s} !default; +$search-autocomplete-item-inner-offset: #{$offset-y-m + $offset-y-s} #{$offset-y-m + $offset-y-s} !default; $search-autocomplete-highlight-background: get-color('ui', 'warning') !default; -$search-autocomplete-highlight-text-decoration: underline !default; - -$search-autocomplete-footer-inner-offset: #{$offset-y-m + $offset-y-s} 0 !default; $search-autocomplete-submit-line-height: $base-line-height !default; $search-autocomplete-submit-border: none !default; @@ -25,8 +23,6 @@ $search-autocomplete-no-found-inner-offset: #{$offset-y-m + $offset-y-s} 0 !defa $search-autocomplete-product-text-decoration: none !default; $search-autocomplete-product-display: flex !default; -$search-autocomplete-product-offset: 0 -#{$offset-y-m + $offset-y-s} !default; -$search-autocomplete-product-inner-offset: #{$offset-y-m + $offset-y-s} #{$offset-y-m + $offset-y-s} !default; $search-autocomplete-product-hover-text-decoration: none !default; $search-autocomplete-product-image-width: 40px !default; diff --git a/src/Oro/Bundle/ProductBundle/Resources/public/js/app/views/search-autocomplete-view.js b/src/Oro/Bundle/ProductBundle/Resources/public/js/app/views/search-autocomplete-view.js index 3954de6439..d20054cf93 100644 --- a/src/Oro/Bundle/ProductBundle/Resources/public/js/app/views/search-autocomplete-view.js +++ b/src/Oro/Bundle/ProductBundle/Resources/public/js/app/views/search-autocomplete-view.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import BaseView from 'oroui/js/app/views/base/view'; import routing from 'routing'; import template from 'tpl-loader!oroproduct/templates/search-autocomplete.html'; +import 'jquery-ui/tabbable'; const SearchAutocompleteView = BaseView.extend({ optionNames: BaseView.prototype.optionNames.concat([ @@ -41,29 +42,62 @@ const SearchAutocompleteView = BaseView.extend({ events: { change: '_onInputChange', keyup: '_onInputChange', - focus: '_onInputRefresh' + focus: '_onInputRefresh', + keydown: '_onKeyDown' }, previousValue: '', + autocompleteItems: '[role="option"]', + /** * @inheritdoc */ constructor: function SearchAutocompleteView(options) { + this.renderSuggestions = _.debounce(this.renderSuggestions.bind(this), this.delay); SearchAutocompleteView.__super__.constructor.call(this, options); }, + preinitialize() { + this.comboboxId = `combobox-${this.cid}`; + }, + /** * @inheritdoc */ initialize(options) { - this.$el.attr('autocomplete', 'off'); + this.$el.attr({ + 'role': 'combobox', + 'autocomplete': 'off', + 'aria-haspopup': true, + 'aria-expanded': false, + 'aria-autocomplete': 'list', + 'aria-controls': this.comboboxId + }); - this.renderSuggestions = _.debounce(this.renderSuggestions.bind(this), this.delay); + SearchAutocompleteView.__super__.initialize.call(this, options); + }, + + /** + * @inheritdoc + */ + delegateEvents: function() { + SearchAutocompleteView.__super__.delegateEvents.call(this); $('body').on(`click${this.eventNamespace()}`, this._onOutsideAction.bind(this)); - SearchAutocompleteView.__super__.initialize.call(this, options); + return this; + }, + + /** + * @inheritdoc + */ + undelegateEvents: function() { + SearchAutocompleteView.__super__.undelegateEvents.call(this); + + $('body').off(this.eventNamespace()); + + return this; }, getInputString() { @@ -75,7 +109,8 @@ const SearchAutocompleteView = BaseView.extend({ */ getTemplateData: function(data = {}) { return Object.assign(data, { - inputString: this.getInputString() + inputString: this.getInputString(), + comboboxId: this.comboboxId }); }, @@ -86,20 +121,109 @@ const SearchAutocompleteView = BaseView.extend({ if (this.disposed) { return; } - this.close(); + this.closeCombobox(); - $('body').off(this.eventNamespace()); + this.$el.attr('aria-expanded', null); SearchAutocompleteView.__super__.dispose.call(this); }, - close() { + closeCombobox() { if (!this.$popup) { return; } this.$popup.remove(); this.$popup = null; + this.$el.attr({ + 'aria-expanded': false, + 'aria-activedescendant': null + }); + this.undoFocusStyle(); + }, + + hideCombobox() { + if (!this.$popup) { + return; + } + + this.$el.attr({ + 'aria-expanded': false, + 'aria-activedescendant': null + }); + this.$popup.hide(); + this.gerSelectedOption().removeAttr('aria-selected'); + this.undoFocusStyle(); + }, + + showCombobox() { + if (!this.$popup) { + return; + } + + this.$popup.show(); + }, + + hasSelectedOption() { + return this.gerSelectedOption().length > 0; + }, + + getAutocompleteItems() { + return this.$el.next().find(this.autocompleteItems); + }, + + gerSelectedOption() { + return this.getAutocompleteItems().filter((i, el) => $(el).attr('aria-selected') === 'true'); + }, + + getNextOption() { + const $options = this.getAutocompleteItems(); + const $activeOption = this.gerSelectedOption(); + + if ( + $activeOption.length === 0 || + ($options.length - 1 === $options.index($activeOption)) + ) { + return $options.first(); + } + + return $options.eq($options.index($activeOption) + 1); + }, + + getPreviousOption() { + const $options = this.getAutocompleteItems(); + const $activeOption = this.gerSelectedOption(); + + if ( + $activeOption.length === 0 || + $options.index($activeOption) === 0 + ) { + return $options.last(); + } + + return $options.eq($options.index($activeOption) - 1); + }, + + /** + * @param {string} direction + */ + goToOption(direction = 'down') { + const $options = this.getAutocompleteItems(); + const $activeOption = direction === 'down' + ? this.getNextOption() + : this.getPreviousOption() + ; + + this.showCombobox(); + $options.attr('aria-selected', false); + $activeOption.attr('aria-selected', true); + this.$el.attr('aria-activedescendant', $activeOption.attr('id')); + }, + + executeSelectedOption() { + if (this.hasSelectedOption()) { + this.gerSelectedOption().find(':first-child')[0].click(); + } }, _getSearchXHR(inputString) { @@ -117,11 +241,19 @@ const SearchAutocompleteView = BaseView.extend({ * @inheritdoc */ render(suggestions) { - this.close(); + this.closeCombobox(); if (this.getInputString().length) { this.$popup = $(this.template(this.getTemplateData(suggestions))); this.$el.after(this.$popup); + this.$el.attr('aria-expanded', true); + + this.getAutocompleteItems().each((i, el) => { + $(el).attr({ + 'id': _.uniqueId('item-'), + 'aria-selected': false + }).find(':tabbable').attr('tabindex', -1); + }); } return this; @@ -148,6 +280,35 @@ const SearchAutocompleteView = BaseView.extend({ ; }, + _onKeyDown(event) { + switch (event.key) { + case 'Tab': + case 'Escape': + this.hideCombobox(); + break; + case 'ArrowUp': + event.preventDefault(); + this.goToOption('up'); + break; + case 'ArrowDown': + event.preventDefault(); + this.goToOption('down'); + break; + case 'Enter': + case ' ': + this.executeSelectedOption(); + break; + default: + break; + } + + this.undoFocusStyle(); + }, + + undoFocusStyle() { + this.$el.toggleClass('undo-focus', this.hasSelectedOption()); + }, + _onInputChange(event) { const inputString = this.getInputString(); if (inputString === this.previousValue) { @@ -156,7 +317,7 @@ const SearchAutocompleteView = BaseView.extend({ this._shouldShowPopup(inputString) ? this.renderSuggestions(inputString) - : this.close(); + : this.closeCombobox(); this.previousValue = inputString; }, @@ -166,16 +327,16 @@ const SearchAutocompleteView = BaseView.extend({ if (!inputString.length && this.searchXHR) { this.searchXHR.abort(); - }; + } this._shouldShowPopup(inputString) ? this.renderSuggestions(inputString) - : this.close(); + : this.closeCombobox(); }, _onOutsideAction(event) { if (!((event.target === this.el) || (this.$popup && $.contains(this.$popup[0], event.target)))) { - this.close(); + this.closeCombobox(); } } }); diff --git a/src/Oro/Bundle/ProductBundle/Resources/public/templates/search-autocomplete.html b/src/Oro/Bundle/ProductBundle/Resources/public/templates/search-autocomplete.html index 82f3626f3c..3202b3832e 100644 --- a/src/Oro/Bundle/ProductBundle/Resources/public/templates/search-autocomplete.html +++ b/src/Oro/Bundle/ProductBundle/Resources/public/templates/search-autocomplete.html @@ -1,7 +1,7 @@ <% function highlightWords(inputString, value) { if (!!inputString && _.isString(inputString)) { - const highlightPattern = '$&'; + const highlightPattern = '$&'; const inputWords = _.escape(inputString).split(' '); const reg = new RegExp(inputWords.join('|'), 'gi'); return _.escape(value).replace(reg, highlightPattern); @@ -14,37 +14,40 @@