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>