aboutsummaryrefslogtreecommitdiff
path: root/js/src/dropdown.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/src/dropdown.js')
-rw-r--r--js/src/dropdown.js359
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