dropdown.js 14 KB

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