UNPKG

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
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(); } } } };