diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 9a15505dd25fc3c26b8202e171a605b231808afe..4cbb2f5ba71ba71f82ab3aa1758fd57b5e180115 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -515,6 +515,12 @@ img.emoji { cursor: pointer; } +// this needs to use "!important" due to some very specific styles +// around buttons +.cursor-default { + cursor: default !important; +} + // Make buttons/dropdowns full-width on mobile .full-width-mobile { @include media-breakpoint-down(xs) { diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/accordion.vue b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion.vue new file mode 100644 index 0000000000000000000000000000000000000000..975f7a578b310eb4c533ccfbe6bd346f65485985 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion.vue @@ -0,0 +1,16 @@ +<script> +import { uniqueId } from 'underscore'; + +export default { + created() { + this.uniqueId = uniqueId('accordion-'); + }, +}; +</script> +<template> + <div> + <ul class="list-group list-group-flush py-2"> + <slot :accordion-id="uniqueId"></slot> + </ul> + </div> +</template> diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_event_bus.js b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_event_bus.js new file mode 100644 index 0000000000000000000000000000000000000000..0674bc043ae7692a55eea09d083855cb85d82735 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_event_bus.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +const accordionEventBus = new Vue(); + +export default accordionEventBus; diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_item.vue b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_item.vue new file mode 100644 index 0000000000000000000000000000000000000000..1e4c298e59277f867bb67b9131afca899d9a3766 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_item.vue @@ -0,0 +1,142 @@ +<script> +import { uniqueId } from 'underscore'; +import { GlSkeletonLoader } from '@gitlab/ui'; + +import Icon from '~/vue_shared/components/icon.vue'; + +import accordionEventBus from './accordion_event_bus'; + +const accordionItemUniqueId = name => uniqueId(`gl-accordion-item-${name}-`); + +export default { + components: { + GlSkeletonLoader, + Icon, + }, + props: { + accordionId: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + maxHeight: { + type: String, + required: false, + default: '', + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isExpanded: false, + }; + }, + computed: { + contentStyles() { + return { + maxHeight: this.maxHeight, + overflow: 'auto', + }; + }, + isDisabled() { + return this.disabled || !this.hasContent; + }, + hasContent() { + return this.$scopedSlots.default !== undefined; + }, + }, + created() { + this.buttonId = accordionItemUniqueId('trigger'); + this.contentContainerId = accordionItemUniqueId('content-container'); + // create a unique event name so multiple accordion instances don't close each other items + this.closeOtherItemsEvent = `${this.accordionId}.closeOtherAccordionItems`; + + accordionEventBus.$on(this.closeOtherItemsEvent, this.onCloseOtherAccordionItems); + }, + destroyed() { + accordionEventBus.$off(this.closeOtherItemsEvent); + }, + methods: { + onCloseOtherAccordionItems(trigger) { + if (trigger !== this) { + this.collapse(); + } + }, + handleClick() { + if (this.isExpanded) { + this.collapse(); + } else { + this.expand(); + } + accordionEventBus.$emit(this.closeOtherItemsEvent, this); + }, + expand() { + this.isExpanded = true; + }, + collapse() { + this.isExpanded = false; + }, + }, +}; +</script> + +<template> + <li class="list-group-item p-0"> + <template v-if="!isLoading"> + <div class="d-flex align-items-stretch"> + <button + :id="buttonId" + ref="expansionTrigger" + type="button" + :disabled="isDisabled" + :aria-expanded="isExpanded" + :aria-controls="contentContainerId" + class="btn-transparent border-0 rounded-0 w-100 p-0 text-left" + :class="{ 'cursor-default': isDisabled }" + @click="handleClick" + > + <div + class="d-flex align-items-center p-2" + :class="{ 'list-group-item-action': !isDisabled }" + > + <icon + :size="16" + class="mr-2 gl-text-gray-900" + :name="isExpanded ? 'angle-down' : 'angle-right'" + /> + <span + ><slot name="title" :is-expanded="isExpanded" :is-disabled="isDisabled"></slot + ></span> + </div> + </button> + </div> + <div + v-show="isExpanded" + :id="contentContainerId" + ref="contentContainer" + :aria-labelledby="buttonId" + role="region" + > + <slot name="subTitle"></slot> + <div ref="content" :style="contentStyles"><slot name="default"></slot></div> + </div> + </template> + <div v-else ref="loadingIndicator" class="d-flex p-2"> + <div class="h-32-px"> + <gl-skeleton-loader :height="32"> + <rect width="12" height="16" rx="4" x="0" y="8" /> + <circle cx="37" cy="15" r="15" /> + <rect width="20" height="16" rx="4" x="63" y="8" /> + </gl-skeleton-loader> + </div> + </div> + </li> +</template> diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/index.js b/ee/app/assets/javascripts/vue_shared/components/accordion/index.js new file mode 100644 index 0000000000000000000000000000000000000000..596bd3ac5a418cbc26ce2cb4d38304822d019109 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/accordion/index.js @@ -0,0 +1,2 @@ +export { default as Accordion } from './accordion.vue'; +export { default as AccordionItem } from './accordion_item.vue'; diff --git a/ee/spec/frontend/vue_shared/components/accordion/accordion_event_bus_spec.js b/ee/spec/frontend/vue_shared/components/accordion/accordion_event_bus_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..20a398db21500a226ce762b7b01d8862d87a74ca --- /dev/null +++ b/ee/spec/frontend/vue_shared/components/accordion/accordion_event_bus_spec.js @@ -0,0 +1,8 @@ +import Vue from 'vue'; +import accordionEventBus from 'ee/vue_shared/components/accordion/accordion_event_bus'; + +describe('Accordion event bus', () => { + it('default exports a vue instance', () => { + expect(accordionEventBus instanceof Vue).toBe(true); + }); +}); diff --git a/ee/spec/frontend/vue_shared/components/accordion/accordion_item_spec.js b/ee/spec/frontend/vue_shared/components/accordion/accordion_item_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..30ec29a5c224684b100f8eba9fcb90a68d5e4830 --- /dev/null +++ b/ee/spec/frontend/vue_shared/components/accordion/accordion_item_spec.js @@ -0,0 +1,213 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { uniqueId } from 'underscore'; + +import { AccordionItem } from 'ee/vue_shared/components/accordion'; +import accordionEventBus from 'ee/vue_shared/components/accordion/accordion_event_bus'; + +jest.mock('ee/vue_shared/components/accordion/accordion_event_bus', () => ({ + $on: jest.fn(), + $emit: jest.fn(), + $off: jest.fn(), +})); + +jest.mock('underscore'); + +const localVue = createLocalVue(); + +describe('AccordionItem component', () => { + const mockUniqueId = 'mockUniqueId'; + const accordionId = 'accordionID'; + + let wrapper; + + const factory = ({ propsData = {}, defaultSlot = `<p></p>`, titleSlot = `<p></p>` } = {}) => { + const defaultPropsData = { + accordionId, + isLoading: false, + maxHeight: '', + }; + + wrapper = shallowMount(AccordionItem, { + localVue, + sync: false, + propsData: { + ...defaultPropsData, + ...propsData, + }, + scopedSlots: { + default: defaultSlot, + title: titleSlot, + }, + }); + }; + + const loadingIndicator = () => wrapper.find({ ref: 'loadingIndicator' }); + const expansionTrigger = () => wrapper.find({ ref: 'expansionTrigger' }); + const contentContainer = () => wrapper.find({ ref: 'contentContainer' }); + const content = () => wrapper.find({ ref: 'content' }); + const namespacedCloseOtherAccordionItemsEvent = `${accordionId}.closeOtherAccordionItems`; + + beforeEach(() => { + uniqueId.mockReturnValue(mockUniqueId); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + jest.clearAllMocks(); + }); + + describe('rendering options', () => { + it('does not show a loading indicator if the "isLoading" prop is set to "false"', () => { + factory({ propsData: { isLoading: false } }); + + expect(loadingIndicator().exists()).toBe(false); + }); + + it('shows a loading indicator if the "isLoading" prop is set to "true"', () => { + factory({ propsData: { isLoading: true } }); + + expect(loadingIndicator().exists()).toBe(true); + }); + + it('does not limit the content height per default', () => { + factory(); + + expect(contentContainer().element.style.maxHeight).toBeFalsy(); + }); + + it('has "maxHeight" prop that limits the height of the content container to the given value', () => { + factory({ propsData: { maxHeight: '200px' } }); + + expect(content().element.style.maxHeight).toBe('200px'); + }); + }); + + describe('scoped slots', () => { + it.each(['default', 'title'])("contains a '%s' slot", slotName => { + const className = `${slotName}-slot-content`; + + factory({ [`${slotName}Slot`]: `<div class='${className}' />` }); + + expect(wrapper.find(`.${className}`).exists()).toBe(true); + }); + + it('contains a default slot', () => { + factory({ defaultSlot: `<div class='foo' />` }); + expect(wrapper.find(`.foo`).exists()).toBe(true); + }); + + it.each([true, false])( + 'passes the "isExpanded" and "isDisabled" state to the title slot', + state => { + const titleSlot = jest.fn(); + + factory({ propsData: { disabled: state }, titleSlot }); + wrapper.vm.isExpanded = state; + + return wrapper.vm.$nextTick().then(() => { + expect(titleSlot).toHaveBeenCalledWith({ + isExpanded: state, + isDisabled: state, + }); + }); + }, + ); + }); + + describe('collapsing and expanding', () => { + beforeEach(factory); + + it('is collapsed per default', () => { + expect(contentContainer().isVisible()).toBe(false); + }); + + it('expands when the trigger-element gets clicked', () => { + expect(contentContainer().isVisible()).toBe(false); + + expansionTrigger().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(contentContainer().isVisible()).toBe(true); + }); + }); + + it('emits a namespaced "closeOtherAccordionItems" event, containing the trigger item as a payload', () => { + expansionTrigger().trigger('click'); + + expect(accordionEventBus.$emit).toHaveBeenCalledTimes(1); + expect(accordionEventBus.$emit).toHaveBeenCalledWith( + namespacedCloseOtherAccordionItemsEvent, + wrapper.vm, + ); + }); + + it('subscribes "onCloseOtherAccordionItems" as handler to the namespaced "closeOtherAccordionItems" event', () => { + expect(accordionEventBus.$on).toHaveBeenCalledTimes(1); + expect(accordionEventBus.$on).toHaveBeenCalledWith( + namespacedCloseOtherAccordionItemsEvent, + wrapper.vm.onCloseOtherAccordionItems, + ); + }); + + it('collapses if "closeOtherAccordionItems" is called with the trigger not being the current item', () => { + wrapper.setData({ isExpanded: true }); + wrapper.vm.onCloseOtherAccordionItems({}); + + expect(wrapper.vm.isExpanded).toBe(false); + }); + + it('does not collapses if "closeOtherAccordionItems" is called with the trigger being the current item', () => { + wrapper.setData({ isExpanded: true }); + wrapper.vm.onCloseOtherAccordionItems(wrapper.vm); + + expect(wrapper.vm.isExpanded).toBe(true); + }); + + it('unsubscribes from namespaced "closeOtherAccordionItems" when the component is destroyed', () => { + wrapper.destroy(); + expect(accordionEventBus.$off).toHaveBeenCalledTimes(1); + expect(accordionEventBus.$off).toHaveBeenCalledWith(namespacedCloseOtherAccordionItemsEvent); + }); + }); + + describe('accessibility', () => { + beforeEach(factory); + + it('contains a expansion trigger element with a unique, namespaced id', () => { + expect(uniqueId).toHaveBeenCalledWith('gl-accordion-item-trigger-'); + + expect(expansionTrigger().attributes('id')).toBe('mockUniqueId'); + }); + + it('contains a content-container element with a unique, namespaced id', () => { + expect(uniqueId).toHaveBeenCalledWith('gl-accordion-item-content-container-'); + expect(contentContainer().attributes('id')).toBe(mockUniqueId); + }); + + it('has a trigger element that has an "aria-expanded" attribute set, to show if it is expanded or collapsed', () => { + expect(expansionTrigger().attributes('aria-expanded')).toBeFalsy(); + + wrapper.setData({ isExpanded: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(expansionTrigger().attributes('aria-expanded')).toBe('true'); + }); + }); + + it('has a trigger element that has a "aria-controls" attribute, which points to the content element', () => { + expect(expansionTrigger().attributes('aria-controls')).toBeTruthy(); + expect(expansionTrigger().attributes('aria-controls')).toBe( + contentContainer().attributes('id'), + ); + }); + + it('has a content-container element that has a "aria-labelledby" attribute, which points to the trigger element', () => { + expect(contentContainer().attributes('aria-labelledby')).toBeTruthy(); + expect(contentContainer().attributes('aria-labelledby')).toBe( + expansionTrigger().attributes('id'), + ); + }); + }); +}); diff --git a/ee/spec/frontend/vue_shared/components/accordion/accordion_spec.js b/ee/spec/frontend/vue_shared/components/accordion/accordion_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..be6b892c6886022fa60d4579239324d56382f767 --- /dev/null +++ b/ee/spec/frontend/vue_shared/components/accordion/accordion_spec.js @@ -0,0 +1,47 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { Accordion } from 'ee/vue_shared/components/accordion'; + +import { uniqueId } from 'underscore'; + +jest.mock('underscore'); + +const localVue = createLocalVue(); + +describe('Accordion component', () => { + let wrapper; + const factory = ({ defaultSlot = '' } = {}) => { + wrapper = shallowMount(Accordion, { + localVue, + sync: false, + scopedSlots: { + default: defaultSlot, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + jest.clearAllMocks(); + }); + + it('contains a default slot', () => { + const defaultSlot = `<span class="content"></span>`; + + factory({ defaultSlot }); + + expect(wrapper.find('.content').exists()).toBe(true); + }); + + it('passes a unique "accordionId" to the default slot', () => { + const mockUniqueIdValue = 'foo'; + uniqueId.mockReturnValueOnce(mockUniqueIdValue); + + const defaultSlot = '<span>{{ props.accordionId }}</span>'; + + factory({ defaultSlot }); + + expect(wrapper.text()).toContain(mockUniqueIdValue); + }); +});