dropdown.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. /**
  2. * --------------------------------------------------------------------------
  3. * Bootstrap (v4.4.1): dropdown.js
  4. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  5. * --------------------------------------------------------------------------
  6. */
  7. import $ from 'jquery'
  8. import Popper from 'popper.js'
  9. import Util from './util'
  10. /**
  11. * ------------------------------------------------------------------------
  12. * Constants
  13. * ------------------------------------------------------------------------
  14. */
  15. const NAME = 'dropdown'
  16. const VERSION = '4.4.1'
  17. const DATA_KEY = 'bs.dropdown'
  18. const EVENT_KEY = `.${DATA_KEY}`
  19. const DATA_API_KEY = '.data-api'
  20. const JQUERY_NO_CONFLICT = $.fn[NAME]
  21. const ESCAPE_KEYCODE = 27 // KeyboardEvent.which value for Escape (Esc) key
  22. const SPACE_KEYCODE = 32 // KeyboardEvent.which value for space key
  23. const TAB_KEYCODE = 9 // KeyboardEvent.which value for tab key
  24. const ARROW_UP_KEYCODE = 38 // KeyboardEvent.which value for up arrow key
  25. const ARROW_DOWN_KEYCODE = 40 // KeyboardEvent.which value for down arrow key
  26. const RIGHT_MOUSE_BUTTON_WHICH = 3 // MouseEvent.which value for the right button (assuming a right-handed mouse)
  27. const REGEXP_KEYDOWN = new RegExp(`${ARROW_UP_KEYCODE}|${ARROW_DOWN_KEYCODE}|${ESCAPE_KEYCODE}`)
  28. const Event = {
  29. HIDE : `hide${EVENT_KEY}`,
  30. HIDDEN : `hidden${EVENT_KEY}`,
  31. SHOW : `show${EVENT_KEY}`,
  32. SHOWN : `shown${EVENT_KEY}`,
  33. CLICK : `click${EVENT_KEY}`,
  34. CLICK_DATA_API : `click${EVENT_KEY}${DATA_API_KEY}`,
  35. KEYDOWN_DATA_API : `keydown${EVENT_KEY}${DATA_API_KEY}`,
  36. KEYUP_DATA_API : `keyup${EVENT_KEY}${DATA_API_KEY}`
  37. }
  38. const ClassName = {
  39. DISABLED : 'disabled',
  40. SHOW : 'show',
  41. DROPUP : 'dropup',
  42. DROPRIGHT : 'dropright',
  43. DROPLEFT : 'dropleft',
  44. MENURIGHT : 'dropdown-menu-right',
  45. MENULEFT : 'dropdown-menu-left',
  46. POSITION_STATIC : 'position-static'
  47. }
  48. const Selector = {
  49. DATA_TOGGLE : '[data-toggle="dropdown"]',
  50. FORM_CHILD : '.dropdown form',
  51. MENU : '.dropdown-menu',
  52. NAVBAR_NAV : '.navbar-nav',
  53. VISIBLE_ITEMS : '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'
  54. }
  55. const AttachmentMap = {
  56. TOP : 'top-start',
  57. TOPEND : 'top-end',
  58. BOTTOM : 'bottom-start',
  59. BOTTOMEND : 'bottom-end',
  60. RIGHT : 'right-start',
  61. RIGHTEND : 'right-end',
  62. LEFT : 'left-start',
  63. LEFTEND : 'left-end'
  64. }
  65. const Default = {
  66. offset : 0,
  67. flip : true,
  68. boundary : 'scrollParent',
  69. reference : 'toggle',
  70. display : 'dynamic',
  71. popperConfig : null
  72. }
  73. const DefaultType = {
  74. offset : '(number|string|function)',
  75. flip : 'boolean',
  76. boundary : '(string|element)',
  77. reference : '(string|element)',
  78. display : 'string',
  79. popperConfig : '(null|object)'
  80. }
  81. /**
  82. * ------------------------------------------------------------------------
  83. * Class Definition
  84. * ------------------------------------------------------------------------
  85. */
  86. class Dropdown {
  87. constructor(element, config) {
  88. this._element = element
  89. this._popper = null
  90. this._config = this._getConfig(config)
  91. this._menu = this._getMenuElement()
  92. this._inNavbar = this._detectNavbar()
  93. this._addEventListeners()
  94. }
  95. // Getters
  96. static get VERSION() {
  97. return VERSION
  98. }
  99. static get Default() {
  100. return Default
  101. }
  102. static get DefaultType() {
  103. return DefaultType
  104. }
  105. // Public
  106. toggle() {
  107. if (this._element.disabled || $(this._element).hasClass(ClassName.DISABLED)) {
  108. return
  109. }
  110. const isActive = $(this._menu).hasClass(ClassName.SHOW)
  111. Dropdown._clearMenus()
  112. if (isActive) {
  113. return
  114. }
  115. this.show(true)
  116. }
  117. show(usePopper = false) {
  118. if (this._element.disabled || $(this._element).hasClass(ClassName.DISABLED) || $(this._menu).hasClass(ClassName.SHOW)) {
  119. return
  120. }
  121. const relatedTarget = {
  122. relatedTarget: this._element
  123. }
  124. const showEvent = $.Event(Event.SHOW, relatedTarget)
  125. const parent = Dropdown._getParentFromElement(this._element)
  126. $(parent).trigger(showEvent)
  127. if (showEvent.isDefaultPrevented()) {
  128. return
  129. }
  130. // Disable totally Popper.js for Dropdown in Navbar
  131. if (!this._inNavbar && usePopper) {
  132. /**
  133. * Check for Popper dependency
  134. * Popper - https://popper.js.org
  135. */
  136. if (typeof Popper === 'undefined') {
  137. throw new TypeError('Bootstrap\'s dropdowns require Popper.js (https://popper.js.org/)')
  138. }
  139. let referenceElement = this._element
  140. if (this._config.reference === 'parent') {
  141. referenceElement = parent
  142. } else if (Util.isElement(this._config.reference)) {
  143. referenceElement = this._config.reference
  144. // Check if it's jQuery element
  145. if (typeof this._config.reference.jquery !== 'undefined') {
  146. referenceElement = this._config.reference[0]
  147. }
  148. }
  149. // If boundary is not `scrollParent`, then set position to `static`
  150. // to allow the menu to "escape" the scroll parent's boundaries
  151. // https://github.com/twbs/bootstrap/issues/24251
  152. if (this._config.boundary !== 'scrollParent') {
  153. $(parent).addClass(ClassName.POSITION_STATIC)
  154. }
  155. this._popper = new Popper(referenceElement, this._menu, this._getPopperConfig())
  156. }
  157. // If this is a touch-enabled device we add extra
  158. // empty mouseover listeners to the body's immediate children;
  159. // only needed because of broken event delegation on iOS
  160. // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
  161. if ('ontouchstart' in document.documentElement &&
  162. $(parent).closest(Selector.NAVBAR_NAV).length === 0) {
  163. $(document.body).children().on('mouseover', null, $.noop)
  164. }
  165. this._element.focus()
  166. this._element.setAttribute('aria-expanded', true)
  167. $(this._menu).toggleClass(ClassName.SHOW)
  168. $(parent)
  169. .toggleClass(ClassName.SHOW)
  170. .trigger($.Event(Event.SHOWN, relatedTarget))
  171. }
  172. hide() {
  173. if (this._element.disabled || $(this._element).hasClass(ClassName.DISABLED) || !$(this._menu).hasClass(ClassName.SHOW)) {
  174. return
  175. }
  176. const relatedTarget = {
  177. relatedTarget: this._element
  178. }
  179. const hideEvent = $.Event(Event.HIDE, relatedTarget)
  180. const parent = Dropdown._getParentFromElement(this._element)
  181. $(parent).trigger(hideEvent)
  182. if (hideEvent.isDefaultPrevented()) {
  183. return
  184. }
  185. if (this._popper) {
  186. this._popper.destroy()
  187. }
  188. $(this._menu).toggleClass(ClassName.SHOW)
  189. $(parent)
  190. .toggleClass(ClassName.SHOW)
  191. .trigger($.Event(Event.HIDDEN, relatedTarget))
  192. }
  193. dispose() {
  194. $.removeData(this._element, DATA_KEY)
  195. $(this._element).off(EVENT_KEY)
  196. this._element = null
  197. this._menu = null
  198. if (this._popper !== null) {
  199. this._popper.destroy()
  200. this._popper = null
  201. }
  202. }
  203. update() {
  204. this._inNavbar = this._detectNavbar()
  205. if (this._popper !== null) {
  206. this._popper.scheduleUpdate()
  207. }
  208. }
  209. // Private
  210. _addEventListeners() {
  211. $(this._element).on(Event.CLICK, (event) => {
  212. event.preventDefault()
  213. event.stopPropagation()
  214. this.toggle()
  215. })
  216. }
  217. _getConfig(config) {
  218. config = {
  219. ...this.constructor.Default,
  220. ...$(this._element).data(),
  221. ...config
  222. }
  223. Util.typeCheckConfig(
  224. NAME,
  225. config,
  226. this.constructor.DefaultType
  227. )
  228. return config
  229. }
  230. _getMenuElement() {
  231. if (!this._menu) {
  232. const parent = Dropdown._getParentFromElement(this._element)
  233. if (parent) {
  234. this._menu = parent.querySelector(Selector.MENU)
  235. }
  236. }
  237. return this._menu
  238. }
  239. _getPlacement() {
  240. const $parentDropdown = $(this._element.parentNode)
  241. let placement = AttachmentMap.BOTTOM
  242. // Handle dropup
  243. if ($parentDropdown.hasClass(ClassName.DROPUP)) {
  244. placement = AttachmentMap.TOP
  245. if ($(this._menu).hasClass(ClassName.MENURIGHT)) {
  246. placement = AttachmentMap.TOPEND
  247. }
  248. } else if ($parentDropdown.hasClass(ClassName.DROPRIGHT)) {
  249. placement = AttachmentMap.RIGHT
  250. } else if ($parentDropdown.hasClass(ClassName.DROPLEFT)) {
  251. placement = AttachmentMap.LEFT
  252. } else if ($(this._menu).hasClass(ClassName.MENURIGHT)) {
  253. placement = AttachmentMap.BOTTOMEND
  254. }
  255. return placement
  256. }
  257. _detectNavbar() {
  258. return $(this._element).closest('.navbar').length > 0
  259. }
  260. _getOffset() {
  261. const offset = {}
  262. if (typeof this._config.offset === 'function') {
  263. offset.fn = (data) => {
  264. data.offsets = {
  265. ...data.offsets,
  266. ...this._config.offset(data.offsets, this._element) || {}
  267. }
  268. return data
  269. }
  270. } else {
  271. offset.offset = this._config.offset
  272. }
  273. return offset
  274. }
  275. _getPopperConfig() {
  276. const popperConfig = {
  277. placement: this._getPlacement(),
  278. modifiers: {
  279. offset: this._getOffset(),
  280. flip: {
  281. enabled: this._config.flip
  282. },
  283. preventOverflow: {
  284. boundariesElement: this._config.boundary
  285. }
  286. }
  287. }
  288. // Disable Popper.js if we have a static display
  289. if (this._config.display === 'static') {
  290. popperConfig.modifiers.applyStyle = {
  291. enabled: false
  292. }
  293. }
  294. return {
  295. ...popperConfig,
  296. ...this._config.popperConfig
  297. }
  298. }
  299. // Static
  300. static _jQueryInterface(config) {
  301. return this.each(function () {
  302. let data = $(this).data(DATA_KEY)
  303. const _config = typeof config === 'object' ? config : null
  304. if (!data) {
  305. data = new Dropdown(this, _config)
  306. $(this).data(DATA_KEY, data)
  307. }
  308. if (typeof config === 'string') {
  309. if (typeof data[config] === 'undefined') {
  310. throw new TypeError(`No method named "${config}"`)
  311. }
  312. data[config]()
  313. }
  314. })
  315. }
  316. static _clearMenus(event) {
  317. if (event && (event.which === RIGHT_MOUSE_BUTTON_WHICH ||
  318. event.type === 'keyup' && event.which !== TAB_KEYCODE)) {
  319. return
  320. }
  321. const toggles = [].slice.call(document.querySelectorAll(Selector.DATA_TOGGLE))
  322. for (let i = 0, len = toggles.length; i < len; i++) {
  323. const parent = Dropdown._getParentFromElement(toggles[i])
  324. const context = $(toggles[i]).data(DATA_KEY)
  325. const relatedTarget = {
  326. relatedTarget: toggles[i]
  327. }
  328. if (event && event.type === 'click') {
  329. relatedTarget.clickEvent = event
  330. }
  331. if (!context) {
  332. continue
  333. }
  334. const dropdownMenu = context._menu
  335. if (!$(parent).hasClass(ClassName.SHOW)) {
  336. continue
  337. }
  338. if (event && (event.type === 'click' &&
  339. /input|textarea/i.test(event.target.tagName) || event.type === 'keyup' && event.which === TAB_KEYCODE) &&
  340. $.contains(parent, event.target)) {
  341. continue
  342. }
  343. const hideEvent = $.Event(Event.HIDE, relatedTarget)
  344. $(parent).trigger(hideEvent)
  345. if (hideEvent.isDefaultPrevented()) {
  346. continue
  347. }
  348. // If this is a touch-enabled device we remove the extra
  349. // empty mouseover listeners we added for iOS support
  350. if ('ontouchstart' in document.documentElement) {
  351. $(document.body).children().off('mouseover', null, $.noop)
  352. }
  353. toggles[i].setAttribute('aria-expanded', 'false')
  354. if (context._popper) {
  355. context._popper.destroy()
  356. }
  357. $(dropdownMenu).removeClass(ClassName.SHOW)
  358. $(parent)
  359. .removeClass(ClassName.SHOW)
  360. .trigger($.Event(Event.HIDDEN, relatedTarget))
  361. }
  362. }
  363. static _getParentFromElement(element) {
  364. let parent
  365. const selector = Util.getSelectorFromElement(element)
  366. if (selector) {
  367. parent = document.querySelector(selector)
  368. }
  369. return parent || element.parentNode
  370. }
  371. // eslint-disable-next-line complexity
  372. static _dataApiKeydownHandler(event) {
  373. // If not input/textarea:
  374. // - And not a key in REGEXP_KEYDOWN => not a dropdown command
  375. // If input/textarea:
  376. // - If space key => not a dropdown command
  377. // - If key is other than escape
  378. // - If key is not up or down => not a dropdown command
  379. // - If trigger inside the menu => not a dropdown command
  380. if (/input|textarea/i.test(event.target.tagName)
  381. ? event.which === SPACE_KEYCODE || event.which !== ESCAPE_KEYCODE &&
  382. (event.which !== ARROW_DOWN_KEYCODE && event.which !== ARROW_UP_KEYCODE ||
  383. $(event.target).closest(Selector.MENU).length) : !REGEXP_KEYDOWN.test(event.which)) {
  384. return
  385. }
  386. event.preventDefault()
  387. event.stopPropagation()
  388. if (this.disabled || $(this).hasClass(ClassName.DISABLED)) {
  389. return
  390. }
  391. const parent = Dropdown._getParentFromElement(this)
  392. const isActive = $(parent).hasClass(ClassName.SHOW)
  393. if (!isActive && event.which === ESCAPE_KEYCODE) {
  394. return
  395. }
  396. if (!isActive || isActive && (event.which === ESCAPE_KEYCODE || event.which === SPACE_KEYCODE)) {
  397. if (event.which === ESCAPE_KEYCODE) {
  398. const toggle = parent.querySelector(Selector.DATA_TOGGLE)
  399. $(toggle).trigger('focus')
  400. }
  401. $(this).trigger('click')
  402. return
  403. }
  404. const items = [].slice.call(parent.querySelectorAll(Selector.VISIBLE_ITEMS))
  405. .filter((item) => $(item).is(':visible'))
  406. if (items.length === 0) {
  407. return
  408. }
  409. let index = items.indexOf(event.target)
  410. if (event.which === ARROW_UP_KEYCODE && index > 0) { // Up
  411. index--
  412. }
  413. if (event.which === ARROW_DOWN_KEYCODE && index < items.length - 1) { // Down
  414. index++
  415. }
  416. if (index < 0) {
  417. index = 0
  418. }
  419. items[index].focus()
  420. }
  421. }
  422. /**
  423. * ------------------------------------------------------------------------
  424. * Data Api implementation
  425. * ------------------------------------------------------------------------
  426. */
  427. $(document)
  428. .on(Event.KEYDOWN_DATA_API, Selector.DATA_TOGGLE, Dropdown._dataApiKeydownHandler)
  429. .on(Event.KEYDOWN_DATA_API, Selector.MENU, Dropdown._dataApiKeydownHandler)
  430. .on(`${Event.CLICK_DATA_API} ${Event.KEYUP_DATA_API}`, Dropdown._clearMenus)
  431. .on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) {
  432. event.preventDefault()
  433. event.stopPropagation()
  434. Dropdown._jQueryInterface.call($(this), 'toggle')
  435. })
  436. .on(Event.CLICK_DATA_API, Selector.FORM_CHILD, (e) => {
  437. e.stopPropagation()
  438. })
  439. /**
  440. * ------------------------------------------------------------------------
  441. * jQuery
  442. * ------------------------------------------------------------------------
  443. */
  444. $.fn[NAME] = Dropdown._jQueryInterface
  445. $.fn[NAME].Constructor = Dropdown
  446. $.fn[NAME].noConflict = () => {
  447. $.fn[NAME] = JQUERY_NO_CONFLICT
  448. return Dropdown._jQueryInterface
  449. }
  450. export default Dropdown