@gitlab/ui 43.11.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.
Files changed (27) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/components/base/new_dropdowns/listbox/listbox.js +43 -19
  3. package/dist/components/base/new_dropdowns/listbox/listbox_group.js +54 -0
  4. package/dist/components/base/new_dropdowns/listbox/listbox_item.js +19 -1
  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 +5 -5
  10. package/scss_to_js/scss_variables.js +1 -0
  11. package/scss_to_js/scss_variables.json +5 -0
  12. package/src/components/base/new_dropdowns/listbox/listbox.md +56 -13
  13. package/src/components/base/new_dropdowns/listbox/listbox.spec.js +60 -33
  14. package/src/components/base/new_dropdowns/listbox/listbox.stories.js +94 -92
  15. package/src/components/base/new_dropdowns/listbox/listbox.vue +71 -19
  16. package/src/components/base/new_dropdowns/listbox/listbox_group.spec.js +47 -0
  17. package/src/components/base/new_dropdowns/listbox/listbox_group.vue +24 -0
  18. package/src/components/base/new_dropdowns/listbox/listbox_item.spec.js +25 -0
  19. package/src/components/base/new_dropdowns/listbox/listbox_item.vue +19 -2
  20. package/src/components/base/new_dropdowns/listbox/mock_data.js +68 -0
  21. package/src/components/base/new_dropdowns/listbox/utils.js +21 -0
  22. package/src/components/base/new_dropdowns/listbox/utils.spec.js +56 -0
  23. package/src/components/base/toggle/toggle.md +0 -2
  24. package/src/scss/utilities.scss +20 -0
  25. package/src/scss/utility-mixins/flex.scss +6 -0
  26. package/src/scss/utility-mixins/sizing.scss +4 -0
  27. package/src/scss/variables.scss +1 -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
@@ -152,6 +154,14 @@ export default {
152
154
  required: false,
153
155
  default: false,
154
156
  },
157
+ /**
158
+ * Center selected item checkmark
159
+ */
160
+ isCheckCentered: {
161
+ type: Boolean,
162
+ required: false,
163
+ default: false,
164
+ },
155
165
  /**
156
166
  * The `aria-labelledby` attribute value for the toggle button
157
167
  */
@@ -169,10 +179,17 @@ export default {
169
179
  };
170
180
  },
171
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
+ },
172
189
  listboxToggleText() {
173
190
  if (!this.toggleText) {
174
191
  if (!this.multiple && this.selectedValues.length) {
175
- return this.items.find(({ value }) => value === this.selectedValues[0])?.text;
192
+ return this.flattenedOptions.find(({ value }) => value === this.selectedValues[0])?.text;
176
193
  }
177
194
  return '';
178
195
  }
@@ -181,7 +198,7 @@ export default {
181
198
  },
182
199
  selectedIndices() {
183
200
  return this.selectedValues
184
- .map((selected) => this.items.findIndex(({ value }) => value === selected))
201
+ .map((selected) => this.flattenedOptions.findIndex(({ value }) => value === selected))
185
202
  .sort();
186
203
  },
187
204
  },
@@ -201,6 +218,9 @@ export default {
201
218
  },
202
219
  },
203
220
  methods: {
221
+ groupClasses(index) {
222
+ return index === 0 ? null : GROUP_TOP_BORDER_CLASSES;
223
+ },
204
224
  onShow() {
205
225
  this.$nextTick(() => {
206
226
  this.focusItem(this.selectedIndices[0] ?? 0, this.getFocusableListItemElements());
@@ -273,6 +293,9 @@ export default {
273
293
  isSelected(item) {
274
294
  return this.selectedValues.some((value) => value === item.value);
275
295
  },
296
+ isFocused(item) {
297
+ return this.nextFocusedItemIndex === this.flattenedOptions.indexOf(item);
298
+ },
276
299
  onSingleSelect(value, isSelected) {
277
300
  if (isSelected) {
278
301
  /**
@@ -295,6 +318,7 @@ export default {
295
318
  );
296
319
  }
297
320
  },
321
+ isOption,
298
322
  },
299
323
  };
300
324
  </script>
@@ -322,7 +346,8 @@ export default {
322
346
  <!-- @slot Content to display in dropdown header -->
323
347
  <slot name="header"></slot>
324
348
 
325
- <ul
349
+ <component
350
+ :is="listboxTag"
326
351
  ref="list"
327
352
  :aria-labelledby="toggleId"
328
353
  role="listbox"
@@ -330,19 +355,46 @@ export default {
330
355
  tabindex="-1"
331
356
  @keydown="onKeydown"
332
357
  >
333
- <gl-listbox-item
334
- v-for="(item, index) in items"
335
- :key="item.value"
336
- :is-selected="isSelected(item)"
337
- :is-focused="nextFocusedItemIndex === index"
338
- @select="onSelect(item, $event)"
339
- >
340
- <!-- @slot Custom template of the listbox item -->
341
- <slot name="list-item" :item="item">
342
- {{ item.text }}
343
- </slot>
344
- </gl-listbox-item>
345
- </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>
346
398
  <!-- @slot Content to display in dropdown footer -->
347
399
  <slot name="footer"></slot>
348
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>
@@ -80,6 +80,31 @@ describe('GlListboxItem', () => {
80
80
  });
81
81
  });
82
82
 
83
+ describe('checkbox', () => {
84
+ describe('is NOT centered', () => {
85
+ beforeEach(() => {
86
+ buildWrapper({ isSelected: true });
87
+ });
88
+
89
+ it('should not center check icon by default', () => {
90
+ expect(findCheckIcon().classes()).toEqual(
91
+ expect.arrayContaining(['gl-mt-3', 'gl-align-self-start'])
92
+ );
93
+ });
94
+ });
95
+
96
+ describe('is centered', () => {
97
+ beforeEach(() => {
98
+ buildWrapper({ isSelected: true, isCheckCentered: true });
99
+ });
100
+ it('should center the check icon', () => {
101
+ expect(findCheckIcon().classes()).not.toEqual(
102
+ expect.arrayContaining(['gl-mt-3', 'gl-align-self-start'])
103
+ );
104
+ });
105
+ });
106
+ });
107
+
83
108
  describe('tabindex', () => {
84
109
  it('should set tabindex to `-1` when item is not focused', () => {
85
110
  buildWrapper({ isFocused: false });
@@ -18,6 +18,20 @@ export default {
18
18
  default: false,
19
19
  required: false,
20
20
  },
21
+ isCheckCentered: {
22
+ type: Boolean,
23
+ required: false,
24
+ default: false,
25
+ },
26
+ },
27
+ computed: {
28
+ checkedClasses() {
29
+ if (this.isCheckCentered) {
30
+ return '';
31
+ }
32
+
33
+ return 'gl-mt-3 gl-align-self-start';
34
+ },
21
35
  },
22
36
  methods: {
23
37
  toggleSelection() {
@@ -48,8 +62,11 @@ export default {
48
62
  <gl-icon
49
63
  name="mobile-issue-close"
50
64
  data-testid="dropdown-item-checkbox"
51
- class="gl-mt-3 gl-align-self-start"
52
- :class="['gl-new-dropdown-item-check-icon', { 'gl-visibility-hidden': !isSelected }]"
65
+ :class="[
66
+ 'gl-new-dropdown-item-check-icon',
67
+ { 'gl-visibility-hidden': !isSelected },
68
+ checkedClasses,
69
+ ]"
53
70
  />
54
71
  <span class="gl-new-dropdown-item-text-wrapper">
55
72
  <slot></slot>
@@ -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;
@@ -4559,6 +4571,14 @@
4559
4571
  max-width: $limited-layout-width !important;
4560
4572
  }
4561
4573
 
4574
+ .gl-max-w-container-xl {
4575
+ max-width: $container-xl;
4576
+ }
4577
+
4578
+ .gl-max-w-container-xl\! {
4579
+ max-width: $container-xl !important;
4580
+ }
4581
+
4562
4582
  .gl-h-auto {
4563
4583
  height: auto;
4564
4584
  }
@@ -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;
@@ -155,6 +155,10 @@
155
155
  max-width: $limited-layout-width;
156
156
  }
157
157
 
158
+ @mixin gl-max-w-container-xl {
159
+ max-width: $container-xl;
160
+ }
161
+
158
162
  @mixin gl-h-auto {
159
163
  height: auto;
160
164
  }
@@ -41,6 +41,7 @@ $breakpoints: (
41
41
 
42
42
  // Max widths
43
43
  $limited-layout-width: 990px !default;
44
+ $container-xl: 1280px !default;
44
45
 
45
46
  // Color schema
46
47
  $black: #000 !default;