@gitlab/ui 39.3.2 → 39.4.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.
@@ -0,0 +1,236 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { nextTick } from 'vue';
3
+ import GlBaseDropdown from '../base_dropdown/base_dropdown.vue';
4
+ import {
5
+ GL_DROPDOWN_SHOWN,
6
+ GL_DROPDOWN_HIDDEN,
7
+ ARROW_DOWN,
8
+ ARROW_UP,
9
+ HOME,
10
+ END,
11
+ } from '../constants';
12
+ import GlListbox, { ITEM_SELECTOR } from './listbox.vue';
13
+ import GlListboxItem from './listbox_item.vue';
14
+
15
+ const mockItems = [
16
+ {
17
+ value: 'eng',
18
+ text: 'Engineering',
19
+ },
20
+ {
21
+ value: 'sales',
22
+ text: 'Sales',
23
+ },
24
+ {
25
+ value: 'marketing',
26
+ text: 'Marketing',
27
+ },
28
+ ];
29
+
30
+ describe('GlListbox', () => {
31
+ let wrapper;
32
+
33
+ const buildWrapper = (propsData, slots = {}) => {
34
+ wrapper = mount(GlListbox, {
35
+ propsData,
36
+ slots,
37
+ attachTo: document.body,
38
+ });
39
+ };
40
+
41
+ const findBaseDropdown = () => wrapper.findComponent(GlBaseDropdown);
42
+ const findListContainer = () => wrapper.find('[role="listbox"]');
43
+ const findListboxItems = () => wrapper.findAllComponents(GlListboxItem);
44
+ const findListItem = (index) => findListboxItems().at(index).find(ITEM_SELECTOR);
45
+
46
+ describe('toggle text', () => {
47
+ describe.each`
48
+ toggleText | multiple | selected | expectedToggleText
49
+ ${'Toggle caption'} | ${true} | ${[mockItems[0].value]} | ${'Toggle caption'}
50
+ ${''} | ${true} | ${[mockItems[0]].value} | ${''}
51
+ ${''} | ${false} | ${mockItems[0].value} | ${mockItems[0].text}
52
+ ${''} | ${false} | ${''} | ${''}
53
+ `('when listbox', ({ toggleText, multiple, selected, expectedToggleText }) => {
54
+ beforeEach(() => {
55
+ buildWrapper({ items: mockItems, toggleText, multiple, selected });
56
+ });
57
+
58
+ it(`is ${multiple ? 'multi' : 'single'}-select, toggleText is ${
59
+ toggleText.length ? '' : 'not '
60
+ }provided and ${selected ? 'has' : 'does not have'} selected`, () => {
61
+ expect(findBaseDropdown().props('toggleText')).toBe(expectedToggleText);
62
+ });
63
+ });
64
+ });
65
+
66
+ describe('ARIA attributes', () => {
67
+ it('should provide `toggleId` to the base dropdown and reference it in`aria-labelledby` attribute of the list container` ', async () => {
68
+ await buildWrapper();
69
+ expect(findBaseDropdown().props('toggleId')).toBe(
70
+ findListContainer().attributes('aria-labelledby')
71
+ );
72
+ });
73
+ });
74
+
75
+ describe('selecting items', () => {
76
+ describe('multi-select', () => {
77
+ beforeEach(() => {
78
+ buildWrapper({
79
+ multiple: true,
80
+ selected: [mockItems[1].value, mockItems[2].value],
81
+ items: mockItems,
82
+ });
83
+ });
84
+
85
+ it('should render items as selected when `selected` provided ', () => {
86
+ expect(findListboxItems().at(1).props('isSelected')).toBe(true);
87
+ expect(findListboxItems().at(2).props('isSelected')).toBe(true);
88
+ });
89
+
90
+ it('should deselect previously selected', async () => {
91
+ findListboxItems().at(1).vm.$emit('select', false);
92
+ await nextTick();
93
+ expect(wrapper.emitted('select')[0][0]).toEqual([mockItems[2].value]);
94
+ });
95
+
96
+ it('should add to selection', async () => {
97
+ findListboxItems().at(0).vm.$emit('select', true);
98
+ await nextTick();
99
+ expect(wrapper.emitted('select')[0][0]).toEqual(
100
+ expect.arrayContaining(mockItems.map(({ value }) => value))
101
+ );
102
+ });
103
+ });
104
+
105
+ describe('single-select', () => {
106
+ beforeEach(() => {
107
+ buildWrapper({ selected: mockItems[1].value, items: mockItems });
108
+ });
109
+
110
+ it('should throw an error when array of selections is provided', () => {
111
+ expect(() => {
112
+ buildWrapper({
113
+ selected: [mockItems[1].value, mockItems[2].value],
114
+ items: mockItems,
115
+ });
116
+ }).toThrowError('To allow multi-selection, please, set "multiple" property to "true"');
117
+ expect(wrapper).toHaveLoggedVueErrors();
118
+ });
119
+
120
+ it('should render item as selected when `selected` provided ', () => {
121
+ expect(findListboxItems().at(1).props('isSelected')).toBe(true);
122
+ });
123
+
124
+ it('should deselect previously selected and select a new item', async () => {
125
+ findListboxItems().at(2).vm.$emit('select', true);
126
+ await nextTick();
127
+ expect(wrapper.emitted('select')[0][0]).toEqual(mockItems[2].value);
128
+ });
129
+ });
130
+ });
131
+
132
+ describe('onShow', () => {
133
+ beforeEach(async () => {
134
+ buildWrapper({
135
+ multiple: true,
136
+ items: mockItems,
137
+ selected: [mockItems[2].value, mockItems[1].value],
138
+ });
139
+ findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
140
+ await nextTick();
141
+ });
142
+
143
+ it('should re-emit the event', () => {
144
+ expect(wrapper.emitted(GL_DROPDOWN_SHOWN)).toHaveLength(1);
145
+ });
146
+
147
+ it('should focus the first selected item', () => {
148
+ expect(findListboxItems().at(1).find(ITEM_SELECTOR).element).toHaveFocus();
149
+ });
150
+ });
151
+
152
+ describe('onHide', () => {
153
+ beforeEach(() => {
154
+ buildWrapper();
155
+ findBaseDropdown().vm.$emit(GL_DROPDOWN_HIDDEN);
156
+ });
157
+
158
+ it('should re-emit the event', () => {
159
+ expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1);
160
+ });
161
+ });
162
+
163
+ describe('navigating the items', () => {
164
+ let firstItem;
165
+ let secondItem;
166
+ let thirdItem;
167
+
168
+ beforeEach(() => {
169
+ buildWrapper({ items: mockItems });
170
+ findBaseDropdown().vm.$emit(GL_DROPDOWN_SHOWN);
171
+ firstItem = findListItem(0);
172
+ secondItem = findListItem(1);
173
+ thirdItem = findListItem(2);
174
+ });
175
+
176
+ it('should move the focus down the list of items on `arrow down` and stop on the last item', async () => {
177
+ expect(firstItem.element).toHaveFocus();
178
+ await firstItem.trigger('keydown', { code: ARROW_DOWN });
179
+ expect(secondItem.element).toHaveFocus();
180
+ await secondItem.trigger('keydown', { code: ARROW_DOWN });
181
+ expect(thirdItem.element).toHaveFocus();
182
+ await thirdItem.trigger('keydown', { code: ARROW_DOWN });
183
+ expect(thirdItem.element).toHaveFocus();
184
+ });
185
+
186
+ it('should move the focus up the list of items on `arrow up` and stop on the first item', async () => {
187
+ await firstItem.trigger('keydown', { code: ARROW_DOWN });
188
+ await secondItem.trigger('keydown', { code: ARROW_DOWN });
189
+ expect(thirdItem.element).toHaveFocus();
190
+ await thirdItem.trigger('keydown', { code: ARROW_UP });
191
+ expect(secondItem.element).toHaveFocus();
192
+ await secondItem.trigger('keydown', { code: ARROW_UP });
193
+ expect(firstItem.element).toHaveFocus();
194
+ await firstItem.trigger('keydown', { code: ARROW_UP });
195
+ expect(firstItem.element).toHaveFocus();
196
+ });
197
+
198
+ it('should move focus to the last item on `END` keydown', async () => {
199
+ expect(firstItem.element).toHaveFocus();
200
+ await firstItem.trigger('keydown', { code: END });
201
+ expect(thirdItem.element).toHaveFocus();
202
+ await thirdItem.trigger('keydown', { code: END });
203
+ expect(thirdItem.element).toHaveFocus();
204
+ });
205
+
206
+ it('should move focus to the first item on `HOME` keydown', async () => {
207
+ await firstItem.trigger('keydown', { code: ARROW_DOWN });
208
+ await secondItem.trigger('keydown', { code: ARROW_DOWN });
209
+ expect(thirdItem.element).toHaveFocus();
210
+ await thirdItem.trigger('keydown', { code: HOME });
211
+ expect(firstItem.element).toHaveFocus();
212
+ await thirdItem.trigger('keydown', { code: HOME });
213
+ expect(firstItem.element).toHaveFocus();
214
+ });
215
+ });
216
+
217
+ describe('when the header slot content is provided', () => {
218
+ const headerContent = 'Header Content';
219
+ const slots = { header: headerContent };
220
+
221
+ it('renders it', () => {
222
+ buildWrapper({}, slots);
223
+ expect(wrapper.text()).toContain(headerContent);
224
+ });
225
+ });
226
+
227
+ describe('when the footer slot content is provided', () => {
228
+ const footerContent = 'Footer Content';
229
+ const slots = { footer: footerContent };
230
+
231
+ it('renders it', () => {
232
+ buildWrapper({}, slots);
233
+ expect(wrapper.text()).toContain(footerContent);
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,276 @@
1
+ import {
2
+ buttonCategoryOptions,
3
+ buttonSizeOptions,
4
+ buttonVariantOptions,
5
+ } from '../../../../utils/constants';
6
+ import {
7
+ GlIcon,
8
+ GlListbox,
9
+ GlSearchBoxByType,
10
+ GlButtonGroup,
11
+ GlButton,
12
+ GlAvatar,
13
+ } from '../../../../index';
14
+ import { makeContainer } from '../../../../utils/story_decorators/container';
15
+ import readme from './listbox.md';
16
+
17
+ const defaultValue = (prop) => GlListbox.props[prop].default;
18
+
19
+ const defaultItems = [
20
+ {
21
+ value: 'prod',
22
+ text: 'Product',
23
+ },
24
+ {
25
+ value: 'ppl',
26
+ text: 'People',
27
+ },
28
+ {
29
+ value: 'fin',
30
+ text: 'Finance',
31
+ },
32
+ {
33
+ value: 'leg',
34
+ text: 'Legal',
35
+ },
36
+ {
37
+ value: 'eng',
38
+ text: 'Engineering',
39
+ },
40
+ {
41
+ value: 'sales',
42
+ text: 'Sales',
43
+ },
44
+ {
45
+ value: 'marketing',
46
+ text: 'Marketing',
47
+ },
48
+ {
49
+ value: 'acc',
50
+ text: 'Accounting',
51
+ },
52
+ {
53
+ value: 'hr',
54
+ text: 'Human Resource Management',
55
+ },
56
+ {
57
+ value: 'rnd',
58
+ text: 'Research and Development',
59
+ },
60
+ {
61
+ value: 'cust',
62
+ text: 'Customer Service',
63
+ },
64
+ {
65
+ value: 'sup',
66
+ text: 'Support',
67
+ },
68
+ ];
69
+ const generateProps = ({
70
+ items = defaultItems,
71
+ category = defaultValue('category'),
72
+ variant = defaultValue('variant'),
73
+ size = defaultValue('size'),
74
+ disabled = defaultValue('disabled'),
75
+ loading = defaultValue('loading'),
76
+ noCaret = defaultValue('noCaret'),
77
+ right = defaultValue('right'),
78
+ toggleText,
79
+ textSrOnly = defaultValue('textSrOnly'),
80
+ icon = '',
81
+ multiple = defaultValue('multiple'),
82
+ ariaLabelledby,
83
+ } = {}) => ({
84
+ items,
85
+ category,
86
+ variant,
87
+ size,
88
+ disabled,
89
+ loading,
90
+ noCaret,
91
+ right,
92
+ toggleText,
93
+ textSrOnly,
94
+ icon,
95
+ multiple,
96
+ ariaLabelledby,
97
+ });
98
+
99
+ function openListbox(component) {
100
+ component.$nextTick(() => component.$el.querySelector('.dropdown-toggle').click());
101
+ }
102
+
103
+ const template = (content, label = '') => `
104
+ <div>
105
+ ${label}
106
+ <br/>
107
+ <gl-listbox
108
+ v-model="selected"
109
+ :items="items"
110
+ :category="category"
111
+ :variant="variant"
112
+ :size="size"
113
+ :disabled="disabled"
114
+ :loading="loading"
115
+ :no-caret="noCaret"
116
+ :right="right"
117
+ :toggle-text="toggleText"
118
+ :text-sr-only="textSrOnly"
119
+ :icon="icon"
120
+ :multiple="multiple"
121
+ :aria-labelledby="ariaLabelledby"
122
+ >
123
+ ${content}
124
+ </gl-listbox>
125
+ </div>
126
+ `;
127
+
128
+ export const Default = (args, { argTypes }) => ({
129
+ props: Object.keys(argTypes),
130
+ components: {
131
+ GlListbox,
132
+ },
133
+ data() {
134
+ return {
135
+ selected: defaultItems[1].value,
136
+ };
137
+ },
138
+ mounted() {
139
+ openListbox(this);
140
+ },
141
+ template: template('', `<span class="gl-my-0" id="listbox-label">Select a department</span>`),
142
+ });
143
+ Default.args = generateProps({ ariaLabelledby: 'listbox-label' });
144
+ Default.decorators = [makeContainer({ height: '150px' })];
145
+
146
+ export const HeaderAndFooter = (args, { argTypes }) => ({
147
+ props: Object.keys(argTypes),
148
+ components: {
149
+ GlListbox,
150
+ GlSearchBoxByType,
151
+ GlButtonGroup,
152
+ GlButton,
153
+ },
154
+ data() {
155
+ return {
156
+ selected: [],
157
+ };
158
+ },
159
+ mounted() {
160
+ openListbox(this);
161
+ },
162
+ methods: {
163
+ selectItem(index) {
164
+ this.selected.push(defaultItems[index].value);
165
+ },
166
+ },
167
+ template: template(`<template #header>
168
+ <gl-search-box-by-type/>
169
+ </template>
170
+ <template #footer>
171
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3">
172
+ <gl-button-group :vertical="false">
173
+ <gl-button @click="selectItem(0)">1st</gl-button>
174
+ <gl-button @click="selectItem(1)">2nd</gl-button>
175
+ <gl-button @click="selectItem(2)">3rd</gl-button>
176
+ </gl-button-group>
177
+ </div>
178
+ </template>`),
179
+ });
180
+ HeaderAndFooter.args = generateProps({ toggleText: 'Header and Footer', multiple: true });
181
+ HeaderAndFooter.decorators = [makeContainer({ height: '220px' })];
182
+
183
+ export const CustomListItem = (args, { argTypes }) => ({
184
+ props: Object.keys(argTypes),
185
+ data() {
186
+ return {
187
+ selected: ['mikegreiling'],
188
+ };
189
+ },
190
+ components: {
191
+ GlListbox,
192
+ GlIcon,
193
+ GlAvatar,
194
+ },
195
+ mounted() {
196
+ openListbox(this);
197
+ },
198
+ computed: {
199
+ headerText() {
200
+ return this.selected.length !== 1
201
+ ? `${this.selected.length} assignees`
202
+ : this.items.find(({ value }) => value === this.selected[0]).text;
203
+ },
204
+ },
205
+ template: `
206
+ <gl-listbox
207
+ v-model="selected"
208
+ :items="items"
209
+ :category="category"
210
+ :variant="variant"
211
+ :size="size"
212
+ :disabled="disabled"
213
+ :loading="loading"
214
+ :no-caret="noCaret"
215
+ :right="right"
216
+ :toggle-text="headerText"
217
+ :text-sr-only="textSrOnly"
218
+ :icon="icon"
219
+ :multiple="multiple"
220
+ :aria-labelledby="ariaLabelledby"
221
+ >
222
+ <template #list-item="{ item }">
223
+ <span class="gl-display-flex gl-align-items-center">
224
+ <gl-avatar :size="32" class-="gl-mr-3"/>
225
+ <span class="gl-display-flex gl-flex-direction-column">
226
+ <span class="gl-font-weight-bold gl-white-space-nowrap">{{ item.text }}</span>
227
+ <span class="gl-text-gray-400"> {{ item.secondaryText }}</span>
228
+ </span>
229
+ </span>
230
+ </template>
231
+ </gl-listbox>
232
+ `,
233
+ });
234
+
235
+ CustomListItem.args = generateProps({
236
+ items: [
237
+ { value: 'mikegreiling', text: 'Mike Greiling', secondaryText: '@mikegreiling', icon: 'foo' },
238
+ { value: 'ohoral', text: 'Olena Horal-Koretska', secondaryText: '@ohoral', icon: 'bar' },
239
+ { value: 'markian', text: 'Mark Florian', secondaryText: '@markian', icon: 'bin' },
240
+ ],
241
+ multiple: true,
242
+ });
243
+ CustomListItem.decorators = [makeContainer({ height: '200px' })];
244
+
245
+ export default {
246
+ title: 'base/new-dropdowns/listbox',
247
+ component: GlListbox,
248
+ parameters: {
249
+ knobs: { disable: true },
250
+ docs: {
251
+ description: {
252
+ component: readme,
253
+ },
254
+ },
255
+ },
256
+ argTypes: {
257
+ category: {
258
+ control: {
259
+ type: 'select',
260
+ options: buttonCategoryOptions,
261
+ },
262
+ },
263
+ variant: {
264
+ control: {
265
+ type: 'select',
266
+ options: buttonVariantOptions,
267
+ },
268
+ },
269
+ size: {
270
+ control: {
271
+ type: 'select',
272
+ options: buttonSizeOptions,
273
+ },
274
+ },
275
+ },
276
+ };