@gitlab/ui 64.0.2 → 64.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "64.0.2",
3
+ "version": "64.1.1",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -62,7 +62,7 @@ describe('base dropdown', () => {
62
62
  expect(autoUpdate).toHaveBeenCalledTimes(1);
63
63
  });
64
64
 
65
- it("stops Floating UI's when closing the dropdown", async () => {
65
+ it('stops Floating UI when closing the dropdown', async () => {
66
66
  buildWrapper();
67
67
  await findDefaultDropdownToggle().trigger('click');
68
68
  await findDefaultDropdownToggle().trigger('click');
@@ -71,7 +71,7 @@ describe('base dropdown', () => {
71
71
  expect(mockStopAutoUpdate).toHaveBeenCalledTimes(1);
72
72
  });
73
73
 
74
- it("restarts Floating UI's when reopening the dropdown", async () => {
74
+ it('restarts Floating UI when reopening the dropdown', async () => {
75
75
  buildWrapper();
76
76
  await findDefaultDropdownToggle().trigger('click');
77
77
  await findDefaultDropdownToggle().trigger('click');
@@ -103,6 +103,7 @@ describe('base dropdown', () => {
103
103
  findDropdownMenu().element,
104
104
  {
105
105
  placement: 'bottom-start',
106
+ strategy: 'absolute',
106
107
  middleware: [offset({ mainAxis: DEFAULT_OFFSET })],
107
108
  }
108
109
  );
@@ -115,7 +116,11 @@ describe('base dropdown', () => {
115
116
  expect(computePosition).toHaveBeenCalledWith(
116
117
  findDefaultDropdownToggle().element,
117
118
  findDropdownMenu().element,
118
- { placement: 'bottom', middleware: [offset({ mainAxis: DEFAULT_OFFSET })] }
119
+ {
120
+ placement: 'bottom',
121
+ strategy: 'absolute',
122
+ middleware: [offset({ mainAxis: DEFAULT_OFFSET })],
123
+ }
119
124
  );
120
125
  });
121
126
 
@@ -126,7 +131,11 @@ describe('base dropdown', () => {
126
131
  expect(computePosition).toHaveBeenCalledWith(
127
132
  findDefaultDropdownToggle().element,
128
133
  findDropdownMenu().element,
129
- { placement: 'bottom-end', middleware: [offset({ mainAxis: DEFAULT_OFFSET })] }
134
+ {
135
+ placement: 'bottom-end',
136
+ strategy: 'absolute',
137
+ middleware: [offset({ mainAxis: DEFAULT_OFFSET })],
138
+ }
130
139
  );
131
140
  });
132
141
 
@@ -143,10 +152,45 @@ describe('base dropdown', () => {
143
152
  findDropdownMenu().element,
144
153
  {
145
154
  placement: 'bottom-end',
155
+ strategy: 'absolute',
146
156
  middleware: [offset(customOffset)],
147
157
  }
148
158
  );
149
159
  });
160
+
161
+ describe('positioningStrategy', () => {
162
+ it('uses the absolute positioning strategy by default', async () => {
163
+ buildWrapper();
164
+
165
+ await findDefaultDropdownToggle().trigger('click');
166
+
167
+ expect(computePosition).toHaveBeenCalledWith(
168
+ findDefaultDropdownToggle().element,
169
+ findDropdownMenu().element,
170
+ expect.objectContaining({
171
+ strategy: 'absolute',
172
+ })
173
+ );
174
+ expect(findDropdownMenu().classes()).toContain('gl-absolute');
175
+ });
176
+
177
+ it('applies the fixed positioning strategy properly', async () => {
178
+ buildWrapper({
179
+ positioningStrategy: 'fixed',
180
+ });
181
+
182
+ await findDefaultDropdownToggle().trigger('click');
183
+
184
+ expect(computePosition).toHaveBeenCalledWith(
185
+ findDefaultDropdownToggle().element,
186
+ findDropdownMenu().element,
187
+ expect.objectContaining({
188
+ strategy: 'fixed',
189
+ })
190
+ );
191
+ expect(findDropdownMenu().classes()).toContain('gl-fixed');
192
+ });
193
+ });
150
194
  });
151
195
  });
152
196
 
@@ -291,6 +335,11 @@ describe('base dropdown', () => {
291
335
  });
292
336
 
293
337
  describe('toggle visibility', () => {
338
+ beforeEach(() => {
339
+ autoUpdate.mockImplementation(jest.requireActual('@floating-ui/dom').autoUpdate);
340
+ computePosition.mockImplementation(() => Promise.resolve);
341
+ });
342
+
294
343
  it('should toggle menu visibility on toggle click', async () => {
295
344
  const toggle = findCustomDropdownToggle();
296
345
  const firstToggleChild = findFirstToggleElement();
@@ -15,6 +15,8 @@ import {
15
15
  SPACE,
16
16
  ARROW_DOWN,
17
17
  GL_DROPDOWN_CONTENTS_CLASS,
18
+ POSITION_ABSOLUTE,
19
+ POSITION_FIXED,
18
20
  } from '../constants';
19
21
  import { logWarning, isElementTabbable, isElementFocusable } from '../../../../utils/utils';
20
22
 
@@ -133,11 +135,21 @@ export default {
133
135
  required: false,
134
136
  default: false,
135
137
  },
138
+ /**
139
+ * Strategy to be applied by computePosition. If this is set to fixed, the dropdown's position
140
+ * needs to be set to fixed in CSS as well.
141
+ * https://floating-ui.com/docs/computePosition#strategy
142
+ */
143
+ positioningStrategy: {
144
+ type: String,
145
+ required: false,
146
+ default: POSITION_ABSOLUTE,
147
+ validator: (strategy) => [POSITION_ABSOLUTE, POSITION_FIXED].includes(strategy),
148
+ },
136
149
  },
137
150
  data() {
138
151
  return {
139
152
  visible: false,
140
- openedYet: false,
141
153
  baseDropdownId: uniqueId('base-dropdown-'),
142
154
  };
143
155
  },
@@ -209,11 +221,17 @@ export default {
209
221
  return {
210
222
  'gl-display-block!': this.visible,
211
223
  [FIXED_WIDTH_CLASS]: !this.fluidWidth,
224
+ 'gl-fixed': this.isFixed,
225
+ 'gl-absolute': !this.isFixed,
212
226
  };
213
227
  },
228
+ isFixed() {
229
+ return this.positioningStrategy === POSITION_FIXED;
230
+ },
214
231
  floatingUIConfig() {
215
232
  return {
216
233
  placement: dropdownPlacements[this.placement],
234
+ strategy: this.positioningStrategy,
217
235
  middleware: [
218
236
  offset(this.offset),
219
237
  flip(),
@@ -263,7 +281,7 @@ export default {
263
281
  );
264
282
  }
265
283
  },
266
- startFloating() {
284
+ async startFloating() {
267
285
  this.calculateNonScrollableAreaHeight();
268
286
  this.observer = new MutationObserver(this.calculateNonScrollableAreaHeight);
269
287
  this.observer.observe(this.$refs.content, {
@@ -272,24 +290,29 @@ export default {
272
290
  subtree: true,
273
291
  });
274
292
 
275
- this.stopAutoUpdate = autoUpdate(this.toggleElement, this.$refs.content, async () => {
276
- const { x, y } = await computePosition(
277
- this.toggleElement,
278
- this.$refs.content,
279
- this.floatingUIConfig
280
- );
293
+ await new Promise((resolve) => {
294
+ const stopAutoUpdate = autoUpdate(this.toggleElement, this.$refs.content, async () => {
295
+ const { x, y } = await computePosition(
296
+ this.toggleElement,
297
+ this.$refs.content,
298
+ this.floatingUIConfig
299
+ );
281
300
 
282
- /**
283
- * Due to the asynchronous nature of computePosition, it's technically possible for the
284
- * component to have been destroyed by the time the promise resolves. In such case, we exit
285
- * early to prevent a TypeError.
286
- */
287
- if (!this.$refs.content) return;
301
+ /**
302
+ * Due to the asynchronous nature of computePosition, it's technically possible for the
303
+ * component to have been destroyed by the time the promise resolves. In such case, we exit
304
+ * early to prevent a TypeError.
305
+ */
306
+ if (!this.$refs.content) return;
307
+
308
+ Object.assign(this.$refs.content.style, {
309
+ left: `${x}px`,
310
+ top: `${y}px`,
311
+ });
288
312
 
289
- Object.assign(this.$refs.content.style, {
290
- left: `${x}px`,
291
- top: `${y}px`,
313
+ resolve(stopAutoUpdate);
292
314
  });
315
+ this.stopAutoUpdate = stopAutoUpdate;
293
316
  });
294
317
  },
295
318
  stopFloating() {
@@ -300,12 +323,16 @@ export default {
300
323
  this.visible = !this.visible;
301
324
 
302
325
  if (this.visible) {
326
+ // The dropdown needs to be actually visible before we compute its position with Floating UI.
327
+ await this.$nextTick();
328
+
303
329
  /**
304
- * We defer the following logic to the next tick as all that comes next relies on the
305
- * dropdown actually being visible.
330
+ * We wait until the dropdown's position has been computed before emitting the `shown` event.
331
+ * This ensures that, if the parent component attempts to focus an inner element, the dropdown
332
+ * is already properly placed in the page. Otherwise, the page would scroll back to the top.
306
333
  */
307
- await this.$nextTick();
308
- this.startFloating();
334
+ await this.startFloating();
335
+
309
336
  this.$emit(GL_DROPDOWN_SHOWN);
310
337
  } else {
311
338
  this.stopFloating();
@@ -11,4 +11,9 @@ export const ENTER = 'Enter';
11
11
  export const HOME = 'Home';
12
12
  export const SPACE = 'Space';
13
13
 
14
+ // Positioning strategies
15
+ // https://floating-ui.com/docs/computePosition#strategy
16
+ export const POSITION_ABSOLUTE = 'absolute';
17
+ export const POSITION_FIXED = 'fixed';
18
+
14
19
  export const GL_DROPDOWN_CONTENTS_CLASS = 'gl-new-dropdown-contents';
@@ -10,6 +10,8 @@ import {
10
10
  ARROW_UP,
11
11
  HOME,
12
12
  END,
13
+ POSITION_ABSOLUTE,
14
+ POSITION_FIXED,
13
15
  } from '../constants';
14
16
  import GlDisclosureDropdown from './disclosure_dropdown.vue';
15
17
  import GlDisclosureDropdownItem from './disclosure_dropdown_item.vue';
@@ -345,4 +347,15 @@ describe('GlDisclosureDropdown', () => {
345
347
  expect(closeSpy).not.toHaveBeenCalled();
346
348
  });
347
349
  });
350
+
351
+ describe('positioningStrategy', () => {
352
+ it.each([POSITION_ABSOLUTE, POSITION_FIXED])(
353
+ 'passes the %s positioning strategy to the base dropdown',
354
+ (positioningStrategy) => {
355
+ buildWrapper({ positioningStrategy });
356
+
357
+ expect(findBaseDropdown().props('positioningStrategy')).toBe(positioningStrategy);
358
+ }
359
+ );
360
+ });
348
361
  });
@@ -41,6 +41,7 @@ const makeBindings = (overrides = {}) =>
41
41
  ':list-aria-labelled-by': 'listAriaLabelledBy',
42
42
  ':fluid-width': 'fluidWidth',
43
43
  ':auto-close': 'autoClose',
44
+ ':positioning-strategy': 'positioningStrategy',
44
45
  ...overrides,
45
46
  })
46
47
  .map(([key, value]) => `${key}="${value}"`)
@@ -14,6 +14,8 @@ import {
14
14
  ARROW_DOWN,
15
15
  ARROW_UP,
16
16
  GL_DROPDOWN_CONTENTS_CLASS,
17
+ POSITION_ABSOLUTE,
18
+ POSITION_FIXED,
17
19
  } from '../constants';
18
20
  import {
19
21
  buttonCategoryOptions,
@@ -196,6 +198,17 @@ export default {
196
198
  required: false,
197
199
  default: true,
198
200
  },
201
+ /**
202
+ * Strategy to be applied by computePosition. If the dropdown's container is too short for it to
203
+ * fit in, setting this to fixed will let it position itself above its container.
204
+ * https://floating-ui.com/docs/computePosition#strategy
205
+ */
206
+ positioningStrategy: {
207
+ type: String,
208
+ required: false,
209
+ default: POSITION_ABSOLUTE,
210
+ validator: (strategy) => [POSITION_ABSOLUTE, POSITION_FIXED].includes(strategy),
211
+ },
199
212
  },
200
213
  data() {
201
214
  return {
@@ -323,6 +336,7 @@ export default {
323
336
  :placement="placement"
324
337
  :offset="dropdownOffset"
325
338
  :fluid-width="fluidWidth"
339
+ :positioning-strategy="positioningStrategy"
326
340
  class="gl-disclosure-dropdown"
327
341
  @[$options.events.GL_DROPDOWN_SHOWN]="onShow"
328
342
  @[$options.events.GL_DROPDOWN_HIDDEN]="onHide"
@@ -20,7 +20,6 @@
20
20
  @include gl-border-gray-200;
21
21
  @include gl-rounded-lg;
22
22
  @include gl-shadow-md;
23
- position: absolute;
24
23
  top: 0;
25
24
  left: 0;
26
25
  min-width: $gl-new-dropdown-min-width;
@@ -11,12 +11,14 @@ import {
11
11
  HOME,
12
12
  END,
13
13
  ENTER,
14
+ POSITION_ABSOLUTE,
15
+ POSITION_FIXED,
14
16
  } from '../constants';
15
17
  import GlIntersectionObserver from '../../../utilities/intersection_observer/intersection_observer.vue';
16
18
  import GlCollapsibleListbox, { ITEM_SELECTOR } from './listbox.vue';
17
19
  import GlListboxItem from './listbox_item.vue';
18
20
  import GlListboxGroup from './listbox_group.vue';
19
- import { mockOptions, mockGroups } from './mock_data';
21
+ import { mockOptions, mockGroups, mockGroupsWithTextSrOnly } from './mock_data';
20
22
 
21
23
  jest.mock('@floating-ui/dom');
22
24
  autoUpdate.mockImplementation(() => {
@@ -396,14 +398,13 @@ describe('GlCollapsibleListbox', () => {
396
398
  });
397
399
 
398
400
  it('passes the `textSrOnly` prop', () => {
399
- const mockGroupsWithTextSrOnly = JSON.parse(JSON.stringify(mockGroups));
400
- mockGroupsWithTextSrOnly[0].textSrOnly = true;
401
- mockGroupsWithTextSrOnly[1].textSrOnly = false;
402
401
  buildWrapper({ items: mockGroupsWithTextSrOnly });
403
402
 
404
403
  const groups = findListboxGroups();
405
404
 
406
- const expectedTextSrOnlyProps = mockGroupsWithTextSrOnly.map((group) => group.textSrOnly);
405
+ const expectedTextSrOnlyProps = mockGroupsWithTextSrOnly.map(
406
+ (group) => group.textSrOnly ?? false
407
+ );
407
408
  const actualTextSrOnlyProps = groups.wrappers.map((group) => group.props('textSrOnly'));
408
409
 
409
410
  expect(actualTextSrOnlyProps).toEqual(expectedTextSrOnlyProps);
@@ -744,4 +745,15 @@ describe('GlCollapsibleListbox', () => {
744
745
  expect(findBaseDropdown().props('fluidWidth')).toBe(true);
745
746
  });
746
747
  });
748
+
749
+ describe('positioningStrategy', () => {
750
+ it.each([POSITION_ABSOLUTE, POSITION_FIXED])(
751
+ 'passes the %s positioning strategy to the base dropdown',
752
+ (positioningStrategy) => {
753
+ buildWrapper({ positioningStrategy });
754
+
755
+ expect(findBaseDropdown().props('positioningStrategy')).toBe(positioningStrategy);
756
+ }
757
+ );
758
+ });
747
759
  });
@@ -24,7 +24,7 @@ import {
24
24
  } from '../../../../utils/stories_constants';
25
25
  import { POSITION } from '../../../utilities/truncate/constants';
26
26
  import readme from './listbox.md';
27
- import { mockOptions, mockGroups, mockUsers } from './mock_data';
27
+ import { mockOptions, mockGroups, mockGroupsWithTextSrOnly, mockUsers } from './mock_data';
28
28
  import { flattenedOptions } from './utils';
29
29
  import GlCollapsibleListbox from './listbox.vue';
30
30
 
@@ -59,6 +59,7 @@ const generateProps = ({
59
59
  showSelectAllButtonLabel = defaultValue('showSelectAllButtonLabel'),
60
60
  startOpened = true,
61
61
  fluidWidth,
62
+ positioningStrategy,
62
63
  } = {}) => ({
63
64
  items,
64
65
  category,
@@ -88,6 +89,7 @@ const generateProps = ({
88
89
  showSelectAllButtonLabel,
89
90
  startOpened,
90
91
  fluidWidth,
92
+ positioningStrategy,
91
93
  });
92
94
 
93
95
  const makeBindings = (overrides = {}) =>
@@ -119,6 +121,7 @@ const makeBindings = (overrides = {}) =>
119
121
  ':reset-button-label': 'resetButtonLabel',
120
122
  ':show-select-all-button-label': 'showSelectAllButtonLabel',
121
123
  ':fluid-width': 'fluidWidth',
124
+ ':positioning-strategy': 'positioningStrategy',
122
125
  ...overrides,
123
126
  })
124
127
  .map(([key, value]) => `${key}="${value}"`)
@@ -459,6 +462,33 @@ export const CustomGroupsAndItems = makeGroupedExample({
459
462
  `),
460
463
  });
461
464
 
465
+ export const GroupWithoutLabel = (args, { argTypes }) => ({
466
+ props: Object.keys(argTypes),
467
+ components: {
468
+ GlBadge,
469
+ GlCollapsibleListbox,
470
+ },
471
+ data() {
472
+ return {
473
+ selected: mockGroupsWithTextSrOnly[1].options[1].value,
474
+ };
475
+ },
476
+ mounted() {
477
+ if (this.startOpened) {
478
+ openListbox(this);
479
+ }
480
+ },
481
+ template: template(`
482
+ <template #list-item="{ item }">
483
+ {{ item.text }} <gl-badge v-if="item.value === 'main'" size="sm">default</gl-badge>
484
+ </template>
485
+ `),
486
+ });
487
+ GroupWithoutLabel.args = generateProps({
488
+ items: mockGroupsWithTextSrOnly,
489
+ headerText: 'Select branch',
490
+ });
491
+
462
492
  export default {
463
493
  title: 'base/new-dropdowns/listbox',
464
494
  component: GlCollapsibleListbox,
@@ -12,6 +12,8 @@ import {
12
12
  ARROW_DOWN,
13
13
  ARROW_UP,
14
14
  GL_DROPDOWN_CONTENTS_CLASS,
15
+ POSITION_ABSOLUTE,
16
+ POSITION_FIXED,
15
17
  } from '../constants';
16
18
  import {
17
19
  buttonCategoryOptions,
@@ -318,6 +320,17 @@ export default {
318
320
  required: false,
319
321
  default: false,
320
322
  },
323
+ /**
324
+ * Strategy to be applied by computePosition. If the dropdown's container is too short for it to
325
+ * fit in, setting this to fixed will let it position itself above its container.
326
+ * https://floating-ui.com/docs/computePosition#strategy
327
+ */
328
+ positioningStrategy: {
329
+ type: String,
330
+ required: false,
331
+ default: POSITION_ABSOLUTE,
332
+ validator: (strategy) => [POSITION_ABSOLUTE, POSITION_FIXED].includes(strategy),
333
+ },
321
334
  },
322
335
  data() {
323
336
  return {
@@ -709,6 +722,7 @@ export default {
709
722
  :placement="placement"
710
723
  :offset="dropdownOffset"
711
724
  :fluid-width="fluidWidth"
725
+ :positioning-strategy="positioningStrategy"
712
726
  @[$options.events.GL_DROPDOWN_SHOWN]="onShow"
713
727
  @[$options.events.GL_DROPDOWN_HIDDEN]="onHide"
714
728
  >
@@ -67,6 +67,49 @@ export const mockGroups = [
67
67
  },
68
68
  ];
69
69
 
70
+ export const mockGroupsWithTextSrOnly = [
71
+ {
72
+ text: 'Default',
73
+ options: [
74
+ {
75
+ text: 'main',
76
+ value: 'main',
77
+ },
78
+ {
79
+ text: 'development',
80
+ value: 'development',
81
+ },
82
+ ],
83
+ textSrOnly: true,
84
+ },
85
+ {
86
+ text: 'Feature branches',
87
+ options: [
88
+ {
89
+ text: 'feature/add-avatar',
90
+ value: 'add',
91
+ },
92
+ {
93
+ text: 'feature/improve-panel',
94
+ value: 'improve',
95
+ },
96
+ ],
97
+ },
98
+ {
99
+ text: 'Bugfix branches',
100
+ options: [
101
+ {
102
+ text: 'fix/border-of-avatar',
103
+ value: 'fix-border',
104
+ },
105
+ {
106
+ text: 'fix/radius-panel',
107
+ value: 'fix-radius',
108
+ },
109
+ ],
110
+ },
111
+ ];
112
+
70
113
  export const mockUsers = [
71
114
  {
72
115
  value: 'mikegreiling',