@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 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: items => {
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$items$find;
199
+ var _this$flattenedOption;
194
200
 
195
- return (_this$items$find = this.items.find(_ref2 => {
201
+ return (_this$flattenedOption = this.flattenedOptions.find(_ref => {
196
202
  let {
197
203
  value
198
- } = _ref2;
204
+ } = _ref;
199
205
  return value === this.selectedValues[0];
200
- })) === null || _this$items$find === void 0 ? void 0 : _this$items$find.text;
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.items.findIndex(_ref3 => {
216
+ return this.selectedValues.map(selected => this.flattenedOptions.findIndex(_ref2 => {
211
217
  let {
212
218
  value
213
- } = _ref3;
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(_ref4, isSelected) {
323
+ onSelect(_ref3, isSelected) {
314
324
  let {
315
325
  value
316
- } = _ref4;
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('ul',{ref:"list",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 _c('gl-listbox-item',{key:item.value,attrs:{"is-selected":_vm.isSelected(item),"is-focused":_vm.nextFocusedItemIndex === index,"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)}),1),_vm._v(" "),_vm._t("footer")],2)};
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "43.13.0",
3
+ "version": "43.14.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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
- Provide the list of options for the listbox - each item in the array should have `value` property.
55
- It is used as a primary key.
56
- To render the default listbox item template, the item should also have `text` property.
57
- If you want to use custom template for rendering the listbox item, use the `list-item` template.
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
- <template #list-item="{ item }">
62
- <span class="gl-display-flex gl-align-items-center">
63
- <gl-avatar :size="32" class-="gl-mr-3"/>
64
- <span class="gl-display-flex gl-flex-direction-column">
65
- <span class="gl-font-weight-bold gl-white-space-nowrap">{{ item.text }}</span>
66
- <span class="gl-text-gray-400"> {{ item.secondaryText }}</span>
67
- </span>
68
- </span>
69
- </template>
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
- const mockItems = [
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 = () => wrapper.findAllComponents(GlListboxItem);
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 | expectedToggleText
49
- ${'Toggle caption'} | ${true} | ${[mockItems[0].value]} | ${'Toggle caption'}
50
- ${''} | ${true} | ${[mockItems[0]].value} | ${''}
51
- ${''} | ${false} | ${mockItems[0].value} | ${mockItems[0].text}
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: mockItems, toggleText, multiple, selected });
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: [mockItems[1].value, mockItems[2].value],
81
- items: mockItems,
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([mockItems[2].value]);
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
- expect.arrayContaining(mockItems.map(({ value }) => value))
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: mockItems[1].value, items: mockItems });
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: [mockItems[1].value, mockItems[2].value],
114
- items: mockItems,
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(mockItems[2].value);
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: mockItems,
137
- selected: [mockItems[2].value, mockItems[1].value],
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
- buildWrapper({ items: mockItems });
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 = defaultItems,
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: defaultItems[1].value,
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(defaultItems[index].value);
125
+ this.selected.push(mockOptions[index].value);
174
126
  },
175
127
  },
176
- template: template(`<template #header>
177
- <gl-search-box-by-type/>
178
- </template>
179
- <template #footer>
180
- <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">
181
- <gl-button-group :vertical="false">
182
- <gl-button @click="selectItem(0)">1st</gl-button>
183
- <gl-button @click="selectItem(1)">2nd</gl-button>
184
- <gl-button @click="selectItem(2)">3rd</gl-button>
185
- </gl-button-group>
186
- </div>
187
- </template>`),
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
- <gl-listbox
221
- v-model="selected"
222
- :items="items"
223
- :category="category"
224
- :variant="variant"
225
- :size="size"
226
- :disabled="disabled"
227
- :loading="loading"
228
- :no-caret="noCaret"
229
- :right="right"
230
- :toggle-text="headerText"
231
- :text-sr-only="textSrOnly"
232
- :icon="icon"
233
- :multiple="multiple"
234
- :is-check-centered="isCheckCentered"
235
- :aria-labelledby="ariaLabelledby"
236
- >
237
- <template #list-item="{ item }">
238
- <span class="gl-display-flex gl-align-items-center">
239
- <gl-avatar :size="32" class-="gl-mr-3"/>
240
- <span class="gl-display-flex gl-flex-direction-column">
241
- <span class="gl-font-weight-bold gl-white-space-nowrap">{{ item.text }}</span>
242
- <span class="gl-text-gray-400"> {{ item.secondaryText }}</span>
243
- </span>
244
- </span>
245
- </template>
246
- </gl-listbox>
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: (items) => {
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.items.find(({ value }) => value === this.selectedValues[0])?.text;
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.items.findIndex(({ value }) => value === selected))
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
- <ul
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
- <gl-listbox-item
342
- v-for="(item, index) in items"
343
- :key="item.value"
344
- :is-selected="isSelected(item)"
345
- :is-focused="nextFocusedItemIndex === index"
346
- :is-check-centered="isCheckCentered"
347
- @select="onSelect(item, $event)"
348
- >
349
- <!-- @slot Custom template of the listbox item -->
350
- <slot name="list-item" :item="item">
351
- {{ item.text }}
352
- </slot>
353
- </gl-listbox-item>
354
- </ul>
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
+ });