@gitlab/ui 56.0.0 → 56.1.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.
@@ -83,6 +83,18 @@ function isElementFocusable(elt) {
83
83
  return isValidTag && hasValidType && !isDisabled && hasValidZIndex && !isInvalidAnchorTag;
84
84
  }
85
85
 
86
+ /**
87
+ * Receives an element and validates that it is reachable via sequential keyboard navigation
88
+ * @param { HTMLElement } The element to validate
89
+ * @return { boolean } Is the element focusable in a sequential tab order
90
+ */
91
+
92
+ function isElementTabbable(el) {
93
+ if (!el) return false;
94
+ const tabindex = parseInt(el.getAttribute('tabindex'), 10);
95
+ return tabindex > -1;
96
+ }
97
+
86
98
  /**
87
99
  * Receives an array of HTML elements and focus the first one possible
88
100
  * @param { Array.<HTMLElement> } An array of element to potentially focus
@@ -142,4 +154,4 @@ function filterVisible(els) {
142
154
  return (els || []).filter(isVisible);
143
155
  }
144
156
 
145
- export { colorFromBackground, debounceByAnimationFrame, filterVisible, focusFirstFocusableElement, hexToRgba, isDev, isElementFocusable, logWarning, rgbFromHex, rgbFromString, stopEvent, throttle, uid };
157
+ export { colorFromBackground, debounceByAnimationFrame, filterVisible, focusFirstFocusableElement, hexToRgba, isDev, isElementFocusable, isElementTabbable, logWarning, rgbFromHex, rgbFromString, stopEvent, throttle, uid };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "56.0.0",
3
+ "version": "56.1.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -62,7 +62,7 @@
62
62
  "dependencies": {
63
63
  "@popperjs/core": "^2.11.2",
64
64
  "bootstrap-vue": "2.20.1",
65
- "dompurify": "^2.4.3",
65
+ "dompurify": "^2.4.4",
66
66
  "echarts": "^5.3.2",
67
67
  "iframe-resizer": "^4.3.2",
68
68
  "lodash": "^4.17.20",
@@ -115,7 +115,7 @@
115
115
  "bootstrap-vue-vue3": "npm:bootstrap-vue@2.23.1",
116
116
  "cypress": "^11.2.0",
117
117
  "emoji-regex": "^10.0.0",
118
- "eslint": "8.32.0",
118
+ "eslint": "8.34.0",
119
119
  "eslint-import-resolver-jest": "3.0.2",
120
120
  "eslint-plugin-cypress": "2.12.1",
121
121
  "eslint-plugin-storybook": "0.6.10",
@@ -198,7 +198,10 @@ describe('base dropdown', () => {
198
198
  });
199
199
 
200
200
  describe('Custom toggle', () => {
201
- const toggleContent = '<div>Custom toggle</div>';
201
+ const customToggleTestId = 'custom-toggle';
202
+ const toggleContent = `<button data-testid="${customToggleTestId}">Custom toggle</button>`;
203
+ const findFirstToggleElement = () =>
204
+ findCustomDropdownToggle().find(`[data-testid="${customToggleTestId}"]`);
202
205
 
203
206
  beforeEach(() => {
204
207
  const slots = { toggle: toggleContent };
@@ -220,23 +223,25 @@ describe('base dropdown', () => {
220
223
  describe('toggle visibility', () => {
221
224
  it('should toggle menu visibility on toggle button ENTER', async () => {
222
225
  const toggle = findCustomDropdownToggle();
226
+ const firstToggleChild = findFirstToggleElement();
223
227
  const menu = findDropdownMenu();
224
228
  // open menu clicking toggle btn
225
229
  await toggle.trigger('keydown', { code: ENTER });
226
230
  expect(menu.classes('gl-display-block!')).toBe(true);
227
- expect(toggle.attributes('aria-expanded')).toBe('true');
231
+ expect(firstToggleChild.attributes('aria-expanded')).toBe('true');
228
232
  await nextTick();
229
233
  expect(wrapper.emitted(GL_DROPDOWN_SHOWN).length).toBe(1);
230
234
 
231
235
  // close menu clicking toggle btn again
232
236
  await toggle.trigger('keydown', { code: ENTER });
233
237
  expect(menu.classes('gl-display-block!')).toBe(false);
234
- expect(toggle.attributes('aria-expanded')).toBeUndefined();
238
+ expect(firstToggleChild.attributes('aria-expanded')).toBe('false');
235
239
  expect(wrapper.emitted(GL_DROPDOWN_HIDDEN).length).toBe(1);
236
240
  });
237
241
 
238
- it('should close the menu when Escape is pressed inside menu and focus toggle', async () => {
242
+ it('should close the menu when Escape is pressed inside menu and focus first child in the toggle', async () => {
239
243
  const toggle = findCustomDropdownToggle();
244
+ const firstToggleChild = findFirstToggleElement();
240
245
  const menu = findDropdownMenu();
241
246
  // open menu clicking toggle btn
242
247
  await toggle.trigger('click');
@@ -245,9 +250,9 @@ describe('base dropdown', () => {
245
250
  // close menu pressing ESC on it
246
251
  await menu.trigger('keydown.esc');
247
252
  expect(menu.classes('gl-display-block!')).toBe(false);
248
- expect(toggle.attributes('aria-expanded')).toBeUndefined();
253
+ expect(firstToggleChild.attributes('aria-expanded')).toBe('false');
249
254
  expect(wrapper.emitted(GL_DROPDOWN_HIDDEN).length).toBe(1);
250
- expect(toggle.element).toHaveFocus();
255
+ expect(toggle.find(`[data-testid="${customToggleTestId}"]`).element).toHaveFocus();
251
256
  });
252
257
  });
253
258
  });
@@ -8,6 +8,7 @@ import {
8
8
  dropdownVariantOptions,
9
9
  } from '../../../../utils/constants';
10
10
  import { POPPER_CONFIG, GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, ENTER, SPACE } from '../constants';
11
+ import { logWarning, isElementTabbable, isElementFocusable } from '../../../../utils/utils';
11
12
 
12
13
  import GlButton from '../../button/button.vue';
13
14
  import GlIcon from '../../icon/icon.vue';
@@ -119,6 +120,14 @@ export default {
119
120
  isIconOnly() {
120
121
  return Boolean(this.icon && (!this.toggleText?.length || this.textSrOnly));
121
122
  },
123
+ ariaAttributes() {
124
+ return {
125
+ 'aria-haspopup': this.ariaHaspopup,
126
+ 'aria-expanded': this.visible,
127
+ 'aria-controls': this.baseDropdownId,
128
+ 'aria-labelledby': this.toggleLabelledBy,
129
+ };
130
+ },
122
131
  toggleButtonClasses() {
123
132
  return [
124
133
  this.toggleClass,
@@ -151,6 +160,7 @@ export default {
151
160
  disabled: this.disabled,
152
161
  loading: this.loading,
153
162
  class: this.toggleButtonClasses,
163
+ ...this.ariaAttributes,
154
164
  listeners: {
155
165
  click: () => this.toggle(),
156
166
  },
@@ -159,9 +169,7 @@ export default {
159
169
 
160
170
  return {
161
171
  is: 'div',
162
- role: 'button',
163
172
  class: 'gl-new-dropdown-custom-toggle',
164
- tabindex: '0',
165
173
  listeners: {
166
174
  keydown: (event) => this.onKeydown(event),
167
175
  click: () => this.toggle(),
@@ -169,7 +177,7 @@ export default {
169
177
  };
170
178
  },
171
179
  toggleElement() {
172
- return this.$refs.toggle.$el || this.$refs.toggle;
180
+ return this.$refs.toggle.$el || this.$refs.toggle?.firstElementChild;
173
181
  },
174
182
  popperConfig() {
175
183
  return {
@@ -178,15 +186,36 @@ export default {
178
186
  };
179
187
  },
180
188
  },
189
+ watch: {
190
+ ariaAttributes: {
191
+ deep: true,
192
+ handler(ariaAttributes) {
193
+ if (this.$scopedSlots.toggle) {
194
+ Object.keys(ariaAttributes).forEach((key) => {
195
+ this.toggleElement.setAttribute(key, ariaAttributes[key]);
196
+ });
197
+ }
198
+ },
199
+ },
200
+ },
181
201
  mounted() {
182
202
  this.$nextTick(() => {
183
203
  this.popper = createPopper(this.toggleElement, this.$refs.content, this.popperConfig);
184
204
  });
205
+ this.checkToggleFocusable();
185
206
  },
186
207
  beforeDestroy() {
187
208
  this.popper.destroy();
188
209
  },
189
210
  methods: {
211
+ checkToggleFocusable() {
212
+ if (!isElementFocusable(this.toggleElement) && !isElementTabbable(this.toggleElement)) {
213
+ logWarning(
214
+ `GlDisclosureDropdown/GlCollapsibleListbox: Toggle is missing a 'tabindex' and cannot be focused.
215
+ Use 'a' or 'button' element instead or make sure to add 'role="button"' along with 'tabindex' otherwise.`
216
+ );
217
+ }
218
+ },
190
219
  async toggle() {
191
220
  this.visible = !this.visible;
192
221
 
@@ -249,10 +278,6 @@ export default {
249
278
  :id="toggleId"
250
279
  ref="toggle"
251
280
  data-testid="base-dropdown-toggle"
252
- :aria-haspopup="ariaHaspopup"
253
- :aria-expanded="visible"
254
- :aria-labelledby="toggleLabelledBy"
255
- :aria-controls="baseDropdownId"
256
281
  v-on="toggleOptions.listeners"
257
282
  >
258
283
  <!-- @slot Custom toggle button content -->
@@ -209,10 +209,12 @@ export const CustomGroupsItemsAndToggle = makeGroupedExample({
209
209
  template: template(
210
210
  `
211
211
  <template #toggle>
212
+ <button class="gl-rounded-base gl-border-none gl-p-2 gl-bg-gray-50 ">
212
213
  <span class="gl-sr-only">
213
214
  Orange Fox user's menu
214
215
  </span>
215
- <gl-avatar :size="32" entity-name="Orange Fox" aria-hidden="true"></gl-avatar>
216
+ <gl-avatar :size="32" entity-name="Orange Fox" aria-hidden="true"/>
217
+ </button>
216
218
  </template>
217
219
  <gl-disclosure-dropdown-group>
218
220
  <gl-disclosure-dropdown-item>
@@ -3,10 +3,12 @@
3
3
  @include gl-vertical-align-middle;
4
4
 
5
5
  .gl-new-dropdown-custom-toggle {
6
- @include gl-cursor-pointer;
6
+ *:first-child {
7
+ @include gl-cursor-pointer;
7
8
 
8
- &:focus {
9
- @include gl-focus;
9
+ &:focus {
10
+ @include gl-focus;
11
+ }
10
12
  }
11
13
  }
12
14
 
@@ -289,7 +289,12 @@ export const CustomToggle = (args, { argTypes }) => ({
289
289
  template: template(
290
290
  `
291
291
  <template #toggle>
292
- <gl-avatar :size="32" :entity-name="selected"></gl-avatar>
292
+ <button class="gl-rounded-base gl-border-none gl-p-2 gl-bg-gray-50 ">
293
+ <span class="gl-sr-only">
294
+ {{selected}}
295
+ </span>
296
+ <gl-avatar :size="32" :entity-name="selected" aria-hidden="true"/>
297
+ </button>
293
298
  </template>
294
299
  <template #list-item="{ item }">
295
300
  <span class="gl-display-flex gl-align-items-center">
@@ -1,13 +1,13 @@
1
1
  ### ECharts Wrapper
2
2
 
3
- The chart component is a Vue component wrapper around
4
- [Apache ECharts](https://echarts.apache.org/en/api.html#echarts). The chart component accepts width
5
- and height props in order to allow the user to make it responsive, but it is not responsive
6
- by default.
3
+ The chart component is a Vue component wrapper around [Apache ECharts](https://echarts.apache.org/en/api.html#echarts).
4
+ The chart component accepts width and height props in order to allow the user to make it responsive,
5
+ but it is not responsive by default.
7
6
 
8
- > Note: In every case there should be a specific component for each type of chart
9
- (i.e. Line, Area, Bar, etc.). This component should only need to be used by chart type components
10
- within GitLab UI as opposed to being used directly within any other codebase.
7
+ > Note: When implementing a chart type that does not already have a GitLab UI component, you can use
8
+ > this component alonside the [ECharts options](https://echarts.apache.org/en/api.html#echarts) to
9
+ > build your chart. Each type of chart should still follow the general guidelines in the
10
+ > [pajamas documentation](https://design.gitlab.com/data-visualization/charts).
11
11
 
12
12
  ### EChart Lifecycle
13
13
 
@@ -92,6 +92,19 @@ export function isElementFocusable(elt) {
92
92
  return isValidTag && hasValidType && !isDisabled && hasValidZIndex && !isInvalidAnchorTag;
93
93
  }
94
94
 
95
+ /**
96
+ * Receives an element and validates that it is reachable via sequential keyboard navigation
97
+ * @param { HTMLElement } The element to validate
98
+ * @return { boolean } Is the element focusable in a sequential tab order
99
+ */
100
+
101
+ export function isElementTabbable(el) {
102
+ if (!el) return false;
103
+
104
+ const tabindex = parseInt(el.getAttribute('tabindex'), 10);
105
+ return tabindex > -1;
106
+ }
107
+
95
108
  /**
96
109
  * Receives an array of HTML elements and focus the first one possible
97
110
  * @param { Array.<HTMLElement> } An array of element to potentially focus
@@ -1,4 +1,9 @@
1
- import { isElementFocusable, focusFirstFocusableElement, stopEvent } from './utils';
1
+ import {
2
+ isElementFocusable,
3
+ isElementTabbable,
4
+ focusFirstFocusableElement,
5
+ stopEvent,
6
+ } from './utils';
2
7
 
3
8
  describe('isElementFocusable', () => {
4
9
  const myBtn = () => document.querySelector('button');
@@ -53,6 +58,38 @@ describe('isElementFocusable', () => {
53
58
  });
54
59
  });
55
60
 
61
+ describe('isElementTabbable', () => {
62
+ const myDiv = () => document.querySelector('div');
63
+
64
+ beforeEach(() => {
65
+ document.body.innerHTML = '';
66
+ });
67
+
68
+ it('should return false for a div without tabindex', () => {
69
+ document.body.innerHTML = '<div> Fake button </div>';
70
+
71
+ expect(isElementTabbable(myDiv())).toBe(false);
72
+ });
73
+
74
+ it('should return false for a div with a tabindex less than 0', () => {
75
+ document.body.innerHTML = '<div tabindex="-1"> Fake button </div>';
76
+
77
+ expect(isElementTabbable(myDiv())).toBe(false);
78
+ });
79
+
80
+ it('should return true for a div with a tabindex equal to 0', () => {
81
+ document.body.innerHTML = '<div tabindex="0"> Fake button </div>';
82
+
83
+ expect(isElementTabbable(myDiv())).toBe(true);
84
+ });
85
+
86
+ it('should return true for a div with a tabindex greater than 0', () => {
87
+ document.body.innerHTML = '<div tabindex="0"> Fake button </div>';
88
+
89
+ expect(isElementTabbable(myDiv())).toBe(true);
90
+ });
91
+ });
92
+
56
93
  describe('focusFirstFocusableElement', () => {
57
94
  const myBtn = () => document.querySelector('button');
58
95
  const myInput = () => document.querySelector('input');