UNPKG

@gitlab/ui

Version:
410 lines (398 loc) • 13.7 kB
import uniqueId from 'lodash/uniqueId'; import { offset, autoPlacement, size, autoUpdate, computePosition } from '@floating-ui/dom'; import { buttonCategoryOptions, dropdownVariantOptions, buttonSizeOptions, dropdownPlacements, dropdownAllowedAutoPlacements } from '../../../../utils/constants'; import { POSITION_ABSOLUTE, POSITION_FIXED, GL_DROPDOWN_CONTENTS_CLASS, GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, ENTER, SPACE, ARROW_DOWN, GL_DROPDOWN_FOCUS_CONTENT } from '../constants'; import { isElementFocusable, isElementTabbable, logWarning } from '../../../../utils/utils'; import GlButton from '../../button/button'; import GlIcon from '../../icon/icon'; import { OutsideDirective } from '../../../../directives/outside/outside'; import { DEFAULT_OFFSET, FIXED_WIDTH_CLASS } from './constants'; import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js'; var script = { name: 'BaseDropdown', components: { GlButton, GlIcon }, directives: { Outside: OutsideDirective }, props: { toggleText: { type: String, required: false, default: '' }, textSrOnly: { type: Boolean, required: false, default: false }, block: { type: Boolean, required: false, default: false }, category: { type: String, required: false, default: buttonCategoryOptions.primary, validator: value => Object.keys(buttonCategoryOptions).includes(value) }, variant: { type: String, required: false, default: dropdownVariantOptions.default, validator: value => Object.keys(dropdownVariantOptions).includes(value) }, size: { type: String, required: false, default: 'medium', validator: value => Object.keys(buttonSizeOptions).includes(value) }, icon: { type: String, required: false, default: '' }, disabled: { type: Boolean, required: false, default: false }, loading: { type: Boolean, required: false, default: false }, toggleClass: { type: [String, Array, Object], required: false, default: null }, noCaret: { type: Boolean, required: false, default: false }, placement: { type: String, required: false, default: 'left', validator: value => Object.keys(dropdownPlacements).includes(value) }, // ARIA props ariaHaspopup: { type: [String, Boolean], required: false, default: false, validator: value => { return ['menu', 'listbox', 'tree', 'grid', 'dialog', true, false].includes(value); } }, /** * Id that will be referenced by `aria-labelledby` attribute of the dropdown content` */ toggleId: { type: String, required: true }, /** * The `aria-labelledby` attribute value for the toggle `button` */ ariaLabelledby: { type: String, required: false, default: null }, /** * Custom value to be passed to the offset middleware. * https://floating-ui.com/docs/offset */ offset: { type: [Number, Object], required: false, default: () => ({ mainAxis: DEFAULT_OFFSET }) }, fluidWidth: { type: Boolean, required: false, default: false }, /** * Strategy to be applied by computePosition. If this is set to fixed, the dropdown's position * needs to be set to fixed in CSS as well. * https://floating-ui.com/docs/computePosition#strategy */ positioningStrategy: { type: String, required: false, default: POSITION_ABSOLUTE, validator: strategy => [POSITION_ABSOLUTE, POSITION_FIXED].includes(strategy) } }, data() { return { openedYet: false, visible: false, baseDropdownId: uniqueId('base-dropdown-') }; }, computed: { isIconOnly() { var _this$toggleText; return Boolean(this.icon && (!((_this$toggleText = this.toggleText) !== null && _this$toggleText !== void 0 && _this$toggleText.length) || this.textSrOnly)); }, ariaAttributes() { return { 'aria-haspopup': this.ariaHaspopup, 'aria-expanded': this.visible, 'aria-controls': this.baseDropdownId, 'aria-labelledby': this.toggleLabelledBy }; }, toggleButtonClasses() { return [this.toggleClass, { 'gl-new-dropdown-toggle': true, 'gl-new-dropdown-icon-only': this.isIconOnly, 'gl-new-dropdown-toggle-no-caret': this.noCaret }]; }, toggleButtonTextClasses() { return this.block ? 'gl-w-full' : ''; }, toggleLabelledBy() { return this.ariaLabelledby ? `${this.ariaLabelledby} ${this.toggleId}` : this.toggleId; }, isDefaultToggle() { return !this.$scopedSlots.toggle; }, toggleOptions() { if (this.isDefaultToggle) { return { is: GlButton, icon: this.icon, block: this.block, buttonTextClasses: this.toggleButtonTextClasses, category: this.category, variant: this.variant, size: this.size, disabled: this.disabled, loading: this.loading, class: this.toggleButtonClasses, ...this.ariaAttributes, listeners: { keydown: event => this.onKeydown(event), click: () => this.toggle() } }; } return { is: 'div', class: 'gl-new-dropdown-custom-toggle', listeners: { keydown: event => this.onKeydown(event), click: () => this.toggle() } }; }, toggleElement() { var _this$$refs$toggle; return this.$refs.toggle.$el || ((_this$$refs$toggle = this.$refs.toggle) === null || _this$$refs$toggle === void 0 ? void 0 : _this$$refs$toggle.firstElementChild); }, panelClasses() { return { 'gl-display-block!': this.visible, [FIXED_WIDTH_CLASS]: !this.fluidWidth, 'gl-fixed': this.openedYet && this.isFixed, 'gl-absolute': this.openedYet && !this.isFixed }; }, isFixed() { return this.positioningStrategy === POSITION_FIXED; }, floatingUIConfig() { return { placement: dropdownPlacements[this.placement], strategy: this.positioningStrategy, middleware: [offset(this.offset), autoPlacement({ allowedPlacements: dropdownAllowedAutoPlacements[this.placement] }), size({ apply: _ref => { var _this$nonScrollableCo; let { availableHeight, elements } = _ref; const contentsEl = elements.floating.querySelector(`.${GL_DROPDOWN_CONTENTS_CLASS}`); if (!contentsEl) { return; } const contentsAvailableHeight = availableHeight - ((_this$nonScrollableCo = this.nonScrollableContentHeight) !== null && _this$nonScrollableCo !== void 0 ? _this$nonScrollableCo : 0) - DEFAULT_OFFSET; Object.assign(contentsEl.style, { maxHeight: `${Math.max(contentsAvailableHeight, 0)}px` }); } })] }; } }, watch: { ariaAttributes: { deep: true, handler(ariaAttributes) { if (this.$scopedSlots.toggle) { Object.keys(ariaAttributes).forEach(key => { this.toggleElement.setAttribute(key, ariaAttributes[key]); }); } } } }, mounted() { this.checkToggleFocusable(); }, beforeDestroy() { this.stopFloating(); }, methods: { checkToggleFocusable() { if (!isElementFocusable(this.toggleElement) && !isElementTabbable(this.toggleElement)) { logWarning(`GlDisclosureDropdown/GlCollapsibleListbox: Toggle is missing a 'tabindex' and cannot be focused. Use 'a' or 'button' element instead or make sure to add 'role="button"' along with 'tabindex' otherwise.`, this.$el); } }, async startFloating() { this.calculateNonScrollableAreaHeight(); this.observer = new MutationObserver(this.calculateNonScrollableAreaHeight); this.observer.observe(this.$refs.content, { attributes: false, childList: true, subtree: true }); this.stopAutoUpdate = autoUpdate(this.toggleElement, this.$refs.content, async () => { const { x, y } = await computePosition(this.toggleElement, this.$refs.content, this.floatingUIConfig); /** * Due to the asynchronous nature of computePosition, it's technically possible for the * component to have been destroyed by the time the promise resolves. In such case, we exit * early to prevent a TypeError. */ if (!this.$refs.content) return; Object.assign(this.$refs.content.style, { left: `${x}px`, top: `${y}px` }); }); }, stopFloating() { var _this$observer, _this$stopAutoUpdate; (_this$observer = this.observer) === null || _this$observer === void 0 ? void 0 : _this$observer.disconnect(); (_this$stopAutoUpdate = this.stopAutoUpdate) === null || _this$stopAutoUpdate === void 0 ? void 0 : _this$stopAutoUpdate.call(this); }, async toggle() { this.visible = !this.visible; if (this.visible) { // The dropdown needs to be actually visible before we compute its position with Floating UI. await this.$nextTick(); this.openedYet = true; /** * We wait until the dropdown's position has been computed before emitting the `shown` event. * This ensures that, if the parent component attempts to focus an inner element, the dropdown * is already properly placed in the page. Otherwise, the page would scroll back to the top. */ this.startFloating(); this.$emit(GL_DROPDOWN_SHOWN); } else { this.stopFloating(); this.$emit(GL_DROPDOWN_HIDDEN); } }, open() { if (this.visible) { return; } this.toggle(); }, close() { if (!this.visible) { return; } this.toggle(); }, closeAndFocus() { if (!this.visible) { return; } this.toggle(); this.focusToggle(); }, focusToggle() { this.toggleElement.focus(); }, onKeydown(event) { const { code, target: { tagName } } = event; let toggleOnEnter = true; let toggleOnSpace = true; if (tagName === 'BUTTON') { toggleOnEnter = false; toggleOnSpace = false; } else if (tagName === 'A') { toggleOnEnter = false; } if (code === ENTER && toggleOnEnter || code === SPACE && toggleOnSpace) { this.toggle(); } if (code === ARROW_DOWN) { this.$emit(GL_DROPDOWN_FOCUS_CONTENT, event); } }, calculateNonScrollableAreaHeight() { var _this$$refs$content; const scrollableArea = (_this$$refs$content = this.$refs.content) === null || _this$$refs$content === void 0 ? void 0 : _this$$refs$content.querySelector(`.${GL_DROPDOWN_CONTENTS_CLASS}`); if (!scrollableArea) return; const floatingElementBoundingBox = this.$refs.content.getBoundingClientRect(); const scrollableAreaBoundingBox = scrollableArea.getBoundingClientRect(); this.nonScrollableContentHeight = floatingElementBoundingBox.height - scrollableAreaBoundingBox.height; } } }; /* script */ const __vue_script__ = script; /* template */ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{directives:[{name:"outside",rawName:"v-outside",value:(_vm.close),expression:"close"}],staticClass:"gl-new-dropdown",class:{ 'gl-display-block!': _vm.block }},[_c(_vm.toggleOptions.is,_vm._g(_vm._b({ref:"toggle",tag:"component",attrs:{"id":_vm.toggleId,"data-testid":"base-dropdown-toggle"}},'component',_vm.toggleOptions,false),_vm.toggleOptions.listeners),[_vm._t("toggle",function(){return [_c('span',{staticClass:"gl-new-dropdown-button-text",class:{ 'gl-sr-only': _vm.textSrOnly }},[_vm._v("\n "+_vm._s(_vm.toggleText)+"\n ")]),_vm._v(" "),(!_vm.noCaret)?_c('gl-icon',{staticClass:"gl-button-icon gl-new-dropdown-chevron",attrs:{"name":"chevron-down"}}):_vm._e()]})],2),_vm._v(" "),_c('div',{ref:"content",staticClass:"gl-new-dropdown-panel",class:_vm.panelClasses,attrs:{"id":_vm.baseDropdownId,"data-testid":"base-dropdown-menu"},on:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"esc",27,$event.key,["Esc","Escape"])){ return null; }$event.stopPropagation();$event.preventDefault();return _vm.closeAndFocus.apply(null, arguments)}}},[_c('div',{staticClass:"gl-new-dropdown-inner"},[_vm._t("default")],2)])],1)}; var __vue_staticRenderFns__ = []; /* style */ const __vue_inject_styles__ = undefined; /* scoped */ const __vue_scope_id__ = undefined; /* module identifier */ const __vue_module_identifier__ = undefined; /* functional template */ const __vue_is_functional_template__ = false; /* style inject */ /* style inject SSR */ /* style inject shadow dom */ const __vue_component__ = __vue_normalize__( { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, __vue_inject_styles__, __vue_script__, __vue_scope_id__, __vue_is_functional_template__, __vue_module_identifier__, false, undefined, undefined, undefined ); export default __vue_component__;