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.

387 lines (378 loc) 10.6 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 const ITEM_SELECTOR = '.dropdown-item:not(.disabled):not([disabled])' // Popper attachment positions const 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: () => {} } }, data () { return { visible: false, inNavbar: null } }, created () { // Create non-reactive property this._popper = null }, 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 () { // In case we are inside a `<keep-alive>` this.visible = false this.setTouchStart(false) this.removePopper() }, /* istanbul ignore next: not easy to test */ beforeDestroy () { this.visible = false this.setTouchStart(false) this.removePopper() }, watch: { visible (state, old) { if (state === old) { // Avoid duplicated emits return } if (state) { this.showMenu() } else { this.hideMenu() } }, disabled (state, old) { if (state !== old && state && this.visible) { // Hide dropdown if disabled changes to true this.visible = false } } }, computed: { toggler () { return this.$refs.toggle.$el || this.$refs.toggle } }, methods: { 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 let 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 () { // 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 (element) { this.removePopper() this._popper = new Popper(element, this.$refs.menu, this.getPopperConfig()) }, 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 */ () { let 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 } const popperConfig = { 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 (on) { /* * 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) { const children = arrayFrom(document.body.children) children.forEach(el => { if (on) { eventOn('mouseover', this._noop) } else { eventOff('mouseover', this._noop) } }) } }, /* istanbul ignore next: not easy to test */ _noop () { // Do nothing event handler (used in touchstart event handler) }, rootCloseListener (vm) { if (vm !== this) { this.visible = false } }, clickOutListener () { this.visible = false }, show () { // Public method to show dropdown if (this.disabled) { return } this.visible = true }, hide () { // Public method to hide dropdown if (this.disabled) { return } this.visible = false }, toggle (evt) { // Called only by a button that toggles the menu evt = evt || {} const type = evt.type const 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 (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 (evt) { // Called from dropdown menu context const 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 (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 (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 (evt) { if (this.$refs.menu.contains(evt.relatedTarget)) { return } this.visible = false }, /* istanbul ignore next: not easy to test */ onMouseOver (evt) { // Focus the item on hover // TODO: Special handling for inputs? Inputs are in a special .dropdown-form container const item = evt.target if ( item.classList.contains('dropdown-item') && !item.disabled && !item.classList.contains('disabled') && item.focus ) { item.focus() } }, focusNext (evt, up) { if (!this.visible) { return } evt.preventDefault() evt.stopPropagation() this.$nextTick(() => { const items = this.getItems() if (items.length < 1) { return } let index = items.indexOf(evt.target) if (up && index > 0) { index-- } else if (!up && index < items.length - 1) { index++ } if (index < 0) { index = 0 } this.focusItem(index, items) }) }, focusItem (idx, items) { let el = items.find((el, i) => i === idx) if (el && getAttr(el, 'tabindex') !== '-1') { el.focus() } }, getItems () { // Get all items return filterVisible(selectAll(ITEM_SELECTOR, this.$refs.menu)) }, getFirstItem () { // Get the first non-disabled item let item = this.getItems()[0] return item || null }, focusFirstItem () { const item = this.getFirstItem() if (item) { this.focusItem(0, [item]) } }, focusToggler () { let toggler = this.toggler if (toggler && toggler.focus) { toggler.focus() } } } }