@gitlab/ui 52.2.1 → 52.3.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 +7 -0
- package/dist/components/base/new_dropdowns/listbox/listbox.js +13 -3
- package/dist/components/base/new_dropdowns/listbox/listbox_item.js +1 -1
- package/dist/components/base/new_dropdowns/listbox/listbox_search_input.js +93 -0
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/package.json +1 -1
- package/src/components/base/new_dropdowns/listbox/listbox.scss +48 -0
- package/src/components/base/new_dropdowns/listbox/listbox.spec.js +6 -6
- package/src/components/base/new_dropdowns/listbox/listbox.stories.js +8 -1
- package/src/components/base/new_dropdowns/listbox/listbox.vue +15 -4
- package/src/components/base/new_dropdowns/listbox/listbox_item.vue +1 -1
- package/src/components/base/new_dropdowns/listbox/listbox_search_input.spec.js +64 -0
- package/src/components/base/new_dropdowns/listbox/listbox_search_input.vue +76 -0
- package/src/scss/components.scss +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
$search-icon-size: 12px;
|
|
2
|
+
$clear-button-size: 24px;
|
|
3
|
+
|
|
4
|
+
.gl-listbox-search {
|
|
5
|
+
@include gl-relative;
|
|
6
|
+
|
|
7
|
+
.gl-listbox-search-input {
|
|
8
|
+
@include gl-w-full;
|
|
9
|
+
@include gl-line-height-normal;
|
|
10
|
+
@include gl-h-auto;
|
|
11
|
+
@include gl-border-none;
|
|
12
|
+
@include gl-pl-7;
|
|
13
|
+
padding-right: calc(#{$gl-spacing-scale-6} + #{$gl-spacing-scale-2});
|
|
14
|
+
@include gl-py-4;
|
|
15
|
+
@include gl-font-base;
|
|
16
|
+
|
|
17
|
+
&:focus {
|
|
18
|
+
@include gl-focus($inset: true);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
&::placeholder {
|
|
22
|
+
@include gl-text-gray-400;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
&::-webkit-search-cancel-button {
|
|
26
|
+
@include gl-display-none;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.gl-listbox-search-icon {
|
|
31
|
+
@include gl-absolute;
|
|
32
|
+
top: calc(50% - #{$search-icon-size} / 2);
|
|
33
|
+
left: $gl-spacing-scale-4;
|
|
34
|
+
@include gl-text-gray-500;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.gl-listbox-search-clear-button {
|
|
38
|
+
@include gl-absolute;
|
|
39
|
+
top: calc(50% - #{$clear-button-size} / 2);
|
|
40
|
+
right: $gl-spacing-scale-2;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.gl-listbox-item {
|
|
45
|
+
&:focus {
|
|
46
|
+
@include gl-focus($inset: true);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -248,25 +248,25 @@ describe('GlListbox', () => {
|
|
|
248
248
|
});
|
|
249
249
|
|
|
250
250
|
describe('when `searchable` is enabled', () => {
|
|
251
|
-
let
|
|
251
|
+
let searchboxInput;
|
|
252
252
|
|
|
253
253
|
beforeEach(() => {
|
|
254
254
|
buildWrapper({ items: mockOptions, searchable: true });
|
|
255
255
|
findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
|
|
256
256
|
firstItem = findListItem(0);
|
|
257
|
-
|
|
257
|
+
searchboxInput = findSearchBox().find('input');
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
it('should move focus to the first item on search input `ARROW_DOWN`', async () => {
|
|
261
|
-
expect(
|
|
262
|
-
|
|
261
|
+
expect(searchboxInput.element).toHaveFocus();
|
|
262
|
+
searchboxInput.trigger('keydown', { code: ARROW_DOWN });
|
|
263
263
|
expect(firstItem.element).toHaveFocus();
|
|
264
264
|
});
|
|
265
265
|
|
|
266
266
|
it('should move focus to the search input on first item `ARROW_UP', async () => {
|
|
267
|
-
|
|
267
|
+
searchboxInput.trigger('keydown', { code: ARROW_DOWN });
|
|
268
268
|
firstItem.trigger('keydown', { code: ARROW_UP });
|
|
269
|
-
expect(
|
|
269
|
+
expect(searchboxInput.element).toHaveFocus();
|
|
270
270
|
});
|
|
271
271
|
});
|
|
272
272
|
});
|
|
@@ -29,6 +29,7 @@ const generateProps = ({
|
|
|
29
29
|
searchable = defaultValue('searchable'),
|
|
30
30
|
searching = defaultValue('searching'),
|
|
31
31
|
noResultsText = defaultValue('noResultsText'),
|
|
32
|
+
searchPlaceholder = defaultValue('searchPlaceholder'),
|
|
32
33
|
noCaret = defaultValue('noCaret'),
|
|
33
34
|
right = defaultValue('right'),
|
|
34
35
|
toggleText,
|
|
@@ -51,6 +52,7 @@ const generateProps = ({
|
|
|
51
52
|
searchable,
|
|
52
53
|
searching,
|
|
53
54
|
noResultsText,
|
|
55
|
+
searchPlaceholder,
|
|
54
56
|
noCaret,
|
|
55
57
|
right,
|
|
56
58
|
toggleText,
|
|
@@ -76,6 +78,7 @@ const makeBindings = (overrides = {}) =>
|
|
|
76
78
|
':searchable': 'searchable',
|
|
77
79
|
':searching': 'searching',
|
|
78
80
|
':no-results-text': 'noResultsText',
|
|
81
|
+
':search-placeholder': 'searchPlaceholder',
|
|
79
82
|
':no-caret': 'noCaret',
|
|
80
83
|
':right': 'right',
|
|
81
84
|
':toggle-text': 'toggleText',
|
|
@@ -437,7 +440,11 @@ export const Searchable = (args, { argTypes }) => ({
|
|
|
437
440
|
}
|
|
438
441
|
),
|
|
439
442
|
});
|
|
440
|
-
Searchable.args = generateProps({
|
|
443
|
+
Searchable.args = generateProps({
|
|
444
|
+
headerText: 'Assign to department',
|
|
445
|
+
searchable: true,
|
|
446
|
+
searchPlaceholder: 'Find department',
|
|
447
|
+
});
|
|
441
448
|
Searchable.decorators = [makeContainer({ height: '370px' })];
|
|
442
449
|
|
|
443
450
|
export const SearchableGroups = (args, { argTypes }) => ({
|
|
@@ -20,13 +20,14 @@ import GlLoadingIcon from '../../loading_icon/loading_icon.vue';
|
|
|
20
20
|
import GlSearchBoxByType from '../../search_box_by_type/search_box_by_type.vue';
|
|
21
21
|
import GlBaseDropdown from '../base_dropdown/base_dropdown.vue';
|
|
22
22
|
import GlListboxItem from './listbox_item.vue';
|
|
23
|
+
import GlListboxSearchInput from './listbox_search_input.vue';
|
|
23
24
|
import GlListboxGroup from './listbox_group.vue';
|
|
24
25
|
import { isOption, itemsValidator, flattenedOptions } from './utils';
|
|
25
26
|
|
|
26
27
|
export const ITEM_SELECTOR = '[role="option"]';
|
|
27
28
|
const HEADER_ITEMS_BORDER_CLASSES = ['gl-border-b-1', 'gl-border-b-solid', 'gl-border-b-gray-200'];
|
|
28
29
|
const GROUP_TOP_BORDER_CLASSES = ['gl-border-t', 'gl-pt-3', 'gl-mt-3'];
|
|
29
|
-
export const SEARCH_INPUT_SELECTOR = '.gl-search-
|
|
30
|
+
export const SEARCH_INPUT_SELECTOR = '.gl-listbox-search-input';
|
|
30
31
|
|
|
31
32
|
export default {
|
|
32
33
|
HEADER_ITEMS_BORDER_CLASSES,
|
|
@@ -40,6 +41,7 @@ export default {
|
|
|
40
41
|
GlListboxGroup,
|
|
41
42
|
GlButton,
|
|
42
43
|
GlSearchBoxByType,
|
|
44
|
+
GlListboxSearchInput,
|
|
43
45
|
GlLoadingIcon,
|
|
44
46
|
},
|
|
45
47
|
model: {
|
|
@@ -57,7 +59,7 @@ export default {
|
|
|
57
59
|
validator: itemsValidator,
|
|
58
60
|
},
|
|
59
61
|
/**
|
|
60
|
-
*
|
|
62
|
+
* Array of selected items values for multi-select and selected item value for single-select
|
|
61
63
|
*/
|
|
62
64
|
selected: {
|
|
63
65
|
type: [Array, String, Number],
|
|
@@ -221,6 +223,14 @@ export default {
|
|
|
221
223
|
required: false,
|
|
222
224
|
default: 'No results found',
|
|
223
225
|
},
|
|
226
|
+
/**
|
|
227
|
+
* Search input placeholder text and aria-label
|
|
228
|
+
*/
|
|
229
|
+
searchPlaceholder: {
|
|
230
|
+
type: String,
|
|
231
|
+
required: false,
|
|
232
|
+
default: 'Search',
|
|
233
|
+
},
|
|
224
234
|
/**
|
|
225
235
|
* The reset button's label, to be rendered in the header. If this is omitted, the button is not
|
|
226
236
|
* rendered.
|
|
@@ -484,7 +494,7 @@ export default {
|
|
|
484
494
|
>
|
|
485
495
|
<div
|
|
486
496
|
v-if="headerText"
|
|
487
|
-
class="gl-display-flex gl-align-items-center gl-
|
|
497
|
+
class="gl-display-flex gl-align-items-center gl-p-4! gl-min-h-8"
|
|
488
498
|
:class="$options.HEADER_ITEMS_BORDER_CLASSES"
|
|
489
499
|
>
|
|
490
500
|
<div
|
|
@@ -506,11 +516,12 @@ export default {
|
|
|
506
516
|
</div>
|
|
507
517
|
|
|
508
518
|
<div v-if="searchable" :class="$options.HEADER_ITEMS_BORDER_CLASSES">
|
|
509
|
-
<gl-search-
|
|
519
|
+
<gl-listbox-search-input
|
|
510
520
|
ref="searchBox"
|
|
511
521
|
v-model="searchStr"
|
|
512
522
|
:aria-owns="listboxId"
|
|
513
523
|
data-testid="listbox-search-input"
|
|
524
|
+
:placeholder="searchPlaceholder"
|
|
514
525
|
@input="search"
|
|
515
526
|
@keydown="onKeydown"
|
|
516
527
|
/>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mount, shallowMount } from '@vue/test-utils';
|
|
2
|
+
import { nextTick } from 'vue';
|
|
3
|
+
import ClearIcon from '~/components/shared_components/clear_icon_button/clear_icon_button.vue';
|
|
4
|
+
import ListboxSearchInput from './listbox_search_input.vue';
|
|
5
|
+
|
|
6
|
+
const modelEvent = ListboxSearchInput.model.event;
|
|
7
|
+
const newSearchValue = 'new value';
|
|
8
|
+
|
|
9
|
+
describe('listbox search input component', () => {
|
|
10
|
+
let wrapper;
|
|
11
|
+
const searchValue = 'some value';
|
|
12
|
+
|
|
13
|
+
const createComponent = ({ listeners, ...propsData }, mountFn = shallowMount) => {
|
|
14
|
+
wrapper = mountFn(ListboxSearchInput, { propsData, listeners });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const findClearIcon = () => wrapper.findComponent(ClearIcon);
|
|
18
|
+
const findInput = () => wrapper.findComponent({ ref: 'input' });
|
|
19
|
+
|
|
20
|
+
describe('clear icon component', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
createComponent({ value: searchValue });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('is not rendered when value is empty', () => {
|
|
26
|
+
createComponent({ value: '' });
|
|
27
|
+
|
|
28
|
+
expect(findClearIcon().exists()).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is rendered when value is provided', () => {
|
|
32
|
+
expect(findClearIcon().exists()).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('emits empty value when clicked', () => {
|
|
36
|
+
findClearIcon().vm.$emit('click', { stopPropagation: jest.fn() });
|
|
37
|
+
|
|
38
|
+
expect(wrapper.emitted('input')).toEqual([['']]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('v-model', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
jest.useFakeTimers();
|
|
45
|
+
createComponent({ value: searchValue }, mount);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('syncs value prop to input value', async () => {
|
|
49
|
+
expect(findInput().element.value).toEqual(searchValue);
|
|
50
|
+
wrapper.setProps({ value: newSearchValue });
|
|
51
|
+
await nextTick();
|
|
52
|
+
|
|
53
|
+
expect(findInput().element.value).toEqual(newSearchValue);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it(`emits ${modelEvent} event when input value changes`, () => {
|
|
57
|
+
findInput().setValue(newSearchValue);
|
|
58
|
+
jest.advanceTimersByTime(199);
|
|
59
|
+
expect(wrapper.emitted('input')).toEqual(undefined);
|
|
60
|
+
jest.advanceTimersByTime(1);
|
|
61
|
+
expect(wrapper.emitted('input')).toEqual([[newSearchValue]]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import debounce from 'lodash/debounce';
|
|
3
|
+
import GlClearIconButton from '../../../shared_components/clear_icon_button/clear_icon_button.vue';
|
|
4
|
+
import GlIcon from '../../icon/icon.vue';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
components: {
|
|
8
|
+
GlClearIconButton,
|
|
9
|
+
GlIcon,
|
|
10
|
+
},
|
|
11
|
+
model: {
|
|
12
|
+
prop: 'value',
|
|
13
|
+
event: 'input',
|
|
14
|
+
},
|
|
15
|
+
props: {
|
|
16
|
+
/**
|
|
17
|
+
* If provided, used as value of search input
|
|
18
|
+
*/
|
|
19
|
+
value: {
|
|
20
|
+
type: String,
|
|
21
|
+
required: false,
|
|
22
|
+
default: '',
|
|
23
|
+
},
|
|
24
|
+
/**
|
|
25
|
+
* Search input placeholder text and aria-label
|
|
26
|
+
*/
|
|
27
|
+
placeholder: {
|
|
28
|
+
type: String,
|
|
29
|
+
required: false,
|
|
30
|
+
default: 'Search',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
computed: {
|
|
34
|
+
hasValue() {
|
|
35
|
+
return Boolean(this.value.length);
|
|
36
|
+
},
|
|
37
|
+
inputListeners() {
|
|
38
|
+
return {
|
|
39
|
+
...this.$listeners,
|
|
40
|
+
input: debounce((event) => {
|
|
41
|
+
this.$emit('input', event.target.value);
|
|
42
|
+
}, 200),
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
methods: {
|
|
47
|
+
clearInput() {
|
|
48
|
+
this.$emit('input', '');
|
|
49
|
+
this.focusInput();
|
|
50
|
+
},
|
|
51
|
+
focusInput() {
|
|
52
|
+
this.$refs.input.focus();
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<div class="gl-listbox-search">
|
|
60
|
+
<gl-icon name="search-sm" :size="12" class="gl-listbox-search-icon" />
|
|
61
|
+
<input
|
|
62
|
+
ref="input"
|
|
63
|
+
type="search"
|
|
64
|
+
:value="value"
|
|
65
|
+
class="gl-listbox-search-input"
|
|
66
|
+
:aria-label="placeholder"
|
|
67
|
+
:placeholder="placeholder"
|
|
68
|
+
v-on="inputListeners"
|
|
69
|
+
/>
|
|
70
|
+
<gl-clear-icon-button
|
|
71
|
+
v-if="hasValue"
|
|
72
|
+
class="gl-listbox-search-clear-button"
|
|
73
|
+
@click.stop="clearInput"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
package/src/scss/components.scss
CHANGED