diff options
Diffstat (limited to 'js/src/dropdown.js')
| -rw-r--r-- | js/src/dropdown.js | 359 |
1 files changed, 185 insertions, 174 deletions
diff --git a/js/src/dropdown.js b/js/src/dropdown.js index 0ac108ab8..d26fa96ca 100644 --- a/js/src/dropdown.js +++ b/js/src/dropdown.js @@ -1,14 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.0.0-alpha3): dropdown.js + * Bootstrap (v5.0.0-beta3): dropdown.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ +import * as Popper from '@popperjs/core' + import { - getjQuery, - onDOMContentLoaded, + defineJQueryPlugin, getElementFromSelector, + isDisabled, isElement, isVisible, isRTL, @@ -18,7 +20,6 @@ import { import Data from './dom/data' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' -import Popper from 'popper.js' import SelectorEngine from './dom/selector-engine' import BaseComponent from './base-component' @@ -51,44 +52,40 @@ const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}` const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}` -const CLASS_NAME_DISABLED = 'disabled' const CLASS_NAME_SHOW = 'show' const CLASS_NAME_DROPUP = 'dropup' const CLASS_NAME_DROPEND = 'dropend' const CLASS_NAME_DROPSTART = 'dropstart' -const CLASS_NAME_MENUEND = 'dropdown-menu-end' const CLASS_NAME_NAVBAR = 'navbar' -const CLASS_NAME_POSITION_STATIC = 'position-static' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]' -const SELECTOR_FORM_CHILD = '.dropdown form' const SELECTOR_MENU = '.dropdown-menu' const SELECTOR_NAVBAR_NAV = '.navbar-nav' const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)' -const PLACEMENT_TOP = isRTL ? 'top-end' : 'top-start' -const PLACEMENT_TOPEND = isRTL ? 'top-start' : 'top-end' -const PLACEMENT_BOTTOM = isRTL ? 'bottom-end' : 'bottom-start' -const PLACEMENT_BOTTOMEND = isRTL ? 'bottom-start' : 'bottom-end' -const PLACEMENT_RIGHT = isRTL ? 'left-start' : 'right-start' -const PLACEMENT_LEFT = isRTL ? 'right-start' : 'left-start' +const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start' +const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end' +const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start' +const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end' +const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start' +const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start' const Default = { - offset: 0, - flip: true, - boundary: 'scrollParent', + offset: [0, 2], + boundary: 'clippingParents', reference: 'toggle', display: 'dynamic', - popperConfig: null + popperConfig: null, + autoClose: true } const DefaultType = { - offset: '(number|string|function)', - flip: 'boolean', + offset: '(array|string|function)', boundary: '(string|element)', - reference: '(string|element)', + reference: '(string|element|object)', display: 'string', - popperConfig: '(null|object)' + popperConfig: '(null|object|function)', + autoClose: '(boolean|string)' } /** @@ -126,15 +123,14 @@ class Dropdown extends BaseComponent { // Public toggle() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED)) { + if (isDisabled(this._element)) { return } const isActive = this._element.classList.contains(CLASS_NAME_SHOW) - Dropdown.clearMenus() - if (isActive) { + this.hide() return } @@ -142,7 +138,7 @@ class Dropdown extends BaseComponent { } show() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED) || this._menu.classList.contains(CLASS_NAME_SHOW)) { + if (isDisabled(this._element) || this._menu.classList.contains(CLASS_NAME_SHOW)) { return } @@ -158,7 +154,9 @@ class Dropdown extends BaseComponent { } // Totally disable Popper for Dropdowns in Navbar - if (!this._inNavbar) { + if (this._inNavbar) { + Manipulator.setDataAttribute(this._menu, 'popper', 'none') + } else { if (typeof Popper === 'undefined') { throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)') } @@ -174,16 +172,18 @@ class Dropdown extends BaseComponent { if (typeof this._config.reference.jquery !== 'undefined') { referenceElement = this._config.reference[0] } + } else if (typeof this._config.reference === 'object') { + referenceElement = this._config.reference } - // If boundary is not `scrollParent`, then set position to `static` - // to allow the menu to "escape" the scroll parent's boundaries - // https://github.com/twbs/bootstrap/issues/24251 - if (this._config.boundary !== 'scrollParent') { - parent.classList.add(CLASS_NAME_POSITION_STATIC) - } + const popperConfig = this._getPopperConfig() + const isDisplayStatic = popperConfig.modifiers.find(modifier => modifier.name === 'applyStyles' && modifier.enabled === false) + + this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig) - this._popper = new Popper(referenceElement, this._menu, this._getPopperConfig()) + if (isDisplayStatic) { + Manipulator.setDataAttribute(this._menu, 'popper', 'static') + } } // If this is a touch-enabled device we add extra @@ -193,7 +193,7 @@ class Dropdown extends BaseComponent { if ('ontouchstart' in document.documentElement && !parent.closest(SELECTOR_NAVBAR_NAV)) { [].concat(...document.body.children) - .forEach(elem => EventHandler.on(elem, 'mouseover', null, noop())) + .forEach(elem => EventHandler.on(elem, 'mouseover', noop)) } this._element.focus() @@ -201,48 +201,36 @@ class Dropdown extends BaseComponent { this._menu.classList.toggle(CLASS_NAME_SHOW) this._element.classList.toggle(CLASS_NAME_SHOW) - EventHandler.trigger(parent, EVENT_SHOWN, relatedTarget) + EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget) } hide() { - if (this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED) || !this._menu.classList.contains(CLASS_NAME_SHOW)) { + if (isDisabled(this._element) || !this._menu.classList.contains(CLASS_NAME_SHOW)) { return } - const parent = Dropdown.getParentFromElement(this._element) const relatedTarget = { relatedTarget: this._element } - const hideEvent = EventHandler.trigger(parent, EVENT_HIDE, relatedTarget) - - if (hideEvent.defaultPrevented) { - return - } - - if (this._popper) { - this._popper.destroy() - } - - this._menu.classList.toggle(CLASS_NAME_SHOW) - this._element.classList.toggle(CLASS_NAME_SHOW) - EventHandler.trigger(parent, EVENT_HIDDEN, relatedTarget) + this._completeHide(relatedTarget) } dispose() { - super.dispose() - EventHandler.off(this._element, EVENT_KEY) this._menu = null + if (this._popper) { this._popper.destroy() this._popper = null } + + super.dispose() } update() { this._inNavbar = this._detectNavbar() if (this._popper) { - this._popper.scheduleUpdate() + this._popper.update() } } @@ -251,11 +239,34 @@ class Dropdown extends BaseComponent { _addEventListeners() { EventHandler.on(this._element, EVENT_CLICK, event => { event.preventDefault() - event.stopPropagation() this.toggle() }) } + _completeHide(relatedTarget) { + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget) + if (hideEvent.defaultPrevented) { + return + } + + // If this is a touch-enabled device we remove the extra + // empty mouseover listeners we added for iOS support + if ('ontouchstart' in document.documentElement) { + [].concat(...document.body.children) + .forEach(elem => EventHandler.off(elem, 'mouseover', noop)) + } + + if (this._popper) { + this._popper.destroy() + } + + this._menu.classList.remove(CLASS_NAME_SHOW) + this._element.classList.remove(CLASS_NAME_SHOW) + this._element.setAttribute('aria-expanded', 'false') + Manipulator.removeDataAttribute(this._menu, 'popper') + EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget) + } + _getConfig(config) { config = { ...this.constructor.Default, @@ -265,6 +276,13 @@ class Dropdown extends BaseComponent { typeCheckConfig(NAME, config, this.constructor.DefaultType) + if (typeof config.reference === 'object' && !isElement(config.reference) && + typeof config.reference.getBoundingClientRect !== 'function' + ) { + // Popper virtual elements require a getBoundingClientRect method + throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`) + } + return config } @@ -274,78 +292,103 @@ class Dropdown extends BaseComponent { _getPlacement() { const parentDropdown = this._element.parentNode - let placement = PLACEMENT_BOTTOM - // Handle dropup + if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { + return PLACEMENT_RIGHT + } + + if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { + return PLACEMENT_LEFT + } + + // We need to trim the value because custom properties can also include spaces + const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end' + if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) { - placement = this._menu.classList.contains(CLASS_NAME_MENUEND) ? - PLACEMENT_TOPEND : - PLACEMENT_TOP - } else if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { - placement = PLACEMENT_RIGHT - } else if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { - placement = PLACEMENT_LEFT - } else if (this._menu.classList.contains(CLASS_NAME_MENUEND)) { - placement = PLACEMENT_BOTTOMEND - } - - return placement + return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP + } + + return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM } _detectNavbar() { - return Boolean(this._element.closest(`.${CLASS_NAME_NAVBAR}`)) + return this._element.closest(`.${CLASS_NAME_NAVBAR}`) !== null } _getOffset() { - const offset = {} + const { offset } = this._config - if (typeof this._config.offset === 'function') { - offset.fn = data => { - data.offsets = { - ...data.offsets, - ...(this._config.offset(data.offsets, this._element) || {}) - } + if (typeof offset === 'string') { + return offset.split(',').map(val => Number.parseInt(val, 10)) + } - return data - } - } else { - offset.offset = this._config.offset + if (typeof offset === 'function') { + return popperData => offset(popperData, this._element) } return offset } _getPopperConfig() { - const popperConfig = { + const defaultBsPopperConfig = { placement: this._getPlacement(), - modifiers: { - offset: this._getOffset(), - flip: { - enabled: this._config.flip - }, - preventOverflow: { - boundariesElement: this._config.boundary + modifiers: [{ + name: 'preventOverflow', + options: { + boundary: this._config.boundary } - } + }, + { + name: 'offset', + options: { + offset: this._getOffset() + } + }] } // Disable Popper if we have a static display if (this._config.display === 'static') { - popperConfig.modifiers.applyStyle = { + defaultBsPopperConfig.modifiers = [{ + name: 'applyStyles', enabled: false - } + }] } return { - ...popperConfig, - ...this._config.popperConfig + ...defaultBsPopperConfig, + ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig) + } + } + + _selectMenuItem(event) { + const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(isVisible) + + if (!items.length) { + return + } + + let index = items.indexOf(event.target) + + // Up + if (event.key === ARROW_UP_KEY && index > 0) { + index-- + } + + // Down + if (event.key === ARROW_DOWN_KEY && index < items.length - 1) { + index++ } + + // index is -1 if the first keydown is an ArrowUp + index = index === -1 ? 0 : index + + items[index].focus() } // Static static dropdownInterface(element, config) { - let data = Data.getData(element, DATA_KEY) + let data = Data.get(element, DATA_KEY) const _config = typeof config === 'object' ? config : null if (!data) { @@ -368,60 +411,54 @@ class Dropdown extends BaseComponent { } static clearMenus(event) { - if (event && (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY))) { - return - } - - const toggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE) - - for (let i = 0, len = toggles.length; i < len; i++) { - const parent = Dropdown.getParentFromElement(toggles[i]) - const context = Data.getData(toggles[i], DATA_KEY) - const relatedTarget = { - relatedTarget: toggles[i] + if (event) { + if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) { + return } - if (event && event.type === 'click') { - relatedTarget.clickEvent = event + if (/input|select|option|textarea|form/i.test(event.target.tagName)) { + return } + } - if (!context) { - continue - } + const toggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE) - const dropdownMenu = context._menu - if (!toggles[i].classList.contains(CLASS_NAME_SHOW)) { + for (let i = 0, len = toggles.length; i < len; i++) { + const context = Data.get(toggles[i], DATA_KEY) + if (!context || context._config.autoClose === false) { continue } - if (event && ((event.type === 'click' && - /input|textarea/i.test(event.target.tagName)) || - (event.type === 'keyup' && event.key === TAB_KEY)) && - dropdownMenu.contains(event.target)) { + if (!context._element.classList.contains(CLASS_NAME_SHOW)) { continue } - const hideEvent = EventHandler.trigger(parent, EVENT_HIDE, relatedTarget) - if (hideEvent.defaultPrevented) { - continue + const relatedTarget = { + relatedTarget: context._element } - // If this is a touch-enabled device we remove the extra - // empty mouseover listeners we added for iOS support - if ('ontouchstart' in document.documentElement) { - [].concat(...document.body.children) - .forEach(elem => EventHandler.off(elem, 'mouseover', null, noop())) - } + if (event) { + const composedPath = event.composedPath() + const isMenuTarget = composedPath.includes(context._menu) + if ( + composedPath.includes(context._element) || + (context._config.autoClose === 'inside' && !isMenuTarget) || + (context._config.autoClose === 'outside' && isMenuTarget) + ) { + continue + } - toggles[i].setAttribute('aria-expanded', 'false') + // Tab navigation through the dropdown menu shouldn't close the menu + if (event.type === 'keyup' && event.key === TAB_KEY && context._menu.contains(event.target)) { + continue + } - if (context._popper) { - context._popper.destroy() + if (event.type === 'click') { + relatedTarget.clickEvent = event + } } - dropdownMenu.classList.remove(CLASS_NAME_SHOW) - toggles[i].classList.remove(CLASS_NAME_SHOW) - EventHandler.trigger(parent, EVENT_HIDDEN, relatedTarget) + context._completeHide(relatedTarget) } } @@ -445,50 +482,38 @@ class Dropdown extends BaseComponent { return } + const isActive = this.classList.contains(CLASS_NAME_SHOW) + + if (!isActive && event.key === ESCAPE_KEY) { + return + } + event.preventDefault() event.stopPropagation() - if (this.disabled || this.classList.contains(CLASS_NAME_DISABLED)) { + if (isDisabled(this)) { return } - const parent = Dropdown.getParentFromElement(this) - const isActive = this.classList.contains(CLASS_NAME_SHOW) + const getToggleButton = () => this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] if (event.key === ESCAPE_KEY) { - const button = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] - button.focus() + getToggleButton().focus() Dropdown.clearMenus() return } - if (!isActive || event.key === SPACE_KEY) { - Dropdown.clearMenus() + if (!isActive && (event.key === ARROW_UP_KEY || event.key === ARROW_DOWN_KEY)) { + getToggleButton().click() return } - const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, parent).filter(isVisible) - - if (!items.length) { + if (!isActive || event.key === SPACE_KEY) { + Dropdown.clearMenus() return } - let index = items.indexOf(event.target) - - // Up - if (event.key === ARROW_UP_KEY && index > 0) { - index-- - } - - // Down - if (event.key === ARROW_DOWN_KEY && index < items.length - 1) { - index++ - } - - // index is -1 if the first keydown is an ArrowUp - index = index === -1 ? 0 : index - - items[index].focus() + Dropdown.getInstance(getToggleButton())._selectMenuItem(event) } } @@ -504,10 +529,8 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, Dropdown.clearMenus) EventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus) EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { event.preventDefault() - event.stopPropagation() - Dropdown.dropdownInterface(this, 'toggle') + Dropdown.dropdownInterface(this) }) -EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_FORM_CHILD, e => e.stopPropagation()) /** * ------------------------------------------------------------------------ @@ -516,18 +539,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_FORM_CHILD, e => e.stop * add .Dropdown to jQuery only if jQuery is present */ -onDOMContentLoaded(() => { - const $ = getjQuery() - /* istanbul ignore if */ - if ($) { - const JQUERY_NO_CONFLICT = $.fn[NAME] - $.fn[NAME] = Dropdown.jQueryInterface - $.fn[NAME].Constructor = Dropdown - $.fn[NAME].noConflict = () => { - $.fn[NAME] = JQUERY_NO_CONFLICT - return Dropdown.jQueryInterface - } - } -}) +defineJQueryPlugin(NAME, Dropdown) export default Dropdown |
