@gitlab/ui 43.13.0 → 43.14.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 +7 -0
- package/dist/components/base/new_dropdowns/listbox/listbox.js +34 -19
- package/dist/components/base/new_dropdowns/listbox/listbox_group.js +54 -0
- package/dist/components/base/new_dropdowns/listbox/mock_data.js +61 -0
- package/dist/components/base/new_dropdowns/listbox/utils.js +34 -0
- package/package.json +1 -1
- package/src/components/base/new_dropdowns/listbox/listbox.md +56 -13
- package/src/components/base/new_dropdowns/listbox/listbox.spec.js +60 -33
- package/src/components/base/new_dropdowns/listbox/listbox.stories.js +86 -92
- package/src/components/base/new_dropdowns/listbox/listbox.vue +63 -20
- package/src/components/base/new_dropdowns/listbox/listbox_group.spec.js +47 -0
- package/src/components/base/new_dropdowns/listbox/listbox_group.vue +24 -0
- package/src/components/base/new_dropdowns/listbox/mock_data.js +68 -0
- package/src/components/base/new_dropdowns/listbox/utils.js +21 -0
- package/src/components/base/new_dropdowns/listbox/utils.spec.js +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [43.14.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v43.13.0...v43.14.0) (2022-09-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **GlListbox:** Add support for grouping ([c6ec8f1](https://gitlab.com/gitlab-org/gitlab-ui/commit/c6ec8f1a53eb4fad9a3ec077a762b23e67ef0245))
|
|
7
|
+
|
|
1
8
|
# [43.13.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v43.12.0...v43.13.0) (2022-09-06)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -5,9 +5,12 @@ import { GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, HOME, END, ARROW_UP, ARROW_DOWN
|
|
|
5
5
|
import { buttonCategoryOptions, dropdownVariantOptions, buttonSizeOptions } from '../../../../utils/constants';
|
|
6
6
|
import GlBaseDropdown from '../base_dropdown/base_dropdown';
|
|
7
7
|
import GlListboxItem from './listbox_item';
|
|
8
|
+
import GlListboxGroup from './listbox_group';
|
|
9
|
+
import { itemsValidator, isOption, flattenedOptions } from './utils';
|
|
8
10
|
import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
|
|
9
11
|
|
|
10
12
|
const ITEM_SELECTOR = '[role="option"]';
|
|
13
|
+
const GROUP_TOP_BORDER_CLASSES = ['gl-border-t', 'gl-pt-3', 'gl-mt-3'];
|
|
11
14
|
var script = {
|
|
12
15
|
events: {
|
|
13
16
|
GL_DROPDOWN_SHOWN,
|
|
@@ -15,7 +18,8 @@ var script = {
|
|
|
15
18
|
},
|
|
16
19
|
components: {
|
|
17
20
|
GlBaseDropdown,
|
|
18
|
-
GlListboxItem
|
|
21
|
+
GlListboxItem,
|
|
22
|
+
GlListboxGroup
|
|
19
23
|
},
|
|
20
24
|
model: {
|
|
21
25
|
prop: 'selected',
|
|
@@ -29,14 +33,7 @@ var script = {
|
|
|
29
33
|
type: Array,
|
|
30
34
|
required: false,
|
|
31
35
|
default: () => [],
|
|
32
|
-
validator:
|
|
33
|
-
return items.every(_ref => {
|
|
34
|
-
let {
|
|
35
|
-
value
|
|
36
|
-
} = _ref;
|
|
37
|
-
return typeof value === 'string';
|
|
38
|
-
});
|
|
39
|
-
}
|
|
36
|
+
validator: itemsValidator
|
|
40
37
|
},
|
|
41
38
|
|
|
42
39
|
/**
|
|
@@ -187,17 +184,26 @@ var script = {
|
|
|
187
184
|
},
|
|
188
185
|
|
|
189
186
|
computed: {
|
|
187
|
+
listboxTag() {
|
|
188
|
+
if (this.items.length === 0 || isOption(this.items[0])) return 'ul';
|
|
189
|
+
return 'div';
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
flattenedOptions() {
|
|
193
|
+
return flattenedOptions(this.items);
|
|
194
|
+
},
|
|
195
|
+
|
|
190
196
|
listboxToggleText() {
|
|
191
197
|
if (!this.toggleText) {
|
|
192
198
|
if (!this.multiple && this.selectedValues.length) {
|
|
193
|
-
var _this$
|
|
199
|
+
var _this$flattenedOption;
|
|
194
200
|
|
|
195
|
-
return (_this$
|
|
201
|
+
return (_this$flattenedOption = this.flattenedOptions.find(_ref => {
|
|
196
202
|
let {
|
|
197
203
|
value
|
|
198
|
-
} =
|
|
204
|
+
} = _ref;
|
|
199
205
|
return value === this.selectedValues[0];
|
|
200
|
-
})) === null || _this$
|
|
206
|
+
})) === null || _this$flattenedOption === void 0 ? void 0 : _this$flattenedOption.text;
|
|
201
207
|
}
|
|
202
208
|
|
|
203
209
|
return '';
|
|
@@ -207,10 +213,10 @@ var script = {
|
|
|
207
213
|
},
|
|
208
214
|
|
|
209
215
|
selectedIndices() {
|
|
210
|
-
return this.selectedValues.map(selected => this.
|
|
216
|
+
return this.selectedValues.map(selected => this.flattenedOptions.findIndex(_ref2 => {
|
|
211
217
|
let {
|
|
212
218
|
value
|
|
213
|
-
} =
|
|
219
|
+
} = _ref2;
|
|
214
220
|
return value === selected;
|
|
215
221
|
})).sort();
|
|
216
222
|
}
|
|
@@ -235,6 +241,10 @@ var script = {
|
|
|
235
241
|
}
|
|
236
242
|
},
|
|
237
243
|
methods: {
|
|
244
|
+
groupClasses(index) {
|
|
245
|
+
return index === 0 ? null : GROUP_TOP_BORDER_CLASSES;
|
|
246
|
+
},
|
|
247
|
+
|
|
238
248
|
onShow() {
|
|
239
249
|
this.$nextTick(() => {
|
|
240
250
|
var _this$selectedIndices;
|
|
@@ -310,10 +320,10 @@ var script = {
|
|
|
310
320
|
});
|
|
311
321
|
},
|
|
312
322
|
|
|
313
|
-
onSelect(
|
|
323
|
+
onSelect(_ref3, isSelected) {
|
|
314
324
|
let {
|
|
315
325
|
value
|
|
316
|
-
} =
|
|
326
|
+
} = _ref3;
|
|
317
327
|
|
|
318
328
|
if (this.multiple) {
|
|
319
329
|
this.onMultiSelect(value, isSelected);
|
|
@@ -326,6 +336,10 @@ var script = {
|
|
|
326
336
|
return this.selectedValues.some(value => value === item.value);
|
|
327
337
|
},
|
|
328
338
|
|
|
339
|
+
isFocused(item) {
|
|
340
|
+
return this.nextFocusedItemIndex === this.flattenedOptions.indexOf(item);
|
|
341
|
+
},
|
|
342
|
+
|
|
329
343
|
onSingleSelect(value, isSelected) {
|
|
330
344
|
if (isSelected) {
|
|
331
345
|
/**
|
|
@@ -345,8 +359,9 @@ var script = {
|
|
|
345
359
|
} else {
|
|
346
360
|
this.$emit('select', this.selectedValues.filter(selectedValue => selectedValue !== value));
|
|
347
361
|
}
|
|
348
|
-
}
|
|
362
|
+
},
|
|
349
363
|
|
|
364
|
+
isOption
|
|
350
365
|
}
|
|
351
366
|
};
|
|
352
367
|
|
|
@@ -354,7 +369,7 @@ var script = {
|
|
|
354
369
|
const __vue_script__ = script;
|
|
355
370
|
|
|
356
371
|
/* template */
|
|
357
|
-
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.ariaLabelledby,"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(" "),_c(
|
|
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.ariaLabelledby,"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(" "),_c(_vm.listboxTag,{ref:"list",tag:"component",staticClass:"gl-new-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0",attrs:{"aria-labelledby":_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._v(" "),_vm._t("footer")],2)};
|
|
358
373
|
var __vue_staticRenderFns__ = [];
|
|
359
374
|
|
|
360
375
|
/* style */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import _uniqueId from 'lodash/uniqueId';
|
|
2
|
+
import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
|
|
3
|
+
|
|
4
|
+
var script = {
|
|
5
|
+
props: {
|
|
6
|
+
name: {
|
|
7
|
+
type: String,
|
|
8
|
+
required: true
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
created() {
|
|
13
|
+
this.nameId = _uniqueId('gl-listbox-group-');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/* script */
|
|
19
|
+
const __vue_script__ = script;
|
|
20
|
+
|
|
21
|
+
/* template */
|
|
22
|
+
var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('ul',{staticClass:"gl-mb-0 gl-pl-0 gl-list-style-none",attrs:{"role":"group","aria-labelledby":_vm.nameId}},[_c('li',{staticClass:"gl-pl-5! gl-py-2! gl-font-base gl-font-weight-bold",attrs:{"id":_vm.nameId,"role":"presentation"}},[_vm._t("group-label",function(){return [_vm._v(_vm._s(_vm.name))]})],2),_vm._v(" "),_vm._t("default")],2)};
|
|
23
|
+
var __vue_staticRenderFns__ = [];
|
|
24
|
+
|
|
25
|
+
/* style */
|
|
26
|
+
const __vue_inject_styles__ = undefined;
|
|
27
|
+
/* scoped */
|
|
28
|
+
const __vue_scope_id__ = undefined;
|
|
29
|
+
/* module identifier */
|
|
30
|
+
const __vue_module_identifier__ = undefined;
|
|
31
|
+
/* functional template */
|
|
32
|
+
const __vue_is_functional_template__ = false;
|
|
33
|
+
/* style inject */
|
|
34
|
+
|
|
35
|
+
/* style inject SSR */
|
|
36
|
+
|
|
37
|
+
/* style inject shadow dom */
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
const __vue_component__ = __vue_normalize__(
|
|
42
|
+
{ render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ },
|
|
43
|
+
__vue_inject_styles__,
|
|
44
|
+
__vue_script__,
|
|
45
|
+
__vue_scope_id__,
|
|
46
|
+
__vue_is_functional_template__,
|
|
47
|
+
__vue_module_identifier__,
|
|
48
|
+
false,
|
|
49
|
+
undefined,
|
|
50
|
+
undefined,
|
|
51
|
+
undefined
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export default __vue_component__;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const mockOptions = [{
|
|
2
|
+
value: 'prod',
|
|
3
|
+
text: 'Product'
|
|
4
|
+
}, {
|
|
5
|
+
value: 'ppl',
|
|
6
|
+
text: 'People'
|
|
7
|
+
}, {
|
|
8
|
+
value: 'fin',
|
|
9
|
+
text: 'Finance'
|
|
10
|
+
}, {
|
|
11
|
+
value: 'leg',
|
|
12
|
+
text: 'Legal'
|
|
13
|
+
}, {
|
|
14
|
+
value: 'eng',
|
|
15
|
+
text: 'Engineering'
|
|
16
|
+
}, {
|
|
17
|
+
value: 'sales',
|
|
18
|
+
text: 'Sales'
|
|
19
|
+
}, {
|
|
20
|
+
value: 'marketing',
|
|
21
|
+
text: 'Marketing'
|
|
22
|
+
}, {
|
|
23
|
+
value: 'acc',
|
|
24
|
+
text: 'Accounting'
|
|
25
|
+
}, {
|
|
26
|
+
value: 'hr',
|
|
27
|
+
text: 'Human Resource Management'
|
|
28
|
+
}, {
|
|
29
|
+
value: 'rnd',
|
|
30
|
+
text: 'Research and Development'
|
|
31
|
+
}, {
|
|
32
|
+
value: 'cust',
|
|
33
|
+
text: 'Customer Service'
|
|
34
|
+
}, {
|
|
35
|
+
value: 'sup',
|
|
36
|
+
text: 'Support'
|
|
37
|
+
}];
|
|
38
|
+
const mockGroups = [{
|
|
39
|
+
text: 'Branches',
|
|
40
|
+
options: [{
|
|
41
|
+
text: 'main',
|
|
42
|
+
value: 'main'
|
|
43
|
+
}, {
|
|
44
|
+
text: 'feature-123',
|
|
45
|
+
value: 'feature-123'
|
|
46
|
+
}]
|
|
47
|
+
}, {
|
|
48
|
+
text: 'Tags',
|
|
49
|
+
options: [{
|
|
50
|
+
text: 'v1.0',
|
|
51
|
+
value: 'v1.0'
|
|
52
|
+
}, {
|
|
53
|
+
text: 'v2.0',
|
|
54
|
+
value: 'v2.0'
|
|
55
|
+
}, {
|
|
56
|
+
text: 'v2.1',
|
|
57
|
+
value: 'v2.1'
|
|
58
|
+
}]
|
|
59
|
+
}];
|
|
60
|
+
|
|
61
|
+
export { mockGroups, mockOptions };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import _isString from 'lodash/isString';
|
|
2
|
+
|
|
3
|
+
const isOption = item => Boolean(item) && _isString(item.value);
|
|
4
|
+
|
|
5
|
+
const isGroup = function () {
|
|
6
|
+
let {
|
|
7
|
+
options
|
|
8
|
+
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
9
|
+
return Array.isArray(options) && options.every(isOption);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const hasNoDuplicates = array => array.length === new Set(array).size;
|
|
13
|
+
|
|
14
|
+
const flattenedOptions = items => items.flatMap(item => isOption(item) ? item : item.options);
|
|
15
|
+
|
|
16
|
+
const isAllOptionsOrAllGroups = items => items.every(isOption) || items.every(isGroup);
|
|
17
|
+
|
|
18
|
+
const hasUniqueValues = items => hasNoDuplicates(flattenedOptions(items).map(_ref => {
|
|
19
|
+
let {
|
|
20
|
+
value
|
|
21
|
+
} = _ref;
|
|
22
|
+
return value;
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const hasUniqueGroups = items => hasNoDuplicates(items.filter(isGroup).map(_ref2 => {
|
|
26
|
+
let {
|
|
27
|
+
text
|
|
28
|
+
} = _ref2;
|
|
29
|
+
return text;
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const itemsValidator = items => isAllOptionsOrAllGroups(items) && hasUniqueValues(items) && hasUniqueGroups(items);
|
|
33
|
+
|
|
34
|
+
export { flattenedOptions, isOption, itemsValidator };
|
package/package.json
CHANGED
|
@@ -51,21 +51,64 @@ On selection the listbox will emit the `select` event with the selected values.
|
|
|
51
51
|
|
|
52
52
|
### Setting listbox options
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
Use the `items` prop to provide options to the listbox. Each item can be
|
|
55
|
+
either an option or a group. Below are the expected shapes of these
|
|
56
|
+
objects:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
type Option = {
|
|
60
|
+
value: string
|
|
61
|
+
text?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Group = {
|
|
65
|
+
text: string
|
|
66
|
+
options: Array<Option>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type ItemsProp = Array<Option> | Array<Group>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
#### Options
|
|
73
|
+
|
|
74
|
+
The `value` property of options must be unique across all options
|
|
75
|
+
provided to the listbox, as it's used as a primary key.
|
|
76
|
+
|
|
77
|
+
The optional `text` property is used to render the default listbox item
|
|
78
|
+
template. If you want to render a custom template for items, use the
|
|
79
|
+
`list-item` scoped slot:
|
|
58
80
|
|
|
59
81
|
```html
|
|
60
82
|
<gl-listbox :items="items">
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
<template #list-item="{ item }">
|
|
84
|
+
<span class="gl-display-flex gl-align-items-center">
|
|
85
|
+
<gl-avatar :size="32" class-="gl-mr-3"/>
|
|
86
|
+
<span class="gl-display-flex gl-flex-direction-column">
|
|
87
|
+
<span class="gl-font-weight-bold gl-white-space-nowrap">{{ item.text }}</span>
|
|
88
|
+
<span class="gl-text-gray-400"> {{ item.secondaryText }}</span>
|
|
89
|
+
</span>
|
|
90
|
+
</span>
|
|
91
|
+
</template>
|
|
92
|
+
</gl-listbox>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
#### Groups
|
|
96
|
+
|
|
97
|
+
Options can be contained within groups. A group has a required `text`
|
|
98
|
+
property, which must be unique across all groups within the listbox, as
|
|
99
|
+
it's used as a primary key. It also has a required property `items` that
|
|
100
|
+
must be an array of options.
|
|
101
|
+
|
|
102
|
+
Groups can be at most one level deep: a group can only contain options.
|
|
103
|
+
Options and groups _cannot_ be siblings. Either all items are options,
|
|
104
|
+
or they are all groups.
|
|
105
|
+
|
|
106
|
+
To render custom group labels, use the `group-label` scoped slot:
|
|
107
|
+
|
|
108
|
+
```html
|
|
109
|
+
<gl-listbox :items="groups">
|
|
110
|
+
<template #group-label="{ group }">
|
|
111
|
+
{{ group.text }} <gl-badge size="sm">{{ group.options.length }}</gl-badge>
|
|
112
|
+
</template>
|
|
70
113
|
</gl-listbox>
|
|
71
114
|
```
|
|
@@ -11,21 +11,8 @@ import {
|
|
|
11
11
|
} from '../constants';
|
|
12
12
|
import GlListbox, { ITEM_SELECTOR } from './listbox.vue';
|
|
13
13
|
import GlListboxItem from './listbox_item.vue';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
value: 'eng',
|
|
18
|
-
text: 'Engineering',
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
value: 'sales',
|
|
22
|
-
text: 'Sales',
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
value: 'marketing',
|
|
26
|
-
text: 'Marketing',
|
|
27
|
-
},
|
|
28
|
-
];
|
|
14
|
+
import GlListboxGroup from './listbox_group.vue';
|
|
15
|
+
import { mockOptions, mockGroups } from './mock_data';
|
|
29
16
|
|
|
30
17
|
describe('GlListbox', () => {
|
|
31
18
|
let wrapper;
|
|
@@ -40,19 +27,20 @@ describe('GlListbox', () => {
|
|
|
40
27
|
|
|
41
28
|
const findBaseDropdown = () => wrapper.findComponent(GlBaseDropdown);
|
|
42
29
|
const findListContainer = () => wrapper.find('[role="listbox"]');
|
|
43
|
-
const findListboxItems = () =>
|
|
30
|
+
const findListboxItems = (root = wrapper) => root.findAllComponents(GlListboxItem);
|
|
31
|
+
const findListboxGroups = () => wrapper.findAllComponents(GlListboxGroup);
|
|
44
32
|
const findListItem = (index) => findListboxItems().at(index).find(ITEM_SELECTOR);
|
|
45
33
|
|
|
46
34
|
describe('toggle text', () => {
|
|
47
35
|
describe.each`
|
|
48
|
-
toggleText | multiple | selected
|
|
49
|
-
${'Toggle caption'} | ${true} | ${[
|
|
50
|
-
${''} | ${true} | ${[
|
|
51
|
-
${''} | ${false} | ${
|
|
52
|
-
${''} | ${false} | ${''}
|
|
36
|
+
toggleText | multiple | selected | expectedToggleText
|
|
37
|
+
${'Toggle caption'} | ${true} | ${[mockOptions[0].value]} | ${'Toggle caption'}
|
|
38
|
+
${''} | ${true} | ${[mockOptions[0]].value} | ${''}
|
|
39
|
+
${''} | ${false} | ${mockOptions[0].value} | ${mockOptions[0].text}
|
|
40
|
+
${''} | ${false} | ${''} | ${''}
|
|
53
41
|
`('when listbox', ({ toggleText, multiple, selected, expectedToggleText }) => {
|
|
54
42
|
beforeEach(() => {
|
|
55
|
-
buildWrapper({ items:
|
|
43
|
+
buildWrapper({ items: mockOptions, toggleText, multiple, selected });
|
|
56
44
|
});
|
|
57
45
|
|
|
58
46
|
it(`is ${multiple ? 'multi' : 'single'}-select, toggleText is ${
|
|
@@ -77,8 +65,8 @@ describe('GlListbox', () => {
|
|
|
77
65
|
beforeEach(() => {
|
|
78
66
|
buildWrapper({
|
|
79
67
|
multiple: true,
|
|
80
|
-
selected: [
|
|
81
|
-
items:
|
|
68
|
+
selected: [mockOptions[1].value, mockOptions[2].value],
|
|
69
|
+
items: mockOptions,
|
|
82
70
|
});
|
|
83
71
|
});
|
|
84
72
|
|
|
@@ -90,28 +78,29 @@ describe('GlListbox', () => {
|
|
|
90
78
|
it('should deselect previously selected', async () => {
|
|
91
79
|
findListboxItems().at(1).vm.$emit('select', false);
|
|
92
80
|
await nextTick();
|
|
93
|
-
expect(wrapper.emitted('select')[0][0]).toEqual([
|
|
81
|
+
expect(wrapper.emitted('select')[0][0]).toEqual([mockOptions[2].value]);
|
|
94
82
|
});
|
|
95
83
|
|
|
96
84
|
it('should add to selection', async () => {
|
|
97
85
|
findListboxItems().at(0).vm.$emit('select', true);
|
|
98
86
|
await nextTick();
|
|
99
87
|
expect(wrapper.emitted('select')[0][0]).toEqual(
|
|
100
|
-
|
|
88
|
+
// The first three items should now be selected.
|
|
89
|
+
expect.arrayContaining(mockOptions.slice(0, 3).map(({ value }) => value))
|
|
101
90
|
);
|
|
102
91
|
});
|
|
103
92
|
});
|
|
104
93
|
|
|
105
94
|
describe('single-select', () => {
|
|
106
95
|
beforeEach(() => {
|
|
107
|
-
buildWrapper({ selected:
|
|
96
|
+
buildWrapper({ selected: mockOptions[1].value, items: mockOptions });
|
|
108
97
|
});
|
|
109
98
|
|
|
110
99
|
it('should throw an error when array of selections is provided', () => {
|
|
111
100
|
expect(() => {
|
|
112
101
|
buildWrapper({
|
|
113
|
-
selected: [
|
|
114
|
-
items:
|
|
102
|
+
selected: [mockOptions[1].value, mockOptions[2].value],
|
|
103
|
+
items: mockOptions,
|
|
115
104
|
});
|
|
116
105
|
}).toThrowError('To allow multi-selection, please, set "multiple" property to "true"');
|
|
117
106
|
expect(wrapper).toHaveLoggedVueErrors();
|
|
@@ -124,7 +113,25 @@ describe('GlListbox', () => {
|
|
|
124
113
|
it('should deselect previously selected and select a new item', async () => {
|
|
125
114
|
findListboxItems().at(2).vm.$emit('select', true);
|
|
126
115
|
await nextTick();
|
|
127
|
-
expect(wrapper.emitted('select')[0][0]).toEqual(
|
|
116
|
+
expect(wrapper.emitted('select')[0][0]).toEqual(mockOptions[2].value);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('with groups', () => {
|
|
121
|
+
const selected = mockGroups[1].options[1].value;
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
buildWrapper({ selected, items: mockGroups });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should render item as selected when `selected` provided ', () => {
|
|
128
|
+
expect(findListboxItems().at(3).props('isSelected')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should deselect previously selected and select a new item', async () => {
|
|
132
|
+
findListboxItems().at(0).vm.$emit('select', true);
|
|
133
|
+
await nextTick();
|
|
134
|
+
expect(wrapper.emitted('select')[0][0]).toEqual(mockGroups[0].options[0].value);
|
|
128
135
|
});
|
|
129
136
|
});
|
|
130
137
|
});
|
|
@@ -133,8 +140,8 @@ describe('GlListbox', () => {
|
|
|
133
140
|
beforeEach(async () => {
|
|
134
141
|
buildWrapper({
|
|
135
142
|
multiple: true,
|
|
136
|
-
items:
|
|
137
|
-
selected: [
|
|
143
|
+
items: mockOptions,
|
|
144
|
+
selected: [mockOptions[2].value, mockOptions[1].value],
|
|
138
145
|
});
|
|
139
146
|
findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
|
|
140
147
|
await nextTick();
|
|
@@ -166,7 +173,8 @@ describe('GlListbox', () => {
|
|
|
166
173
|
let thirdItem;
|
|
167
174
|
|
|
168
175
|
beforeEach(() => {
|
|
169
|
-
|
|
176
|
+
// These tests are more easily written with a small list of items.
|
|
177
|
+
buildWrapper({ items: mockOptions.slice(0, 3) });
|
|
170
178
|
findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
|
|
171
179
|
firstItem = findListItem(0);
|
|
172
180
|
secondItem = findListItem(1);
|
|
@@ -233,4 +241,23 @@ describe('GlListbox', () => {
|
|
|
233
241
|
expect(wrapper.text()).toContain(footerContent);
|
|
234
242
|
});
|
|
235
243
|
});
|
|
244
|
+
|
|
245
|
+
describe('with groups', () => {
|
|
246
|
+
it('renders groups of items', () => {
|
|
247
|
+
buildWrapper({ items: mockGroups });
|
|
248
|
+
|
|
249
|
+
const groups = findListboxGroups();
|
|
250
|
+
|
|
251
|
+
expect(groups.length).toBe(mockGroups.length);
|
|
252
|
+
|
|
253
|
+
const expectedNameProps = mockGroups.map((group) => group.text);
|
|
254
|
+
const actualNameProps = groups.wrappers.map((group) => group.props('name'));
|
|
255
|
+
|
|
256
|
+
expect(actualNameProps).toEqual(expectedNameProps);
|
|
257
|
+
|
|
258
|
+
mockGroups.forEach((group, i) => {
|
|
259
|
+
expect(findListboxItems(groups.at(i))).toHaveLength(group.options.length);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
236
263
|
});
|
|
@@ -9,65 +9,17 @@ import {
|
|
|
9
9
|
GlSearchBoxByType,
|
|
10
10
|
GlButtonGroup,
|
|
11
11
|
GlButton,
|
|
12
|
+
GlBadge,
|
|
12
13
|
GlAvatar,
|
|
13
14
|
} from '../../../../index';
|
|
14
15
|
import { makeContainer } from '../../../../utils/story_decorators/container';
|
|
15
16
|
import readme from './listbox.md';
|
|
17
|
+
import { mockOptions, mockGroups } from './mock_data';
|
|
16
18
|
|
|
17
19
|
const defaultValue = (prop) => GlListbox.props[prop].default;
|
|
18
20
|
|
|
19
|
-
const defaultItems = [
|
|
20
|
-
{
|
|
21
|
-
value: 'prod',
|
|
22
|
-
text: 'Product',
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
value: 'ppl',
|
|
26
|
-
text: 'People',
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
value: 'fin',
|
|
30
|
-
text: 'Finance',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
value: 'leg',
|
|
34
|
-
text: 'Legal',
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
value: 'eng',
|
|
38
|
-
text: 'Engineering',
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
value: 'sales',
|
|
42
|
-
text: 'Sales',
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
value: 'marketing',
|
|
46
|
-
text: 'Marketing',
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
value: 'acc',
|
|
50
|
-
text: 'Accounting',
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
value: 'hr',
|
|
54
|
-
text: 'Human Resource Management',
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
value: 'rnd',
|
|
58
|
-
text: 'Research and Development',
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
value: 'cust',
|
|
62
|
-
text: 'Customer Service',
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
value: 'sup',
|
|
66
|
-
text: 'Support',
|
|
67
|
-
},
|
|
68
|
-
];
|
|
69
21
|
const generateProps = ({
|
|
70
|
-
items =
|
|
22
|
+
items = mockOptions,
|
|
71
23
|
category = defaultValue('category'),
|
|
72
24
|
variant = defaultValue('variant'),
|
|
73
25
|
size = defaultValue('size'),
|
|
@@ -137,7 +89,7 @@ export const Default = (args, { argTypes }) => ({
|
|
|
137
89
|
},
|
|
138
90
|
data() {
|
|
139
91
|
return {
|
|
140
|
-
selected:
|
|
92
|
+
selected: mockOptions[1].value,
|
|
141
93
|
};
|
|
142
94
|
},
|
|
143
95
|
mounted() {
|
|
@@ -170,21 +122,23 @@ export const HeaderAndFooter = (args, { argTypes }) => ({
|
|
|
170
122
|
},
|
|
171
123
|
methods: {
|
|
172
124
|
selectItem(index) {
|
|
173
|
-
this.selected.push(
|
|
125
|
+
this.selected.push(mockOptions[index].value);
|
|
174
126
|
},
|
|
175
127
|
},
|
|
176
|
-
template: template(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
128
|
+
template: template(`
|
|
129
|
+
<template #header>
|
|
130
|
+
<gl-search-box-by-type/>
|
|
131
|
+
</template>
|
|
132
|
+
<template #footer>
|
|
133
|
+
<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">
|
|
134
|
+
<gl-button-group :vertical="false">
|
|
135
|
+
<gl-button @click="selectItem(0)">1st</gl-button>
|
|
136
|
+
<gl-button @click="selectItem(1)">2nd</gl-button>
|
|
137
|
+
<gl-button @click="selectItem(2)">3rd</gl-button>
|
|
138
|
+
</gl-button-group>
|
|
139
|
+
</div>
|
|
140
|
+
</template>
|
|
141
|
+
`),
|
|
188
142
|
});
|
|
189
143
|
HeaderAndFooter.args = generateProps({
|
|
190
144
|
toggleText: 'Header and Footer',
|
|
@@ -217,33 +171,33 @@ export const CustomListItem = (args, { argTypes }) => ({
|
|
|
217
171
|
},
|
|
218
172
|
},
|
|
219
173
|
template: `
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
174
|
+
<gl-listbox
|
|
175
|
+
v-model="selected"
|
|
176
|
+
:items="items"
|
|
177
|
+
:category="category"
|
|
178
|
+
:variant="variant"
|
|
179
|
+
:size="size"
|
|
180
|
+
:disabled="disabled"
|
|
181
|
+
:loading="loading"
|
|
182
|
+
:no-caret="noCaret"
|
|
183
|
+
:right="right"
|
|
184
|
+
:toggle-text="headerText"
|
|
185
|
+
:text-sr-only="textSrOnly"
|
|
186
|
+
:icon="icon"
|
|
187
|
+
:multiple="multiple"
|
|
188
|
+
:is-check-centered="isCheckCentered"
|
|
189
|
+
:aria-labelledby="ariaLabelledby"
|
|
190
|
+
>
|
|
191
|
+
<template #list-item="{ item }">
|
|
192
|
+
<span class="gl-display-flex gl-align-items-center">
|
|
193
|
+
<gl-avatar :size="32" class-="gl-mr-3"/>
|
|
194
|
+
<span class="gl-display-flex gl-flex-direction-column">
|
|
195
|
+
<span class="gl-font-weight-bold gl-white-space-nowrap">{{ item.text }}</span>
|
|
196
|
+
<span class="gl-text-gray-400"> {{ item.secondaryText }}</span>
|
|
197
|
+
</span>
|
|
198
|
+
</span>
|
|
199
|
+
</template>
|
|
200
|
+
</gl-listbox>
|
|
247
201
|
`,
|
|
248
202
|
});
|
|
249
203
|
|
|
@@ -258,6 +212,46 @@ CustomListItem.args = generateProps({
|
|
|
258
212
|
});
|
|
259
213
|
CustomListItem.decorators = [makeContainer({ height: '200px' })];
|
|
260
214
|
|
|
215
|
+
const makeGroupedExample = (changes) => {
|
|
216
|
+
const story = (args, { argTypes }) => ({
|
|
217
|
+
props: Object.keys(argTypes),
|
|
218
|
+
components: {
|
|
219
|
+
GlBadge,
|
|
220
|
+
GlListbox,
|
|
221
|
+
},
|
|
222
|
+
data() {
|
|
223
|
+
return {
|
|
224
|
+
selected: 'v1.0',
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
mounted() {
|
|
228
|
+
if (this.startOpened) {
|
|
229
|
+
openListbox(this);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
template: template(''),
|
|
233
|
+
...changes,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
story.args = generateProps({ items: mockGroups });
|
|
237
|
+
story.decorators = [makeContainer({ height: '280px' })];
|
|
238
|
+
|
|
239
|
+
return story;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const Groups = makeGroupedExample();
|
|
243
|
+
|
|
244
|
+
export const CustomGroupsAndItems = makeGroupedExample({
|
|
245
|
+
template: template(`
|
|
246
|
+
<template #group-label="{ group }">
|
|
247
|
+
{{ group.text }} <gl-badge size="sm">{{ group.options.length }}</gl-badge>
|
|
248
|
+
</template>
|
|
249
|
+
<template #list-item="{ item }">
|
|
250
|
+
{{ item.text }} <gl-badge v-if="item.value === 'main'" size="sm">default</gl-badge>
|
|
251
|
+
</template>
|
|
252
|
+
`),
|
|
253
|
+
});
|
|
254
|
+
|
|
261
255
|
export default {
|
|
262
256
|
title: 'base/new-dropdowns/listbox',
|
|
263
257
|
component: GlListbox,
|
|
@@ -17,8 +17,11 @@ import {
|
|
|
17
17
|
} from '../../../../utils/constants';
|
|
18
18
|
import GlBaseDropdown from '../base_dropdown/base_dropdown.vue';
|
|
19
19
|
import GlListboxItem from './listbox_item.vue';
|
|
20
|
+
import GlListboxGroup from './listbox_group.vue';
|
|
21
|
+
import { isOption, itemsValidator, flattenedOptions } from './utils';
|
|
20
22
|
|
|
21
23
|
export const ITEM_SELECTOR = '[role="option"]';
|
|
24
|
+
const GROUP_TOP_BORDER_CLASSES = ['gl-border-t', 'gl-pt-3', 'gl-mt-3'];
|
|
22
25
|
|
|
23
26
|
export default {
|
|
24
27
|
events: {
|
|
@@ -28,6 +31,7 @@ export default {
|
|
|
28
31
|
components: {
|
|
29
32
|
GlBaseDropdown,
|
|
30
33
|
GlListboxItem,
|
|
34
|
+
GlListboxGroup,
|
|
31
35
|
},
|
|
32
36
|
model: {
|
|
33
37
|
prop: 'selected',
|
|
@@ -41,9 +45,7 @@ export default {
|
|
|
41
45
|
type: Array,
|
|
42
46
|
required: false,
|
|
43
47
|
default: () => [],
|
|
44
|
-
validator:
|
|
45
|
-
return items.every(({ value }) => typeof value === 'string');
|
|
46
|
-
},
|
|
48
|
+
validator: itemsValidator,
|
|
47
49
|
},
|
|
48
50
|
/**
|
|
49
51
|
* array of selected items values for multi-select and selected item value for single-select
|
|
@@ -177,10 +179,17 @@ export default {
|
|
|
177
179
|
};
|
|
178
180
|
},
|
|
179
181
|
computed: {
|
|
182
|
+
listboxTag() {
|
|
183
|
+
if (this.items.length === 0 || isOption(this.items[0])) return 'ul';
|
|
184
|
+
return 'div';
|
|
185
|
+
},
|
|
186
|
+
flattenedOptions() {
|
|
187
|
+
return flattenedOptions(this.items);
|
|
188
|
+
},
|
|
180
189
|
listboxToggleText() {
|
|
181
190
|
if (!this.toggleText) {
|
|
182
191
|
if (!this.multiple && this.selectedValues.length) {
|
|
183
|
-
return this.
|
|
192
|
+
return this.flattenedOptions.find(({ value }) => value === this.selectedValues[0])?.text;
|
|
184
193
|
}
|
|
185
194
|
return '';
|
|
186
195
|
}
|
|
@@ -189,7 +198,7 @@ export default {
|
|
|
189
198
|
},
|
|
190
199
|
selectedIndices() {
|
|
191
200
|
return this.selectedValues
|
|
192
|
-
.map((selected) => this.
|
|
201
|
+
.map((selected) => this.flattenedOptions.findIndex(({ value }) => value === selected))
|
|
193
202
|
.sort();
|
|
194
203
|
},
|
|
195
204
|
},
|
|
@@ -209,6 +218,9 @@ export default {
|
|
|
209
218
|
},
|
|
210
219
|
},
|
|
211
220
|
methods: {
|
|
221
|
+
groupClasses(index) {
|
|
222
|
+
return index === 0 ? null : GROUP_TOP_BORDER_CLASSES;
|
|
223
|
+
},
|
|
212
224
|
onShow() {
|
|
213
225
|
this.$nextTick(() => {
|
|
214
226
|
this.focusItem(this.selectedIndices[0] ?? 0, this.getFocusableListItemElements());
|
|
@@ -281,6 +293,9 @@ export default {
|
|
|
281
293
|
isSelected(item) {
|
|
282
294
|
return this.selectedValues.some((value) => value === item.value);
|
|
283
295
|
},
|
|
296
|
+
isFocused(item) {
|
|
297
|
+
return this.nextFocusedItemIndex === this.flattenedOptions.indexOf(item);
|
|
298
|
+
},
|
|
284
299
|
onSingleSelect(value, isSelected) {
|
|
285
300
|
if (isSelected) {
|
|
286
301
|
/**
|
|
@@ -303,6 +318,7 @@ export default {
|
|
|
303
318
|
);
|
|
304
319
|
}
|
|
305
320
|
},
|
|
321
|
+
isOption,
|
|
306
322
|
},
|
|
307
323
|
};
|
|
308
324
|
</script>
|
|
@@ -330,7 +346,8 @@ export default {
|
|
|
330
346
|
<!-- @slot Content to display in dropdown header -->
|
|
331
347
|
<slot name="header"></slot>
|
|
332
348
|
|
|
333
|
-
<
|
|
349
|
+
<component
|
|
350
|
+
:is="listboxTag"
|
|
334
351
|
ref="list"
|
|
335
352
|
:aria-labelledby="toggleId"
|
|
336
353
|
role="listbox"
|
|
@@ -338,20 +355,46 @@ export default {
|
|
|
338
355
|
tabindex="-1"
|
|
339
356
|
@keydown="onKeydown"
|
|
340
357
|
>
|
|
341
|
-
<
|
|
342
|
-
v-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
358
|
+
<template v-for="(item, index) in items">
|
|
359
|
+
<template v-if="isOption(item)">
|
|
360
|
+
<gl-listbox-item
|
|
361
|
+
:key="item.value"
|
|
362
|
+
:is-selected="isSelected(item)"
|
|
363
|
+
:is-focused="isFocused(item)"
|
|
364
|
+
:is-check-centered="isCheckCentered"
|
|
365
|
+
@select="onSelect(item, $event)"
|
|
366
|
+
>
|
|
367
|
+
<!-- @slot Custom template of the listbox item -->
|
|
368
|
+
<slot name="list-item" :item="item">
|
|
369
|
+
{{ item.text }}
|
|
370
|
+
</slot>
|
|
371
|
+
</gl-listbox-item>
|
|
372
|
+
</template>
|
|
373
|
+
|
|
374
|
+
<template v-else>
|
|
375
|
+
<gl-listbox-group :key="item.text" :name="item.text" :class="groupClasses(index)">
|
|
376
|
+
<template v-if="$scopedSlots['group-label']" #group-label>
|
|
377
|
+
<!-- @slot Custom template for group names -->
|
|
378
|
+
<slot name="group-label" :group="item"></slot>
|
|
379
|
+
</template>
|
|
380
|
+
|
|
381
|
+
<gl-listbox-item
|
|
382
|
+
v-for="option in item.options"
|
|
383
|
+
:key="option.value"
|
|
384
|
+
:is-selected="isSelected(option)"
|
|
385
|
+
:is-focused="isFocused(option)"
|
|
386
|
+
:is-check-centered="isCheckCentered"
|
|
387
|
+
@select="onSelect(option, $event)"
|
|
388
|
+
>
|
|
389
|
+
<!-- @slot Custom template of the listbox item -->
|
|
390
|
+
<slot name="list-item" :item="option">
|
|
391
|
+
{{ option.text }}
|
|
392
|
+
</slot>
|
|
393
|
+
</gl-listbox-item>
|
|
394
|
+
</gl-listbox-group>
|
|
395
|
+
</template>
|
|
396
|
+
</template>
|
|
397
|
+
</component>
|
|
355
398
|
<!-- @slot Content to display in dropdown footer -->
|
|
356
399
|
<slot name="footer"></slot>
|
|
357
400
|
</gl-base-dropdown>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { shallowMount } from '@vue/test-utils';
|
|
2
|
+
import GlListboxGroup from './listbox_group.vue';
|
|
3
|
+
|
|
4
|
+
describe('GlListboxGroup', () => {
|
|
5
|
+
let wrapper;
|
|
6
|
+
const name = 'Group name';
|
|
7
|
+
|
|
8
|
+
const buildWrapper = ({ propsData, slots } = {}) => {
|
|
9
|
+
wrapper = shallowMount(GlListboxGroup, {
|
|
10
|
+
propsData: {
|
|
11
|
+
name,
|
|
12
|
+
...propsData,
|
|
13
|
+
},
|
|
14
|
+
slots,
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const findByTestId = (testid, root = wrapper) => root.find(`[data-testid="${testid}"]`);
|
|
19
|
+
const findLabelElement = () => {
|
|
20
|
+
const labelElementId = wrapper.attributes('aria-labelledby');
|
|
21
|
+
return wrapper.find(`#${labelElementId}`);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
it('renders a group', () => {
|
|
25
|
+
buildWrapper();
|
|
26
|
+
|
|
27
|
+
expect(wrapper.find('ul[role="group"]').element).toBe(wrapper.element);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders default slot content', () => {
|
|
31
|
+
buildWrapper({ slots: { default: '<li data-testid="default-slot-content"></li>' } });
|
|
32
|
+
|
|
33
|
+
expect(findByTestId('default-slot-content').exists()).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('labels the group', () => {
|
|
37
|
+
buildWrapper();
|
|
38
|
+
|
|
39
|
+
expect(findLabelElement().text()).toBe(name);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('allows arbitrary content for group label', () => {
|
|
43
|
+
buildWrapper({ slots: { 'group-label': '<i data-testid="custom-name"></i>' } });
|
|
44
|
+
|
|
45
|
+
expect(findByTestId('custom-name', findLabelElement()).exists()).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { uniqueId } from 'lodash';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
props: {
|
|
6
|
+
name: {
|
|
7
|
+
type: String,
|
|
8
|
+
required: true,
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
created() {
|
|
12
|
+
this.nameId = uniqueId('gl-listbox-group-');
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<ul role="group" :aria-labelledby="nameId" class="gl-mb-0 gl-pl-0 gl-list-style-none">
|
|
19
|
+
<li :id="nameId" role="presentation" class="gl-pl-5! gl-py-2! gl-font-base gl-font-weight-bold">
|
|
20
|
+
<slot name="group-label">{{ name }}</slot>
|
|
21
|
+
</li>
|
|
22
|
+
<slot></slot>
|
|
23
|
+
</ul>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const mockOptions = [
|
|
2
|
+
{
|
|
3
|
+
value: 'prod',
|
|
4
|
+
text: 'Product',
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
value: 'ppl',
|
|
8
|
+
text: 'People',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
value: 'fin',
|
|
12
|
+
text: 'Finance',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
value: 'leg',
|
|
16
|
+
text: 'Legal',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
value: 'eng',
|
|
20
|
+
text: 'Engineering',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
value: 'sales',
|
|
24
|
+
text: 'Sales',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
value: 'marketing',
|
|
28
|
+
text: 'Marketing',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
value: 'acc',
|
|
32
|
+
text: 'Accounting',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
value: 'hr',
|
|
36
|
+
text: 'Human Resource Management',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
value: 'rnd',
|
|
40
|
+
text: 'Research and Development',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
value: 'cust',
|
|
44
|
+
text: 'Customer Service',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
value: 'sup',
|
|
48
|
+
text: 'Support',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export const mockGroups = [
|
|
53
|
+
{
|
|
54
|
+
text: 'Branches',
|
|
55
|
+
options: [
|
|
56
|
+
{ text: 'main', value: 'main' },
|
|
57
|
+
{ text: 'feature-123', value: 'feature-123' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
text: 'Tags',
|
|
62
|
+
options: [
|
|
63
|
+
{ text: 'v1.0', value: 'v1.0' },
|
|
64
|
+
{ text: 'v2.0', value: 'v2.0' },
|
|
65
|
+
{ text: 'v2.1', value: 'v2.1' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isString } from 'lodash';
|
|
2
|
+
|
|
3
|
+
const isOption = (item) => Boolean(item) && isString(item.value);
|
|
4
|
+
|
|
5
|
+
const isGroup = ({ options } = {}) => Array.isArray(options) && options.every(isOption);
|
|
6
|
+
|
|
7
|
+
const hasNoDuplicates = (array) => array.length === new Set(array).size;
|
|
8
|
+
|
|
9
|
+
const flattenedOptions = (items) => items.flatMap((item) => (isOption(item) ? item : item.options));
|
|
10
|
+
|
|
11
|
+
const isAllOptionsOrAllGroups = (items) => items.every(isOption) || items.every(isGroup);
|
|
12
|
+
|
|
13
|
+
const hasUniqueValues = (items) =>
|
|
14
|
+
hasNoDuplicates(flattenedOptions(items).map(({ value }) => value));
|
|
15
|
+
|
|
16
|
+
const hasUniqueGroups = (items) => hasNoDuplicates(items.filter(isGroup).map(({ text }) => text));
|
|
17
|
+
|
|
18
|
+
const itemsValidator = (items) =>
|
|
19
|
+
isAllOptionsOrAllGroups(items) && hasUniqueValues(items) && hasUniqueGroups(items);
|
|
20
|
+
|
|
21
|
+
export { isOption, itemsValidator, flattenedOptions };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isOption, flattenedOptions, itemsValidator } from './utils';
|
|
2
|
+
import { mockOptions, mockGroups } from './mock_data';
|
|
3
|
+
|
|
4
|
+
describe('isOption', () => {
|
|
5
|
+
it.each([null, undefined, {}, { value: null }, { text: 'group', options: [] }])(
|
|
6
|
+
'isOption(%p) === false',
|
|
7
|
+
(notAnOption) => {
|
|
8
|
+
expect(isOption(notAnOption)).toBe(false);
|
|
9
|
+
}
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
it.each([{ value: '' }, { value: 'foo', text: 'bar' }, { value: 'qux', foo: true }])(
|
|
13
|
+
'isOption(%p) === true',
|
|
14
|
+
(option) => {
|
|
15
|
+
expect(isOption(option)).toBe(true);
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('flattenedOptions', () => {
|
|
21
|
+
it('returns flattened items as-is', () => {
|
|
22
|
+
expect(flattenedOptions(mockOptions)).toEqual(mockOptions);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns flattened items given groups items', () => {
|
|
26
|
+
expect(flattenedOptions(mockGroups)).toEqual([
|
|
27
|
+
...mockGroups[0].options,
|
|
28
|
+
...mockGroups[1].options,
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns flattened items given mixed items/groups', () => {
|
|
33
|
+
expect(flattenedOptions([...mockOptions, ...mockGroups])).toEqual([
|
|
34
|
+
...mockOptions,
|
|
35
|
+
...mockGroups[0].options,
|
|
36
|
+
...mockGroups[1].options,
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('itemsValidator', () => {
|
|
42
|
+
it.each`
|
|
43
|
+
description | value | expected
|
|
44
|
+
${'valid flat items'} | ${mockOptions} | ${true}
|
|
45
|
+
${'valid grouped items'} | ${mockGroups} | ${true}
|
|
46
|
+
${'empty list'} | ${[]} | ${true}
|
|
47
|
+
${'invalid items'} | ${[{ foo: true }]} | ${false}
|
|
48
|
+
${'group with invalid items'} | ${[{ text: 'foo', options: [{ foo: true }] }]} | ${false}
|
|
49
|
+
${'non-unique items'} | ${[{ value: 'a' }, { value: 'a' }]} | ${false}
|
|
50
|
+
${'non-unique items across groups'} | ${[{ text: 'a', options: [{ value: 'b' }] }, { text: 'z', options: [{ value: 'b' }] }]} | ${false}
|
|
51
|
+
${'non-unique groups'} | ${[{ text: 'a', options: [] }, { text: 'a', options: [] }]} | ${false}
|
|
52
|
+
${'sibling groups and options'} | ${[...mockOptions, ...mockGroups]} | ${false}
|
|
53
|
+
`('returns $expected given $description', ({ value, expected }) => {
|
|
54
|
+
expect(itemsValidator(value)).toBe(expected);
|
|
55
|
+
});
|
|
56
|
+
});
|