@gitlab/ui 56.0.1 → 56.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.
@@ -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.1",
3
+ "version": "56.1.1",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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 -->
@@ -277,5 +277,20 @@ describe('GlDisclosureDropdown', () => {
277
277
  buildWrapper({}, { default: 'Some other content' });
278
278
  expect(findDisclosureContent().element.tagName).toBe('DIV');
279
279
  });
280
+
281
+ describe('discouraged usage', () => {
282
+ it('should render `ul` as content tag when default slot contains LI tags', () => {
283
+ const slots = {
284
+ default: `
285
+ <li>Item</li>
286
+ <li>Item</li>
287
+ <li>Item</li>
288
+ `,
289
+ };
290
+
291
+ buildWrapper({}, slots);
292
+ expect(findDisclosureContent().element.tagName).toBe('UL');
293
+ });
294
+ });
280
295
  });
281
296
  });
@@ -52,15 +52,15 @@ function openDisclosure(component) {
52
52
  });
53
53
  }
54
54
 
55
- const template = (content, { bindingOverrides = {} } = {}, after) => `
55
+ const template = (content = '', { bindingOverrides = {}, after = '' } = {}) => `
56
56
  <div>
57
57
  <gl-disclosure-dropdown
58
58
  ref="disclosure"
59
59
  ${makeBindings(bindingOverrides)}
60
60
  >
61
- ${content || ''}
61
+ ${content}
62
62
  </gl-disclosure-dropdown>
63
- ${after || ''}
63
+ ${after}
64
64
  </div>
65
65
  `;
66
66
 
@@ -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>
@@ -250,12 +252,13 @@ export const CustomGroupsItemsAndToggle = makeGroupedExample({
250
252
  </gl-disclosure-dropdown-group>
251
253
  <gl-disclosure-dropdown-group bordered :group="$options.groups[1]"/>
252
254
  `,
253
- {},
254
- `
255
+ {
256
+ after: `
255
257
  <gl-modal :visible="feedBackModalVisible" @change="toggleModalVisibility" modal-id="feedbackModal" size="sm">
256
258
  <textarea class="gl-w-full">Tell us what you think!</textarea>
257
259
  </gl-modal>
258
- `
260
+ `,
261
+ }
259
262
  ),
260
263
  data() {
261
264
  return {
@@ -31,12 +31,14 @@ const hasOnlyListItems = ({ default: defaultSlot }) => {
31
31
  return false;
32
32
  }
33
33
  const nodes = defaultSlot();
34
- return (
35
- Array.isArray(nodes) &&
36
- nodes.filter((vNode) => vNode.tag).length &&
37
- (nodes.filter((vNode) => vNode.tag).every(isValidSlotTagVue2) ||
38
- nodes.filter((vNode) => vNode.tag).every(isValidSlotTag))
39
- );
34
+
35
+ if (!Array.isArray(nodes)) {
36
+ return false;
37
+ }
38
+
39
+ const tags = nodes.filter((vNode) => vNode.tag);
40
+
41
+ return tags.length && tags.every((tag) => isValidSlotTag(tag) || isValidSlotTagVue2(tag));
40
42
  };
41
43
 
42
44
  export { itemsValidator, isItem, isGroup, hasOnlyListItems };
@@ -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">
@@ -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');