@gitlab/ui 39.3.2 → 39.5.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/components/base/alert/alert.js +1 -1
  3. package/dist/components/base/filtered_search/filtered_search_term.js +2 -1
  4. package/dist/components/base/filtered_search/filtered_search_token.js +3 -2
  5. package/dist/components/base/filtered_search/filtered_search_token_segment.js +3 -2
  6. package/dist/components/base/new_dropdowns/base_dropdown/base_dropdown.js +240 -0
  7. package/dist/components/base/new_dropdowns/constants.js +20 -0
  8. package/dist/components/base/new_dropdowns/listbox/listbox.js +381 -0
  9. package/dist/components/base/new_dropdowns/listbox/listbox_item.js +77 -0
  10. package/dist/index.css +1 -1
  11. package/dist/index.css.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/utility_classes.css +1 -1
  14. package/dist/utility_classes.css.map +1 -1
  15. package/dist/utils/utils.js +24 -1
  16. package/package.json +5 -4
  17. package/src/components/base/alert/alert.spec.js +3 -1
  18. package/src/components/base/alert/alert.vue +1 -1
  19. package/src/components/base/dropdown/dropdown.scss +10 -3
  20. package/src/components/base/dropdown/dropdown_item.scss +1 -0
  21. package/src/components/base/filtered_search/filtered_search_term.vue +9 -1
  22. package/src/components/base/filtered_search/filtered_search_token.vue +16 -3
  23. package/src/components/base/filtered_search/filtered_search_token_segment.vue +5 -4
  24. package/src/components/base/link/link.stories.js +9 -7
  25. package/src/components/base/new_dropdowns/base_dropdown/base_dropdown.spec.js +171 -0
  26. package/src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue +221 -0
  27. package/src/components/base/new_dropdowns/constants.js +22 -0
  28. package/src/components/base/new_dropdowns/listbox/listbox.md +71 -0
  29. package/src/components/base/new_dropdowns/listbox/listbox.spec.js +236 -0
  30. package/src/components/base/new_dropdowns/listbox/listbox.stories.js +276 -0
  31. package/src/components/base/new_dropdowns/listbox/listbox.vue +348 -0
  32. package/src/components/base/new_dropdowns/listbox/listbox_item.spec.js +104 -0
  33. package/src/components/base/new_dropdowns/listbox/listbox_item.vue +59 -0
  34. package/src/components/utilities/friendly_wrap/friendly_wrap.stories.js +10 -11
  35. package/src/components/utilities/sprintf/sprintf.stories.js +11 -9
  36. package/src/index.js +4 -0
  37. package/src/scss/utilities.scss +10 -0
  38. package/src/scss/utility-mixins/composite.scss +20 -0
  39. package/src/scss/utility-mixins/index.scss +1 -0
  40. package/src/utils/data_utils.js +2 -21
  41. package/src/utils/utils.js +18 -0
  42. package/src/utils/utils.spec.js +41 -1
@@ -119,5 +119,28 @@ function logWarning() {
119
119
  console.warn(message); // eslint-disable-line no-console
120
120
  }
121
121
  }
122
+ /**
123
+ * Stop default event handling and propagation
124
+ */
125
+
126
+ function stopEvent(event) {
127
+ let {
128
+ preventDefault = true,
129
+ stopPropagation = true,
130
+ stopImmediatePropagation = false
131
+ } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
132
+
133
+ if (preventDefault) {
134
+ event.preventDefault();
135
+ }
136
+
137
+ if (stopPropagation) {
138
+ event.stopPropagation();
139
+ }
140
+
141
+ if (stopImmediatePropagation) {
142
+ event.stopImmediatePropagation();
143
+ }
144
+ }
122
145
 
123
- export { colorFromBackground, debounceByAnimationFrame, focusFirstFocusableElement, hexToRgba, isDev, isElementFocusable, logWarning, rgbFromHex, rgbFromString, throttle, uid };
146
+ export { colorFromBackground, debounceByAnimationFrame, focusFirstFocusableElement, hexToRgba, isDev, isElementFocusable, logWarning, rgbFromHex, rgbFromString, stopEvent, throttle, uid };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "39.3.2",
3
+ "version": "39.5.1",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -55,6 +55,7 @@
55
55
  "generate:component": "plop"
56
56
  },
57
57
  "dependencies": {
58
+ "@popperjs/core": "^2.11.2",
58
59
  "bootstrap-vue": "2.20.1",
59
60
  "dompurify": "^2.3.6",
60
61
  "echarts": "^5.2.1",
@@ -122,10 +123,10 @@
122
123
  "npm-run-all": "^4.1.5",
123
124
  "pikaday": "^1.8.0",
124
125
  "plop": "^2.5.4",
125
- "postcss": "8.4.5",
126
+ "postcss": "8.4.12",
126
127
  "postcss-loader": "^3.0.0",
127
- "postcss-scss": "^2.1.1",
128
- "prettier": "2.2.1",
128
+ "postcss-scss": "4.0.4",
129
+ "prettier": "2.6.2",
129
130
  "puppeteer": "11.0.0",
130
131
  "raw-loader": "^0.5.1",
131
132
  "rollup": "^2.53.1",
@@ -71,13 +71,15 @@ describe('Alert component', () => {
71
71
  expect(findDismissButton().exists()).toBe(false);
72
72
  });
73
73
 
74
- it('renders the provided title', () => {
74
+ it('renders the provided title with heading level 2', () => {
75
75
  const title = 'foo';
76
76
  createComponent({ propsData: { title } });
77
77
 
78
78
  const titleWrapper = findTitle();
79
79
  expect(titleWrapper.exists()).toBe(true);
80
80
  expect(titleWrapper.text()).toContain(title);
81
+ // the title needs to be in a level 2 heading for accessibility reasons
82
+ expect(titleWrapper.element.tagName).toEqual('H2');
81
83
  });
82
84
 
83
85
  describe('given primaryButtonText', () => {
@@ -184,7 +184,7 @@ export default {
184
184
  />
185
185
 
186
186
  <div class="gl-alert-content" role="alert">
187
- <h4 v-if="title" class="gl-alert-title">{{ title }}</h4>
187
+ <h2 v-if="title" class="gl-alert-title">{{ title }}</h2>
188
188
 
189
189
  <div class="gl-alert-body">
190
190
  <!-- @slot The alert message to display. -->
@@ -128,8 +128,14 @@
128
128
  }
129
129
  }
130
130
 
131
- .gl-dropdown-toggle.btn-block {
132
- @include gl-justify-content-space-between;
131
+ .gl-dropdown-toggle {
132
+ &.btn-block {
133
+ @include gl-justify-content-space-between;
134
+ }
135
+
136
+ .gl-button-text {
137
+ @include gl-display-inline-flex;
138
+ }
133
139
  }
134
140
 
135
141
  .gl-new-dropdown-button-text {
@@ -173,7 +179,8 @@
173
179
  }
174
180
 
175
181
  .dropdown-icon-only {
176
- .dropdown-icon {
182
+ .dropdown-icon,
183
+ .gl-button-icon.gl-button-icon {
177
184
  @include gl-mr-0;
178
185
  }
179
186
 
@@ -20,6 +20,7 @@
20
20
  .gl-new-dropdown-item {
21
21
  .dropdown-item {
22
22
  @include gl-tmp-dropdown-item-style;
23
+ @include gl-cursor-pointer;
23
24
 
24
25
  .gl-avatar {
25
26
  @include gl-flex-shrink-0;
@@ -65,7 +65,8 @@ export default {
65
65
  },
66
66
  cursorPosition: {
67
67
  type: String,
68
- required: true,
68
+ required: false,
69
+ default: 'end',
69
70
  validator: (value) => ['start', 'end'].includes(value),
70
71
  },
71
72
  },
@@ -111,24 +112,29 @@ export default {
111
112
  Emitted when this term token is clicked.
112
113
  @event activate
113
114
  -->
115
+
114
116
  <!--
115
117
  Emitted when this term token will lose its focus.
116
118
  @event deactivate
117
119
  -->
120
+
118
121
  <!--
119
122
  Emitted when autocomplete entry is selected.
120
123
  @event replace
121
124
  @property {object} token Replacement token configuration.
122
125
  -->
126
+
123
127
  <!--
124
128
  Emitted when the token is submitted.
125
129
  @event submit
126
130
  -->
131
+
127
132
  <!--
128
133
  Emitted when Space is pressed in-between term text.
129
134
  @event split
130
135
  @property {array} newTokens Token configurations
131
136
  -->
137
+
132
138
  <gl-filtered-search-token-segment
133
139
  ref="segment"
134
140
  v-model="internalValue"
@@ -158,6 +164,7 @@ export default {
158
164
  {{ item.title }}
159
165
  </gl-filtered-search-suggestion>
160
166
  </template>
167
+
161
168
  <template #view>
162
169
  <input
163
170
  v-if="placeholder"
@@ -167,6 +174,7 @@ export default {
167
174
  :aria-label="placeholder"
168
175
  data-testid="filtered-search-term-input"
169
176
  />
177
+
170
178
  <template v-else>{{ value.data }}</template>
171
179
  </template>
172
180
  </gl-filtered-search-token-segment>
@@ -67,7 +67,8 @@ export default {
67
67
  },
68
68
  cursorPosition: {
69
69
  type: String,
70
- required: true,
70
+ required: false,
71
+ default: 'end',
71
72
  validator: (value) => ['start', 'end'].includes(value),
72
73
  },
73
74
  },
@@ -299,6 +300,7 @@ export default {
299
300
  Emitted when the token is submitted.
300
301
  @event submit
301
302
  -->
303
+
302
304
  <gl-filtered-search-token-segment
303
305
  key="title-segment"
304
306
  :value="config.title"
@@ -318,10 +320,12 @@ export default {
318
320
  class="gl-filtered-search-token-type"
319
321
  :class="getAdditionalSegmentClasses($options.segments.SEGMENT_TITLE)"
320
322
  view-only
321
- >{{ inputValue }}</gl-token
322
323
  >
324
+ {{ inputValue }}
325
+ </gl-token>
323
326
  </template>
324
327
  </gl-filtered-search-token-segment>
328
+
325
329
  <gl-filtered-search-token-segment
326
330
  key="operator-segment"
327
331
  v-model="tokenValue.operator"
@@ -343,9 +347,11 @@ export default {
343
347
  variant="search-value"
344
348
  :class="getAdditionalSegmentClasses($options.segments.SEGMENT_OPERATOR)"
345
349
  view-only
346
- >{{ operatorDescription }}</gl-token
347
350
  >
351
+ {{ operatorDescription }}
352
+ </gl-token>
348
353
  </template>
354
+
349
355
  <template #option="{ option }">
350
356
  <div class="gl-display-flex">
351
357
  {{ option.value }}
@@ -355,16 +361,19 @@ export default {
355
361
  </div>
356
362
  </template>
357
363
  </gl-filtered-search-token-segment>
364
+
358
365
  <!--
359
366
  Emitted when a suggestion has been selected.
360
367
  @event select
361
368
  @type {string} value The value of the selected suggestion.
362
369
  -->
370
+
363
371
  <!--
364
372
  Emitted when Space is pressed in-between term text.
365
373
  @event split
366
374
  @property {array} newTokens Token configurations
367
375
  -->
376
+
368
377
  <gl-filtered-search-token-segment
369
378
  v-if="hasDataOrDataSegmentIsCurrentlyActive"
370
379
  key="data-segment"
@@ -386,10 +395,13 @@ export default {
386
395
  >
387
396
  <template #suggestions>
388
397
  <!-- @slot The suggestions (implemented with GlFilteredSearchSuggestion). -->
398
+
389
399
  <slot name="suggestions"></slot>
390
400
  </template>
401
+
391
402
  <template #view="{ inputValue }">
392
403
  <!-- @slot Used to customize how the token is rendered. -->
404
+
393
405
  <slot
394
406
  name="view-token"
395
407
  v-bind="{
@@ -412,6 +424,7 @@ export default {
412
424
  @slot Template for token value in inactive state
413
425
  @binding {array} suggestions Slot for rendering autocomplete suggestions when no options are provided.
414
426
  -->
427
+
415
428
  <slot name="view" v-bind="{ inputValue }">{{ inputValue }}</slot>
416
429
  </span>
417
430
  </gl-token>
@@ -79,7 +79,8 @@ export default {
79
79
  },
80
80
  cursorPosition: {
81
81
  type: String,
82
- required: true,
82
+ required: false,
83
+ default: 'end',
83
84
  validator: (value) => ['start', 'end'].includes(value),
84
85
  },
85
86
  },
@@ -346,6 +347,7 @@ export default {
346
347
  @keydown="handleInputKeydown"
347
348
  @blur="handleBlur"
348
349
  />
350
+
349
351
  <portal :key="`operator-${_uid}`" :to="portalName">
350
352
  <gl-filtered-search-suggestion-list
351
353
  v-if="hasOptionsOrSuggestions"
@@ -361,11 +363,10 @@ export default {
361
363
  :value="option.value"
362
364
  :icon-name="option.icon"
363
365
  >
364
- <slot name="option" v-bind="{ option }">
365
- {{ option[optionTextField] }}
366
- </slot>
366
+ <slot name="option" v-bind="{ option }"> {{ option[optionTextField] }} </slot>
367
367
  </gl-filtered-search-suggestion>
368
368
  </template>
369
+
369
370
  <slot v-else name="suggestions"></slot>
370
371
  </gl-filtered-search-suggestion-list>
371
372
  </portal>
@@ -7,13 +7,15 @@ const generateProps = ({ href = '#', target = null } = {}) => ({
7
7
  target,
8
8
  });
9
9
 
10
- const makeStory = (options) => (args, { argTypes }) => ({
11
- components: {
12
- GlLink,
13
- },
14
- props: Object.keys(argTypes),
15
- ...options,
16
- });
10
+ const makeStory =
11
+ (options) =>
12
+ (args, { argTypes }) => ({
13
+ components: {
14
+ GlLink,
15
+ },
16
+ props: Object.keys(argTypes),
17
+ ...options,
18
+ });
17
19
 
18
20
  export const DefaultLink = makeStory({
19
21
  components: { GlLink },
@@ -0,0 +1,171 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { nextTick } from 'vue';
3
+ import { GL_DROPDOWN_HIDDEN, GL_DROPDOWN_SHOWN, POPPER_CONFIG } from '../constants';
4
+ import GlBaseDropdown from './base_dropdown.vue';
5
+
6
+ const destroyPopper = jest.fn();
7
+ const updatePopper = jest.fn();
8
+ const mockCreatePopper = jest.fn().mockImplementation(() => ({
9
+ destroy: destroyPopper,
10
+ update: updatePopper,
11
+ }));
12
+
13
+ jest.mock('@popperjs/core', () => ({
14
+ createPopper: (...args) => mockCreatePopper(...args),
15
+ }));
16
+
17
+ const DEFAULT_BTN_TOGGLE_CLASSES = [
18
+ 'btn',
19
+ 'btn-default',
20
+ 'btn-md',
21
+ 'gl-button',
22
+ 'dropdown-toggle',
23
+ 'gl-dropdown-toggle',
24
+ ];
25
+
26
+ describe('base dropdown', () => {
27
+ let wrapper;
28
+
29
+ const buildWrapper = (propsData, slots = {}) => {
30
+ wrapper = mount(GlBaseDropdown, {
31
+ propsData: {
32
+ toggleId: 'dropdown-toggle-btn-1',
33
+ ...propsData,
34
+ },
35
+ slots,
36
+ attachTo: document.body,
37
+ });
38
+ };
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ });
43
+
44
+ const findDropdownToggle = () => wrapper.find('.btn.gl-dropdown-toggle');
45
+ const findDropdownMenu = () => wrapper.find('.dropdown-menu');
46
+
47
+ describe('popper.js instance', () => {
48
+ it('should initialize popper.js instance with toggle and menu elements and config for left-aligned menu', async () => {
49
+ await buildWrapper();
50
+ expect(mockCreatePopper).toHaveBeenCalledWith(
51
+ findDropdownToggle().element,
52
+ findDropdownMenu().element,
53
+ { ...POPPER_CONFIG, placement: 'bottom-start' }
54
+ );
55
+ });
56
+
57
+ it('should initialize popper.js instance with toggle and menu elements and config for right-aligned menu', async () => {
58
+ await buildWrapper({ right: true });
59
+ expect(mockCreatePopper).toHaveBeenCalledWith(
60
+ findDropdownToggle().element,
61
+ findDropdownMenu().element,
62
+ { ...POPPER_CONFIG, placement: 'bottom-end' }
63
+ );
64
+ });
65
+
66
+ it('should update popper instance when component is updated', async () => {
67
+ await buildWrapper();
68
+ await findDropdownToggle().trigger('click');
69
+ await wrapper.setProps({ category: 'tertiary' });
70
+ expect(updatePopper).toHaveBeenCalled();
71
+ });
72
+
73
+ it('should destroy popper instance when component is destroyed', async () => {
74
+ await buildWrapper();
75
+ wrapper.destroy();
76
+ expect(destroyPopper).toHaveBeenCalled();
77
+ });
78
+ });
79
+
80
+ describe('renders content to the default slot', () => {
81
+ const defaultContent = 'Some content here';
82
+ const slots = { default: defaultContent };
83
+
84
+ it('renders the content', () => {
85
+ buildWrapper({}, slots);
86
+ expect(wrapper.find('.gl-new-dropdown-inner').html()).toContain(defaultContent);
87
+ });
88
+ });
89
+
90
+ describe.each`
91
+ props | toggleClasses
92
+ ${{}} | ${[]}
93
+ ${{ toggleText: 'toggleText' }} | ${[]}
94
+ ${{ toggleText: 'toggleText', icon: 'close' }} | ${['dropdown-icon-text']}
95
+ ${{ icon: 'close' }} | ${['dropdown-icon-only']}
96
+ ${{ icon: 'close', toggleText: 'toggleText', textSrOnly: true }} | ${['dropdown-icon-only']}
97
+ ${{ icon: 'close', textSrOnly: true }} | ${['dropdown-icon-only']}
98
+ ${{ toggleText: 'toggleText', noCaret: true }} | ${['dropdown-toggle-no-caret']}
99
+ `('dropdown with props $props', ({ props, toggleClasses }) => {
100
+ beforeEach(async () => {
101
+ buildWrapper(props);
102
+
103
+ await nextTick();
104
+ });
105
+
106
+ it(`sets toggle button classes to '${toggleClasses}'`, () => {
107
+ const classes = findDropdownToggle().classes().sort();
108
+
109
+ expect(classes).toEqual([...DEFAULT_BTN_TOGGLE_CLASSES, ...toggleClasses].sort());
110
+ });
111
+ });
112
+
113
+ describe.each`
114
+ toggleClass | expectedClasses | type
115
+ ${'my-class'} | ${[...DEFAULT_BTN_TOGGLE_CLASSES, 'my-class']} | ${'string'}
116
+ ${{ 'my-class': true }} | ${[...DEFAULT_BTN_TOGGLE_CLASSES, 'my-class']} | ${'object'}
117
+ ${['cls-1', 'cls-2']} | ${[...DEFAULT_BTN_TOGGLE_CLASSES, 'cls-1', 'cls-2']} | ${'array'}
118
+ ${null} | ${[...DEFAULT_BTN_TOGGLE_CLASSES]} | ${'null'}
119
+ `('with toggle classes', ({ toggleClass, expectedClasses, type }) => {
120
+ beforeEach(async () => {
121
+ buildWrapper({ toggleClass });
122
+
123
+ await nextTick();
124
+ });
125
+
126
+ it(`class is inherited from toggle class of type ${type}`, () => {
127
+ expect(findDropdownToggle().classes().sort()).toEqual(
128
+ expect.arrayContaining(expectedClasses.sort())
129
+ );
130
+ });
131
+ });
132
+
133
+ describe('toggle visibility', () => {
134
+ beforeEach(() => {
135
+ buildWrapper();
136
+ });
137
+
138
+ it('should toggle menu visibility on toggle button click ', async () => {
139
+ const toggle = findDropdownToggle();
140
+ const menu = findDropdownMenu();
141
+
142
+ // open menu clicking toggle btn
143
+ await toggle.trigger('click');
144
+ expect(menu.classes('show')).toBe(true);
145
+ expect(toggle.attributes('aria-expanded')).toBe('true');
146
+ expect(wrapper.emitted(GL_DROPDOWN_SHOWN).length).toBe(1);
147
+
148
+ // close menu clicking toggle btn again
149
+ await toggle.trigger('click');
150
+ expect(menu.classes('show')).toBe(false);
151
+ expect(toggle.attributes('aria-expanded')).toBeUndefined();
152
+ expect(wrapper.emitted(GL_DROPDOWN_HIDDEN).length).toBe(1);
153
+ });
154
+
155
+ it('should close the menu when Escape is pressed inside menu and focus toggle', async () => {
156
+ const toggle = findDropdownToggle();
157
+ const menu = findDropdownMenu();
158
+
159
+ // open menu clicking toggle btn
160
+ await toggle.trigger('click');
161
+ expect(menu.classes('show')).toBe(true);
162
+
163
+ // close menu pressing ESC on it
164
+ await menu.trigger('keydown.esc');
165
+ expect(menu.classes('show')).toBe(false);
166
+ expect(toggle.attributes('aria-expanded')).toBeUndefined();
167
+ expect(wrapper.emitted(GL_DROPDOWN_HIDDEN).length).toBe(1);
168
+ expect(toggle.element).toHaveFocus();
169
+ });
170
+ });
171
+ });