@gitlab/ui 44.1.0 → 45.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/components/base/new_dropdowns/base_dropdown/base_dropdown.js +8 -0
- package/dist/components/base/new_dropdowns/listbox/listbox.js +116 -18
- package/package.json +1 -1
- package/src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue +6 -0
- package/src/components/base/new_dropdowns/listbox/listbox.md +22 -0
- package/src/components/base/new_dropdowns/listbox/listbox.spec.js +101 -7
- package/src/components/base/new_dropdowns/listbox/listbox.stories.js +244 -20
- package/src/components/base/new_dropdowns/listbox/listbox.vue +138 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [45.0.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v44.1.0...v45.0.0) (2022-10-05)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **GlListbox:** add support for listbox filtering ([c6edcc4](https://gitlab.com/gitlab-org/gitlab-ui/commit/c6edcc44966891ce4e2682c9b9785e6bd16ae642))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
* **GlListbox:** `ariaLabelledby` property was renamed
|
|
12
|
+
to `toggleAriaLabelledBy `for better consistency
|
|
13
|
+
with a new `listAriaLabelledBy` property
|
|
14
|
+
|
|
1
15
|
# [44.1.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v44.0.0...v44.1.0) (2022-10-05)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -3,6 +3,8 @@ import _clamp from 'lodash/clamp';
|
|
|
3
3
|
import { stopEvent } from '../../../../utils/utils';
|
|
4
4
|
import { GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, HOME, END, ARROW_UP, ARROW_DOWN } from '../constants';
|
|
5
5
|
import { buttonCategoryOptions, dropdownVariantOptions, buttonSizeOptions } from '../../../../utils/constants';
|
|
6
|
+
import GlLoadingIcon from '../../loading_icon/loading_icon';
|
|
7
|
+
import GlSearchBoxByType from '../../search_box_by_type/search_box_by_type';
|
|
6
8
|
import GlBaseDropdown from '../base_dropdown/base_dropdown';
|
|
7
9
|
import GlListboxItem from './listbox_item';
|
|
8
10
|
import GlListboxGroup from './listbox_group';
|
|
@@ -11,6 +13,7 @@ import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
|
|
|
11
13
|
|
|
12
14
|
const ITEM_SELECTOR = '[role="option"]';
|
|
13
15
|
const GROUP_TOP_BORDER_CLASSES = ['gl-border-t', 'gl-pt-3', 'gl-mt-3'];
|
|
16
|
+
const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input';
|
|
14
17
|
var script = {
|
|
15
18
|
events: {
|
|
16
19
|
GL_DROPDOWN_SHOWN,
|
|
@@ -19,7 +22,9 @@ var script = {
|
|
|
19
22
|
components: {
|
|
20
23
|
GlBaseDropdown,
|
|
21
24
|
GlListboxItem,
|
|
22
|
-
GlListboxGroup
|
|
25
|
+
GlListboxGroup,
|
|
26
|
+
GlSearchBoxByType,
|
|
27
|
+
GlLoadingIcon
|
|
23
28
|
},
|
|
24
29
|
model: {
|
|
25
30
|
prop: 'selected',
|
|
@@ -122,6 +127,7 @@ var script = {
|
|
|
122
127
|
|
|
123
128
|
/**
|
|
124
129
|
* Set to "true" when dropdown content (items) is loading
|
|
130
|
+
* It will render a small loader in the dropdown toggle and make it disabled
|
|
125
131
|
*/
|
|
126
132
|
loading: {
|
|
127
133
|
type: Boolean,
|
|
@@ -167,11 +173,50 @@ var script = {
|
|
|
167
173
|
|
|
168
174
|
/**
|
|
169
175
|
* The `aria-labelledby` attribute value for the toggle button
|
|
176
|
+
* Provide the string of ids seperated by space
|
|
170
177
|
*/
|
|
171
|
-
|
|
178
|
+
toggleAriaLabelledBy: {
|
|
172
179
|
type: String,
|
|
173
180
|
required: false,
|
|
174
181
|
default: null
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The `aria-labelledby` attribute value for the list of options
|
|
186
|
+
* Provide the string of ids seperated by space
|
|
187
|
+
*/
|
|
188
|
+
listAriaLabelledBy: {
|
|
189
|
+
type: String,
|
|
190
|
+
required: false,
|
|
191
|
+
default: null
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Enable search
|
|
196
|
+
*/
|
|
197
|
+
searchable: {
|
|
198
|
+
type: Boolean,
|
|
199
|
+
required: false,
|
|
200
|
+
default: false
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Set to "true" when items search is in progress.
|
|
205
|
+
* It will display loading icon below the search input
|
|
206
|
+
*/
|
|
207
|
+
searching: {
|
|
208
|
+
type: Boolean,
|
|
209
|
+
required: false,
|
|
210
|
+
default: false
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Message to be displayed when filtering produced no results
|
|
215
|
+
*/
|
|
216
|
+
noResultsText: {
|
|
217
|
+
type: String,
|
|
218
|
+
required: false,
|
|
219
|
+
default: 'No results found'
|
|
175
220
|
}
|
|
176
221
|
},
|
|
177
222
|
|
|
@@ -179,7 +224,9 @@ var script = {
|
|
|
179
224
|
return {
|
|
180
225
|
selectedValues: [],
|
|
181
226
|
toggleId: _uniqueId('dropdown-toggle-btn-'),
|
|
182
|
-
|
|
227
|
+
listboxId: _uniqueId('listbox-'),
|
|
228
|
+
nextFocusedItemIndex: null,
|
|
229
|
+
searchStr: ''
|
|
183
230
|
};
|
|
184
231
|
},
|
|
185
232
|
|
|
@@ -219,6 +266,18 @@ var script = {
|
|
|
219
266
|
} = _ref2;
|
|
220
267
|
return value === selected;
|
|
221
268
|
})).sort();
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
showList() {
|
|
272
|
+
return this.flattenedOptions.length && !this.searching;
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
showNoResultsText() {
|
|
276
|
+
return !this.flattenedOptions.length && !this.searching;
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
announceSRSearchResults() {
|
|
280
|
+
return this.searchable && !this.showNoResultsText && this.$scopedSlots['search-summary-sr-only'];
|
|
222
281
|
}
|
|
223
282
|
|
|
224
283
|
},
|
|
@@ -241,21 +300,34 @@ var script = {
|
|
|
241
300
|
}
|
|
242
301
|
},
|
|
243
302
|
methods: {
|
|
303
|
+
open() {
|
|
304
|
+
this.$refs.baseDropdown.open();
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
close() {
|
|
308
|
+
this.$refs.baseDropdown.close();
|
|
309
|
+
},
|
|
310
|
+
|
|
244
311
|
groupClasses(index) {
|
|
245
312
|
return index === 0 ? null : GROUP_TOP_BORDER_CLASSES;
|
|
246
313
|
},
|
|
247
314
|
|
|
248
315
|
onShow() {
|
|
249
316
|
this.$nextTick(() => {
|
|
250
|
-
|
|
317
|
+
if (this.searchable) {
|
|
318
|
+
this.focusSearchInput();
|
|
319
|
+
} else {
|
|
320
|
+
var _this$selectedIndices;
|
|
251
321
|
|
|
252
|
-
|
|
322
|
+
this.focusItem((_this$selectedIndices = this.selectedIndices[0]) !== null && _this$selectedIndices !== void 0 ? _this$selectedIndices : 0, this.getFocusableListItemElements());
|
|
323
|
+
}
|
|
253
324
|
/**
|
|
254
325
|
* Emitted when dropdown is shown
|
|
255
326
|
*
|
|
256
327
|
* @event shown
|
|
257
328
|
*/
|
|
258
329
|
|
|
330
|
+
|
|
259
331
|
this.$emit(GL_DROPDOWN_SHOWN);
|
|
260
332
|
});
|
|
261
333
|
},
|
|
@@ -272,20 +344,34 @@ var script = {
|
|
|
272
344
|
|
|
273
345
|
onKeydown(event) {
|
|
274
346
|
const {
|
|
275
|
-
code
|
|
347
|
+
code,
|
|
348
|
+
target
|
|
276
349
|
} = event;
|
|
277
350
|
const elements = this.getFocusableListItemElements();
|
|
278
351
|
if (elements.length < 1) return;
|
|
279
352
|
let stop = true;
|
|
353
|
+
const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
|
|
280
354
|
|
|
281
355
|
if (code === HOME) {
|
|
282
356
|
this.focusItem(0, elements);
|
|
283
357
|
} else if (code === END) {
|
|
284
358
|
this.focusItem(elements.length - 1, elements);
|
|
285
359
|
} else if (code === ARROW_UP) {
|
|
286
|
-
|
|
360
|
+
if (isSearchInput) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (this.searchable && elements.indexOf(target) === 0) {
|
|
365
|
+
this.focusSearchInput();
|
|
366
|
+
} else {
|
|
367
|
+
this.focusNextItem(event, elements, -1);
|
|
368
|
+
}
|
|
287
369
|
} else if (code === ARROW_DOWN) {
|
|
288
|
-
|
|
370
|
+
if (isSearchInput) {
|
|
371
|
+
this.focusItem(0, elements);
|
|
372
|
+
} else {
|
|
373
|
+
this.focusNextItem(event, elements, 1);
|
|
374
|
+
}
|
|
289
375
|
} else {
|
|
290
376
|
stop = false;
|
|
291
377
|
}
|
|
@@ -296,8 +382,10 @@ var script = {
|
|
|
296
382
|
},
|
|
297
383
|
|
|
298
384
|
getFocusableListItemElements() {
|
|
299
|
-
|
|
300
|
-
|
|
385
|
+
var _this$$refs$list;
|
|
386
|
+
|
|
387
|
+
const items = (_this$$refs$list = this.$refs.list) === null || _this$$refs$list === void 0 ? void 0 : _this$$refs$list.querySelectorAll(ITEM_SELECTOR);
|
|
388
|
+
return Array.from(items || []);
|
|
301
389
|
},
|
|
302
390
|
|
|
303
391
|
focusNextItem(event, elements, offset) {
|
|
@@ -320,15 +408,15 @@ var script = {
|
|
|
320
408
|
});
|
|
321
409
|
},
|
|
322
410
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
} = _ref3;
|
|
411
|
+
focusSearchInput() {
|
|
412
|
+
this.$refs.searchBox.focusInput();
|
|
413
|
+
},
|
|
327
414
|
|
|
415
|
+
onSelect(item, isSelected) {
|
|
328
416
|
if (this.multiple) {
|
|
329
|
-
this.onMultiSelect(value, isSelected);
|
|
417
|
+
this.onMultiSelect(item.value, isSelected);
|
|
330
418
|
} else {
|
|
331
|
-
this.onSingleSelect(value, isSelected);
|
|
419
|
+
this.onSingleSelect(item.value, isSelected);
|
|
332
420
|
}
|
|
333
421
|
},
|
|
334
422
|
|
|
@@ -361,6 +449,16 @@ var script = {
|
|
|
361
449
|
}
|
|
362
450
|
},
|
|
363
451
|
|
|
452
|
+
search(searchTerm) {
|
|
453
|
+
/**
|
|
454
|
+
* Emitted when the search query string is changed
|
|
455
|
+
*
|
|
456
|
+
* @event search
|
|
457
|
+
* @type {string}
|
|
458
|
+
*/
|
|
459
|
+
this.$emit('search', searchTerm);
|
|
460
|
+
},
|
|
461
|
+
|
|
364
462
|
isOption
|
|
365
463
|
}
|
|
366
464
|
};
|
|
@@ -369,7 +467,7 @@ var script = {
|
|
|
369
467
|
const __vue_script__ = script;
|
|
370
468
|
|
|
371
469
|
/* template */
|
|
372
|
-
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-base-dropdown',{ref:"baseDropdown",attrs:{"aria-haspopup":"listbox","aria-labelledby":_vm.
|
|
470
|
+
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-base-dropdown',{ref:"baseDropdown",attrs:{"aria-haspopup":"listbox","aria-labelledby":_vm.toggleAriaLabelledBy,"toggle-id":_vm.toggleId,"toggle-text":_vm.listboxToggleText,"toggle-class":_vm.toggleClass,"text-sr-only":_vm.textSrOnly,"category":_vm.category,"variant":_vm.variant,"size":_vm.size,"icon":_vm.icon,"disabled":_vm.disabled,"loading":_vm.loading,"no-caret":_vm.noCaret,"right":_vm.right},on:_vm._d({},[_vm.$options.events.GL_DROPDOWN_SHOWN,_vm.onShow,_vm.$options.events.GL_DROPDOWN_HIDDEN,_vm.onHide])},[_vm._t("header"),_vm._v(" "),(_vm.searchable)?_c('div',{staticClass:"gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"},[_c('gl-search-box-by-type',{ref:"searchBox",attrs:{"aria-owns":_vm.listboxId,"data-testid":"listbox-search-input"},on:{"input":_vm.search,"keydown":_vm.onKeydown},model:{value:(_vm.searchStr),callback:function ($$v) {_vm.searchStr=$$v;},expression:"searchStr"}}),_vm._v(" "),(_vm.searching)?_c('gl-loading-icon',{staticClass:"gl-my-3",attrs:{"data-testid":"listbox-search-loader","size":"md"}}):_vm._e()],1):_vm._e(),_vm._v(" "),(_vm.showList)?_c(_vm.listboxTag,{ref:"list",tag:"component",staticClass:"gl-new-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0",attrs:{"id":"listbox","aria-labelledby":_vm.listAriaLabelledBy || _vm.toggleId,"role":"listbox","tabindex":"-1"},on:{"keydown":_vm.onKeydown}},[_vm._l((_vm.items),function(item,index){return [(_vm.isOption(item))?[_c('gl-listbox-item',{key:item.value,attrs:{"is-selected":_vm.isSelected(item),"is-focused":_vm.isFocused(item),"is-check-centered":_vm.isCheckCentered},on:{"select":function($event){return _vm.onSelect(item, $event)}}},[_vm._t("list-item",function(){return [_vm._v("\n "+_vm._s(item.text)+"\n ")]},{"item":item})],2)]:[_c('gl-listbox-group',{key:item.text,class:_vm.groupClasses(index),attrs:{"name":item.text},scopedSlots:_vm._u([(_vm.$scopedSlots['group-label'])?{key:"group-label",fn:function(){return [_vm._t("group-label",null,{"group":item})]},proxy:true}:null],null,true)},[_vm._v(" "),_vm._l((item.options),function(option){return _c('gl-listbox-item',{key:option.value,attrs:{"is-selected":_vm.isSelected(option),"is-focused":_vm.isFocused(option),"is-check-centered":_vm.isCheckCentered},on:{"select":function($event){return _vm.onSelect(option, $event)}}},[_vm._t("list-item",function(){return [_vm._v("\n "+_vm._s(option.text)+"\n ")]},{"item":option})],2)})],2)]]})],2):_vm._e(),_vm._v(" "),(_vm.announceSRSearchResults)?_c('span',{staticClass:"gl-sr-only",attrs:{"data-testid":"listbox-number-of-results","aria-live":"assertive"}},[_vm._t("search-summary-sr-only")],2):(_vm.showNoResultsText)?_c('div',{staticClass:"gl-pl-7 gl-pr-5 gl-pt-3 gl-font-base gl-text-gray-600",attrs:{"aria-live":"assertive","data-testid":"listbox-no-results-text"}},[_vm._v("\n "+_vm._s(_vm.noResultsText)+"\n ")]):_vm._e(),_vm._v(" "),_vm._t("footer")],2)};
|
|
373
471
|
var __vue_staticRenderFns__ = [];
|
|
374
472
|
|
|
375
473
|
/* style */
|
|
@@ -402,4 +500,4 @@ var __vue_staticRenderFns__ = [];
|
|
|
402
500
|
);
|
|
403
501
|
|
|
404
502
|
export default __vue_component__;
|
|
405
|
-
export { ITEM_SELECTOR };
|
|
503
|
+
export { ITEM_SELECTOR, SEARCH_INPUT_SELECTOR };
|
package/package.json
CHANGED
|
@@ -112,3 +112,25 @@ To render custom group labels, use the `group-label` scoped slot:
|
|
|
112
112
|
</template>
|
|
113
113
|
</gl-listbox>
|
|
114
114
|
```
|
|
115
|
+
|
|
116
|
+
#### Search
|
|
117
|
+
|
|
118
|
+
To filter out items by search query set `searchable` property to `true`.
|
|
119
|
+
Listbox will render the search field and will emit `search` event with the `searchQuery` value.
|
|
120
|
+
Performing the search is the responsibility of the listbox's consumer component.
|
|
121
|
+
When performing search set `searching` prop to `true` - this will render the loader
|
|
122
|
+
while search is in progress instead of the list of items.
|
|
123
|
+
To update content of the listbox, toggle the `searching` property
|
|
124
|
+
and update the `items` property with a new array. Be sure to debounce (or
|
|
125
|
+
similar) the `search` event handler to avoid rendering stale results.
|
|
126
|
+
To improve the accessibility, provide the `search-summary-sr-only` scoped slot
|
|
127
|
+
with a number of found search results text.
|
|
128
|
+
Screen reader will announce this text when the list is updated.
|
|
129
|
+
|
|
130
|
+
```html
|
|
131
|
+
<gl-listbox :items="items" searchable>
|
|
132
|
+
<template #search-summary-sr-only>
|
|
133
|
+
5 users found
|
|
134
|
+
</template>
|
|
135
|
+
</gl-listbox>
|
|
136
|
+
```
|
|
@@ -30,6 +30,10 @@ describe('GlListbox', () => {
|
|
|
30
30
|
const findListboxItems = (root = wrapper) => root.findAllComponents(GlListboxItem);
|
|
31
31
|
const findListboxGroups = () => wrapper.findAllComponents(GlListboxGroup);
|
|
32
32
|
const findListItem = (index) => findListboxItems().at(index).find(ITEM_SELECTOR);
|
|
33
|
+
const findSearchBox = () => wrapper.find("[data-testid='listbox-search-input']");
|
|
34
|
+
const findNoResultsText = () => wrapper.find("[data-testid='listbox-no-results-text']");
|
|
35
|
+
const findLoadingIcon = () => wrapper.find("[data-testid='listbox-search-loader']");
|
|
36
|
+
const findSRNumberOfResultsText = () => wrapper.find("[data-testid='listbox-number-of-results']");
|
|
33
37
|
|
|
34
38
|
describe('toggle text', () => {
|
|
35
39
|
describe.each`
|
|
@@ -53,11 +57,17 @@ describe('GlListbox', () => {
|
|
|
53
57
|
|
|
54
58
|
describe('ARIA attributes', () => {
|
|
55
59
|
it('should provide `toggleId` to the base dropdown and reference it in`aria-labelledby` attribute of the list container`', async () => {
|
|
56
|
-
await buildWrapper();
|
|
60
|
+
await buildWrapper({ items: mockOptions });
|
|
57
61
|
expect(findBaseDropdown().props('toggleId')).toBe(
|
|
58
62
|
findListContainer().attributes('aria-labelledby')
|
|
59
63
|
);
|
|
60
64
|
});
|
|
65
|
+
|
|
66
|
+
it('should reference `listAriaLabelledby`', async () => {
|
|
67
|
+
const listAriaLabelledBy = 'first-label-id second-label-id';
|
|
68
|
+
await buildWrapper({ items: mockOptions, listAriaLabelledBy });
|
|
69
|
+
expect(findListContainer().attributes('aria-labelledby')).toBe(listAriaLabelledBy);
|
|
70
|
+
});
|
|
61
71
|
});
|
|
62
72
|
|
|
63
73
|
describe('selecting items', () => {
|
|
@@ -137,23 +147,36 @@ describe('GlListbox', () => {
|
|
|
137
147
|
});
|
|
138
148
|
|
|
139
149
|
describe('onShow', () => {
|
|
140
|
-
|
|
150
|
+
let focusSpy;
|
|
151
|
+
|
|
152
|
+
const showDropdown = async ({ searchable = false } = {}) => {
|
|
141
153
|
buildWrapper({
|
|
142
154
|
multiple: true,
|
|
143
155
|
items: mockOptions,
|
|
144
156
|
selected: [mockOptions[2].value, mockOptions[1].value],
|
|
157
|
+
searchable,
|
|
145
158
|
});
|
|
159
|
+
if (searchable) {
|
|
160
|
+
focusSpy = jest.spyOn(wrapper.vm.$refs.searchBox, 'focusInput');
|
|
161
|
+
}
|
|
146
162
|
findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
|
|
147
163
|
await nextTick();
|
|
148
|
-
}
|
|
164
|
+
};
|
|
149
165
|
|
|
150
|
-
it('should re-emit the event', () => {
|
|
166
|
+
it('should re-emit the event', async () => {
|
|
167
|
+
await showDropdown();
|
|
151
168
|
expect(wrapper.emitted(GL_DROPDOWN_SHOWN)).toHaveLength(1);
|
|
152
169
|
});
|
|
153
170
|
|
|
154
|
-
it('should focus the first selected item', () => {
|
|
171
|
+
it('should focus the first selected item', async () => {
|
|
172
|
+
await showDropdown();
|
|
155
173
|
expect(findListboxItems().at(1).find(ITEM_SELECTOR).element).toHaveFocus();
|
|
156
174
|
});
|
|
175
|
+
|
|
176
|
+
it('should focus the search input when search is enabled', async () => {
|
|
177
|
+
await showDropdown({ searchable: true });
|
|
178
|
+
expect(focusSpy).toHaveBeenCalled();
|
|
179
|
+
});
|
|
157
180
|
});
|
|
158
181
|
|
|
159
182
|
describe('onHide', () => {
|
|
@@ -181,7 +204,7 @@ describe('GlListbox', () => {
|
|
|
181
204
|
thirdItem = findListItem(2);
|
|
182
205
|
});
|
|
183
206
|
|
|
184
|
-
it('should move the focus down the list of items on `
|
|
207
|
+
it('should move the focus down the list of items on `ARROW_DOWN` and stop on the last item', async () => {
|
|
185
208
|
expect(firstItem.element).toHaveFocus();
|
|
186
209
|
await firstItem.trigger('keydown', { code: ARROW_DOWN });
|
|
187
210
|
expect(secondItem.element).toHaveFocus();
|
|
@@ -191,7 +214,7 @@ describe('GlListbox', () => {
|
|
|
191
214
|
expect(thirdItem.element).toHaveFocus();
|
|
192
215
|
});
|
|
193
216
|
|
|
194
|
-
it('should move the focus up the list of items on `
|
|
217
|
+
it('should move the focus up the list of items on `ARROW_UP` and stop on the first item', async () => {
|
|
195
218
|
await firstItem.trigger('keydown', { code: ARROW_DOWN });
|
|
196
219
|
await secondItem.trigger('keydown', { code: ARROW_DOWN });
|
|
197
220
|
expect(thirdItem.element).toHaveFocus();
|
|
@@ -220,6 +243,23 @@ describe('GlListbox', () => {
|
|
|
220
243
|
await thirdItem.trigger('keydown', { code: HOME });
|
|
221
244
|
expect(firstItem.element).toHaveFocus();
|
|
222
245
|
});
|
|
246
|
+
|
|
247
|
+
describe('when `searchable` is enabled', () => {
|
|
248
|
+
it('should move focus to the first item on search input `ARROW_DOWN`', async () => {
|
|
249
|
+
buildWrapper({ items: mockOptions, searchable: true });
|
|
250
|
+
findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
|
|
251
|
+
findSearchBox().trigger('keydown', { code: ARROW_DOWN });
|
|
252
|
+
expect(firstItem.element).toHaveFocus();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should move focus to the search input on first item `ARROW_UP', async () => {
|
|
256
|
+
buildWrapper({ items: mockOptions, searchable: true });
|
|
257
|
+
findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
|
|
258
|
+
const focusSpy = jest.spyOn(wrapper.vm.$refs.searchBox, 'focusInput');
|
|
259
|
+
await firstItem.trigger('keydown', { code: ARROW_UP });
|
|
260
|
+
expect(focusSpy).toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
223
263
|
});
|
|
224
264
|
|
|
225
265
|
describe('when the header slot content is provided', () => {
|
|
@@ -260,4 +300,58 @@ describe('GlListbox', () => {
|
|
|
260
300
|
});
|
|
261
301
|
});
|
|
262
302
|
});
|
|
303
|
+
|
|
304
|
+
describe('when `searchable` is enabled', () => {
|
|
305
|
+
it('should render the search box', () => {
|
|
306
|
+
buildWrapper({ items: mockOptions, searchable: true });
|
|
307
|
+
|
|
308
|
+
expect(findSearchBox().exists()).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should emit the search value when typing in the search box', async () => {
|
|
312
|
+
buildWrapper({ items: mockOptions, searchable: true });
|
|
313
|
+
|
|
314
|
+
const searchStr = 'search value';
|
|
315
|
+
findSearchBox().vm.$emit('input', searchStr);
|
|
316
|
+
await nextTick();
|
|
317
|
+
expect(wrapper.emitted('search')[0][0]).toEqual(searchStr);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should not render the loading icon and render the list if NOT searching', () => {
|
|
321
|
+
buildWrapper({ items: mockOptions, searchable: true });
|
|
322
|
+
|
|
323
|
+
expect(findLoadingIcon().exists()).toBe(false);
|
|
324
|
+
expect(findListContainer().exists()).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should render the loading icon and NOT render the list when searching', () => {
|
|
328
|
+
buildWrapper({ items: mockOptions, searchable: true, searching: true });
|
|
329
|
+
|
|
330
|
+
expect(findLoadingIcon().exists()).toBe(true);
|
|
331
|
+
expect(findListContainer().exists()).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should display `noResultText` if no items found', () => {
|
|
335
|
+
const noResultsText = 'Nothing found';
|
|
336
|
+
buildWrapper({ items: [], searchable: true, searching: false, noResultsText });
|
|
337
|
+
|
|
338
|
+
expect(findLoadingIcon().exists()).toBe(false);
|
|
339
|
+
expect(findListContainer().exists()).toBe(false);
|
|
340
|
+
expect(findNoResultsText().text()).toBe(noResultsText);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('Screen reader text with number of search results', () => {
|
|
344
|
+
it('when the #search-summary-sr-only slot content is provided', () => {
|
|
345
|
+
const searchResultsContent = 'Found 5 results';
|
|
346
|
+
const slots = { 'search-summary-sr-only': searchResultsContent };
|
|
347
|
+
buildWrapper({ items: mockOptions, searchable: true, searching: false }, slots);
|
|
348
|
+
expect(findSRNumberOfResultsText().text()).toBe(searchResultsContent);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should not display SR text when no matching results', () => {
|
|
352
|
+
buildWrapper({ items: [], searchable: true, searching: false });
|
|
353
|
+
expect(findSRNumberOfResultsText().exists()).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
|
263
357
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { makeContainer } from '../../../../utils/story_decorators/container';
|
|
16
16
|
import readme from './listbox.md';
|
|
17
17
|
import { mockOptions, mockGroups } from './mock_data';
|
|
18
|
+
import { flattenedOptions } from './utils';
|
|
18
19
|
|
|
19
20
|
const defaultValue = (prop) => GlListbox.props[prop].default;
|
|
20
21
|
|
|
@@ -25,6 +26,9 @@ const generateProps = ({
|
|
|
25
26
|
size = defaultValue('size'),
|
|
26
27
|
disabled = defaultValue('disabled'),
|
|
27
28
|
loading = defaultValue('loading'),
|
|
29
|
+
searchable = defaultValue('searchable'),
|
|
30
|
+
searching = defaultValue('searching'),
|
|
31
|
+
noResultsText = defaultValue('noResultsText'),
|
|
28
32
|
noCaret = defaultValue('noCaret'),
|
|
29
33
|
right = defaultValue('right'),
|
|
30
34
|
toggleText,
|
|
@@ -32,7 +36,8 @@ const generateProps = ({
|
|
|
32
36
|
icon = '',
|
|
33
37
|
multiple = defaultValue('multiple'),
|
|
34
38
|
isCheckCentered = defaultValue('isCheckCentered'),
|
|
35
|
-
|
|
39
|
+
toggleAriaLabelledBy,
|
|
40
|
+
listAriaLabelledBy,
|
|
36
41
|
startOpened = true,
|
|
37
42
|
} = {}) => ({
|
|
38
43
|
items,
|
|
@@ -41,6 +46,9 @@ const generateProps = ({
|
|
|
41
46
|
size,
|
|
42
47
|
disabled,
|
|
43
48
|
loading,
|
|
49
|
+
searchable,
|
|
50
|
+
searching,
|
|
51
|
+
noResultsText,
|
|
44
52
|
noCaret,
|
|
45
53
|
right,
|
|
46
54
|
toggleText,
|
|
@@ -48,12 +56,15 @@ const generateProps = ({
|
|
|
48
56
|
icon,
|
|
49
57
|
multiple,
|
|
50
58
|
isCheckCentered,
|
|
51
|
-
|
|
59
|
+
toggleAriaLabelledBy,
|
|
60
|
+
listAriaLabelledBy,
|
|
52
61
|
startOpened,
|
|
53
62
|
});
|
|
54
63
|
|
|
55
64
|
function openListbox(component) {
|
|
56
|
-
component.$nextTick(() =>
|
|
65
|
+
component.$nextTick(() => {
|
|
66
|
+
component.$refs.listbox.open();
|
|
67
|
+
});
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
const template = (content, label = '') => `
|
|
@@ -61,6 +72,7 @@ const template = (content, label = '') => `
|
|
|
61
72
|
${label}
|
|
62
73
|
<br/>
|
|
63
74
|
<gl-listbox
|
|
75
|
+
ref="listbox"
|
|
64
76
|
v-model="selected"
|
|
65
77
|
:items="items"
|
|
66
78
|
:category="category"
|
|
@@ -68,6 +80,9 @@ const template = (content, label = '') => `
|
|
|
68
80
|
:size="size"
|
|
69
81
|
:disabled="disabled"
|
|
70
82
|
:loading="loading"
|
|
83
|
+
:searchable="searchable"
|
|
84
|
+
:searching="searching"
|
|
85
|
+
:no-results-text="noResultsText"
|
|
71
86
|
:no-caret="noCaret"
|
|
72
87
|
:right="right"
|
|
73
88
|
:toggle-text="toggleText"
|
|
@@ -75,7 +90,8 @@ const template = (content, label = '') => `
|
|
|
75
90
|
:icon="icon"
|
|
76
91
|
:multiple="multiple"
|
|
77
92
|
:is-check-centered="isCheckCentered"
|
|
78
|
-
:aria-
|
|
93
|
+
:toggle-aria-labelled-by="toggleAriaLabelledBy"
|
|
94
|
+
:list-aria-labelled-by="listAriaLabelledBy"
|
|
79
95
|
>
|
|
80
96
|
${content}
|
|
81
97
|
</gl-listbox>
|
|
@@ -99,7 +115,7 @@ export const Default = (args, { argTypes }) => ({
|
|
|
99
115
|
},
|
|
100
116
|
template: template('', `<span class="gl-my-0" id="listbox-label">Select a department</span>`),
|
|
101
117
|
});
|
|
102
|
-
Default.args = generateProps({
|
|
118
|
+
Default.args = generateProps({ toggleAriaLabelledBy: 'listbox-label' });
|
|
103
119
|
Default.decorators = [makeContainer({ height: '370px' })];
|
|
104
120
|
|
|
105
121
|
export const HeaderAndFooter = (args, { argTypes }) => ({
|
|
@@ -125,9 +141,9 @@ export const HeaderAndFooter = (args, { argTypes }) => ({
|
|
|
125
141
|
this.selected.push(mockOptions[index].value);
|
|
126
142
|
},
|
|
127
143
|
},
|
|
128
|
-
template: template(
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
template: template(
|
|
145
|
+
`<template #header>
|
|
146
|
+
<p class="gl-font-weight-bold gl-font-sm gl-m-0 gl-text-center gl-py-2 gl-border-1 gl-border-b-solid gl-border-gray-200">Assign to department</p>
|
|
131
147
|
</template>
|
|
132
148
|
<template #footer>
|
|
133
149
|
<div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3">
|
|
@@ -138,7 +154,8 @@ export const HeaderAndFooter = (args, { argTypes }) => ({
|
|
|
138
154
|
</gl-button-group>
|
|
139
155
|
</div>
|
|
140
156
|
</template>
|
|
141
|
-
`
|
|
157
|
+
`
|
|
158
|
+
),
|
|
142
159
|
});
|
|
143
160
|
HeaderAndFooter.args = generateProps({
|
|
144
161
|
toggleText: 'Header and Footer',
|
|
@@ -172,6 +189,7 @@ export const CustomListItem = (args, { argTypes }) => ({
|
|
|
172
189
|
},
|
|
173
190
|
template: `
|
|
174
191
|
<gl-listbox
|
|
192
|
+
ref="listbox"
|
|
175
193
|
v-model="selected"
|
|
176
194
|
:items="items"
|
|
177
195
|
:category="category"
|
|
@@ -179,6 +197,9 @@ export const CustomListItem = (args, { argTypes }) => ({
|
|
|
179
197
|
:size="size"
|
|
180
198
|
:disabled="disabled"
|
|
181
199
|
:loading="loading"
|
|
200
|
+
:searchable="searchable"
|
|
201
|
+
:searching="searching"
|
|
202
|
+
:no-results-text="noResultsText"
|
|
182
203
|
:no-caret="noCaret"
|
|
183
204
|
:right="right"
|
|
184
205
|
:toggle-text="headerText"
|
|
@@ -186,24 +207,30 @@ export const CustomListItem = (args, { argTypes }) => ({
|
|
|
186
207
|
:icon="icon"
|
|
187
208
|
:multiple="multiple"
|
|
188
209
|
:is-check-centered="isCheckCentered"
|
|
189
|
-
:aria-
|
|
210
|
+
:toggle-aria-labelled-by="toggleAriaLabelledBy"
|
|
211
|
+
:list-aria-labelled-by="listAriaLabelledBy"
|
|
190
212
|
>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
<template #list-item="{ item }">
|
|
214
|
+
<span class="gl-display-flex gl-align-items-center">
|
|
215
|
+
<gl-avatar :size="32" class-="gl-mr-3"/>
|
|
216
|
+
<span class="gl-display-flex gl-flex-direction-column">
|
|
217
|
+
<span class="gl-font-weight-bold gl-white-space-nowrap">{{ item.text }}</span>
|
|
218
|
+
<span class="gl-text-gray-400"> {{ item.secondaryText }}</span>
|
|
219
|
+
</span>
|
|
220
|
+
</span>
|
|
221
|
+
</template>
|
|
200
222
|
</gl-listbox>
|
|
201
223
|
`,
|
|
202
224
|
});
|
|
203
225
|
|
|
204
226
|
CustomListItem.args = generateProps({
|
|
205
227
|
items: [
|
|
206
|
-
{
|
|
228
|
+
{
|
|
229
|
+
value: 'mikegreiling',
|
|
230
|
+
text: 'Mike Greiling',
|
|
231
|
+
secondaryText: '@mikegreiling',
|
|
232
|
+
icon: 'foo',
|
|
233
|
+
},
|
|
207
234
|
{ value: 'ohoral', text: 'Olena Horal-Koretska', secondaryText: '@ohoral', icon: 'bar' },
|
|
208
235
|
{ value: 'markian', text: 'Mark Florian', secondaryText: '@markian', icon: 'bin' },
|
|
209
236
|
],
|
|
@@ -283,3 +310,200 @@ export default {
|
|
|
283
310
|
},
|
|
284
311
|
},
|
|
285
312
|
};
|
|
313
|
+
|
|
314
|
+
export const Searchable = (args, { argTypes }) => ({
|
|
315
|
+
props: Object.keys(argTypes),
|
|
316
|
+
components: {
|
|
317
|
+
GlListbox,
|
|
318
|
+
},
|
|
319
|
+
data() {
|
|
320
|
+
return {
|
|
321
|
+
selected: mockOptions[1].value,
|
|
322
|
+
filteredItems: mockOptions,
|
|
323
|
+
searchInProgress: false,
|
|
324
|
+
timeoutId: null,
|
|
325
|
+
headerId: 'listbox-header',
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
mounted() {
|
|
329
|
+
if (this.startOpened) {
|
|
330
|
+
openListbox(this);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
methods: {
|
|
334
|
+
filterList(searchTerm) {
|
|
335
|
+
if (this.timeoutId) {
|
|
336
|
+
clearTimeout(this.timeoutId);
|
|
337
|
+
}
|
|
338
|
+
this.searchInProgress = true;
|
|
339
|
+
|
|
340
|
+
// eslint-disable-next-line no-restricted-globals
|
|
341
|
+
this.timeoutId = setTimeout(() => {
|
|
342
|
+
this.filteredItems = this.items.filter(({ text }) =>
|
|
343
|
+
text.toLowerCase().includes(searchTerm.toLowerCase())
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
this.searchInProgress = false;
|
|
347
|
+
}, 2000);
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
computed: {
|
|
351
|
+
customToggleText() {
|
|
352
|
+
let toggleText = 'Search for department';
|
|
353
|
+
const selectedValues = Array.isArray(this.selected) ? this.selected : [this.selected];
|
|
354
|
+
|
|
355
|
+
if (selectedValues.length === 1) {
|
|
356
|
+
toggleText = this.items.find(({ value }) => value === selectedValues[0]).text;
|
|
357
|
+
} else {
|
|
358
|
+
toggleText = `Selected ${selectedValues.length} departments`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return toggleText;
|
|
362
|
+
},
|
|
363
|
+
numberOfSearchResults() {
|
|
364
|
+
return this.filteredItems.length === 1 ? '1 result' : `${this.filteredItems.length} results`;
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
template: `
|
|
368
|
+
<gl-listbox
|
|
369
|
+
ref="listbox"
|
|
370
|
+
v-model="selected"
|
|
371
|
+
:items="filteredItems"
|
|
372
|
+
:category="category"
|
|
373
|
+
:variant="variant"
|
|
374
|
+
:size="size"
|
|
375
|
+
:disabled="disabled"
|
|
376
|
+
:loading="loading"
|
|
377
|
+
:no-caret="noCaret"
|
|
378
|
+
:right="right"
|
|
379
|
+
:toggle-text="customToggleText"
|
|
380
|
+
:text-sr-only="textSrOnly"
|
|
381
|
+
:icon="icon"
|
|
382
|
+
:multiple="multiple"
|
|
383
|
+
:is-check-centered="isCheckCentered"
|
|
384
|
+
:toggle-aria-labelled-by="toggleAriaLabelledBy"
|
|
385
|
+
:list-aria-labelled-by="headerId"
|
|
386
|
+
:searchable="searchable"
|
|
387
|
+
:searching="searchInProgress"
|
|
388
|
+
:no-results-text="noResultsText"
|
|
389
|
+
@search="filterList"
|
|
390
|
+
>
|
|
391
|
+
<template #header>
|
|
392
|
+
<p :id="headerId"
|
|
393
|
+
class="gl-font-weight-bold gl-font-sm gl-m-0 gl-text-center gl-py-2 gl-border-1 gl-border-b-solid gl-border-gray-200">
|
|
394
|
+
Assign to department</p>
|
|
395
|
+
</template>
|
|
396
|
+
<template #search-summary-sr-only>
|
|
397
|
+
{{ numberOfSearchResults }}
|
|
398
|
+
</template>
|
|
399
|
+
</gl-listbox>
|
|
400
|
+
`,
|
|
401
|
+
});
|
|
402
|
+
Searchable.args = generateProps({ searchable: true });
|
|
403
|
+
Searchable.decorators = [makeContainer({ height: '370px' })];
|
|
404
|
+
|
|
405
|
+
export const SearchableGroups = (args, { argTypes }) => ({
|
|
406
|
+
props: Object.keys(argTypes),
|
|
407
|
+
components: {
|
|
408
|
+
GlListbox,
|
|
409
|
+
},
|
|
410
|
+
data() {
|
|
411
|
+
return {
|
|
412
|
+
selected: mockGroups[1].options[0].value,
|
|
413
|
+
filteredGroupOptions: mockGroups,
|
|
414
|
+
searchInProgress: false,
|
|
415
|
+
timeoutId: null,
|
|
416
|
+
headerId: 'listbox-header',
|
|
417
|
+
};
|
|
418
|
+
},
|
|
419
|
+
mounted() {
|
|
420
|
+
if (this.startOpened) {
|
|
421
|
+
openListbox(this);
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
computed: {
|
|
425
|
+
flattenedOptions() {
|
|
426
|
+
return flattenedOptions(this.items);
|
|
427
|
+
},
|
|
428
|
+
flattenedFilteredOptions() {
|
|
429
|
+
return flattenedOptions(this.filteredGroupOptions);
|
|
430
|
+
},
|
|
431
|
+
customToggleText() {
|
|
432
|
+
let toggleText = 'Search for department';
|
|
433
|
+
const selectedValues = Array.isArray(this.selected) ? this.selected : [this.selected];
|
|
434
|
+
|
|
435
|
+
if (selectedValues.length === 1) {
|
|
436
|
+
toggleText = this.flattenedOptions.find(({ value }) => value === selectedValues[0]).text;
|
|
437
|
+
} else {
|
|
438
|
+
toggleText = `Selected ${selectedValues.length} departments`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return toggleText;
|
|
442
|
+
},
|
|
443
|
+
numberOfSearchResults() {
|
|
444
|
+
return this.flattenedFilteredOptions.length === 1
|
|
445
|
+
? '1 result'
|
|
446
|
+
: `${this.flattenedFilteredOptions.length} results`;
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
methods: {
|
|
450
|
+
filterList(searchTerm) {
|
|
451
|
+
if (this.timeoutId) {
|
|
452
|
+
clearTimeout(this.timeoutId);
|
|
453
|
+
}
|
|
454
|
+
this.searchInProgress = true;
|
|
455
|
+
|
|
456
|
+
// eslint-disable-next-line no-restricted-globals
|
|
457
|
+
this.timeoutId = setTimeout(() => {
|
|
458
|
+
this.filteredGroupOptions = this.items
|
|
459
|
+
.map(({ text, options }) => {
|
|
460
|
+
return {
|
|
461
|
+
text,
|
|
462
|
+
options: options.filter((option) =>
|
|
463
|
+
option.text.toLowerCase().includes(searchTerm.toLowerCase())
|
|
464
|
+
),
|
|
465
|
+
};
|
|
466
|
+
})
|
|
467
|
+
.filter(({ options }) => options.length);
|
|
468
|
+
|
|
469
|
+
this.searchInProgress = false;
|
|
470
|
+
}, 2000);
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
template: `
|
|
474
|
+
<gl-listbox
|
|
475
|
+
ref="listbox"
|
|
476
|
+
v-model="selected"
|
|
477
|
+
:items="filteredGroupOptions"
|
|
478
|
+
:category="category"
|
|
479
|
+
:variant="variant"
|
|
480
|
+
:size="size"
|
|
481
|
+
:disabled="disabled"
|
|
482
|
+
:loading="loading"
|
|
483
|
+
:no-caret="noCaret"
|
|
484
|
+
:right="right"
|
|
485
|
+
:toggle-text="customToggleText"
|
|
486
|
+
:text-sr-only="textSrOnly"
|
|
487
|
+
:icon="icon"
|
|
488
|
+
:multiple="multiple"
|
|
489
|
+
:is-check-centered="isCheckCentered"
|
|
490
|
+
:toggle-aria-labelled-by="toggleAriaLabelledBy"
|
|
491
|
+
:list-aria-labelled-by="headerId"
|
|
492
|
+
:searching="searchInProgress"
|
|
493
|
+
:no-results-text="noResultsText"
|
|
494
|
+
:searchable="searchable"
|
|
495
|
+
@search="filterList"
|
|
496
|
+
>
|
|
497
|
+
<template #header>
|
|
498
|
+
<p :id="headerId"
|
|
499
|
+
class="gl-font-weight-bold gl-font-sm gl-m-0 gl-text-center gl-py-2 gl-border-1 gl-border-b-solid gl-border-gray-200">
|
|
500
|
+
Assign to department</p>
|
|
501
|
+
</template>
|
|
502
|
+
<template #search-summary-sr-only>
|
|
503
|
+
{{ numberOfSearchResults }}
|
|
504
|
+
</template>
|
|
505
|
+
</gl-listbox>
|
|
506
|
+
`,
|
|
507
|
+
});
|
|
508
|
+
SearchableGroups.args = generateProps({ searchable: true, items: mockGroups });
|
|
509
|
+
SearchableGroups.decorators = [makeContainer({ height: '370px' })];
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
buttonSizeOptions,
|
|
16
16
|
dropdownVariantOptions,
|
|
17
17
|
} from '../../../../utils/constants';
|
|
18
|
+
import GlLoadingIcon from '../../loading_icon/loading_icon.vue';
|
|
19
|
+
import GlSearchBoxByType from '../../search_box_by_type/search_box_by_type.vue';
|
|
18
20
|
import GlBaseDropdown from '../base_dropdown/base_dropdown.vue';
|
|
19
21
|
import GlListboxItem from './listbox_item.vue';
|
|
20
22
|
import GlListboxGroup from './listbox_group.vue';
|
|
@@ -22,6 +24,7 @@ import { isOption, itemsValidator, flattenedOptions } from './utils';
|
|
|
22
24
|
|
|
23
25
|
export const ITEM_SELECTOR = '[role="option"]';
|
|
24
26
|
const GROUP_TOP_BORDER_CLASSES = ['gl-border-t', 'gl-pt-3', 'gl-mt-3'];
|
|
27
|
+
export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input';
|
|
25
28
|
|
|
26
29
|
export default {
|
|
27
30
|
events: {
|
|
@@ -32,6 +35,8 @@ export default {
|
|
|
32
35
|
GlBaseDropdown,
|
|
33
36
|
GlListboxItem,
|
|
34
37
|
GlListboxGroup,
|
|
38
|
+
GlSearchBoxByType,
|
|
39
|
+
GlLoadingIcon,
|
|
35
40
|
},
|
|
36
41
|
model: {
|
|
37
42
|
prop: 'selected',
|
|
@@ -124,6 +129,7 @@ export default {
|
|
|
124
129
|
},
|
|
125
130
|
/**
|
|
126
131
|
* Set to "true" when dropdown content (items) is loading
|
|
132
|
+
* It will render a small loader in the dropdown toggle and make it disabled
|
|
127
133
|
*/
|
|
128
134
|
loading: {
|
|
129
135
|
type: Boolean,
|
|
@@ -164,18 +170,55 @@ export default {
|
|
|
164
170
|
},
|
|
165
171
|
/**
|
|
166
172
|
* The `aria-labelledby` attribute value for the toggle button
|
|
173
|
+
* Provide the string of ids seperated by space
|
|
167
174
|
*/
|
|
168
|
-
|
|
175
|
+
toggleAriaLabelledBy: {
|
|
169
176
|
type: String,
|
|
170
177
|
required: false,
|
|
171
178
|
default: null,
|
|
172
179
|
},
|
|
180
|
+
/**
|
|
181
|
+
* The `aria-labelledby` attribute value for the list of options
|
|
182
|
+
* Provide the string of ids seperated by space
|
|
183
|
+
*/
|
|
184
|
+
listAriaLabelledBy: {
|
|
185
|
+
type: String,
|
|
186
|
+
required: false,
|
|
187
|
+
default: null,
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* Enable search
|
|
191
|
+
*/
|
|
192
|
+
searchable: {
|
|
193
|
+
type: Boolean,
|
|
194
|
+
required: false,
|
|
195
|
+
default: false,
|
|
196
|
+
},
|
|
197
|
+
/**
|
|
198
|
+
* Set to "true" when items search is in progress.
|
|
199
|
+
* It will display loading icon below the search input
|
|
200
|
+
*/
|
|
201
|
+
searching: {
|
|
202
|
+
type: Boolean,
|
|
203
|
+
required: false,
|
|
204
|
+
default: false,
|
|
205
|
+
},
|
|
206
|
+
/**
|
|
207
|
+
* Message to be displayed when filtering produced no results
|
|
208
|
+
*/
|
|
209
|
+
noResultsText: {
|
|
210
|
+
type: String,
|
|
211
|
+
required: false,
|
|
212
|
+
default: 'No results found',
|
|
213
|
+
},
|
|
173
214
|
},
|
|
174
215
|
data() {
|
|
175
216
|
return {
|
|
176
217
|
selectedValues: [],
|
|
177
218
|
toggleId: uniqueId('dropdown-toggle-btn-'),
|
|
219
|
+
listboxId: uniqueId('listbox-'),
|
|
178
220
|
nextFocusedItemIndex: null,
|
|
221
|
+
searchStr: '',
|
|
179
222
|
};
|
|
180
223
|
},
|
|
181
224
|
computed: {
|
|
@@ -201,6 +244,17 @@ export default {
|
|
|
201
244
|
.map((selected) => this.flattenedOptions.findIndex(({ value }) => value === selected))
|
|
202
245
|
.sort();
|
|
203
246
|
},
|
|
247
|
+
showList() {
|
|
248
|
+
return this.flattenedOptions.length && !this.searching;
|
|
249
|
+
},
|
|
250
|
+
showNoResultsText() {
|
|
251
|
+
return !this.flattenedOptions.length && !this.searching;
|
|
252
|
+
},
|
|
253
|
+
announceSRSearchResults() {
|
|
254
|
+
return (
|
|
255
|
+
this.searchable && !this.showNoResultsText && this.$scopedSlots['search-summary-sr-only']
|
|
256
|
+
);
|
|
257
|
+
},
|
|
204
258
|
},
|
|
205
259
|
watch: {
|
|
206
260
|
selected: {
|
|
@@ -218,12 +272,22 @@ export default {
|
|
|
218
272
|
},
|
|
219
273
|
},
|
|
220
274
|
methods: {
|
|
275
|
+
open() {
|
|
276
|
+
this.$refs.baseDropdown.open();
|
|
277
|
+
},
|
|
278
|
+
close() {
|
|
279
|
+
this.$refs.baseDropdown.close();
|
|
280
|
+
},
|
|
221
281
|
groupClasses(index) {
|
|
222
282
|
return index === 0 ? null : GROUP_TOP_BORDER_CLASSES;
|
|
223
283
|
},
|
|
224
284
|
onShow() {
|
|
225
285
|
this.$nextTick(() => {
|
|
226
|
-
|
|
286
|
+
if (this.searchable) {
|
|
287
|
+
this.focusSearchInput();
|
|
288
|
+
} else {
|
|
289
|
+
this.focusItem(this.selectedIndices[0] ?? 0, this.getFocusableListItemElements());
|
|
290
|
+
}
|
|
227
291
|
/**
|
|
228
292
|
* Emitted when dropdown is shown
|
|
229
293
|
*
|
|
@@ -242,21 +306,33 @@ export default {
|
|
|
242
306
|
this.nextFocusedItemIndex = null;
|
|
243
307
|
},
|
|
244
308
|
onKeydown(event) {
|
|
245
|
-
const { code } = event;
|
|
309
|
+
const { code, target } = event;
|
|
246
310
|
const elements = this.getFocusableListItemElements();
|
|
247
311
|
|
|
248
312
|
if (elements.length < 1) return;
|
|
249
313
|
|
|
250
314
|
let stop = true;
|
|
315
|
+
const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
|
|
251
316
|
|
|
252
317
|
if (code === HOME) {
|
|
253
318
|
this.focusItem(0, elements);
|
|
254
319
|
} else if (code === END) {
|
|
255
320
|
this.focusItem(elements.length - 1, elements);
|
|
256
321
|
} else if (code === ARROW_UP) {
|
|
257
|
-
|
|
322
|
+
if (isSearchInput) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (this.searchable && elements.indexOf(target) === 0) {
|
|
326
|
+
this.focusSearchInput();
|
|
327
|
+
} else {
|
|
328
|
+
this.focusNextItem(event, elements, -1);
|
|
329
|
+
}
|
|
258
330
|
} else if (code === ARROW_DOWN) {
|
|
259
|
-
|
|
331
|
+
if (isSearchInput) {
|
|
332
|
+
this.focusItem(0, elements);
|
|
333
|
+
} else {
|
|
334
|
+
this.focusNextItem(event, elements, 1);
|
|
335
|
+
}
|
|
260
336
|
} else {
|
|
261
337
|
stop = false;
|
|
262
338
|
}
|
|
@@ -266,8 +342,8 @@ export default {
|
|
|
266
342
|
}
|
|
267
343
|
},
|
|
268
344
|
getFocusableListItemElements() {
|
|
269
|
-
const items = this.$refs.list
|
|
270
|
-
return Array.from(items);
|
|
345
|
+
const items = this.$refs.list?.querySelectorAll(ITEM_SELECTOR);
|
|
346
|
+
return Array.from(items || []);
|
|
271
347
|
},
|
|
272
348
|
focusNextItem(event, elements, offset) {
|
|
273
349
|
const { target } = event;
|
|
@@ -283,11 +359,14 @@ export default {
|
|
|
283
359
|
elements[index]?.focus();
|
|
284
360
|
});
|
|
285
361
|
},
|
|
286
|
-
|
|
362
|
+
focusSearchInput() {
|
|
363
|
+
this.$refs.searchBox.focusInput();
|
|
364
|
+
},
|
|
365
|
+
onSelect(item, isSelected) {
|
|
287
366
|
if (this.multiple) {
|
|
288
|
-
this.onMultiSelect(value, isSelected);
|
|
367
|
+
this.onMultiSelect(item.value, isSelected);
|
|
289
368
|
} else {
|
|
290
|
-
this.onSingleSelect(value, isSelected);
|
|
369
|
+
this.onSingleSelect(item.value, isSelected);
|
|
291
370
|
}
|
|
292
371
|
},
|
|
293
372
|
isSelected(item) {
|
|
@@ -318,6 +397,15 @@ export default {
|
|
|
318
397
|
);
|
|
319
398
|
}
|
|
320
399
|
},
|
|
400
|
+
search(searchTerm) {
|
|
401
|
+
/**
|
|
402
|
+
* Emitted when the search query string is changed
|
|
403
|
+
*
|
|
404
|
+
* @event search
|
|
405
|
+
* @type {string}
|
|
406
|
+
*/
|
|
407
|
+
this.$emit('search', searchTerm);
|
|
408
|
+
},
|
|
321
409
|
isOption,
|
|
322
410
|
},
|
|
323
411
|
};
|
|
@@ -327,7 +415,7 @@ export default {
|
|
|
327
415
|
<gl-base-dropdown
|
|
328
416
|
ref="baseDropdown"
|
|
329
417
|
aria-haspopup="listbox"
|
|
330
|
-
:aria-labelledby="
|
|
418
|
+
:aria-labelledby="toggleAriaLabelledBy"
|
|
331
419
|
:toggle-id="toggleId"
|
|
332
420
|
:toggle-text="listboxToggleText"
|
|
333
421
|
:toggle-class="toggleClass"
|
|
@@ -346,10 +434,29 @@ export default {
|
|
|
346
434
|
<!-- @slot Content to display in dropdown header -->
|
|
347
435
|
<slot name="header"></slot>
|
|
348
436
|
|
|
437
|
+
<div v-if="searchable" class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200">
|
|
438
|
+
<gl-search-box-by-type
|
|
439
|
+
ref="searchBox"
|
|
440
|
+
v-model="searchStr"
|
|
441
|
+
:aria-owns="listboxId"
|
|
442
|
+
data-testid="listbox-search-input"
|
|
443
|
+
@input="search"
|
|
444
|
+
@keydown="onKeydown"
|
|
445
|
+
/>
|
|
446
|
+
<gl-loading-icon
|
|
447
|
+
v-if="searching"
|
|
448
|
+
data-testid="listbox-search-loader"
|
|
449
|
+
size="md"
|
|
450
|
+
class="gl-my-3"
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
349
454
|
<component
|
|
350
455
|
:is="listboxTag"
|
|
456
|
+
v-if="showList"
|
|
457
|
+
id="listbox"
|
|
351
458
|
ref="list"
|
|
352
|
-
:aria-labelledby="toggleId"
|
|
459
|
+
:aria-labelledby="listAriaLabelledBy || toggleId"
|
|
353
460
|
role="listbox"
|
|
354
461
|
class="gl-new-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0"
|
|
355
462
|
tabindex="-1"
|
|
@@ -395,6 +502,25 @@ export default {
|
|
|
395
502
|
</template>
|
|
396
503
|
</template>
|
|
397
504
|
</component>
|
|
505
|
+
|
|
506
|
+
<span
|
|
507
|
+
v-if="announceSRSearchResults"
|
|
508
|
+
data-testid="listbox-number-of-results"
|
|
509
|
+
class="gl-sr-only"
|
|
510
|
+
aria-live="assertive"
|
|
511
|
+
>
|
|
512
|
+
<!-- @slot Text read by screen reader announcing a number of search results -->
|
|
513
|
+
<slot name="search-summary-sr-only"></slot>
|
|
514
|
+
</span>
|
|
515
|
+
|
|
516
|
+
<div
|
|
517
|
+
v-else-if="showNoResultsText"
|
|
518
|
+
aria-live="assertive"
|
|
519
|
+
class="gl-pl-7 gl-pr-5 gl-pt-3 gl-font-base gl-text-gray-600"
|
|
520
|
+
data-testid="listbox-no-results-text"
|
|
521
|
+
>
|
|
522
|
+
{{ noResultsText }}
|
|
523
|
+
</div>
|
|
398
524
|
<!-- @slot Content to display in dropdown footer -->
|
|
399
525
|
<slot name="footer"></slot>
|
|
400
526
|
</gl-base-dropdown>
|