@gitlab/ui 43.13.0 → 43.16.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.
Files changed (25) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/components/base/banner/banner.js +1 -1
  3. package/dist/components/base/new_dropdowns/listbox/listbox.js +34 -19
  4. package/dist/components/base/new_dropdowns/listbox/listbox_group.js +54 -0
  5. package/dist/components/base/new_dropdowns/listbox/mock_data.js +61 -0
  6. package/dist/components/base/new_dropdowns/listbox/utils.js +34 -0
  7. package/dist/utility_classes.css +1 -1
  8. package/dist/utility_classes.css.map +1 -1
  9. package/package.json +3 -3
  10. package/src/components/base/banner/banner.spec.js +1 -1
  11. package/src/components/base/banner/banner.vue +1 -1
  12. package/src/components/base/button/button.stories.js +23 -4
  13. package/src/components/base/new_dropdowns/listbox/listbox.md +56 -13
  14. package/src/components/base/new_dropdowns/listbox/listbox.spec.js +60 -33
  15. package/src/components/base/new_dropdowns/listbox/listbox.stories.js +86 -92
  16. package/src/components/base/new_dropdowns/listbox/listbox.vue +63 -20
  17. package/src/components/base/new_dropdowns/listbox/listbox_group.spec.js +47 -0
  18. package/src/components/base/new_dropdowns/listbox/listbox_group.vue +24 -0
  19. package/src/components/base/new_dropdowns/listbox/mock_data.js +68 -0
  20. package/src/components/base/new_dropdowns/listbox/utils.js +21 -0
  21. package/src/components/base/new_dropdowns/listbox/utils.spec.js +56 -0
  22. package/src/components/base/sorting/sorting.stories.js +1 -1
  23. package/src/components/base/sorting/sorting_item.stories.js +1 -1
  24. package/src/scss/utilities.scss +10 -0
  25. package/src/scss/utility-mixins/spacing.scss +6 -0
@@ -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
+ });
@@ -10,7 +10,7 @@ const components = {
10
10
  const propDefault = (prop) => GlSorting.props[prop].default;
11
11
 
12
12
  const generateProps = ({
13
- text = 'Sorting Dropdown',
13
+ text = 'Sorting options',
14
14
  isAscending = propDefault('isAscending'),
15
15
  sortDirectionToolTip = propDefault('sortDirectionToolTip'),
16
16
  dropdownClass = propDefault('dropdownClass'),
@@ -13,7 +13,7 @@ const generateProps = ({ href = null, active = false } = {}) => ({
13
13
  });
14
14
 
15
15
  const template = `
16
- <gl-sorting text="Sorting Dropdown">
16
+ <gl-sorting text="Sorting options">
17
17
  <gl-sorting-item :href="href" :active="active">Some item</gl-sorting-item>
18
18
  </gl-sorting>`;
19
19
 
@@ -6392,6 +6392,16 @@
6392
6392
  margin-top: $gl-spacing-scale-5 !important;
6393
6393
  }
6394
6394
  }
6395
+ .gl-sm-mt-6 {
6396
+ @include gl-media-breakpoint-up(sm) {
6397
+ margin-top: $gl-spacing-scale-6;
6398
+ }
6399
+ }
6400
+ .gl-sm-mt-6\! {
6401
+ @include gl-media-breakpoint-up(sm) {
6402
+ margin-top: $gl-spacing-scale-6 !important;
6403
+ }
6404
+ }
6395
6405
  .gl-sm-mb-7 {
6396
6406
  @include gl-media-breakpoint-up(sm) {
6397
6407
  margin-bottom: $gl-spacing-scale-7;
@@ -883,6 +883,12 @@
883
883
  }
884
884
  }
885
885
 
886
+ @mixin gl-sm-mt-6 {
887
+ @include gl-media-breakpoint-up(sm) {
888
+ @include gl-mt-6;
889
+ }
890
+ }
891
+
886
892
  @mixin gl-sm-mb-7 {
887
893
  @include gl-media-breakpoint-up(sm) {
888
894
  @include gl-mb-7;