UNPKG

@gitlab/ui

Version:
431 lines (417 loc) • 13.6 kB
import Popper from 'popper.js'; import { extend } from '../vue'; import { NAME_DROPDOWN } from '../constants/components'; import { EVENT_NAME_SHOWN, EVENT_NAME_HIDDEN, EVENT_NAME_TOGGLE, EVENT_NAME_CLICK, EVENT_NAME_SHOW, EVENT_NAME_HIDE } from '../constants/events'; import { CODE_ENTER, CODE_SPACE, CODE_DOWN, CODE_ESC, CODE_UP } from '../constants/key-codes'; import { PLACEMENT_TOP_END, PLACEMENT_TOP_START, PLACEMENT_BOTTOM_START, PLACEMENT_RIGHT_START, PLACEMENT_LEFT_START, PLACEMENT_BOTTOM_END } from '../constants/popper'; import { PROP_TYPE_STRING, PROP_TYPE_BOOLEAN, PROP_TYPE_NUMBER_STRING, PROP_TYPE_OBJECT } from '../constants/props'; import { HTMLElement } from '../constants/safe-types'; import { BvEvent } from '../utils/bv-event.class'; import { requestAF, contains, closest, attemptFocus, selectAll, isVisible } from '../utils/dom'; import { getRootEventName, stopEvent } from '../utils/events'; import { sortKeys, mergeDeep } from '../utils/object'; import { makePropsConfigurable, makeProp } from '../utils/props'; import { warn } from '../utils/warn'; import { clickOutMixin } from './click-out'; import { focusInMixin } from './focus-in'; import { props as props$1, idMixin } from './id'; import { listenOnRootMixin } from './listen-on-root'; import { registerElementToInstance, removeElementToInstance } from '../utils/element-to-vue-instance-registry'; // --- Constants --- const ROOT_EVENT_NAME_SHOWN = getRootEventName(NAME_DROPDOWN, EVENT_NAME_SHOWN); const ROOT_EVENT_NAME_HIDDEN = getRootEventName(NAME_DROPDOWN, EVENT_NAME_HIDDEN); // CSS selectors const SELECTOR_FORM_CHILD = '.dropdown form'; const SELECTOR_ITEM = ['.dropdown-item', '.b-dropdown-form'].map(selector => `${selector}:not(.disabled):not([disabled])`).join(', '); // --- Helper methods --- // Return an array of visible items const filterVisibles = els => (els || []).filter(isVisible); // --- Props --- const props = makePropsConfigurable(sortKeys({ ...props$1, // String: `scrollParent`, `window` or `viewport` // HTMLElement: HTML Element reference boundary: makeProp([HTMLElement, PROP_TYPE_STRING], 'scrollParent'), disabled: makeProp(PROP_TYPE_BOOLEAN, false), // Place left if possible dropleft: makeProp(PROP_TYPE_BOOLEAN, false), // Place right if possible dropright: makeProp(PROP_TYPE_BOOLEAN, false), // Place on top if possible dropup: makeProp(PROP_TYPE_BOOLEAN, false), // Disable auto-flipping of menu from bottom <=> top noFlip: makeProp(PROP_TYPE_BOOLEAN, false), // Number of pixels or a CSS unit value to offset menu // (i.e. `1px`, `1rem`, etc.) offset: makeProp(PROP_TYPE_NUMBER_STRING, 0), popperOpts: makeProp(PROP_TYPE_OBJECT, {}), // Right align menu (default is left align) right: makeProp(PROP_TYPE_BOOLEAN, false) }), NAME_DROPDOWN); // --- Mixin --- // @vue/component const dropdownMixin = extend({ mixins: [idMixin, listenOnRootMixin, clickOutMixin, focusInMixin], provide() { return { getBvDropdown: () => this }; }, props, data() { return { visible: false, visibleChangePrevented: false }; }, computed: { toggler() { const { toggle } = this.$refs; return toggle ? toggle.$el || toggle : null; }, directionClass() { if (this.dropup) { return 'dropup'; } else if (this.dropright) { return 'dropright'; } else if (this.dropleft) { return 'dropleft'; } return ''; }, boundaryClass() { // Position `static` is needed to allow menu to "breakout" of the `scrollParent` // boundaries when boundary is anything other than `scrollParent` // See: https://github.com/twbs/bootstrap/issues/24251#issuecomment-341413786 return this.boundary !== 'scrollParent' ? 'gl-static' : ''; } }, watch: { visible(newValue, oldValue) { if (this.visibleChangePrevented) { this.visibleChangePrevented = false; return; } if (newValue !== oldValue) { const eventName = newValue ? EVENT_NAME_SHOW : EVENT_NAME_HIDE; const bvEvent = new BvEvent(eventName, { cancelable: true, vueTarget: this, target: this.$refs.menu, relatedTarget: null, componentId: this.safeId ? this.safeId() : this.id || null }); this.emitEvent(bvEvent); if (bvEvent.defaultPrevented) { // Reset value and exit if canceled this.visibleChangePrevented = true; this.visible = oldValue; // Just in case a child element triggered `this.hide(true)` this.$off(EVENT_NAME_HIDDEN, this.focusToggler); return; } if (newValue) { this.showMenu(); } else { this.hideMenu(); } } }, disabled(newValue, oldValue) { if (newValue !== oldValue && newValue && this.visible) { // Hide dropdown if disabled changes to true this.visible = false; } } }, created() { // Create private non-reactive props this.$_popper = null; }, /* istanbul ignore next */ deactivated() { // In case we are inside a `<keep-alive>` this.visible = false; this.whileOpenListen(false); this.destroyPopper(); }, mounted() { registerElementToInstance(this.$el, this); }, beforeDestroy() { this.visible = false; this.whileOpenListen(false); this.destroyPopper(); removeElementToInstance(this.$el); }, methods: { // Event emitter emitEvent(bvEvent) { const { type } = bvEvent; this.emitOnRoot(getRootEventName(NAME_DROPDOWN, type), bvEvent); this.$emit(type, bvEvent); }, showMenu() { if (this.disabled) { /* istanbul ignore next */ return; } if (typeof Popper === 'undefined') { /* istanbul ignore next */ warn('Popper.js not found. Falling back to CSS positioning', NAME_DROPDOWN); } else { // For dropup with alignment we use the parent element as popper container let el = this.dropup && this.right || this.split ? this.$el : this.$refs.toggle; // Make sure we have a reference to an element, not a component! el = el.$el || el; // Instantiate Popper.js this.createPopper(el); } // Ensure other menus are closed this.emitOnRoot(ROOT_EVENT_NAME_SHOWN, this); // Enable listeners this.whileOpenListen(true); // Wrap in `$nextTick()` to ensure menu is fully rendered/shown this.$nextTick(() => { // Focus on the menu container on show this.focusMenu(); // Emit the shown event this.$emit(EVENT_NAME_SHOWN); }); }, hideMenu() { this.whileOpenListen(false); this.emitOnRoot(ROOT_EVENT_NAME_HIDDEN, this); this.$emit(EVENT_NAME_HIDDEN); this.destroyPopper(); }, createPopper(element) { this.destroyPopper(); this.$_popper = new Popper(element, this.$refs.menu, this.getPopperConfig()); }, // Ensure popper event listeners are removed cleanly destroyPopper() { this.$_popper && this.$_popper.destroy(); this.$_popper = null; }, // Instructs popper to re-computes the dropdown position // useful if the content changes size updatePopper() { try { this.$_popper.scheduleUpdate(); } catch {} }, getPopperConfig() { let placement = PLACEMENT_BOTTOM_START; if (this.dropup) { placement = this.right ? PLACEMENT_TOP_END : PLACEMENT_TOP_START; } else if (this.dropright) { placement = PLACEMENT_RIGHT_START; } else if (this.dropleft) { placement = PLACEMENT_LEFT_START; } else if (this.right) { placement = PLACEMENT_BOTTOM_END; } const popperConfig = { placement, modifiers: { offset: { offset: this.offset || 0 }, flip: { enabled: !this.noFlip } } }; const boundariesElement = this.boundary; if (boundariesElement) { popperConfig.modifiers.preventOverflow = { boundariesElement }; } return mergeDeep(popperConfig, this.popperOpts || {}); }, // Turn listeners on/off while open whileOpenListen(isOpen) { // Hide the dropdown when clicked outside this.listenForClickOut = isOpen; // Hide the dropdown when it loses focus this.listenForFocusIn = isOpen; // Hide the dropdown when another dropdown is opened const method = isOpen ? 'listenOnRoot' : 'listenOffRoot'; this[method](ROOT_EVENT_NAME_SHOWN, this.rootCloseListener); }, rootCloseListener(vm) { if (vm !== this) { this.visible = false; } }, // Public method to show dropdown show() { if (this.disabled) { return; } // Wrap in a `requestAF()` to allow any previous // click handling to occur first requestAF(() => { this.visible = true; }); }, // Public method to hide dropdown hide() { let refocus = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; /* istanbul ignore next */ if (this.disabled) { return; } this.visible = false; if (refocus) { // Child element is closing the dropdown on click this.$once(EVENT_NAME_HIDDEN, this.focusToggler); } }, // Called only by a button that toggles the menu toggle(event) { event = event || {}; // Early exit when not a click event or ENTER, SPACE or DOWN were pressed const { type, keyCode } = event; if (type !== 'click' && !(type === 'keydown' && [CODE_ENTER, CODE_SPACE, CODE_DOWN].indexOf(keyCode) !== -1)) { /* istanbul ignore next */ return; } /* istanbul ignore next */ if (this.disabled) { this.visible = false; return; } this.$emit(EVENT_NAME_TOGGLE, event); stopEvent(event); // Toggle visibility if (this.visible) { this.hide(true); } else { this.show(); } }, // Mousedown handler for the toggle /* istanbul ignore next */ onMousedown(event) { // We prevent the 'mousedown' event for the toggle to stop the // 'focusin' event from being fired // The event would otherwise be picked up by the global 'focusin' // listener and there is no cross-browser solution to detect it // relates to the toggle click // The 'click' event will still be fired and we handle closing // other dropdowns there too // See https://github.com/bootstrap-vue/bootstrap-vue/issues/4328 stopEvent(event, { propagation: false }); }, // Called from dropdown menu context onKeydown(event) { const { keyCode } = event; if (keyCode === CODE_ESC) { // Close on ESC this.onEsc(event); } else if (keyCode === CODE_DOWN) { // Down Arrow this.focusNext(event, false); } else if (keyCode === CODE_UP) { // Up Arrow this.focusNext(event, true); } }, // If user presses ESC, close the menu onEsc(event) { if (this.visible) { this.visible = false; stopEvent(event); // Return focus to original trigger button this.$once(EVENT_NAME_HIDDEN, this.focusToggler); } }, // Called only in split button mode, for the split button onSplitClick(event) { /* istanbul ignore next */ if (this.disabled) { this.visible = false; return; } this.$emit(EVENT_NAME_CLICK, event); }, // Shared hide handler between click-out and focus-in events hideHandler(event) { const { target } = event; if (this.visible && !contains(this.$refs.menu, target) && !contains(this.toggler, target)) { this.hide(); } }, // Document click-out listener clickOutHandler(event) { this.hideHandler(event); }, // Document focus-in listener focusInHandler(event) { this.hideHandler(event); }, // Keyboard nav focusNext(event, up) { // Ignore key up/down on form elements const { target } = event; if (!this.visible || event && closest(SELECTOR_FORM_CHILD, target)) { /* istanbul ignore next: should never happen */ return; } stopEvent(event); this.$nextTick(() => { const items = this.getItems(); if (items.length < 1) { /* istanbul ignore next: should never happen */ return; } let index = items.indexOf(target); if (up && index > 0) { index--; } else if (!up && index < items.length - 1) { index++; } if (index < 0) { /* istanbul ignore next: should never happen */ index = 0; } this.focusItem(index, items); }); }, focusItem(index, items) { const el = items.find((el, i) => i === index); attemptFocus(el); }, getItems() { // Get all items return filterVisibles(selectAll(SELECTOR_ITEM, this.$refs.menu)); }, focusMenu() { attemptFocus(this.$refs.menu); }, focusToggler() { this.$nextTick(() => { attemptFocus(this.toggler); }); } } }); export { dropdownMixin, props };