UNPKG

@gitlab/ui

Version:
528 lines (511 loc) • 18.3 kB
import uniqueId from 'lodash/uniqueId'; import { offset, autoPlacement, shift, arrow, 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_BEFORE_CLOSE, GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, ENTER, SPACE, ARROW_DOWN, GL_DROPDOWN_FOCUS_CONTENT } from '../constants'; import { logWarning, isElementFocusable, isElementTabbable } from '../../../../utils/utils'; import { OutsideDirective } from '../../../../directives/outside/outside'; import GlButton from '../../button/button'; import GlIcon from '../../icon/icon'; import { DEFAULT_OFFSET, FIXED_WIDTH_CLASS, ARROW_X_MINIMUM } from './constants'; import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js'; const BASE_DROPDOWN_CLASS = 'gl-new-dropdown'; var script = { name: 'BaseDropdown', BASE_DROPDOWN_CLASS, 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: 'bottom-start', validator: value => { if (['left', 'center', 'right'].includes(value)) { logWarning(`GlDisclosureDropdown/GlCollapsibleListbox: "${value}" placement is deprecated. Use ${dropdownPlacements[value]} instead.`); } return 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: { hasNoVisibleToggleText() { var _this$toggleText; return !((_this$toggleText = this.toggleText) !== null && _this$toggleText !== void 0 && _this$toggleText.length) || this.textSrOnly; }, isIconOnly() { return Boolean(this.icon && this.hasNoVisibleToggleText); }, isEllipsisButton() { return this.isIconOnly && this.icon === 'ellipsis_h'; }, isCaretOnly() { return !this.noCaret && !this.icon && this.hasNoVisibleToggleText; }, ariaAttributes() { return { 'aria-haspopup': this.ariaHaspopup, 'aria-expanded': String(this.visible), 'aria-controls': this.baseDropdownId, 'aria-labelledby': this.toggleLabelledBy }; }, toggleButtonClasses() { return [this.toggleClass, { 'gl-new-dropdown-toggle': true, 'button-ellipsis-horizontal': this.isEllipsisButton, 'gl-new-dropdown-icon-only btn-icon': this.isIconOnly && !this.isEllipsisButton, 'gl-new-dropdown-toggle-no-caret': this.noCaret, 'gl-new-dropdown-caret-only btn-icon': this.isCaretOnly }]; }, 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: event => this.toggle(event) } }; } return { is: 'div', class: 'gl-new-dropdown-custom-toggle', listeners: { keydown: event => this.onKeydown(event), click: event => this.toggle(event) } }; }, toggleListeners() { return this.toggleOptions.listeners; }, toggleAttributes() { const { listeners, is, ...attributes } = this.toggleOptions; return attributes; }, toggleComponent() { return this.toggleOptions.is; }, 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-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() { const placement = dropdownPlacements[this.placement]; const [, alignment] = placement.split('-'); return { placement, strategy: this.positioningStrategy, middleware: [offset(this.offset), autoPlacement({ alignment, allowedPlacements: dropdownAllowedAutoPlacements[this.placement] }), shift(), arrow({ element: this.$refs.dropdownArrow }), 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); } }, getArrowOffsets(actualPlacement) { // Try to extract the base direction (top, bottom, left, right) from the placement const direction = actualPlacement === null || actualPlacement === void 0 ? void 0 : actualPlacement.split('-')[0]; const offsetConfigs = { top: { staticSide: 'bottom', rotation: '225deg' }, bottom: { staticSide: 'top', rotation: '45deg' }, left: { staticSide: 'right', rotation: '135deg' }, right: { staticSide: 'left', rotation: '315deg' } }; return offsetConfigs[direction] || offsetConfigs.bottom; }, async startFloating() { this.calculateNonScrollableAreaHeight(); this.observer = new MutationObserver(this.calculateNonScrollableAreaHeight); this.observer.observe(this.$refs.content, { childList: true, subtree: true }); this.stopAutoUpdate = autoUpdate(this.toggleElement, this.$refs.content, async () => { const result = 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; const { x, y, middlewareData, placement } = result; // Get offsets based on actual placement, not requested placement const { rotation, staticSide } = this.getArrowOffsets(placement); // Assign dropdown window position Object.assign(this.$refs.content.style, { left: `${x}px`, top: `${y}px` }); // Assign arrow position if (middlewareData && middlewareData.arrow) { const { x: arrowX, y: arrowY } = middlewareData.arrow; /** * Clamp arrow X position to a minimum of 24px from the edge of the dropdown. * This prevents wide toggles from pushing the arrow to the very edge of the dropdown. */ const toggleRect = this.toggleElement.getBoundingClientRect(); const contentRect = this.$refs.content.getBoundingClientRect(); const clampedArrowX = toggleRect.width > contentRect.width ? Math.min(Math.max(arrowX, ARROW_X_MINIMUM), contentRect.width - ARROW_X_MINIMUM) : arrowX; Object.assign(this.$refs.dropdownArrow.style, { left: arrowX != null ? `${clampedArrowX}px` : '', top: arrowY != null ? `${arrowY}px` : '', right: '', bottom: '', [staticSide]: '-4px', transform: `rotate(${rotation})` }); } }); }, 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(event) { if (event && this.visible) { let prevented = false; this.$emit(GL_DROPDOWN_BEFORE_CLOSE, { originalEvent: event, preventDefault() { prevented = true; } }); if (prevented) return false; } 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); } // this is to check whether `toggle` was prevented or not return true; }, open() { if (this.visible) { return; } this.toggle(); }, close(event) { if (!this.visible) { return; } this.toggle(event); }, /** * Closes the dropdown and returns the focus to the toggle unless it has has moved outside * of the dropdown, meaning that the consumer needed to put some other element in focus. * * @param {KeyboardEvent?} event The keyboard event that caused the dropdown to close. */ async closeAndFocus(event) { if (!this.visible) { return; } const hadFocusWithin = this.$el.contains(document.activeElement); const hasToggled = await this.toggle(event); if (!hadFocusWithin) { return; } if (hasToggled) { 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(event); } 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.click.focusin",value:(_vm.close),expression:"close",modifiers:{"click":true,"focusin":true}}],class:[_vm.$options.BASE_DROPDOWN_CLASS, { '!gl-block': _vm.block }]},[_c(_vm.toggleComponent,_vm._g(_vm._b({ref:"toggle",tag:"component",attrs:{"id":_vm.toggleId,"data-testid":"base-dropdown-toggle"},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.close.apply(null, arguments)}}},'component',_vm.toggleAttributes,false),_vm.toggleListeners),[_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',{ref:"dropdownArrow",staticClass:"gl-new-dropdown-arrow"}),_vm._v(" "),_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__ = /*#__PURE__*/__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 { BASE_DROPDOWN_CLASS, __vue_component__ as default };