@gitlab/ui 62.9.3 → 62.11.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": "62.9.3",
3
+ "version": "62.11.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -89,7 +89,7 @@
89
89
  "@gitlab/eslint-plugin": "19.0.0",
90
90
  "@gitlab/fonts": "^1.2.0",
91
91
  "@gitlab/stylelint-config": "4.1.0",
92
- "@gitlab/svgs": "3.44.0",
92
+ "@gitlab/svgs": "3.45.0",
93
93
  "@rollup/plugin-commonjs": "^11.1.0",
94
94
  "@rollup/plugin-node-resolve": "^7.1.3",
95
95
  "@rollup/plugin-replace": "^2.3.2",
@@ -36,6 +36,7 @@ The disclosure dropdown is closed by any of the following:
36
36
 
37
37
  - pressing <kbd>Esc</kbd>
38
38
  - clicking anywhere outside the component
39
+ - clicking the action or link inside the dropdown
39
40
 
40
41
  After closing, `GlDisclosureDropdown` emits a `hidden` event.
41
42
 
@@ -323,4 +323,20 @@ describe('GlDisclosureDropdown', () => {
323
323
  expect(findBaseDropdown().props('fluidWidth')).toBe(true);
324
324
  });
325
325
  });
326
+
327
+ describe('auto closing', () => {
328
+ it('closes the dropdown when `autoClose` is set on item click', () => {
329
+ buildWrapper({ items: mockItems });
330
+ const closeSpy = jest.spyOn(wrapper.vm.$refs.baseDropdown, 'closeAndFocus');
331
+ findListItem(0).trigger('click');
332
+ expect(closeSpy).toHaveBeenCalled();
333
+ });
334
+
335
+ it('does not close the dropdown on item click when `autoClose` is set to `false`', () => {
336
+ buildWrapper({ items: mockItems, autoClose: false });
337
+ const closeSpy = jest.spyOn(wrapper.vm.$refs.baseDropdown, 'closeAndFocus');
338
+ findListItem(0).trigger('click');
339
+ expect(closeSpy).not.toHaveBeenCalled();
340
+ });
341
+ });
326
342
  });
@@ -40,6 +40,7 @@ const makeBindings = (overrides = {}) =>
40
40
  ':toggle-aria-labelled-by': 'toggleAriaLabelledBy',
41
41
  ':list-aria-labelled-by': 'listAriaLabelledBy',
42
42
  ':fluid-width': 'fluidWidth',
43
+ ':auto-close': 'autoClose',
43
44
  ...overrides,
44
45
  })
45
46
  .map(([key, value]) => `${key}="${value}"`)
@@ -216,7 +217,7 @@ export const CustomGroupsItemsAndToggle = makeGroupedExample({
216
217
  </button>
217
218
  </template>
218
219
  <gl-disclosure-dropdown-group>
219
- <gl-disclosure-dropdown-item>
220
+ <gl-disclosure-dropdown-item @action="closeDropdown">
220
221
  <template #list-item>
221
222
  <span class="gl-display-flex gl-flex-direction-column">
222
223
  <span class="gl-font-weight-bold gl-white-space-nowrap">Orange Fox</span>
@@ -225,7 +226,7 @@ export const CustomGroupsItemsAndToggle = makeGroupedExample({
225
226
  </template>
226
227
  </gl-disclosure-dropdown-item>
227
228
  </gl-disclosure-dropdown-group>
228
- <gl-disclosure-dropdown-group bordered :group="$options.groups[0]">
229
+ <gl-disclosure-dropdown-group bordered :group="$options.groups[0]" @action="closeDropdown">
229
230
  <template #list-item="{ item }">
230
231
  <span class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
231
232
  {{item.text}}
@@ -249,7 +250,7 @@ export const CustomGroupsItemsAndToggle = makeGroupedExample({
249
250
  <template #list-item>Provide feedback</template>
250
251
  </gl-disclosure-dropdown-item>
251
252
  </gl-disclosure-dropdown-group>
252
- <gl-disclosure-dropdown-group bordered :group="$options.groups[1]"/>
253
+ <gl-disclosure-dropdown-group bordered :group="$options.groups[1]" @action="closeDropdown"/>
253
254
  `,
254
255
  {
255
256
  after: `
@@ -266,11 +267,19 @@ export const CustomGroupsItemsAndToggle = makeGroupedExample({
266
267
  };
267
268
  },
268
269
  methods: {
270
+ closeDropdown() {
271
+ this.$refs.disclosure.closeAndFocus();
272
+ },
269
273
  toggleModalVisibility(value) {
270
274
  this.feedBackModalVisible = value;
275
+ this.closeDropdown();
271
276
  },
272
277
  toggleNewNavigation() {
273
278
  this.newNavigation = !this.newNavigation;
279
+ // eslint-disable-next-line no-restricted-globals
280
+ setTimeout(() => {
281
+ this.closeDropdown();
282
+ }, 500);
274
283
  },
275
284
  },
276
285
  groups: mockProfileGroups,
@@ -279,6 +288,7 @@ CustomGroupsItemsAndToggle.args = {
279
288
  icon: 'plus-square',
280
289
  toggleText: 'User profile menu',
281
290
  textSrOnly: true,
291
+ autoClose: false,
282
292
  };
283
293
  CustomGroupsItemsAndToggle.decorators = [makeContainer({ height: '400px' })];
284
294
 
@@ -7,6 +7,8 @@ import {
7
7
  GL_DROPDOWN_SHOWN,
8
8
  GL_DROPDOWN_HIDDEN,
9
9
  GL_DROPDOWN_FOCUS_CONTENT,
10
+ ENTER,
11
+ SPACE,
10
12
  HOME,
11
13
  END,
12
14
  ARROW_DOWN,
@@ -23,6 +25,8 @@ import GlDisclosureDropdownItem, { ITEM_CLASS } from './disclosure_dropdown_item
23
25
  import GlDisclosureDropdownGroup from './disclosure_dropdown_group.vue';
24
26
  import { itemsValidator, isItem, hasOnlyListItems } from './utils';
25
27
 
28
+ export const ITEM_SELECTOR = `.${ITEM_CLASS}`;
29
+
26
30
  export default {
27
31
  name: 'GlDisclosureDropdown',
28
32
  events: {
@@ -183,6 +187,14 @@ export default {
183
187
  required: false,
184
188
  default: false,
185
189
  },
190
+ /**
191
+ * Close the dropdown on item click (action)
192
+ */
193
+ autoClose: {
194
+ type: Boolean,
195
+ required: false,
196
+ default: true,
197
+ },
186
198
  },
187
199
  data() {
188
200
  return {
@@ -241,6 +253,8 @@ export default {
241
253
  this.focusNextItem(event, elements, -1);
242
254
  } else if (code === ARROW_DOWN) {
243
255
  this.focusNextItem(event, elements, 1);
256
+ } else if (code === ENTER || code === SPACE) {
257
+ this.handleAutoClose(event);
244
258
  } else {
245
259
  stop = false;
246
260
  }
@@ -250,7 +264,7 @@ export default {
250
264
  }
251
265
  },
252
266
  getFocusableListItemElements() {
253
- const items = this.$refs.content?.querySelectorAll(`.${ITEM_CLASS}`);
267
+ const items = this.$refs.content?.querySelectorAll(ITEM_SELECTOR);
254
268
  return filterVisible(Array.from(items || []));
255
269
  },
256
270
  focusNextItem(event, elements, offset) {
@@ -276,6 +290,11 @@ export default {
276
290
  */
277
291
  this.$emit('action', action);
278
292
  },
293
+ handleAutoClose(e) {
294
+ if (this.autoClose && e.target.closest(ITEM_SELECTOR)) {
295
+ this.closeAndFocus();
296
+ }
297
+ },
279
298
  uniqueItemId() {
280
299
  return uniqueId(`disclosure-item-`);
281
300
  },
@@ -324,6 +343,7 @@ export default {
324
343
  class="gl-new-dropdown-contents"
325
344
  tabindex="-1"
326
345
  @keydown="onKeydown"
346
+ @click="handleAutoClose"
327
347
  >
328
348
  <slot>
329
349
  <template v-for="(item, index) in items">
@@ -76,11 +76,10 @@ export default {
76
76
  const { code } = event;
77
77
 
78
78
  if (code === ENTER || code === SPACE) {
79
- stopEvent(event);
80
-
81
79
  if (this.isCustomContent) {
82
80
  this.action();
83
81
  } else {
82
+ stopEvent(event);
84
83
  /** Instead of simply navigating or calling the action, we want
85
84
  * the `a/button` to be the target of the event as it might have additional attributes.
86
85
  * E.g. `a` might have `target` attribute.
@@ -1,3 +1,4 @@
1
+ import { nextTick } from 'vue';
1
2
  import { shallowMount } from '@vue/test-utils';
2
3
 
3
4
  import { LEGEND_LAYOUT_INLINE, LEGEND_LAYOUT_TABLE } from '~/utils/charts/constants';
@@ -41,7 +42,7 @@ describe('area component', () => {
41
42
  it('emits `created`, with the chart instance', async () => {
42
43
  createShallowWrapper();
43
44
 
44
- await wrapper.vm.$nextTick();
45
+ await nextTick();
45
46
 
46
47
  expect(wrapper.emitted('created').length).toBe(1);
47
48
  expect(wrapper.emitted('created')[0][0]).toBe(mockChartInstance);
@@ -51,7 +52,7 @@ describe('area component', () => {
51
52
  it('are hidden by default', async () => {
52
53
  createShallowWrapper();
53
54
 
54
- await wrapper.vm.$nextTick();
55
+ await nextTick();
55
56
 
56
57
  expect(findAnnotationsTooltip().exists()).toBe(false);
57
58
  });
@@ -68,7 +69,7 @@ describe('area component', () => {
68
69
  },
69
70
  });
70
71
 
71
- await wrapper.vm.$nextTick();
72
+ await nextTick();
72
73
 
73
74
  expect(findAnnotationsTooltip().exists()).toBe(true);
74
75
  });
@@ -94,7 +95,7 @@ describe('area component', () => {
94
95
  },
95
96
  });
96
97
 
97
- await wrapper.vm.$nextTick();
98
+ await nextTick();
98
99
 
99
100
  expect(findAnnotationsTooltip().exists()).toBe(true);
100
101
  });
@@ -128,7 +129,7 @@ describe('area component', () => {
128
129
 
129
130
  wrapper.vm.onChartDataPointMouseOver(params);
130
131
 
131
- await wrapper.vm.$nextTick();
132
+ await nextTick();
132
133
 
133
134
  expect(findAnnotationsTooltip().html()).toContain(params.data.xAxis);
134
135
  expect(findAnnotationsTooltip().html()).toContain(params.data.tooltipData.content);
@@ -154,7 +155,7 @@ describe('area component', () => {
154
155
 
155
156
  wrapper.setData({ dataTooltipPosition: { left, top }, dataTooltipTitle });
156
157
 
157
- await wrapper.vm.$nextTick();
158
+ await nextTick();
158
159
 
159
160
  expect(findDataTooltip().props('left')).toBe(`${left}`);
160
161
  expect(findDataTooltip().props('top')).toBe(`${top}`);
@@ -166,7 +167,7 @@ describe('area component', () => {
166
167
  it('is inline by default', async () => {
167
168
  createShallowWrapper();
168
169
 
169
- await wrapper.vm.$nextTick();
170
+ await nextTick();
170
171
 
171
172
  expect(findLegend().props('layout')).toBe(LEGEND_LAYOUT_INLINE);
172
173
  });
@@ -178,7 +179,7 @@ describe('area component', () => {
178
179
  },
179
180
  });
180
181
 
181
- await wrapper.vm.$nextTick();
182
+ await nextTick();
182
183
 
183
184
  expect(findLegend().props('layout')).toBe(LEGEND_LAYOUT_INLINE);
184
185
  });
@@ -190,7 +191,7 @@ describe('area component', () => {
190
191
  },
191
192
  });
192
193
 
193
- await wrapper.vm.$nextTick();
194
+ await nextTick();
194
195
 
195
196
  expect(findLegend().props('layout')).toBe(LEGEND_LAYOUT_TABLE);
196
197
  });
@@ -211,7 +212,7 @@ describe('area component', () => {
211
212
  },
212
213
  });
213
214
 
214
- await wrapper.vm.$nextTick();
215
+ await nextTick();
215
216
 
216
217
  expect(findLegend().props('seriesInfo')).toEqual(expect.arrayContaining(legendSeriesInfo));
217
218
  });
@@ -12,7 +12,7 @@ jest.mock('echarts', () => ({
12
12
  registerTheme: jest.fn(),
13
13
  }));
14
14
 
15
- const seriesInfo = [
15
+ const mockSeriesInfo = [
16
16
  {
17
17
  type: 'solid',
18
18
  name: 'Example Title 1',
@@ -51,7 +51,7 @@ describe('chart legend component', () => {
51
51
  },
52
52
  ];
53
53
 
54
- const buildLegend = (propsData = {}) => {
54
+ const buildLegend = ({ propsData = {}, seriesInfo = mockSeriesInfo } = {}) => {
55
55
  legendWrapper = shallowMount(Legend, {
56
56
  propsData: {
57
57
  ...propsData,
@@ -79,13 +79,21 @@ describe('chart legend component', () => {
79
79
  });
80
80
 
81
81
  it('allows user to override max value label text using props', () => {
82
- buildLegend({ maxText: 'maxText' });
82
+ buildLegend({
83
+ propsData: {
84
+ maxText: 'maxText',
85
+ },
86
+ });
83
87
 
84
88
  expect(legendWrapper.text()).toMatch('maxText');
85
89
  });
86
90
 
87
91
  it('allows user to override average value label text using props', () => {
88
- buildLegend({ averageText: 'averageText' });
92
+ buildLegend({
93
+ propsData: {
94
+ averageText: 'averageText',
95
+ },
96
+ });
89
97
 
90
98
  expect(legendWrapper.text()).toMatch('averageText');
91
99
  });
@@ -111,6 +119,36 @@ describe('chart legend component', () => {
111
119
  legendWrapper.findComponent(GlChartSeriesLabel).trigger('click');
112
120
  expect(chart.dispatchAction).toHaveBeenCalled();
113
121
  });
122
+
123
+ it('does not dispatch a `legendToggleSelect` action when there is only one active series', () => {
124
+ buildLegend({ seriesInfo: [mockSeriesInfo[0]] });
125
+
126
+ legendWrapper.findComponent(GlChartSeriesLabel).trigger('click');
127
+
128
+ expect(chart.dispatchAction).toHaveBeenCalledTimes(0);
129
+ });
130
+
131
+ it('does not dispatch a `legendToggleSelect` action on the chart when disabled=true', () => {
132
+ const disabledLegendItem = {
133
+ type: 'solid',
134
+ name: 'Example Title 4',
135
+ color: 'red',
136
+ data: [1, 2, 3, 4, 5],
137
+ disabled: true,
138
+ };
139
+
140
+ const seriesInfo = [...mockSeriesInfo, disabledLegendItem];
141
+ const disabledLegendItemIndex = seriesInfo.length - 1;
142
+
143
+ buildLegend({ seriesInfo });
144
+
145
+ legendWrapper
146
+ .findAllComponents(GlChartSeriesLabel)
147
+ .at(disabledLegendItemIndex)
148
+ .trigger('click');
149
+
150
+ expect(chart.dispatchAction).toHaveBeenCalledTimes(0);
151
+ });
114
152
  });
115
153
 
116
154
  it('renders the inline layout by default', () => {
@@ -121,7 +159,11 @@ describe('chart legend component', () => {
121
159
 
122
160
  describe('when setting the layout prop to table', () => {
123
161
  beforeEach(() => {
124
- buildLegend({ layout: 'table' });
162
+ buildLegend({
163
+ propsData: {
164
+ layout: 'table',
165
+ },
166
+ });
125
167
  legendWrapper.vm.$nextTick();
126
168
  });
127
169
 
@@ -6,6 +6,7 @@ import {
6
6
  SERIES_NAME_LONG,
7
7
  SERIES_NAME_LONG_WITHOUT_SPACES,
8
8
  } from '../../../utils/stories_constants';
9
+ import { gray200 } from '../../../../scss_to_js/scss_variables';
9
10
 
10
11
  const generateOptions = (seriesLength, seriesNameType) => {
11
12
  return {
@@ -79,6 +80,13 @@ const baseStoryOptions = {
79
80
  },
80
81
  };
81
82
 
83
+ const disabledLegendItem = {
84
+ type: 'solid',
85
+ name: 'Disabled Item',
86
+ color: gray200,
87
+ disabled: true,
88
+ };
89
+
82
90
  const getStoryOptions = (seriesLength, seriesNameType, legendLayoutType) => {
83
91
  return {
84
92
  ...baseStoryOptions,
@@ -89,16 +97,28 @@ const getStoryOptions = (seriesLength, seriesNameType, legendLayoutType) => {
89
97
  };
90
98
 
91
99
  export const Default = () => getStoryOptions(10, SERIES_NAME_SHORT);
100
+ export const DefaultWithDisabledLegendItem = () => {
101
+ const storyOptions = getStoryOptions(10, SERIES_NAME_SHORT);
92
102
 
103
+ storyOptions.seriesInfo = [...storyOptions.seriesInfo, disabledLegendItem];
104
+
105
+ return storyOptions;
106
+ };
93
107
  export const DefaultWithLongSeriesNames = () => getStoryOptions(10, SERIES_NAME_LONG);
94
108
 
95
109
  export const DefaultWithLongSeriesNamesAndNoSpaces = () =>
96
110
  getStoryOptions(10, SERIES_NAME_LONG_WITHOUT_SPACES);
97
111
 
98
112
  export const WithTabularLayout = () => getStoryOptions(10, SERIES_NAME_SHORT, LEGEND_LAYOUT_TABLE);
113
+ export const WithTabularLayoutAndDisabledLegendItem = () => {
114
+ const storyOptions = getStoryOptions(10, SERIES_NAME_SHORT, LEGEND_LAYOUT_TABLE);
115
+
116
+ storyOptions.seriesInfo = [...storyOptions.seriesInfo, disabledLegendItem];
117
+
118
+ return storyOptions;
119
+ };
99
120
  export const WithTabularLayoutAndLongSeriesNames = () =>
100
121
  getStoryOptions(10, SERIES_NAME_LONG, LEGEND_LAYOUT_TABLE);
101
-
102
122
  export const WithTabularLayoutAndLongSeriesNamesWithNoSpaces = () =>
103
123
  getStoryOptions(10, SERIES_NAME_LONG_WITHOUT_SPACES, LEGEND_LAYOUT_TABLE);
104
124
 
@@ -82,6 +82,7 @@ export default {
82
82
  data() {
83
83
  return {
84
84
  disabledSeries: {},
85
+ lastActiveSeriesLabel: null,
85
86
  };
86
87
  },
87
88
  computed: {
@@ -91,6 +92,15 @@ export default {
91
92
  fontSize: `${this.textStyle.fontSize || defaultFontSize}px`,
92
93
  };
93
94
  },
95
+ hasOneSeriesElement() {
96
+ return this.seriesInfo.length === 1;
97
+ },
98
+ },
99
+ created() {
100
+ this.chart.on('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
101
+ },
102
+ beforeDestroy() {
103
+ this.chart.off('legendselectchanged', this.suppressLastActiveSeriesLabelToggle);
94
104
  },
95
105
  methods: {
96
106
  sanitizeSeriesData(seriesData) {
@@ -115,7 +125,9 @@ export default {
115
125
  seriesNameIsLong(seriesName) {
116
126
  return seriesName.length > 120;
117
127
  },
118
- handleClick(name, key) {
128
+ handleClick({ name, disabled }, key) {
129
+ if (this.hasOneSeriesElement || this.isToggleDisabled(name, disabled)) return;
130
+
119
131
  this.chart.dispatchAction({ type: 'legendToggleSelect', name });
120
132
  this.disabledSeries = { ...this.disabledSeries, [key]: !this.disabledSeries[key] };
121
133
  },
@@ -128,6 +140,29 @@ export default {
128
140
  getColor(color, key) {
129
141
  return this.disabledSeries[key] ? gray200 : color;
130
142
  },
143
+ suppressLastActiveSeriesLabelToggle({ selected }) {
144
+ const selectedSeriesLabels = Object.entries(selected).filter(([, isSelected]) =>
145
+ Boolean(isSelected)
146
+ );
147
+
148
+ this.lastActiveSeriesLabel = null;
149
+
150
+ if (selectedSeriesLabels.length === 1) {
151
+ const [lastActiveSeriesLabelName] = selectedSeriesLabels[0];
152
+
153
+ this.lastActiveSeriesLabel = lastActiveSeriesLabelName;
154
+ }
155
+ },
156
+ /**
157
+ * Disables toggling legend if it is the last active one or if its data series
158
+ * has a disabled property set to true
159
+ * @param {String} name Series name
160
+ * @param {Boolean} isDisabled Value of the series element's disabled property
161
+ * @returns {boolean}
162
+ */
163
+ isToggleDisabled(name, isDisabled) {
164
+ return name === this.lastActiveSeriesLabel || isDisabled;
165
+ },
131
166
  },
132
167
  legendLayoutTypes: {
133
168
  LEGEND_LAYOUT_INLINE,
@@ -137,17 +172,23 @@ export default {
137
172
  </script>
138
173
 
139
174
  <template>
140
- <div>
175
+ <div data-testid="gl-chart-legend">
141
176
  <template v-if="layout === $options.legendLayoutTypes.LEGEND_LAYOUT_INLINE">
142
177
  <div class="gl-legend-inline">
143
178
  <div
144
179
  v-for="(series, key) in seriesInfo"
145
180
  :key="key"
146
- :class="{ 'text-muted': disabledSeries[key], 'w-100': seriesNameIsLong(series.name) }"
181
+ :class="{
182
+ 'text-muted': disabledSeries[key],
183
+ 'w-100': seriesNameIsLong(series.name),
184
+ 'gl-hover-cursor-not-allowed!':
185
+ hasOneSeriesElement || isToggleDisabled(series.name, series.disabled),
186
+ }"
147
187
  class="gl-legend-inline-series"
148
188
  :style="fontStyle"
189
+ :aria-disabled="hasOneSeriesElement || isToggleDisabled(series.name, series.disabled)"
149
190
  role="button"
150
- @click="handleClick(series.name, key)"
191
+ @click="handleClick(series, key)"
151
192
  @mouseenter="handleMouseEnter(series.name)"
152
193
  @mouseleave="handleMouseLeave(series.name)"
153
194
  >
@@ -182,11 +223,16 @@ export default {
182
223
  <div
183
224
  v-for="(series, key) in seriesInfo"
184
225
  :key="key"
185
- :class="{ 'text-muted': disabledSeries[key] }"
226
+ :class="{
227
+ 'text-muted': disabledSeries[key],
228
+ 'gl-hover-cursor-not-allowed!':
229
+ hasOneSeriesElement || isToggleDisabled(series.name, series.disabled),
230
+ }"
186
231
  class="gl-legend-tabular-row"
187
232
  :style="fontStyle"
233
+ :aria-disabled="isToggleDisabled(series.name, series.disabled)"
188
234
  role="button"
189
- @click="handleClick(series.name, key)"
235
+ @click="handleClick(series, key)"
190
236
  @mouseenter="handleMouseEnter(series.name)"
191
237
  @mouseleave="handleMouseLeave(series.name)"
192
238
  >
@@ -2805,10 +2805,18 @@
2805
2805
  cursor: not-allowed
2806
2806
  }
2807
2807
 
2808
+ .gl-hover-cursor-not-allowed:hover {
2809
+ cursor: not-allowed
2810
+ }
2811
+
2808
2812
  .gl-cursor-not-allowed\! {
2809
2813
  cursor: not-allowed !important
2810
2814
  }
2811
2815
 
2816
+ .gl-hover-cursor-not-allowed\!:hover {
2817
+ cursor: not-allowed !important
2818
+ }
2819
+
2812
2820
  .gl-cursor-text {
2813
2821
  cursor: text
2814
2822
  }
@@ -19,7 +19,7 @@
19
19
  cursor: grabbing;
20
20
  }
21
21
 
22
- @mixin gl-cursor-not-allowed {
22
+ @mixin gl-cursor-not-allowed($hover: true) {
23
23
  cursor: not-allowed;
24
24
  }
25
25