@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
@@ -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
+ };
@@ -0,0 +1,348 @@
1
+ <script>
2
+ import { clamp, uniqueId } from 'lodash';
3
+ import { stopEvent } from '../../../../utils/utils';
4
+ import {
5
+ GL_DROPDOWN_SHOWN,
6
+ GL_DROPDOWN_HIDDEN,
7
+ HOME,
8
+ END,
9
+ ARROW_DOWN,
10
+ ARROW_UP,
11
+ } from '../constants';
12
+ import {
13
+ buttonCategoryOptions,
14
+ buttonSizeOptions,
15
+ dropdownVariantOptions,
16
+ } from '../../../../utils/constants';
17
+ import GlBaseDropdown from '../base_dropdown/base_dropdown.vue';
18
+ import GlListboxItem from './listbox_item.vue';
19
+
20
+ export const ITEM_SELECTOR = '[role="option"]';
21
+
22
+ export default {
23
+ events: {
24
+ GL_DROPDOWN_SHOWN,
25
+ GL_DROPDOWN_HIDDEN,
26
+ },
27
+ components: {
28
+ GlBaseDropdown,
29
+ GlListboxItem,
30
+ },
31
+ model: {
32
+ prop: 'selected',
33
+ event: 'select',
34
+ },
35
+ props: {
36
+ /**
37
+ * Items to display in the dropdown
38
+ */
39
+ items: {
40
+ type: Array,
41
+ required: false,
42
+ default: () => [],
43
+ validator: (items) => {
44
+ return items.every(({ value }) => typeof value === 'string');
45
+ },
46
+ },
47
+ /**
48
+ * array of selected items values for multi-select and selected item value for single-select
49
+ */
50
+ selected: {
51
+ type: [Array, String, Number],
52
+ required: false,
53
+ default: () => [],
54
+ },
55
+ /**
56
+ * Allow multi-selection
57
+ */
58
+ multiple: {
59
+ type: Boolean,
60
+ required: false,
61
+ default: false,
62
+ },
63
+ /**
64
+ * Toggle button text
65
+ */
66
+ toggleText: {
67
+ type: String,
68
+ required: false,
69
+ default: '',
70
+ },
71
+ /**
72
+ * Toggle text to be read by screen readers only
73
+ */
74
+ textSrOnly: {
75
+ type: Boolean,
76
+ required: false,
77
+ default: false,
78
+ },
79
+ /**
80
+ * Styling option - dropdown's toggle category
81
+ */
82
+ category: {
83
+ type: String,
84
+ required: false,
85
+ default: buttonCategoryOptions.primary,
86
+ validator: (value) => Object.keys(buttonCategoryOptions).includes(value),
87
+ },
88
+ /**
89
+ * Styling option - dropdown's toggle variant
90
+ */
91
+ variant: {
92
+ type: String,
93
+ required: false,
94
+ default: dropdownVariantOptions.default,
95
+ validator: (value) => Object.keys(dropdownVariantOptions).includes(value),
96
+ },
97
+ /**
98
+ * The size of the dropdown toggle
99
+ */
100
+ size: {
101
+ type: String,
102
+ required: false,
103
+ default: buttonSizeOptions.medium,
104
+ validator: (value) => Object.keys(buttonSizeOptions).includes(value),
105
+ },
106
+ /**
107
+ * Icon name that will be rendered in the toggle button
108
+ */
109
+ icon: {
110
+ type: String,
111
+ required: false,
112
+ default: '',
113
+ },
114
+ /**
115
+ * Set to "true" to disable the dropdown
116
+ */
117
+ disabled: {
118
+ type: Boolean,
119
+ required: false,
120
+ default: false,
121
+ },
122
+ /**
123
+ * Set to "true" when dropdown content (items) is loading
124
+ */
125
+ loading: {
126
+ type: Boolean,
127
+ required: false,
128
+ default: false,
129
+ },
130
+ /**
131
+ * Additional CSS classes to customize toggle appearance
132
+ */
133
+ toggleClass: {
134
+ type: [String, Array, Object],
135
+ required: false,
136
+ default: null,
137
+ },
138
+ /**
139
+ * Set to "true" to hide the caret
140
+ */
141
+ noCaret: {
142
+ type: Boolean,
143
+ required: false,
144
+ default: false,
145
+ },
146
+ /**
147
+ * Right align listbox menu with respect to the toggle button
148
+ */
149
+ right: {
150
+ type: Boolean,
151
+ required: false,
152
+ default: false,
153
+ },
154
+ /**
155
+ * The `aria-labelledby` attribute value for the toggle button
156
+ */
157
+ ariaLabelledby: {
158
+ type: String,
159
+ required: false,
160
+ default: null,
161
+ },
162
+ },
163
+ data() {
164
+ return {
165
+ selectedValues: [],
166
+ toggleId: uniqueId('dropdown-toggle-btn-'),
167
+ nextFocusedItemIndex: null,
168
+ };
169
+ },
170
+ computed: {
171
+ listboxToggleText() {
172
+ if (!this.toggleText) {
173
+ if (!this.multiple && this.selectedValues.length) {
174
+ return this.items.find(({ value }) => value === this.selectedValues[0])?.text;
175
+ }
176
+ return '';
177
+ }
178
+
179
+ return this.toggleText;
180
+ },
181
+ selectedIndices() {
182
+ return this.selectedValues
183
+ .map((selected) => this.items.findIndex(({ value }) => value === selected))
184
+ .sort();
185
+ },
186
+ },
187
+ watch: {
188
+ selected: {
189
+ immediate: true,
190
+ handler(newSelected) {
191
+ if (Array.isArray(newSelected)) {
192
+ if (!this.multiple && newSelected.length) {
193
+ throw new Error('To allow multi-selection, please, set "multiple" property to "true"');
194
+ }
195
+ this.selectedValues = [...newSelected];
196
+ } else {
197
+ this.selectedValues = [newSelected];
198
+ }
199
+ },
200
+ },
201
+ },
202
+ methods: {
203
+ onShow() {
204
+ this.$nextTick(() => {
205
+ this.focusItem(this.selectedIndices[0] ?? 0, this.getFocusableListItemElements());
206
+ /**
207
+ * Emitted when dropdown is shown
208
+ *
209
+ * @event shown
210
+ */
211
+ this.$emit(GL_DROPDOWN_SHOWN);
212
+ });
213
+ },
214
+ onHide() {
215
+ /**
216
+ * Emitted when dropdown is hidden
217
+ *
218
+ * @event hidden
219
+ */
220
+ this.$emit(GL_DROPDOWN_HIDDEN);
221
+ this.nextFocusedItemIndex = null;
222
+ },
223
+ onKeydown(event) {
224
+ const { code } = event;
225
+ const elements = this.getFocusableListItemElements();
226
+
227
+ if (elements.length < 1) return;
228
+
229
+ let stop = true;
230
+
231
+ if (code === HOME) {
232
+ this.focusItem(0, elements);
233
+ } else if (code === END) {
234
+ this.focusItem(elements.length - 1, elements);
235
+ } else if (code === ARROW_UP) {
236
+ this.focusNextItem(event, elements, -1);
237
+ } else if (code === ARROW_DOWN) {
238
+ this.focusNextItem(event, elements, 1);
239
+ } else {
240
+ stop = false;
241
+ }
242
+
243
+ if (stop) {
244
+ stopEvent(event);
245
+ }
246
+ },
247
+ getFocusableListItemElements() {
248
+ const items = this.$refs.list.querySelectorAll(ITEM_SELECTOR);
249
+ return Array.from(items);
250
+ },
251
+ focusNextItem(event, elements, offset) {
252
+ const { target } = event;
253
+ const currentIndex = elements.indexOf(target);
254
+ const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
255
+
256
+ this.focusItem(nextIndex, elements);
257
+ },
258
+ focusItem(index, elements) {
259
+ this.nextFocusedItemIndex = index;
260
+
261
+ this.$nextTick(() => {
262
+ elements[index]?.focus();
263
+ });
264
+ },
265
+ onSelect({ value }, isSelected) {
266
+ if (this.multiple) {
267
+ this.onMultiSelect(value, isSelected);
268
+ } else {
269
+ this.onSingleSelect(value, isSelected);
270
+ }
271
+ },
272
+ isSelected(item) {
273
+ return this.selectedValues.some((value) => value === item.value);
274
+ },
275
+ onSingleSelect(value, isSelected) {
276
+ if (isSelected) {
277
+ /**
278
+ * Emitted when selection is changed
279
+ *
280
+ * @event select
281
+ */
282
+ this.$emit('select', value);
283
+ }
284
+
285
+ this.$refs.baseDropdown.closeAndFocus();
286
+ },
287
+ onMultiSelect(value, isSelected) {
288
+ if (isSelected) {
289
+ this.$emit('select', [...this.selectedValues, value]);
290
+ } else {
291
+ this.$emit(
292
+ 'select',
293
+ this.selectedValues.filter((selectedValue) => selectedValue !== value)
294
+ );
295
+ }
296
+ },
297
+ },
298
+ };
299
+ </script>
300
+
301
+ <template>
302
+ <gl-base-dropdown
303
+ ref="baseDropdown"
304
+ aria-haspopup="listbox"
305
+ :aria-labelledby="ariaLabelledby"
306
+ :toggle-id="toggleId"
307
+ :toggle-text="listboxToggleText"
308
+ :toggle-class="toggleClass"
309
+ :text-sr-only="textSrOnly"
310
+ :category="category"
311
+ :variant="variant"
312
+ :size="size"
313
+ :icon="icon"
314
+ :disabled="disabled"
315
+ :loading="loading"
316
+ :no-caret="noCaret"
317
+ :right="right"
318
+ @[$options.events.GL_DROPDOWN_SHOWN]="onShow"
319
+ @[$options.events.GL_DROPDOWN_HIDDEN]="onHide"
320
+ >
321
+ <!-- @slot Content to display in dropdown header -->
322
+ <slot name="header"></slot>
323
+
324
+ <ul
325
+ ref="list"
326
+ :aria-labelledby="toggleId"
327
+ role="listbox"
328
+ class="gl-new-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0"
329
+ tabindex="-1"
330
+ @keydown="onKeydown"
331
+ >
332
+ <gl-listbox-item
333
+ v-for="(item, index) in items"
334
+ :key="item.value"
335
+ :is-selected="isSelected(item)"
336
+ :is-focused="nextFocusedItemIndex === index"
337
+ @select="onSelect(item, $event)"
338
+ >
339
+ <!-- @slot Custom template of the listbox item -->
340
+ <slot name="list-item" :item="item">
341
+ {{ item.text }}
342
+ </slot>
343
+ </gl-listbox-item>
344
+ </ul>
345
+ <!-- @slot Content to display in dropdown footer -->
346
+ <slot name="footer"></slot>
347
+ </gl-base-dropdown>
348
+ </template>