Commit 22066dc7 authored by David Pisek's avatar David Pisek Committed by Natalia Tepluhina

Add accordion component to vue-shared

This commit adds a reusable accordion as a shared component.

Note: While it tries to keep the component as generic as possible,
it was built with a specific implementation goal.

See this issue for more information:
https://gitlab.com/gitlab-org/gitlab/issues/11190
parent 044caa1f
......@@ -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) {
......
<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>
import Vue from 'vue';
const accordionEventBus = new Vue();
export default accordionEventBus;
<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>
export { default as Accordion } from './accordion.vue';
export { default as AccordionItem } from './accordion_item.vue';
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);
});
});
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'),
);
});
});
});
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);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment