@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.
package/dist/index.js CHANGED
@@ -44,6 +44,8 @@ export { default as GlDropdownSectionHeader } from './components/base/dropdown/d
44
44
  export { default as GlDropdownDivider } from './components/base/dropdown/dropdown_divider';
45
45
  export { default as GlDropdownText } from './components/base/dropdown/dropdown_text';
46
46
  export { default as GlDropdown } from './components/base/dropdown/dropdown';
47
+ export { default as GlListbox } from './components/base/new_dropdowns/listbox/listbox';
48
+ export { default as GlListboxItem } from './components/base/new_dropdowns/listbox/listbox_item';
47
49
  export { default as GlPath } from './components/base/path/path';
48
50
  export { default as GlTable } from './components/base/table/table';
49
51
  export { default as GlBreadcrumb } from './components/base/breadcrumb/breadcrumb';
@@ -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.4.0",
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",
@@ -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;
@@ -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
+ });
@@ -0,0 +1,221 @@
1
+ <script>
2
+ import { createPopper } from '@popperjs/core';
3
+ import {
4
+ buttonCategoryOptions,
5
+ buttonSizeOptions,
6
+ dropdownVariantOptions,
7
+ } from '../../../../utils/constants';
8
+ import { POPPER_CONFIG, GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN } from '../constants';
9
+
10
+ import GlButton from '../../button/button.vue';
11
+ import GlIcon from '../../icon/icon.vue';
12
+ import { OutsideDirective } from '../../../../directives/outside/outside';
13
+
14
+ export default {
15
+ components: {
16
+ GlButton,
17
+ GlIcon,
18
+ },
19
+ directives: { Outside: OutsideDirective },
20
+ props: {
21
+ toggleText: {
22
+ type: String,
23
+ required: false,
24
+ default: '',
25
+ },
26
+ textSrOnly: {
27
+ type: Boolean,
28
+ required: false,
29
+ default: false,
30
+ },
31
+ category: {
32
+ type: String,
33
+ required: false,
34
+ default: buttonCategoryOptions.primary,
35
+ validator: (value) => Object.keys(buttonCategoryOptions).includes(value),
36
+ },
37
+ variant: {
38
+ type: String,
39
+ required: false,
40
+ default: dropdownVariantOptions.default,
41
+ validator: (value) => Object.keys(dropdownVariantOptions).includes(value),
42
+ },
43
+ size: {
44
+ type: String,
45
+ required: false,
46
+ default: buttonSizeOptions.medium,
47
+ validator: (value) => Object.keys(buttonSizeOptions).includes(value),
48
+ },
49
+ icon: {
50
+ type: String,
51
+ required: false,
52
+ default: '',
53
+ },
54
+ disabled: {
55
+ type: Boolean,
56
+ required: false,
57
+ default: false,
58
+ },
59
+ loading: {
60
+ type: Boolean,
61
+ required: false,
62
+ default: false,
63
+ },
64
+ toggleClass: {
65
+ type: [String, Array, Object],
66
+ required: false,
67
+ default: null,
68
+ },
69
+ noCaret: {
70
+ type: Boolean,
71
+ required: false,
72
+ default: false,
73
+ },
74
+ /**
75
+ * Right align dropdown menu with respect to the toggle button
76
+ */
77
+ right: {
78
+ type: Boolean,
79
+ required: false,
80
+ default: false,
81
+ },
82
+ // ARIA props
83
+ ariaHaspopup: {
84
+ type: [String, Boolean],
85
+ required: false,
86
+ default: false,
87
+ validator: (value) => {
88
+ return ['menu', 'listbox', 'tree', 'grid', 'dialog', true, false].includes(value);
89
+ },
90
+ },
91
+ /**
92
+ * Id that will be referenced by `aria-labelledby` attribute of the dropdown content`
93
+ */
94
+ toggleId: {
95
+ type: String,
96
+ required: true,
97
+ },
98
+ /**
99
+ * The `aria-labelledby` attribute value for the toggle `button`
100
+ */
101
+ ariaLabelledby: {
102
+ type: String,
103
+ required: false,
104
+ default: null,
105
+ },
106
+ },
107
+ data() {
108
+ return {
109
+ visible: false,
110
+ };
111
+ },
112
+ computed: {
113
+ isIconOnly() {
114
+ return Boolean(this.icon && (!this.toggleText?.length || this.textSrOnly));
115
+ },
116
+ isIconWithText() {
117
+ return Boolean(this.icon && this.toggleText?.length && !this.textSrOnly);
118
+ },
119
+ toggleButtonClasses() {
120
+ return [
121
+ this.toggleClass,
122
+ {
123
+ 'gl-dropdown-toggle': true,
124
+ 'dropdown-toggle': true,
125
+ 'dropdown-icon-only': this.isIconOnly,
126
+ 'dropdown-icon-text': this.isIconWithText,
127
+ 'dropdown-toggle-no-caret': this.noCaret,
128
+ },
129
+ ];
130
+ },
131
+ toggleLabelledBy() {
132
+ return this.ariaLabelledby ? `${this.ariaLabelledby} ${this.toggleId}` : this.toggleId;
133
+ },
134
+ popperConfig() {
135
+ return {
136
+ placement: this.right ? 'bottom-end' : 'bottom-start',
137
+ ...POPPER_CONFIG,
138
+ };
139
+ },
140
+ },
141
+ updated() {
142
+ if (this.visible) {
143
+ this.popper?.update();
144
+ }
145
+ },
146
+ mounted() {
147
+ this.$nextTick(() => {
148
+ this.popper = createPopper(this.$refs.toggle.$el, this.$refs.content, this.popperConfig);
149
+ });
150
+ },
151
+ beforeDestroy() {
152
+ this.popper.destroy();
153
+ },
154
+ methods: {
155
+ toggle() {
156
+ this.visible = !this.visible;
157
+
158
+ if (this.visible) {
159
+ this.popper.update();
160
+ this.$emit(GL_DROPDOWN_SHOWN);
161
+ } else {
162
+ this.$emit(GL_DROPDOWN_HIDDEN);
163
+ }
164
+ },
165
+ close() {
166
+ if (!this.visible) {
167
+ return;
168
+ }
169
+ this.toggle();
170
+ },
171
+ closeAndFocus() {
172
+ if (!this.visible) {
173
+ return;
174
+ }
175
+ this.toggle();
176
+ this.focusToggle();
177
+ },
178
+ focusToggle() {
179
+ this.$refs.toggle.$el.focus();
180
+ },
181
+ },
182
+ };
183
+ </script>
184
+
185
+ <template>
186
+ <div v-outside="close" class="gl-new-dropdown dropdown btn-group">
187
+ <gl-button
188
+ :id="toggleId"
189
+ ref="toggle"
190
+ data-testid="base-dropdown-toggle"
191
+ :icon="icon"
192
+ :category="category"
193
+ :variant="variant"
194
+ :size="size"
195
+ :disabled="disabled"
196
+ :loading="loading"
197
+ :class="toggleButtonClasses"
198
+ :aria-haspopup="ariaHaspopup"
199
+ :aria-expanded="visible"
200
+ :aria-labelledby="toggleLabelledBy"
201
+ @click="toggle"
202
+ >
203
+ <span class="gl-new-dropdown-button-text" :class="{ 'gl-sr-only': textSrOnly }">
204
+ {{ toggleText }}
205
+ </span>
206
+ <gl-icon v-if="!noCaret" class="gl-button-icon dropdown-chevron" name="chevron-down" />
207
+ </gl-button>
208
+
209
+ <div
210
+ ref="content"
211
+ data-testid="base-dropdown-menu"
212
+ class="dropdown-menu"
213
+ :class="{ show: visible }"
214
+ @keydown.esc.stop.prevent="closeAndFocus"
215
+ >
216
+ <div class="gl-new-dropdown-inner">
217
+ <slot></slot>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </template>
@@ -0,0 +1,22 @@
1
+ export const POPPER_CONFIG = {
2
+ modifiers: [
3
+ {
4
+ name: 'offset',
5
+ options: {
6
+ offset: [0, 4],
7
+ },
8
+ },
9
+ ],
10
+ };
11
+
12
+ // base dropdown events
13
+ export const GL_DROPDOWN_SHOWN = 'shown';
14
+ export const GL_DROPDOWN_HIDDEN = 'hidden';
15
+
16
+ // KEY Codes
17
+ export const HOME = 'Home';
18
+ export const END = 'End';
19
+ export const ARROW_UP = 'ArrowUp';
20
+ export const ARROW_DOWN = 'ArrowDown';
21
+ export const ENTER = 'Enter';
22
+ export const SPACE = 'Space';
@@ -0,0 +1,71 @@
1
+ A listbox dropdown is a button that toggles a panel containing a list of options.
2
+ Listbox supports single and multi-selection.
3
+
4
+ **Single-select:** By default, selecting an option will update the toggle label with the choice.
5
+ But the custom toggle text can be provided.
6
+ When option is selected, the dropdown will be closed and focus set on the toggle button.
7
+
8
+ **Multi-select:** Selecting an option will not update the toggle, but it can be customized
9
+ providing `toggleText` property. Also, selecting or deselecting an item won't close the dropdown.
10
+
11
+ ### Icon-only listbox
12
+
13
+ Icon-only listboxes must have an accessible name.
14
+ You can provide this with the combination of `toggleText` and `textSrOnly` props.
15
+ For single-select listboxes `toggleText` will be set to the selected item's `text` property value
16
+ by default.
17
+
18
+ Optionally, you can use `no-caret` to remove the caret and `category="tertiary"` to remove the border.
19
+
20
+ ```html
21
+ <gl-listbox
22
+ icon="ellipsis_v"
23
+ toggle-text="More options"
24
+ text-sr-only
25
+ category="tertiary"
26
+ no-caret
27
+ >
28
+ ```
29
+
30
+ ### Opening the listbox
31
+
32
+ Listbox will open on toggle button click (if it was previously closed).
33
+ On open, `GlListbox` will emit the `shown` event.
34
+
35
+ ### Closing the listbox
36
+
37
+ The listbox is closed by any of the following:
38
+
39
+ - pressing <kbd>Esc</kbd>
40
+ - clicking anywhere outside the component
41
+ - selecting an option in single-select mode
42
+
43
+ After closing, `GlListbox` emits a `hidden` event.
44
+
45
+ ### Selecting items
46
+
47
+ Set the `v-model` on the listbox to have 2-way data binding for the selected items in the listbox.
48
+ Alternatively, you can set `selected` property to the array of selected items
49
+ `value` properties (for multi-select) or to the selected item `value` property for a single-select.
50
+ On selection the listbox will emit the `select` event with the selected values.
51
+
52
+ ### Setting listbox options
53
+
54
+ Provide the list of options for the listbox - each item in the array should have `value` property.
55
+ It is used as a primary key.
56
+ To render the default listbox item template, the item should also have `text` property.
57
+ If you want to use custom template for rendering the listbox item, use the `list-item` template.
58
+
59
+ ```html
60
+ <gl-listbox :items="items">
61
+ <template #list-item="{ item }">
62
+ <span class="gl-display-flex gl-align-items-center">
63
+ <gl-avatar :size="32" class-="gl-mr-3"/>
64
+ <span class="gl-display-flex gl-flex-direction-column">
65
+ <span class="gl-font-weight-bold gl-white-space-nowrap">{{ item.text }}</span>
66
+ <span class="gl-text-gray-400"> {{ item.secondaryText }}</span>
67
+ </span>
68
+ </span>
69
+ </template>
70
+ </gl-litsbox>
71
+ ```