Skip to content

Commit 707887e

Browse files
BB-20361: Make Search Autocomplete drop-down accessible by keyboard arrows (#31255)
1 parent af1005b commit 707887e

File tree

6 files changed

+273
-57
lines changed

6 files changed

+273
-57
lines changed

src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/components/search-autocomplete.scss

+10-9
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
.search-autocomplete {
44
position: $search-autocomplete-position;
5-
display: $search-autocomplete-display;
6-
width: $search-autocomplete-width;
75
z-index: $search-autocomplete-z-index;
86

97
&__content {
@@ -15,16 +13,21 @@
1513
}
1614

1715
&__item {
16+
margin: $search-autocomplete-item-offset;
17+
padding: $search-autocomplete-item-inner-offset;
1818
border-bottom: $search-autocomplete-item-border-bottom;
19+
20+
&:last-child {
21+
border-bottom-width: 0;
22+
}
23+
24+
&[aria-selected='true'] {
25+
box-shadow: $search-autocomplete-selected-box-shadow;
26+
}
1927
}
2028

2129
&__highlight {
2230
background: $search-autocomplete-highlight-background;
23-
text-decoration: $search-autocomplete-highlight-text-decoration;
24-
}
25-
26-
&__footer {
27-
padding: $search-autocomplete-footer-inner-offset;
2831
}
2932

3033
&__submit {
@@ -40,8 +43,6 @@
4043
.search-autocomplete-product {
4144
text-decoration: $search-autocomplete-product-text-decoration;
4245
display: $search-autocomplete-product-display;
43-
margin: $search-autocomplete-product-offset;
44-
padding: $search-autocomplete-product-inner-offset;
4546

4647
&:hover {
4748
text-decoration: $search-autocomplete-product-hover-text-decoration;

src/Oro/Bundle/ProductBundle/Resources/public/blank/scss/variables/search-autocomplete-config.scss

+3-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
/* @theme: blank; */
22

33
$search-autocomplete-position: absolute !default;
4-
$search-autocomplete-display: block !default;
5-
$search-autocomplete-width: 100% !default;
64
$search-autocomplete-z-index: z('dropdown') + 1 !default;
5+
$search-autocomplete-selected-box-shadow: $focus-visible-style !default;
76

87
$search-autocomplete-content-min-width: 385px !default;
98
$search-autocomplete-content-display: block !default;
@@ -12,11 +11,10 @@ $search-autocomplete-content-float: none !default;
1211
$search-autocomplete-content-position: static !default;
1312

1413
$search-autocomplete-item-border-bottom: 1px solid get-color('additional', 'light') !default;
14+
$search-autocomplete-item-offset: 0 -#{$offset-y-m + $offset-y-s} !default;
15+
$search-autocomplete-item-inner-offset: #{$offset-y-m + $offset-y-s} #{$offset-y-m + $offset-y-s} !default;
1516

1617
$search-autocomplete-highlight-background: get-color('ui', 'warning') !default;
17-
$search-autocomplete-highlight-text-decoration: underline !default;
18-
19-
$search-autocomplete-footer-inner-offset: #{$offset-y-m + $offset-y-s} 0 !default;
2018

2119
$search-autocomplete-submit-line-height: $base-line-height !default;
2220
$search-autocomplete-submit-border: none !default;
@@ -25,8 +23,6 @@ $search-autocomplete-no-found-inner-offset: #{$offset-y-m + $offset-y-s} 0 !defa
2523

2624
$search-autocomplete-product-text-decoration: none !default;
2725
$search-autocomplete-product-display: flex !default;
28-
$search-autocomplete-product-offset: 0 -#{$offset-y-m + $offset-y-s} !default;
29-
$search-autocomplete-product-inner-offset: #{$offset-y-m + $offset-y-s} #{$offset-y-m + $offset-y-s} !default;
3026
$search-autocomplete-product-hover-text-decoration: none !default;
3127

3228
$search-autocomplete-product-image-width: 40px !default;

src/Oro/Bundle/ProductBundle/Resources/public/js/app/views/search-autocomplete-view.js

+174-13
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import _ from 'underscore';
44
import BaseView from 'oroui/js/app/views/base/view';
55
import routing from 'routing';
66
import template from 'tpl-loader!oroproduct/templates/search-autocomplete.html';
7+
import 'jquery-ui/tabbable';
78

89
const SearchAutocompleteView = BaseView.extend({
910
optionNames: BaseView.prototype.optionNames.concat([
@@ -41,29 +42,62 @@ const SearchAutocompleteView = BaseView.extend({
4142
events: {
4243
change: '_onInputChange',
4344
keyup: '_onInputChange',
44-
focus: '_onInputRefresh'
45+
focus: '_onInputRefresh',
46+
keydown: '_onKeyDown'
4547
},
4648

4749
previousValue: '',
4850

51+
autocompleteItems: '[role="option"]',
52+
4953
/**
5054
* @inheritdoc
5155
*/
5256
constructor: function SearchAutocompleteView(options) {
57+
this.renderSuggestions = _.debounce(this.renderSuggestions.bind(this), this.delay);
5358
SearchAutocompleteView.__super__.constructor.call(this, options);
5459
},
5560

61+
preinitialize() {
62+
this.comboboxId = `combobox-${this.cid}`;
63+
},
64+
5665
/**
5766
* @inheritdoc
5867
*/
5968
initialize(options) {
60-
this.$el.attr('autocomplete', 'off');
69+
this.$el.attr({
70+
'role': 'combobox',
71+
'autocomplete': 'off',
72+
'aria-haspopup': true,
73+
'aria-expanded': false,
74+
'aria-autocomplete': 'list',
75+
'aria-controls': this.comboboxId
76+
});
6177

62-
this.renderSuggestions = _.debounce(this.renderSuggestions.bind(this), this.delay);
78+
SearchAutocompleteView.__super__.initialize.call(this, options);
79+
},
80+
81+
/**
82+
* @inheritdoc
83+
*/
84+
delegateEvents: function() {
85+
SearchAutocompleteView.__super__.delegateEvents.call(this);
6386

6487
$('body').on(`click${this.eventNamespace()}`, this._onOutsideAction.bind(this));
6588

66-
SearchAutocompleteView.__super__.initialize.call(this, options);
89+
return this;
90+
},
91+
92+
/**
93+
* @inheritdoc
94+
*/
95+
undelegateEvents: function() {
96+
SearchAutocompleteView.__super__.undelegateEvents.call(this);
97+
98+
$('body').off(this.eventNamespace());
99+
100+
return this;
67101
},
68102

69103
getInputString() {
@@ -75,7 +109,8 @@ const SearchAutocompleteView = BaseView.extend({
75109
*/
76110
getTemplateData: function(data = {}) {
77111
return Object.assign(data, {
78-
inputString: this.getInputString()
112+
inputString: this.getInputString(),
113+
comboboxId: this.comboboxId
79114
});
80115
},
81116

@@ -86,20 +121,109 @@ const SearchAutocompleteView = BaseView.extend({
86121
if (this.disposed) {
87122
return;
88123
}
89-
this.close();
124+
this.closeCombobox();
90125

91-
$('body').off(this.eventNamespace());
126+
this.$el.attr('aria-expanded', null);
92127

93128
SearchAutocompleteView.__super__.dispose.call(this);
94129
},
95130

96-
close() {
131+
closeCombobox() {
97132
if (!this.$popup) {
98133
return;
99134
}
100135

101136
this.$popup.remove();
102137
this.$popup = null;
138+
this.$el.attr({
139+
'aria-expanded': false,
140+
'aria-activedescendant': null
141+
});
142+
this.undoFocusStyle();
143+
},
144+
145+
hideCombobox() {
146+
if (!this.$popup) {
147+
return;
148+
}
149+
150+
this.$el.attr({
151+
'aria-expanded': false,
152+
'aria-activedescendant': null
153+
});
154+
this.$popup.hide();
155+
this.gerSelectedOption().removeAttr('aria-selected');
156+
this.undoFocusStyle();
157+
},
158+
159+
showCombobox() {
160+
if (!this.$popup) {
161+
return;
162+
}
163+
164+
this.$popup.show();
165+
},
166+
167+
hasSelectedOption() {
168+
return this.gerSelectedOption().length > 0;
169+
},
170+
171+
getAutocompleteItems() {
172+
return this.$el.next().find(this.autocompleteItems);
173+
},
174+
175+
gerSelectedOption() {
176+
return this.getAutocompleteItems().filter((i, el) => $(el).attr('aria-selected') === 'true');
177+
},
178+
179+
getNextOption() {
180+
const $options = this.getAutocompleteItems();
181+
const $activeOption = this.gerSelectedOption();
182+
183+
if (
184+
$activeOption.length === 0 ||
185+
($options.length - 1 === $options.index($activeOption))
186+
) {
187+
return $options.first();
188+
}
189+
190+
return $options.eq($options.index($activeOption) + 1);
191+
},
192+
193+
getPreviousOption() {
194+
const $options = this.getAutocompleteItems();
195+
const $activeOption = this.gerSelectedOption();
196+
197+
if (
198+
$activeOption.length === 0 ||
199+
$options.index($activeOption) === 0
200+
) {
201+
return $options.last();
202+
}
203+
204+
return $options.eq($options.index($activeOption) - 1);
205+
},
206+
207+
/**
208+
* @param {string} direction
209+
*/
210+
goToOption(direction = 'down') {
211+
const $options = this.getAutocompleteItems();
212+
const $activeOption = direction === 'down'
213+
? this.getNextOption()
214+
: this.getPreviousOption()
215+
;
216+
217+
this.showCombobox();
218+
$options.attr('aria-selected', false);
219+
$activeOption.attr('aria-selected', true);
220+
this.$el.attr('aria-activedescendant', $activeOption.attr('id'));
221+
},
222+
223+
executeSelectedOption() {
224+
if (this.hasSelectedOption()) {
225+
this.gerSelectedOption().find(':first-child')[0].click();
226+
}
103227
},
104228

105229
_getSearchXHR(inputString) {
@@ -117,11 +241,19 @@ const SearchAutocompleteView = BaseView.extend({
117241
* @inheritdoc
118242
*/
119243
render(suggestions) {
120-
this.close();
244+
this.closeCombobox();
121245

122246
if (this.getInputString().length) {
123247
this.$popup = $(this.template(this.getTemplateData(suggestions)));
124248
this.$el.after(this.$popup);
249+
this.$el.attr('aria-expanded', true);
250+
251+
this.getAutocompleteItems().each((i, el) => {
252+
$(el).attr({
253+
'id': _.uniqueId('item-'),
254+
'aria-selected': false
255+
}).find(':tabbable').attr('tabindex', -1);
256+
});
125257
}
126258

127259
return this;
@@ -148,6 +280,35 @@ const SearchAutocompleteView = BaseView.extend({
148280
;
149281
},
150282

283+
_onKeyDown(event) {
284+
switch (event.key) {
285+
case 'Tab':
286+
case 'Escape':
287+
this.hideCombobox();
288+
break;
289+
case 'ArrowUp':
290+
event.preventDefault();
291+
this.goToOption('up');
292+
break;
293+
case 'ArrowDown':
294+
event.preventDefault();
295+
this.goToOption('down');
296+
break;
297+
case 'Enter':
298+
case ' ':
299+
this.executeSelectedOption();
300+
break;
301+
default:
302+
break;
303+
}
304+
305+
this.undoFocusStyle();
306+
},
307+
308+
undoFocusStyle() {
309+
this.$el.toggleClass('undo-focus', this.hasSelectedOption());
310+
},
311+
151312
_onInputChange(event) {
152313
const inputString = this.getInputString();
153314
if (inputString === this.previousValue) {
@@ -156,7 +317,7 @@ const SearchAutocompleteView = BaseView.extend({
156317

157318
this._shouldShowPopup(inputString)
158319
? this.renderSuggestions(inputString)
159-
: this.close();
320+
: this.closeCombobox();
160321

161322
this.previousValue = inputString;
162323
},
@@ -166,16 +327,16 @@ const SearchAutocompleteView = BaseView.extend({
166327

167328
if (!inputString.length && this.searchXHR) {
168329
this.searchXHR.abort();
169-
};
330+
}
170331

171332
this._shouldShowPopup(inputString)
172333
? this.renderSuggestions(inputString)
173-
: this.close();
334+
: this.closeCombobox();
174335
},
175336

176337
_onOutsideAction(event) {
177338
if (!((event.target === this.el) || (this.$popup && $.contains(this.$popup[0], event.target)))) {
178-
this.close();
339+
this.closeCombobox();
179340
}
180341
}
181342
});

0 commit comments

Comments
 (0)