src/components/base/new_dropdowns/disclosure/disclosure_dropdown.vue (435 lines of code) (raw):
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import clamp from 'lodash/clamp';
import uniqueId from 'lodash/uniqueId';
import { stopEvent, filterVisible } from '../../../../utils/utils';
import {
GL_DROPDOWN_SHOWN,
GL_DROPDOWN_HIDDEN,
GL_DROPDOWN_BEFORE_CLOSE,
GL_DROPDOWN_FOCUS_CONTENT,
ENTER,
SPACE,
HOME,
END,
ARROW_DOWN,
ARROW_UP,
GL_DROPDOWN_CONTENTS_CLASS,
POSITION_ABSOLUTE,
POSITION_FIXED,
} from '../constants';
import {
buttonCategoryOptions,
buttonSizeOptions,
dropdownPlacements,
dropdownVariantOptions,
} from '../../../../utils/constants';
import GlBaseDropdown, { BASE_DROPDOWN_CLASS } from '../base_dropdown/base_dropdown.vue';
import GlDisclosureDropdownItem, { ITEM_CLASS } from './disclosure_dropdown_item.vue';
import GlDisclosureDropdownGroup from './disclosure_dropdown_group.vue';
import { itemsValidator, isItem, hasOnlyListItems } from './utils';
export const DROPDOWN_SELECTOR = `.${BASE_DROPDOWN_CLASS}`;
export const ITEM_SELECTOR = `.${ITEM_CLASS}`;
export default {
name: 'GlDisclosureDropdown',
events: {
GL_DROPDOWN_SHOWN,
GL_DROPDOWN_HIDDEN,
GL_DROPDOWN_BEFORE_CLOSE,
GL_DROPDOWN_FOCUS_CONTENT,
},
components: {
GlBaseDropdown,
GlDisclosureDropdownItem,
GlDisclosureDropdownGroup,
},
props: {
/**
* Items to display in the dropdown
*/
items: {
type: Array,
required: false,
default: () => [],
validator: itemsValidator,
},
/**
* Toggle button text
*/
toggleText: {
type: String,
required: false,
default: '',
},
/**
* Toggle text to be read by screen readers only
*/
textSrOnly: {
type: Boolean,
required: false,
default: false,
},
/**
* Styling option - dropdown's toggle category
*/
category: {
type: String,
required: false,
default: buttonCategoryOptions.primary,
validator: (value) => value in buttonCategoryOptions,
},
/**
* Styling option - dropdown's toggle variant
*/
variant: {
type: String,
required: false,
default: dropdownVariantOptions.default,
validator: (value) => value in dropdownVariantOptions,
},
/**
* The size of the dropdown toggle
*/
size: {
type: String,
required: false,
default: 'medium',
validator: (value) => value in buttonSizeOptions,
},
/**
* Icon name that will be rendered in the toggle button
*/
icon: {
type: String,
required: false,
default: '',
},
/**
* Set to "true" to disable the dropdown
*/
disabled: {
type: Boolean,
required: false,
default: false,
},
/**
* Set to "true" when dropdown content (items) is loading
* It will render a small loader in the dropdown toggle and make it disabled
*/
loading: {
type: Boolean,
required: false,
default: false,
},
/**
* Custom toggle id.
* For instance, it can be referenced by tooltip or popover
*/
toggleId: {
type: String,
required: false,
default: () => uniqueId('dropdown-toggle-btn-'),
},
/**
* Additional CSS classes to customize toggle appearance
*/
toggleClass: {
type: [String, Array, Object],
required: false,
default: null,
},
/**
* Set to "true" to hide the caret
*/
noCaret: {
type: Boolean,
required: false,
default: false,
},
/**
* Align disclosure dropdown with respect to the toggle button
*/
placement: {
type: String,
required: false,
default: 'bottom-start',
validator: (value) => Object.keys(dropdownPlacements).includes(value),
},
/**
* The `aria-labelledby` attribute value for the toggle button
* Provide the string of ids seperated by space
*/
toggleAriaLabelledBy: {
type: String,
required: false,
default: null,
},
/**
* The `aria-labelledby` attribute value for the list of options
* Provide the string of ids seperated by space
*/
listAriaLabelledBy: {
type: String,
required: false,
default: null,
},
/**
* Render the toggle button as a block element
*/
block: {
type: Boolean,
required: false,
default: false,
},
/**
* Custom offset to be applied to Floating UI's offset middleware.
* https://floating-ui.com/docs/offset
*/
dropdownOffset: {
type: [Number, Object],
required: false,
default: undefined,
},
/**
* Lets the dropdown extend to match its content's width, up to a maximum width
* defined by the `$gl-new-dropdown-max-width` variable.
*/
fluidWidth: {
type: Boolean,
required: false,
default: false,
},
/**
* Close the dropdown on item click (action)
*/
autoClose: {
type: Boolean,
required: false,
default: true,
},
/**
* Strategy to be applied by computePosition. If the dropdown's container is too short for it to
* fit in, setting this to fixed will let it position itself above its container.
* https://floating-ui.com/docs/computePosition#strategy
*/
positioningStrategy: {
type: String,
required: false,
default: POSITION_ABSOLUTE,
validator: (strategy) => [POSITION_ABSOLUTE, POSITION_FIXED].includes(strategy),
},
/**
* Opens dropdown on render
*/
startOpened: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
disclosureId: uniqueId('disclosure-'),
nextFocusedItemIndex: null,
};
},
computed: {
disclosureTag() {
if (
this.items?.length ||
// eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
hasOnlyListItems(this.$scopedSlots.default || this.$slots.default)
) {
return 'ul';
}
return 'div';
},
hasCustomToggle() {
return Boolean(this.$scopedSlots.toggle);
},
},
mounted() {
if (this.startOpened) {
this.open();
}
},
methods: {
open() {
this.$refs.baseDropdown.open();
},
close() {
this.$refs.baseDropdown.close();
},
onShow() {
/**
* Emitted when dropdown is shown
*
* @event shown
*/
this.$emit(GL_DROPDOWN_SHOWN);
},
onBeforeClose(event) {
/**
* Emitted when dropdown is about to be closed
*
* @event beforeClose
*/
this.$emit(GL_DROPDOWN_BEFORE_CLOSE, event);
},
onHide() {
/**
* Emitted when dropdown is hidden
*
* @event hidden
*/
this.$emit(GL_DROPDOWN_HIDDEN);
this.nextFocusedItemIndex = null;
},
onKeydown(event) {
const { code } = event;
const elements = this.getFocusableListItemElements();
if (elements.length < 1) return;
let stop = true;
if (code === HOME) {
this.focusItem(0, elements);
} else if (code === END) {
this.focusItem(elements.length - 1, elements);
} else if (code === ARROW_UP) {
this.focusNextItem(event, elements, -1);
} else if (code === ARROW_DOWN) {
this.focusNextItem(event, elements, 1);
} else if (code === ENTER || code === SPACE) {
this.handleAutoClose(event);
} else {
stop = false;
}
if (stop) {
stopEvent(event);
}
},
getFocusableListItemElements() {
const items = this.$refs.content?.querySelectorAll(ITEM_SELECTOR);
return filterVisible(Array.from(items || []));
},
focusNextItem(event, elements, offset) {
const { target } = event;
const currentIndex = elements.indexOf(target);
const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
this.focusItem(nextIndex, elements);
},
focusItem(index, elements) {
this.nextFocusedItemIndex = index;
elements[index]?.focus();
},
closeAndFocus() {
this.$refs.baseDropdown.closeAndFocus();
},
handleAction(action) {
// See https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4376 for
// detailed explanation why we need requestAnimationFrame
window.requestAnimationFrame(() => {
/**
* Emitted when one of disclosure dropdown items is clicked
*
* @event action
*/
this.$emit('action', action);
});
},
handleAutoClose(e) {
if (
this.autoClose &&
e.target.closest(ITEM_SELECTOR) &&
e.target.closest(DROPDOWN_SELECTOR) === this.$refs.baseDropdown.$el
) {
this.closeAndFocus();
}
},
uniqueItemId() {
return uniqueId(`disclosure-item-`);
},
isItem,
},
GL_DROPDOWN_CONTENTS_CLASS,
};
</script>
<template>
<gl-base-dropdown
ref="baseDropdown"
:aria-labelledby="toggleAriaLabelledBy"
:arrow-element="$refs.disclosureArrow"
:toggle-id="toggleId"
:toggle-text="toggleText"
:toggle-class="toggleClass"
:text-sr-only="textSrOnly"
:category="category"
:variant="variant"
:size="size"
:icon="icon"
:disabled="disabled"
:loading="loading"
:no-caret="noCaret"
:placement="placement"
:block="block"
:offset="dropdownOffset"
:fluid-width="fluidWidth"
:positioning-strategy="positioningStrategy"
class="gl-disclosure-dropdown"
@[$options.events.GL_DROPDOWN_SHOWN]="onShow"
@[$options.events.GL_DROPDOWN_HIDDEN]="onHide"
@[$options.events.GL_DROPDOWN_BEFORE_CLOSE]="onBeforeClose"
@[$options.events.GL_DROPDOWN_FOCUS_CONTENT]="onKeydown"
>
<template v-if="hasCustomToggle" #toggle>
<!-- @slot Custom toggle content -->
<slot name="toggle"></slot>
</template>
<!-- @slot Content to display in dropdown header -->
<slot name="header"></slot>
<component
:is="disclosureTag"
:id="disclosureId"
ref="content"
:aria-labelledby="listAriaLabelledBy || toggleId"
data-testid="disclosure-content"
:class="$options.GL_DROPDOWN_CONTENTS_CLASS"
tabindex="-1"
@keydown="onKeydown"
@click="handleAutoClose"
>
<slot>
<template v-for="(item, index) in items">
<template v-if="isItem(item)">
<!-- eslint-disable-next-line vue/valid-v-for -->
<gl-disclosure-dropdown-item :key="uniqueItemId()" :item="item" @action="handleAction">
<template v-if="'list-item' in $scopedSlots" #list-item>
<!-- @slot Custom template of the disclosure dropdown item -->
<slot name="list-item" :item="item"></slot>
</template>
</gl-disclosure-dropdown-item>
</template>
<template v-else>
<gl-disclosure-dropdown-group
:key="item.name"
:bordered="index !== 0"
:group="item"
@action="handleAction"
>
<template v-if="$scopedSlots['group-label']" #group-label>
<!-- @slot Custom template for group names -->
<slot name="group-label" :group="item"></slot>
</template>
<template v-if="$scopedSlots['list-item']" #default>
<!-- eslint-disable vue/valid-v-for -->
<gl-disclosure-dropdown-item
v-for="groupItem in item.items"
:key="uniqueItemId()"
:item="groupItem"
@action="handleAction"
>
<template #list-item>
<!-- @slot Custom template of the disclosure dropdown item -->
<slot name="list-item" :item="groupItem"></slot>
</template>
</gl-disclosure-dropdown-item>
<!-- eslint-enable vue/valid-v-for -->
</template>
</gl-disclosure-dropdown-group>
</template>
</template>
</slot>
</component>
<!-- @slot Content to display in dropdown footer -->
<slot name="footer"></slot>
</gl-base-dropdown>
</template>