@gitlab/ui 43.12.0 → 43.15.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.
@@ -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
+ });
@@ -1,5 +1,3 @@
1
- # Toggle
2
-
3
1
  ## Usage
4
2
 
5
3
  The toggle component must have a `label` prop to give the toggle button an accessible name.
@@ -3112,6 +3112,18 @@
3112
3112
  }
3113
3113
  }
3114
3114
 
3115
+ .gl-sm-align-items-center {
3116
+ @include gl-media-breakpoint-up(sm) {
3117
+ align-items: center;
3118
+ }
3119
+ }
3120
+
3121
+ .gl-sm-align-items-center\! {
3122
+ @include gl-media-breakpoint-up(sm) {
3123
+ align-items: center !important;
3124
+ }
3125
+ }
3126
+
3115
3127
  .gl-md-align-items-center {
3116
3128
  @include gl-media-breakpoint-up(md) {
3117
3129
  align-items: center;
@@ -36,6 +36,12 @@
36
36
  }
37
37
  }
38
38
 
39
+ @mixin gl-sm-align-items-center {
40
+ @include gl-media-breakpoint-up(sm) {
41
+ @include gl-align-items-center;
42
+ }
43
+ }
44
+
39
45
  @mixin gl-md-align-items-center {
40
46
  @include gl-media-breakpoint-up(md) {
41
47
  @include gl-align-items-center;