diff --git a/src/components/base/button/button.spec.js b/src/components/base/button/button.spec.js index 7e62fcd1c220621c17fa139fbb381e3a111c8291..f2e0d46f4fe5f00caa5e4bca05b01e4a82d8cb21 100644 --- a/src/components/base/button/button.spec.js +++ b/src/components/base/button/button.spec.js @@ -85,6 +85,17 @@ describe('button component', () => { it('should render the loading indicator with the `gl-button-loading-indicator` class', () => { expect(findLoadingIcon().classes()).toContain('gl-button-loading-indicator'); }); + it('should add the disabled class to the button when loading', () => { + expect(wrapper.classes()).toContain('disabled'); + }); + + it('should not remove the button from tab order when loading but not disabled', () => { + expect(wrapper.attributes('tabindex')).toBeUndefined(); + }); + + it('should set aria-disabled attribute when loading', () => { + expect(wrapper.attributes('aria-disabled')).toBe('true'); + }); }); it.each` diff --git a/src/components/base/button/button.vue b/src/components/base/button/button.vue index 262c56e79e0e3fffacd5064d5a9725b1d6b7a19d..f1a46a1f37bbc9f5912d0c8eb85144a30eb34760 100644 --- a/src/components/base/button/button.vue +++ b/src/components/base/button/button.vue @@ -222,7 +222,7 @@ export default { hasIconOnly() { return isSlotEmpty(this, 'default') && this.hasIcon; }, - isButtonDisabled() { + isDisabledOrLoading() { return this.disabled || this.loading; }, buttonClasses() { @@ -244,6 +244,10 @@ export default { classes.push('btn', 'btn-label'); } + if (this.isDisabledOrLoading) { + classes.push('disabled'); + } + return classes; }, buttonSize() { @@ -270,7 +274,7 @@ export default { }, tabindex() { // When disabled remove links and non-standard tags from tab order - if (this.disabled) { + if (this.disabled && !this.loading) { return this.isLink || this.isNonStandardTag ? '-1' : this.$attrs.tabindex; } @@ -282,14 +286,17 @@ export default { // Type only used for "real" buttons type: this.isButton ? this.type : null, // Disabled only set on "real" buttons - disabled: this.isButton ? this.isButtonDisabled : null, + disabled: this.isButton ? this.disabled : null, // We add a role of button when the tag is not a link or button or when link has `href` of `#` role: this.isNonStandardTag || this.isHashLink ? 'button' : this.$attrs?.role, - // We set the `aria-disabled` state for non-standard tags - ...(this.isNonStandardTag ? { 'aria-disabled': String(this.disabled) } : {}), tabindex: this.tabindex, }; + // We set the `aria-disabled` state for non-standard tags and loading buttons + if (this.isNonStandardTag || this.loading) { + base['aria-disabled'] = this.isDisabledOrLoading; + } + if (this.isLink) { return { ...this.$attrs, @@ -355,7 +362,7 @@ export default { } }, onClick(event) { - if (this.disabled && isEvent(event)) { + if (this.isDisabledOrLoading && isEvent(event)) { stopEvent(event); } }, diff --git a/src/components/base/new_dropdowns/base_dropdown/base_dropdown.spec.js b/src/components/base/new_dropdowns/base_dropdown/base_dropdown.spec.js index 6d249a50a8b0d2050f5e857756411760da33a94c..f9d0384c945adf01ad35e0fa4275a0f1ea461d00 100644 --- a/src/components/base/new_dropdowns/base_dropdown/base_dropdown.spec.js +++ b/src/components/base/new_dropdowns/base_dropdown/base_dropdown.spec.js @@ -614,4 +614,32 @@ describe('base dropdown', () => { expect(findDefaultDropdownToggle().attributes('aria-labelledby')).toBe(undefined); }); }); + + describe('loading dropdown behavior', () => { + it('should allow closing dropdown when loading', async () => { + // First create the dropdown without loading state + buildWrapper(); + + const toggle = findDefaultDropdownToggle(); + const menu = findDropdownMenu(); + + // Open the dropdown first + await toggle.trigger('click'); + + // Verify it's open + expect(menu.classes('!gl-block')).toBe(true); + expect(toggle.attributes('aria-expanded')).toBe('true'); + + // Now set loading state + await wrapper.setProps({ loading: true }); + + // Attempt to close it + await toggle.trigger('click'); + + // Verify it closes successfully despite loading state + expect(menu.classes('!gl-block')).toBe(false); + expect(toggle.attributes('aria-expanded')).toBe('false'); + expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1); + }); + }); }); diff --git a/src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue b/src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue index e0b5a7c4b4333e950db87eabf361da297aca9c38..6a490df933bc341891b0cd954dc35faca182b9a0 100644 --- a/src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue +++ b/src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue @@ -28,7 +28,12 @@ import { POSITION_ABSOLUTE, POSITION_FIXED, } from '../constants'; -import { logWarning, isElementTabbable, isElementFocusable } from '../../../../utils/utils'; +import { + logWarning, + isElementTabbable, + isElementFocusable, + stopEvent, +} from '../../../../utils/utils'; import { OutsideDirective } from '../../../../directives/outside/outside'; import GlButton from '../../button/button.vue'; import GlIcon from '../../icon/icon.vue'; @@ -429,6 +434,12 @@ export default { this.stopAutoUpdate?.(); }, async toggle(event) { + // If loading and trying to open the dropdown, prevent and exit + if (this.loading && event && !this.visible) { + stopEvent(event); + return false; + } + if (event && this.visible) { let prevented = false; this.$emit(GL_DROPDOWN_BEFORE_CLOSE, { @@ -499,6 +510,11 @@ export default { this.toggleElement.focus(); }, onKeydown(event) { + if (this.loading && !this.visible) { + stopEvent(event); + return; + } + const { code, target: { tagName }, diff --git a/src/components/base/new_dropdowns/listbox/listbox.stories.js b/src/components/base/new_dropdowns/listbox/listbox.stories.js index 7662a00e51fb001d363a14e888aa6bb9c4c5fa14..2603c236e75e1c53718e4d69c84c74ae083a3e9a 100644 --- a/src/components/base/new_dropdowns/listbox/listbox.stories.js +++ b/src/components/base/new_dropdowns/listbox/listbox.stories.js @@ -945,3 +945,23 @@ export const InFormGroup = (args, { argTypes }) => ({ }); InFormGroup.args = generateProps({ toggleId: 'department-picker' }); InFormGroup.decorators = [makeContainer({ height: LISTBOX_CONTAINER_HEIGHT })]; + +export const LoadingDropdown = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + GlCollapsibleListbox, + }, + data() { + return { + selected: null, + }; + }, + template: template(), +}); + +LoadingDropdown.args = generateProps({ + loading: true, + toggleText: 'Loading Dropdown', + headerText: 'This dropdown is in loading state', + startOpened: false, +}); diff --git a/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-dropdown-collapsible-listbox-loading-dropdown-1-snap.png b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-dropdown-collapsible-listbox-loading-dropdown-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..1ed599ee891bf553801fb534a8756c5d90bc3d8d Binary files /dev/null and b/tests/__image_snapshots__/storyshots-spec-js-image-storyshots-base-dropdown-collapsible-listbox-loading-dropdown-1-snap.png differ