@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.
- 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 +7 -7
- package/src/components/base/banner/banner.spec.js +1 -1
- package/src/components/base/banner/banner.vue +1 -1
- 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/toggle/toggle.md +0 -2
- package/src/scss/utilities.scss +12 -0
- package/src/scss/utility-mixins/flex.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
|
+
});
|
package/src/scss/utilities.scss
CHANGED
|
@@ -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;
|