@gitlab/ui
Version:
GitLab UI Components
528 lines (511 loc) • 18.3 kB
JavaScript
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 };