@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.
- package/CHANGELOG.md +22 -0
- package/dist/components/base/banner/banner.js +1 -1
- 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/dist/utility_classes.css +1 -1
- package/dist/utility_classes.css.map +1 -1
- package/package.json +3 -3
- package/src/components/base/banner/banner.spec.js +1 -1
- package/src/components/base/banner/banner.vue +1 -1
- package/src/components/base/button/button.stories.js +23 -4
- 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/src/components/base/sorting/sorting.stories.js +1 -1
- package/src/components/base/sorting/sorting_item.stories.js +1 -1
- package/src/scss/utilities.scss +10 -0
- 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:
|
|
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
|
+
});
|
|
@@ -10,7 +10,7 @@ const components = {
|
|
|
10
10
|
const propDefault = (prop) => GlSorting.props[prop].default;
|
|
11
11
|
|
|
12
12
|
const generateProps = ({
|
|
13
|
-
text = 'Sorting
|
|
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
|
|
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
|
|
package/src/scss/utilities.scss
CHANGED
|
@@ -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;
|