@gitlab/ui 42.5.0 → 42.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "42.5.0",
3
+ "version": "42.6.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -1,4 +1,4 @@
1
- export const tokenList = [
1
+ export const stringTokenList = [
2
2
  'giraffe',
3
3
  'dog',
4
4
  'dodo',
@@ -14,3 +14,18 @@ export const tokenList = [
14
14
  ];
15
15
 
16
16
  export const labelText = 'Animals We Tolerate';
17
+
18
+ export const objectTokenList = [
19
+ { id: '1', title: 'giraffe' },
20
+ { id: '2', title: 'dog' },
21
+ { id: '3', title: 'dodo' },
22
+ { id: '4', title: 'komodo dragon' },
23
+ { id: '5', title: 'hippo' },
24
+ { id: '6', title: 'platypus' },
25
+ { id: '7', title: 'jackalope' },
26
+ { id: '8', title: 'quetzal' },
27
+ { id: '9', title: 'badger' },
28
+ { id: '10', title: 'vicuña' },
29
+ { id: '11', title: 'whale' },
30
+ { id: '12', title: 'xenarthra' },
31
+ ];
@@ -5,6 +5,7 @@
5
5
 
6
6
  .show-dropdown {
7
7
  @include gl-display-block;
8
+ max-height: $gl-max-dropdown-max-height;
8
9
  }
9
10
 
10
11
  .highlight-dropdown {
@@ -1,11 +1,16 @@
1
1
  import { mount } from '@vue/test-utils';
2
2
  import GlDropdownItem from '../../dropdown/dropdown_item.vue';
3
3
  import GlFormInput from '../form_input/form_input.vue';
4
- import { tokenList, labelText } from './constants';
4
+ import { stringTokenList, labelText, objectTokenList } from './constants';
5
5
  import GlFormCombobox from './form_combobox.vue';
6
6
 
7
7
  const partialToken = 'do';
8
- const partialTokenMatch = ['dog', 'dodo', 'komodo dragon'];
8
+ const partialStringTokenMatch = ['dog', 'dodo', 'komodo dragon'];
9
+ const partialObjectTokenMatch = [
10
+ { id: '2', title: 'dog' },
11
+ { id: '3', title: 'dodo' },
12
+ { id: '4', title: 'komodo dragon' },
13
+ ];
9
14
  const unlistedToken = 'elephant';
10
15
 
11
16
  const doTimes = (num, fn) => {
@@ -17,13 +22,14 @@ const doTimes = (num, fn) => {
17
22
  describe('GlFormCombobox', () => {
18
23
  let wrapper;
19
24
 
20
- const createComponent = () => {
25
+ const createComponent = ({ tokens = stringTokenList, matchValueToAttr = undefined } = {}) => {
21
26
  wrapper = mount({
22
27
  data() {
23
28
  return {
24
29
  inputVal: '',
25
- tokens: tokenList,
30
+ tokens,
26
31
  labelText,
32
+ matchValueToAttr,
27
33
  };
28
34
  },
29
35
  components: { GlFormCombobox },
@@ -32,7 +38,8 @@ describe('GlFormCombobox', () => {
32
38
  <gl-form-combobox
33
39
  v-model="inputVal"
34
40
  :token-list="tokens"
35
- :labelText="labelText"
41
+ :label-text="labelText"
42
+ :match-value-to-attr="matchValueToAttr"
36
43
  />
37
44
  </div>
38
45
  `,
@@ -48,123 +55,163 @@ describe('GlFormCombobox', () => {
48
55
  const setInput = (val) => findInput().setValue(val);
49
56
  const arrowDown = () => findInput().trigger('keydown.down');
50
57
 
51
- describe('match and filter functionality', () => {
52
- beforeEach(() => {
53
- createComponent();
54
- });
55
-
56
- it('is closed when the input is empty', () => {
57
- expect(findInput().isVisible()).toBe(true);
58
- expect(findInputValue()).toBe('');
59
- expect(findDropdown().isVisible()).toBe(false);
60
- });
61
-
62
- it('is open when the input text matches a token', async () => {
63
- await setInput(partialToken);
64
- expect(findDropdown().isVisible()).toBe(true);
65
- });
66
-
67
- it('shows partial matches at string start and mid-string', async () => {
68
- await setInput(partialToken);
69
- expect(findDropdown().isVisible()).toBe(true);
70
- expect(findDropdownOptions()).toEqual(partialTokenMatch);
71
- });
72
-
73
- it('is closed when the text does not match', async () => {
74
- await setInput(unlistedToken);
75
- expect(findDropdown().isVisible()).toBe(false);
76
- });
77
- });
58
+ describe.each`
59
+ valueType | tokens | matchValueToAttr | partialTokenMatch
60
+ ${'string'} | ${stringTokenList} | ${undefined} | ${partialStringTokenMatch}
61
+ ${'object'} | ${objectTokenList} | ${'title'} | ${partialObjectTokenMatch}
62
+ `('with value as $valueType', ({ valueType, tokens, matchValueToAttr, partialTokenMatch }) => {
63
+ describe('match and filter functionality', () => {
64
+ beforeEach(() => {
65
+ createComponent({ tokens, matchValueToAttr });
66
+ });
78
67
 
79
- describe('keyboard navigation in dropdown', () => {
80
- beforeEach(() => {
81
- createComponent();
82
- });
68
+ it('is closed when the input is empty', () => {
69
+ expect(findInput().isVisible()).toBe(true);
70
+ expect(findInputValue()).toBe('');
71
+ expect(findDropdown().isVisible()).toBe(false);
72
+ });
83
73
 
84
- describe('on down arrow + enter', () => {
85
- it('selects the next item in the list and closes the dropdown', async () => {
74
+ it('is open when the input text matches a token', async () => {
86
75
  await setInput(partialToken);
87
- findInput().trigger('keydown.down');
88
- await findInput().trigger('keydown.enter');
89
- expect(findInputValue()).toBe(partialTokenMatch[0]);
76
+ expect(findDropdown().isVisible()).toBe(true);
90
77
  });
91
78
 
92
- it('loops to the top when it reaches the bottom', async () => {
79
+ it('shows partial matches at string start and mid-string', async () => {
93
80
  await setInput(partialToken);
94
- doTimes(findDropdownOptions().length + 1, arrowDown);
95
- await findInput().trigger('keydown.enter');
96
- expect(findInputValue()).toBe(partialTokenMatch[0]);
81
+ expect(findDropdown().isVisible()).toBe(true);
82
+
83
+ if (valueType === 'string') {
84
+ expect(findDropdownOptions()).toEqual(partialTokenMatch);
85
+ } else {
86
+ findDropdownOptions().forEach((option, index) => {
87
+ expect(option).toContain(partialTokenMatch[index][matchValueToAttr]);
88
+ });
89
+ }
90
+ });
91
+
92
+ it('is closed when the text does not match', async () => {
93
+ await setInput(unlistedToken);
94
+ expect(findDropdown().isVisible()).toBe(false);
97
95
  });
98
96
  });
99
97
 
100
- describe('on up arrow + enter', () => {
101
- it('selects the previous item in the list and closes the dropdown', async () => {
102
- setInput(partialToken);
98
+ describe('keyboard navigation in dropdown', () => {
99
+ beforeEach(() => {
100
+ createComponent({ tokens, matchValueToAttr });
101
+ });
103
102
 
104
- await wrapper.vm.$nextTick();
105
- doTimes(3, arrowDown);
106
- findInput().trigger('keydown.up');
107
- findInput().trigger('keydown.enter');
103
+ describe('on down arrow + enter', () => {
104
+ it('selects the next item in the list and closes the dropdown', async () => {
105
+ await setInput(partialToken);
106
+ findInput().trigger('keydown.down');
107
+ await findInput().trigger('keydown.enter');
108
+
109
+ if (valueType === 'string') {
110
+ expect(findInputValue()).toBe(partialTokenMatch[0]);
111
+ } else {
112
+ expect(findInputValue()).toBe(partialTokenMatch[0][matchValueToAttr]);
113
+ }
114
+ });
108
115
 
109
- await wrapper.vm.$nextTick();
110
- expect(findInputValue()).toBe(partialTokenMatch[1]);
111
- expect(findDropdown().isVisible()).toBe(false);
116
+ it('loops to the top when it reaches the bottom', async () => {
117
+ await setInput(partialToken);
118
+ doTimes(findDropdownOptions().length + 1, arrowDown);
119
+ await findInput().trigger('keydown.enter');
120
+
121
+ if (valueType === 'string') {
122
+ expect(findInputValue()).toBe(partialTokenMatch[0]);
123
+ } else {
124
+ expect(findInputValue()).toBe(partialTokenMatch[0][matchValueToAttr]);
125
+ }
126
+ });
112
127
  });
113
128
 
114
- it('loops to the bottom when it reaches the top', async () => {
115
- await setInput(partialToken);
116
- findInput().trigger('keydown.down');
117
- findInput().trigger('keydown.up');
118
- await findInput().trigger('keydown.enter');
119
- expect(findInputValue()).toBe(partialTokenMatch[partialTokenMatch.length - 1]);
120
- });
121
- });
129
+ describe('on up arrow + enter', () => {
130
+ it('selects the previous item in the list and closes the dropdown', async () => {
131
+ setInput(partialToken);
122
132
 
123
- describe('on enter with no item highlighted', () => {
124
- it('does not select any item and closes the dropdown', async () => {
125
- await setInput(partialToken);
126
- await findInput().trigger('keydown.enter');
127
- expect(findInputValue()).toBe(partialToken);
128
- expect(findDropdown().isVisible()).toBe(false);
133
+ await wrapper.vm.$nextTick();
134
+ doTimes(3, arrowDown);
135
+ findInput().trigger('keydown.up');
136
+ findInput().trigger('keydown.enter');
137
+
138
+ await wrapper.vm.$nextTick();
139
+
140
+ if (valueType === 'string') {
141
+ expect(findInputValue()).toBe(partialTokenMatch[1]);
142
+ } else {
143
+ expect(findInputValue()).toBe(partialTokenMatch[1][matchValueToAttr]);
144
+ }
145
+ expect(findDropdown().isVisible()).toBe(false);
146
+ });
147
+
148
+ it('loops to the bottom when it reaches the top', async () => {
149
+ await setInput(partialToken);
150
+ findInput().trigger('keydown.down');
151
+ findInput().trigger('keydown.up');
152
+ await findInput().trigger('keydown.enter');
153
+
154
+ if (valueType === 'string') {
155
+ expect(findInputValue()).toBe(partialTokenMatch[partialTokenMatch.length - 1]);
156
+ } else {
157
+ expect(findInputValue()).toBe(
158
+ partialTokenMatch[partialTokenMatch.length - 1][matchValueToAttr]
159
+ );
160
+ }
161
+ });
129
162
  });
130
- });
131
163
 
132
- describe('on click', () => {
133
- it('selects the clicked item regardless of arrow highlight', async () => {
134
- await setInput(partialToken);
135
- await wrapper.find('[data-testid="combobox-dropdown"] button').trigger('click');
136
- expect(findInputValue()).toBe(partialTokenMatch[0]);
164
+ describe('on enter with no item highlighted', () => {
165
+ it('does not select any item and closes the dropdown', async () => {
166
+ await setInput(partialToken);
167
+ await findInput().trigger('keydown.enter');
168
+ expect(findInputValue()).toBe(partialToken);
169
+ expect(findDropdown().isVisible()).toBe(false);
170
+ });
137
171
  });
138
- });
139
172
 
140
- describe('on tab', () => {
141
- it('selects entered text, closes dropdown', async () => {
142
- await setInput(partialToken);
143
- findInput().trigger('keydown.tab');
144
- doTimes(2, arrowDown);
173
+ describe('on click', () => {
174
+ it('selects the clicked item regardless of arrow highlight', async () => {
175
+ await setInput(partialToken);
176
+ await wrapper.find('[data-testid="combobox-dropdown"] button').trigger('click');
145
177
 
146
- await wrapper.vm.$nextTick();
147
- expect(findInputValue()).toBe(partialToken);
148
- expect(findDropdown().isVisible()).toBe(false);
178
+ if (valueType === 'string') {
179
+ expect(findInputValue()).toBe(partialTokenMatch[0]);
180
+ } else {
181
+ expect(findInputValue()).toBe(partialTokenMatch[0][matchValueToAttr]);
182
+ }
183
+ });
149
184
  });
150
- });
151
185
 
152
- describe('on esc', () => {
153
- describe('when dropdown is open', () => {
154
- it('closes dropdown and does not select anything', async () => {
186
+ describe('on tab', () => {
187
+ it('selects entered text, closes dropdown', async () => {
155
188
  await setInput(partialToken);
156
- await findInput().trigger('keydown.esc');
189
+ findInput().trigger('keydown.tab');
190
+ doTimes(2, arrowDown);
191
+
192
+ await wrapper.vm.$nextTick();
157
193
  expect(findInputValue()).toBe(partialToken);
158
194
  expect(findDropdown().isVisible()).toBe(false);
159
195
  });
160
196
  });
161
197
 
162
- describe('when dropdown is closed', () => {
163
- it('clears the input field', async () => {
164
- await setInput(unlistedToken);
165
- expect(findDropdown().isVisible()).toBe(false);
166
- await findInput().trigger('keydown.esc');
167
- expect(findInputValue()).toBe('');
198
+ describe('on esc', () => {
199
+ describe('when dropdown is open', () => {
200
+ it('closes dropdown and does not select anything', async () => {
201
+ await setInput(partialToken);
202
+ await findInput().trigger('keydown.esc');
203
+ expect(findInputValue()).toBe(partialToken);
204
+ expect(findDropdown().isVisible()).toBe(false);
205
+ });
206
+ });
207
+
208
+ describe('when dropdown is closed', () => {
209
+ it('clears the input field', async () => {
210
+ await setInput(unlistedToken);
211
+ expect(findDropdown().isVisible()).toBe(false);
212
+ await findInput().trigger('keydown.esc');
213
+ expect(findInputValue()).toBe('');
214
+ });
168
215
  });
169
216
  });
170
217
  });
@@ -1,10 +1,19 @@
1
- import { tokenList, labelText } from './constants';
1
+ import { stringTokenList, labelText, objectTokenList } from './constants';
2
2
  import readme from './form_combobox.md';
3
3
  import GlFormCombobox from './form_combobox.vue';
4
4
 
5
- const getProps = () => ({
5
+ const template = `
6
+ <gl-form-combobox
7
+ v-model="value"
8
+ :token-list="tokenList"
9
+ :label-text="labelText"
10
+ :match-value-to-attr="matchValueToAttr"
11
+ />`;
12
+
13
+ const generateProps = ({ tokenList = stringTokenList, matchValueToAttr = undefined } = {}) => ({
6
14
  tokenList,
7
15
  labelText,
16
+ matchValueToAttr,
8
17
  });
9
18
 
10
19
  const Template = (args) => ({
@@ -15,17 +24,40 @@ const Template = (args) => ({
15
24
  };
16
25
  },
17
26
  props: Object.keys(args),
18
- template: `
19
- <gl-form-combobox
20
- v-model="value"
21
- :token-list="tokenList"
22
- :labelText="labelText"
23
- />
24
- `,
27
+ template,
25
28
  });
26
29
 
27
30
  export const Default = Template.bind({});
28
- Default.args = getProps();
31
+ Default.args = generateProps();
32
+
33
+ export const WithObjectValue = (args, { argTypes }) => ({
34
+ components: { GlFormCombobox },
35
+ props: Object.keys(argTypes),
36
+ mounted() {
37
+ document.querySelector('.gl-form-input').focus();
38
+ },
39
+ data: () => {
40
+ return {
41
+ value: '',
42
+ };
43
+ },
44
+ template: `
45
+ <gl-form-combobox
46
+ v-model="value"
47
+ :token-list="tokenList"
48
+ :label-text="labelText"
49
+ :match-value-to-attr="matchValueToAttr"
50
+ >
51
+ <template #result="{ item }">
52
+ <div class="gl-display-flex">
53
+ <div class="gl-text-gray-400 gl-mr-4">{{ item.id }}</div>
54
+ <div>{{ item.title }}</div>
55
+ </div>
56
+ </template>
57
+ </gl-form-combobox>
58
+ `,
59
+ });
60
+ WithObjectValue.args = generateProps({ tokenList: objectTokenList, matchValueToAttr: 'title' });
29
61
 
30
62
  export default {
31
63
  title: 'base/form/form-combobox',
@@ -31,9 +31,19 @@ export default {
31
31
  required: true,
32
32
  },
33
33
  value: {
34
- type: String,
34
+ type: [String, Object],
35
35
  required: true,
36
36
  },
37
+ matchValueToAttr: {
38
+ type: String,
39
+ required: false,
40
+ default: undefined,
41
+ },
42
+ autofocus: {
43
+ type: Boolean,
44
+ required: false,
45
+ default: false,
46
+ },
37
47
  },
38
48
  data() {
39
49
  return {
@@ -54,6 +64,11 @@ export default {
54
64
  showSuggestions() {
55
65
  return this.results.length > 0;
56
66
  },
67
+ displayedValue() {
68
+ return this.matchValueToAttr && this.value[this.matchValueToAttr]
69
+ ? this.value[this.matchValueToAttr]
70
+ : this.value;
71
+ },
57
72
  },
58
73
  mounted() {
59
74
  document.addEventListener('click', this.handleClickOutside);
@@ -112,9 +127,12 @@ export default {
112
127
  return;
113
128
  }
114
129
 
115
- const filteredTokens = this.tokenList.filter((token) =>
116
- token.toLowerCase().includes(value.toLowerCase())
117
- );
130
+ const filteredTokens = this.tokenList.filter((token) => {
131
+ if (this.matchValueToAttr) {
132
+ return token[this.matchValueToAttr].toLowerCase().includes(value.toLowerCase());
133
+ }
134
+ return token.toLowerCase().includes(value.toLowerCase());
135
+ });
118
136
 
119
137
  if (filteredTokens.length) {
120
138
  this.openSuggestions(filteredTokens);
@@ -147,13 +165,14 @@ export default {
147
165
  <gl-form-group :label="labelText" :label-for="inputId" :label-sr-only="labelSrOnly">
148
166
  <gl-form-input
149
167
  :id="inputId"
150
- :value="value"
168
+ :value="displayedValue"
151
169
  type="text"
152
170
  role="searchbox"
153
171
  :autocomplete="showAutocomplete"
154
172
  aria-autocomplete="list"
155
173
  :aria-controls="suggestionsId"
156
174
  aria-haspopup="listbox"
175
+ :autofocus="autofocus"
157
176
  @input="onEntry"
158
177
  @keydown.down="onArrowDown"
159
178
  @keydown.up="onArrowUp"
@@ -163,11 +182,11 @@ export default {
163
182
  />
164
183
  </gl-form-group>
165
184
 
166
- <div
185
+ <ul
167
186
  v-show="showSuggestions && !userDismissedResults"
168
187
  :id="suggestionsId"
169
188
  data-testid="combobox-dropdown"
170
- class="dropdown-menu dropdown-full-width"
189
+ class="dropdown-menu dropdown-full-width gl-list-style-none gl-pl-0 gl-mb-0 gl-overflow-y-auto"
171
190
  :class="{ 'show-dropdown': showSuggestions }"
172
191
  >
173
192
  <gl-dropdown-item
@@ -179,8 +198,8 @@ export default {
179
198
  tabindex="-1"
180
199
  @click="selectToken(result)"
181
200
  >
182
- {{ result }}
201
+ <slot name="result" :item="result">{{ result }}</slot>
183
202
  </gl-dropdown-item>
184
- </div>
203
+ </ul>
185
204
  </div>
186
205
  </template>