bootstrap-vue
Version:
BootstrapVue provides one of the most comprehensive implementations of Bootstrap 4 components and grid system for Vue.js and with extensive and automated WAI-ARIA accessibility markup.
395 lines (377 loc) • 11.4 kB
JavaScript
import Popper from 'popper.js';
import clickoutMixin from './clickout';
import listenOnRootMixin from './listen-on-root';
import { from as arrayFrom } from '../utils/array';
import { assign } from '../utils/object';
import KeyCodes from '../utils/key-codes';
import warn from '../utils/warn';
import { isVisible, closest, selectAll, getAttr, eventOn, eventOff } from '../utils/dom';
// Return an Array of visible items
function filterVisible(els) {
return (els || []).filter(isVisible);
}
// Dropdown item CSS selectors
// TODO: .dropdown-form handling
var ITEM_SELECTOR = '.dropdown-item:not(.disabled):not([disabled])';
// Popper attachment positions
var AttachmentMap = {
// DropUp Left Align
TOP: 'top-start',
// DropUp Right Align
TOPEND: 'top-end',
// Dropdown left Align
BOTTOM: 'bottom-start',
// Dropdown Right Align
BOTTOMEND: 'bottom-end'
};
export default {
mixins: [clickoutMixin, listenOnRootMixin],
props: {
disabled: {
type: Boolean,
default: false
},
text: {
// Button label
type: String,
default: ''
},
dropup: {
// place on top if possible
type: Boolean,
default: false
},
right: {
// Right align menu (default is left align)
type: Boolean,
default: false
},
offset: {
// Number of pixels to offset menu, or a CSS unit value (i.e. 1px, 1rem, etc)
type: [Number, String],
default: 0
},
noFlip: {
// Disable auto-flipping of menu from bottom<=>top
type: Boolean,
default: false
},
popperOpts: {
type: Object,
default: function _default() {}
}
},
data: function data() {
return {
visible: false,
inNavbar: null
};
},
created: function created() {
// Create non-reactive property
this._popper = null;
},
mounted: function mounted() {
// To keep one dropdown opened on page
this.listenOnRoot('bv::dropdown::shown', this.rootCloseListener);
// Hide when clicked on links
this.listenOnRoot('clicked::link', this.rootCloseListener);
// Use new namespaced events
this.listenOnRoot('bv::link::clicked', this.rootCloseListener);
},
/* istanbul ignore next: not easy to test */
deactivated: function deactivated() {
// In case we are inside a `<keep-alive>`
this.visible = false;
this.setTouchStart(false);
this.removePopper();
},
/* istanbul ignore next: not easy to test */
beforeDestroy: function beforeDestroy() {
this.visible = false;
this.setTouchStart(false);
this.removePopper();
},
watch: {
visible: function visible(state, old) {
if (state === old) {
// Avoid duplicated emits
return;
}
if (state) {
this.showMenu();
} else {
this.hideMenu();
}
},
disabled: function disabled(state, old) {
if (state !== old && state && this.visible) {
// Hide dropdown if disabled changes to true
this.visible = false;
}
}
},
computed: {
toggler: function toggler() {
return this.$refs.toggle.$el || this.$refs.toggle;
}
},
methods: {
showMenu: function showMenu() {
if (this.disabled) {
return;
}
// TODO: move emit show to visible watcher, to allow cancelling of show
this.$emit('show');
// Ensure other menus are closed
this.emitOnRoot('bv::dropdown::shown', this);
// Are we in a navbar ?
if (this.inNavbar === null && this.isNav) {
this.inNavbar = Boolean(closest('.navbar', this.$el));
}
// Disable totally Popper.js for Dropdown in Navbar
/* istnbul ignore next: can't test popper in JSDOM */
if (!this.inNavbar) {
if (typeof Popper === 'undefined') {
warn('b-dropdown: Popper.js not found. Falling back to CSS positioning.');
} else {
// for dropup with alignment we use the parent element as popper container
var element = this.dropup && this.right || this.split ? this.$el : this.$refs.toggle;
// Make sure we have a reference to an element, not a component!
element = element.$el || element;
// Instantiate popper.js
this.createPopper(element);
}
}
this.setTouchStart(true);
this.$emit('shown');
// Focus on the first item on show
this.$nextTick(this.focusFirstItem);
},
hideMenu: function hideMenu() {
// TODO: move emit hide to visible watcher, to allow cancelling of hide
this.$emit('hide');
this.setTouchStart(false);
this.emitOnRoot('bv::dropdown::hidden', this);
this.$emit('hidden');
this.removePopper();
},
createPopper: function createPopper(element) {
this.removePopper();
this._popper = new Popper(element, this.$refs.menu, this.getPopperConfig());
},
removePopper: function removePopper() {
if (this._popper) {
// Ensure popper event listeners are removed cleanly
this._popper.destroy();
}
this._popper = null;
},
getPopperConfig /* istanbul ignore next: can't test popper in JSDOM */: function getPopperConfig() {
var placement = AttachmentMap.BOTTOM;
if (this.dropup && this.right) {
// dropup + right
placement = AttachmentMap.TOPEND;
} else if (this.dropup) {
// dropup + left
placement = AttachmentMap.TOP;
} else if (this.right) {
// dropdown + right
placement = AttachmentMap.BOTTOMEND;
}
var popperConfig = {
placement: placement,
modifiers: {
offset: {
offset: this.offset || 0
},
flip: {
enabled: !this.noFlip
}
}
};
if (this.boundary) {
popperConfig.modifiers.preventOverflow = {
boundariesElement: this.boundary
};
}
return assign(popperConfig, this.popperOpts || {});
},
setTouchStart: function setTouchStart(on) {
var _this = this;
/*
* If this is a touch-enabled device we add extra
* empty mouseover listeners to the body's immediate children;
* only needed because of broken event delegation on iOS
* https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
*/
if ('ontouchstart' in document.documentElement) {
var children = arrayFrom(document.body.children);
children.forEach(function (el) {
if (on) {
eventOn('mouseover', _this._noop);
} else {
eventOff('mouseover', _this._noop);
}
});
}
},
/* istanbul ignore next: not easy to test */
_noop: function _noop() {
// Do nothing event handler (used in touchstart event handler)
},
rootCloseListener: function rootCloseListener(vm) {
if (vm !== this) {
this.visible = false;
}
},
clickOutListener: function clickOutListener() {
this.visible = false;
},
show: function show() {
// Public method to show dropdown
if (this.disabled) {
return;
}
this.visible = true;
},
hide: function hide() {
// Public method to hide dropdown
if (this.disabled) {
return;
}
this.visible = false;
},
toggle: function toggle(evt) {
// Called only by a button that toggles the menu
evt = evt || {};
var type = evt.type;
var key = evt.keyCode;
if (type !== 'click' && !(type === 'keydown' && (key === KeyCodes.ENTER || key === KeyCodes.SPACE || key === KeyCodes.DOWN))) {
// We only toggle on Click, Enter, Space, and Arrow Down
return;
}
evt.preventDefault();
evt.stopPropagation();
if (this.disabled) {
this.visible = false;
return;
}
// Toggle visibility
this.visible = !this.visible;
},
click: function click(evt) {
// Calle only in split button mode, for the split button
if (this.disabled) {
this.visible = false;
return;
}
this.$emit('click', evt);
},
/* istanbul ignore next: not easy to test */
onKeydown: function onKeydown(evt) {
// Called from dropdown menu context
var key = evt.keyCode;
if (key === KeyCodes.ESC) {
// Close on ESC
this.onEsc(evt);
} else if (key === KeyCodes.TAB) {
// Close on tab out
this.onTab(evt);
} else if (key === KeyCodes.DOWN) {
// Down Arrow
this.focusNext(evt, false);
} else if (key === KeyCodes.UP) {
// Up Arrow
this.focusNext(evt, true);
}
},
/* istanbul ignore next: not easy to test */
onEsc: function onEsc(evt) {
if (this.visible) {
this.visible = false;
evt.preventDefault();
evt.stopPropagation();
// Return focus to original trigger button
this.$nextTick(this.focusToggler);
}
},
/* istanbul ignore next: not easy to test */
onTab: function onTab(evt) {
if (this.visible) {
// TODO: Need special handler for dealing with form inputs
// Tab, if in a text-like input, we should just focus next item in the dropdown
// Note: Inputs are in a special .dropdown-form container
this.visible = false;
}
},
onFocusOut: function onFocusOut(evt) {
if (this.$refs.menu.contains(evt.relatedTarget)) {
return;
}
this.visible = false;
},
/* istanbul ignore next: not easy to test */
onMouseOver: function onMouseOver(evt) {
// Focus the item on hover
// TODO: Special handling for inputs? Inputs are in a special .dropdown-form container
var item = evt.target;
if (item.classList.contains('dropdown-item') && !item.disabled && !item.classList.contains('disabled') && item.focus) {
item.focus();
}
},
focusNext: function focusNext(evt, up) {
var _this2 = this;
if (!this.visible) {
return;
}
evt.preventDefault();
evt.stopPropagation();
this.$nextTick(function () {
var items = _this2.getItems();
if (items.length < 1) {
return;
}
var index = items.indexOf(evt.target);
if (up && index > 0) {
index--;
} else if (!up && index < items.length - 1) {
index++;
}
if (index < 0) {
index = 0;
}
_this2.focusItem(index, items);
});
},
focusItem: function focusItem(idx, items) {
var el = items.find(function (el, i) {
return i === idx;
});
if (el && getAttr(el, 'tabindex') !== '-1') {
el.focus();
}
},
getItems: function getItems() {
// Get all items
return filterVisible(selectAll(ITEM_SELECTOR, this.$refs.menu));
},
getFirstItem: function getFirstItem() {
// Get the first non-disabled item
var item = this.getItems()[0];
return item || null;
},
focusFirstItem: function focusFirstItem() {
var item = this.getFirstItem();
if (item) {
this.focusItem(0, [item]);
}
},
focusToggler: function focusToggler() {
var toggler = this.toggler;
if (toggler && toggler.focus) {
toggler.focus();
}
}
}
};