@gitlab/ui 42.4.1 → 42.7.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.4.1",
3
+ "version": "42.7.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,8 @@ $gl-sidebar-width: 290px;
10
10
  @include gl-shadow-drawer;
11
11
  @include gl-font-base;
12
12
  @include gl-line-height-normal;
13
+ @include gl-display-flex;
14
+ @include gl-flex-direction-column;
13
15
 
14
16
  .gl-drawer-header-sticky {
15
17
  @include gl-bg-white;
@@ -51,6 +53,20 @@ $gl-sidebar-width: 290px;
51
53
  }
52
54
  }
53
55
 
56
+ .gl-drawer-footer {
57
+ @include gl-border-t-solid;
58
+ @include gl-border-t-gray-100;
59
+ @include gl-border-t-1;
60
+ @include gl-px-6;
61
+ @include gl-py-5;
62
+ }
63
+
64
+ .gl-drawer-footer-sticky {
65
+ @include gl-bg-white;
66
+ @include gl-bottom-0;
67
+ @include gl-sticky;
68
+ }
69
+
54
70
  .gl-drawer-header {
55
71
  @include gl-border-b-solid;
56
72
  @include gl-border-b-gray-100;
@@ -74,6 +90,10 @@ $gl-sidebar-width: 290px;
74
90
  @include gl-border-none;
75
91
  }
76
92
 
93
+ .gl-drawer-body {
94
+ @include gl-flex-grow-1;
95
+ }
96
+
77
97
  .gl-drawer-body > * {
78
98
  @include gl-border-b-solid;
79
99
  @include gl-border-b-gray-100;
@@ -90,6 +90,7 @@ describe('drawer component', () => {
90
90
  ${'title'} | ${'.gl-drawer-title'}
91
91
  ${'header'} | ${'.gl-drawer-header'}
92
92
  ${'default'} | ${'.gl-drawer-body'}
93
+ ${'footer'} | ${'.gl-drawer-footer'}
93
94
  `('renders nodes when added to the $slot slot', ({ slot, parentSelector }) => {
94
95
  mountWithOpts({
95
96
  slots: {
@@ -4,7 +4,19 @@ import readme from './drawer.md';
4
4
 
5
5
  const components = { GlDrawer, GlButton };
6
6
 
7
- const drawerContent = [
7
+ const generateDrawerContent = (items) =>
8
+ items
9
+ .map(
10
+ (str) => `
11
+ <div>
12
+ <label class="gl-font-weight-bold">${str}</label>
13
+ <div>None</div>
14
+ </div>
15
+ `
16
+ )
17
+ .join('');
18
+
19
+ const drawerContent = generateDrawerContent([
8
20
  'One',
9
21
  'Two',
10
22
  'Three',
@@ -19,16 +31,9 @@ const drawerContent = [
19
31
  'Twelve',
20
32
  'Thirteen',
21
33
  'Fourteen',
22
- ]
23
- .map(
24
- (str) => `
25
- <div>
26
- <label class="gl-font-weight-bold">${str}</label>
27
- <div>None</div>
28
- </div>
29
- `
30
- )
31
- .join('');
34
+ ]);
35
+
36
+ const drawerContentShortList = generateDrawerContent(['One', 'Two', 'Three']);
32
37
 
33
38
  const createSidebarTemplate = (content) => `
34
39
  <gl-drawer
@@ -106,6 +111,40 @@ export const WithActions = (_args, { viewMode }) => ({
106
111
  });
107
112
  WithActions.args = generateProps();
108
113
 
114
+ export const WithStickyFooterShortContent = (_args, { viewMode }) => ({
115
+ ...storyOptions(viewMode),
116
+ template: `
117
+ <div>
118
+ <gl-button @click="toggle">Toggle Drawer</gl-button>
119
+ ${createSidebarTemplate(`
120
+ <template #title>List Settings</template>
121
+ ${drawerContentShortList}
122
+ <template #footer>
123
+ Drawer footer
124
+ </template>
125
+ `)}
126
+ </div>`,
127
+ });
128
+
129
+ WithStickyFooterShortContent.args = generateProps();
130
+
131
+ export const WithStickyFooter = (_args, { viewMode }) => ({
132
+ ...storyOptions(viewMode),
133
+ template: `
134
+ <div>
135
+ <gl-button @click="toggle">Toggle Drawer</gl-button>
136
+ ${createSidebarTemplate(`
137
+ <template #title>List Settings</template>
138
+ ${drawerContent}
139
+ <template #footer>
140
+ Drawer footer
141
+ </template>
142
+ `)}
143
+ </div>`,
144
+ });
145
+
146
+ WithStickyFooter.args = generateProps();
147
+
109
148
  export const SidebarVariant = (_args, { viewMode }) => ({
110
149
  ...storyOptions(viewMode),
111
150
  template: `
@@ -128,7 +167,7 @@ SidebarVariant.args = generateProps({
128
167
  variant: drawerVariants.sidebar,
129
168
  });
130
169
 
131
- export const StickyHeader = (_args, { viewMode }) => ({
170
+ export const StickyHeaderFooter = (_args, { viewMode }) => ({
132
171
  ...storyOptions(viewMode),
133
172
  template: `
134
173
  <div>
@@ -136,10 +175,13 @@ export const StickyHeader = (_args, { viewMode }) => ({
136
175
  ${createSidebarTemplate(`
137
176
  <template #title>List Settings</template>
138
177
  ${drawerContent}
178
+ <template #footer>
179
+ Drawer footer
180
+ </template>
139
181
  `)}
140
182
  </div>`,
141
183
  });
142
- StickyHeader.args = generateProps({
184
+ StickyHeaderFooter.args = generateProps({
143
185
  headerSticky: true,
144
186
  });
145
187
 
@@ -56,6 +56,9 @@ export default {
56
56
  zIndex: this.headerSticky ? maxZIndex : null,
57
57
  };
58
58
  },
59
+ shouldRenderFooter() {
60
+ return Boolean(this.$slots.footer);
61
+ },
59
62
  variantClass() {
60
63
  return `gl-drawer-${this.variant}`;
61
64
  },
@@ -110,6 +113,13 @@ export default {
110
113
  <div class="gl-drawer-body">
111
114
  <slot></slot>
112
115
  </div>
116
+ <div
117
+ v-if="shouldRenderFooter"
118
+ class="gl-drawer-footer gl-drawer-footer-sticky"
119
+ :style="{ zIndex }"
120
+ >
121
+ <slot name="footer"></slot>
122
+ </div>
113
123
  </aside>
114
124
  </transition>
115
125
  </template>
@@ -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',