src/components/base/new_dropdowns/base_dropdown/base_dropdown.vue (545 lines of code) (raw):
<script>
import uniqueId from 'lodash/uniqueId';
import {
arrow,
computePosition,
autoUpdate,
offset,
size,
autoPlacement,
shift,
} from '@floating-ui/dom';
import {
buttonCategoryOptions,
buttonSizeOptions,
dropdownPlacements,
dropdownAllowedAutoPlacements,
dropdownVariantOptions,
} from '../../../../utils/constants';
import {
GL_DROPDOWN_SHOWN,
GL_DROPDOWN_HIDDEN,
GL_DROPDOWN_BEFORE_CLOSE,
GL_DROPDOWN_FOCUS_CONTENT,
ENTER,
SPACE,
ARROW_DOWN,
GL_DROPDOWN_CONTENTS_CLASS,
POSITION_ABSOLUTE,
POSITION_FIXED,
} from '../constants';
import { logWarning, isElementTabbable, isElementFocusable } from '../../../../utils/utils';
import { OutsideDirective } from '../../../../directives/outside/outside';
import GlButton from '../../button/button.vue';
import GlIcon from '../../icon/icon.vue';
import { ARROW_X_MINIMUM, DEFAULT_OFFSET, FIXED_WIDTH_CLASS } from './constants';
export const BASE_DROPDOWN_CLASS = 'gl-new-dropdown';
export default {
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() {
return !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}` : undefined;
},
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() {
return this.$refs.toggle.$el || 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: ({ availableWidth, availableHeight, elements }) => {
const contentsEl = elements.floating.querySelector(`.${GL_DROPDOWN_CONTENTS_CLASS}`);
if (!contentsEl) return;
const contentsAvailableHeight =
availableHeight - (this.nonScrollableContentHeight ?? 0) - DEFAULT_OFFSET;
const maxWidth = this.fluidWidth
? {
maxWidth: `${Math.max(0, availableWidth)}px`,
}
: {};
Object.assign(
contentsEl.style,
{
maxHeight: `${Math.max(contentsAvailableHeight, 0)}px`,
},
maxWidth
);
},
}),
],
};
},
},
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?.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() {
this.observer?.disconnect();
this.stopAutoUpdate?.();
},
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() {
const scrollableArea = 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>
<template>
<div
v-outside.click.focusin="close"
:class="[$options.BASE_DROPDOWN_CLASS, { '!gl-block': block }]"
>
<component
:is="toggleComponent"
v-bind="toggleAttributes"
:id="toggleId"
ref="toggle"
data-testid="base-dropdown-toggle"
v-on="toggleListeners"
@keydown.esc.stop.prevent="close"
>
<!-- @slot Custom toggle button content -->
<slot name="toggle">
<span class="gl-new-dropdown-button-text" :class="{ 'gl-sr-only': textSrOnly }">
{{ toggleText }}
</span>
<gl-icon
v-if="!noCaret"
class="gl-button-icon gl-new-dropdown-chevron"
name="chevron-down"
/>
</slot>
</component>
<div
:id="baseDropdownId"
ref="content"
data-testid="base-dropdown-menu"
class="gl-new-dropdown-panel"
:class="panelClasses"
@keydown.esc.stop.prevent="closeAndFocus"
>
<div ref="dropdownArrow" class="gl-new-dropdown-arrow"></div>
<div class="gl-new-dropdown-inner">
<slot :visible="visible"></slot>
</div>
</div>
</div>
</template>