dropdown.js 12.7 KB
Newer Older
1
2
/* global Popper */

fat's avatar
fat committed
3
4
5
6
7
import Util from './util'


/**
 * --------------------------------------------------------------------------
Mark Otto's avatar
Mark Otto committed
8
 * Bootstrap (v4.0.0-alpha.6): dropdown.js
fat's avatar
fat committed
9
10
11
12
13
14
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
 * --------------------------------------------------------------------------
 */

const Dropdown = (($) => {

Johann-S's avatar
Johann-S committed
15
16
17
18
19
  /**
   * Check for Popper dependency
   * Popper - https://popper.js.org
   */
  if (typeof Popper === 'undefined') {
20
    throw new Error('Bootstrap dropdown require Popper.js (https://popper.js.org)')
Johann-S's avatar
Johann-S committed
21
  }
fat's avatar
fat committed
22
23
24
25
26
27
28

  /**
   * ------------------------------------------------------------------------
   * Constants
   * ------------------------------------------------------------------------
   */

29
  const NAME                     = 'dropdown'
Mark Otto's avatar
Mark Otto committed
30
  const VERSION                  = '4.0.0-alpha.6'
31
32
33
34
35
  const DATA_KEY                 = 'bs.dropdown'
  const EVENT_KEY                = `.${DATA_KEY}`
  const DATA_API_KEY             = '.data-api'
  const JQUERY_NO_CONFLICT       = $.fn[NAME]
  const ESCAPE_KEYCODE           = 27 // KeyboardEvent.which value for Escape (Esc) key
36
  const SPACE_KEYCODE            = 32 // KeyboardEvent.which value for space key
Pierre-Denis Vanduynslager's avatar
Pierre-Denis Vanduynslager committed
37
  const TAB_KEYCODE              = 9 // KeyboardEvent.which value for tab key
38
39
40
  const ARROW_UP_KEYCODE         = 38 // KeyboardEvent.which value for up arrow key
  const ARROW_DOWN_KEYCODE       = 40 // KeyboardEvent.which value for down arrow key
  const RIGHT_MOUSE_BUTTON_WHICH = 3 // MouseEvent.which value for the right button (assuming a right-handed mouse)
Pierre-Denis Vanduynslager's avatar
Indent    
Pierre-Denis Vanduynslager committed
41
  const REGEXP_KEYDOWN           = new RegExp(`${ARROW_UP_KEYCODE}|${ARROW_DOWN_KEYCODE}|${ESCAPE_KEYCODE}`)
fat's avatar
fat committed
42
43

  const Event = {
XhmikosR's avatar
XhmikosR committed
44
45
46
47
48
    HIDE             : `hide${EVENT_KEY}`,
    HIDDEN           : `hidden${EVENT_KEY}`,
    SHOW             : `show${EVENT_KEY}`,
    SHOWN            : `shown${EVENT_KEY}`,
    CLICK            : `click${EVENT_KEY}`,
fat's avatar
fat committed
49
    CLICK_DATA_API   : `click${EVENT_KEY}${DATA_API_KEY}`,
Pierre-Denis Vanduynslager's avatar
Pierre-Denis Vanduynslager committed
50
51
    KEYDOWN_DATA_API : `keydown${EVENT_KEY}${DATA_API_KEY}`,
    KEYUP_DATA_API   : `keyup${EVENT_KEY}${DATA_API_KEY}`
fat's avatar
fat committed
52
53
54
  }

  const ClassName = {
55
56
57
58
59
    DISABLED  : 'disabled',
    SHOW      : 'show',
    DROPUP    : 'dropup',
    MENURIGHT : 'dropdown-menu-right',
    MENULEFT  : 'dropdown-menu-left'
fat's avatar
fat committed
60
61
62
63
64
  }

  const Selector = {
    DATA_TOGGLE   : '[data-toggle="dropdown"]',
    FORM_CHILD    : '.dropdown form',
65
    MENU          : '.dropdown-menu',
fat's avatar
fat committed
66
    NAVBAR_NAV    : '.navbar-nav',
67
    VISIBLE_ITEMS : '.dropdown-menu .dropdown-item:not(.disabled)'
fat's avatar
fat committed
68
69
  }

Johann-S's avatar
Johann-S committed
70
  const AttachmentMap = {
71
72
73
74
    TOP       : 'top-start',
    TOPEND    : 'top-end',
    BOTTOM    : 'bottom-start',
    BOTTOMEND : 'bottom-end'
Johann-S's avatar
Johann-S committed
75
76
  }

Johann-S's avatar
Johann-S committed
77
  const Default = {
Johann-S's avatar
Johann-S committed
78
    placement   : AttachmentMap.BOTTOM,
79
80
    offset      : 0,
    flip        : true
Johann-S's avatar
Johann-S committed
81
82
83
  }

  const DefaultType = {
84
    placement   : 'string',
85
86
    offset      : '(number|string)',
    flip        : 'boolean'
Johann-S's avatar
Johann-S committed
87
88
  }

fat's avatar
fat committed
89
90
91
92
93
94
95
96
97

  /**
   * ------------------------------------------------------------------------
   * Class Definition
   * ------------------------------------------------------------------------
   */

  class Dropdown {

Johann-S's avatar
Johann-S committed
98
    constructor(element, config) {
99
100
101
102
103
      this._element  = element
      this._popper   = null
      this._config   = this._getConfig(config)
      this._menu     = this._getMenuElement()
      this._inNavbar = this._detectNavbar()
fat's avatar
fat committed
104
105

      this._addEventListeners()
fat's avatar
fat committed
106
107
    }

108
109
110
111
112
113
114

    // getters

    static get VERSION() {
      return VERSION
    }

Johann-S's avatar
Johann-S committed
115
116
117
118
119
120
121
122
    static get Default() {
      return Default
    }

    static get DefaultType() {
      return DefaultType
    }

fat's avatar
fat committed
123
124
125
    // public

    toggle() {
126
127
      if (this._element.disabled || $(this._element).hasClass(ClassName.DISABLED)) {
        return
fat's avatar
fat committed
128
129
      }

130
131
      const parent   = Dropdown._getParentFromElement(this._element)
      const isActive = $(this._menu).hasClass(ClassName.SHOW)
fat's avatar
fat committed
132
133
134
135

      Dropdown._clearMenus()

      if (isActive) {
136
        return
fat's avatar
fat committed
137
138
      }

139
      const relatedTarget = {
140
        relatedTarget : this._element
141
      }
Johann-S's avatar
Johann-S committed
142
      const showEvent = $.Event(Event.SHOW, relatedTarget)
fat's avatar
fat committed
143
144
145
146

      $(parent).trigger(showEvent)

      if (showEvent.isDefaultPrevented()) {
147
        return
fat's avatar
fat committed
148
149
      }

150
151
152
153
154
155
156
      let element = this._element
      // for dropup with alignment we use the parent as popper container
      if ($(parent).hasClass(ClassName.DROPUP)) {
        if ($(this._menu).hasClass(ClassName.MENULEFT) || $(this._menu).hasClass(ClassName.MENURIGHT)) {
          element = parent
        }
      }
157
      this._popper = new Popper(element, this._menu, this._getPopperConfig())
Johann-S's avatar
Johann-S committed
158

159
160
161
162
      // 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
163
164
      if ('ontouchstart' in document.documentElement &&
         !$(parent).closest(Selector.NAVBAR_NAV).length) {
165
        $('body').children().on('mouseover', null, $.noop)
166
167
      }

168
169
      this._element.focus()
      this._element.setAttribute('aria-expanded', true)
fat's avatar
fat committed
170

171
      $(this._menu).toggleClass(ClassName.SHOW)
Johann-S's avatar
Johann-S committed
172
173
174
      $(parent)
        .toggleClass(ClassName.SHOW)
        .trigger($.Event(Event.SHOWN, relatedTarget))
fat's avatar
fat committed
175
176
    }

fat's avatar
fat committed
177
178
179
180
    dispose() {
      $.removeData(this._element, DATA_KEY)
      $(this._element).off(EVENT_KEY)
      this._element = null
Johann-S's avatar
Johann-S committed
181
182
183
184
      this._menu = null
      if (this._popper !== null) {
        this._popper.destroy()
      }
185
      this._popper = null
fat's avatar
fat committed
186
187
    }

188
189
190
191
192
    update() {
      if (this._popper !== null) {
        this._popper.scheduleUpdate()
      }
    }
fat's avatar
fat committed
193
194
195
196

    // private

    _addEventListeners() {
197
198
199
200
201
      $(this._element).on(Event.CLICK, (event) => {
        event.preventDefault()
        event.stopPropagation()
        this.toggle()
      })
fat's avatar
fat committed
202
203
    }

Johann-S's avatar
Johann-S committed
204
    _getConfig(config) {
205
206
207
208
209
      const elementData = $(this._element).data()
      if (elementData.placement !== undefined) {
        elementData.placement = AttachmentMap[elementData.placement.toUpperCase()]
      }

Johann-S's avatar
Johann-S committed
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
      config = $.extend(
        {},
        this.constructor.Default,
        $(this._element).data(),
        config
      )

      Util.typeCheckConfig(
        NAME,
        config,
        this.constructor.DefaultType
      )

      return config
    }

    _getMenuElement() {
      if (!this._menu) {
228
        const parent = Dropdown._getParentFromElement(this._element)
Johann-S's avatar
Johann-S committed
229
230
231
232
        this._menu = $(parent).find(Selector.MENU)[0]
      }
      return this._menu
    }
fat's avatar
fat committed
233

234
235
236
237
238
239
240
241
242
    _getPlacement() {
      const $parentDropdown = $(this._element).parent()
      let placement = this._config.placement

      // Handle dropup
      if ($parentDropdown.hasClass(ClassName.DROPUP) || this._config.placement === AttachmentMap.TOP) {
        placement = AttachmentMap.TOP
        if ($(this._menu).hasClass(ClassName.MENURIGHT)) {
          placement = AttachmentMap.TOPEND
243
        }
Johann-S's avatar
Johann-S committed
244
245
      } else if ($(this._menu).hasClass(ClassName.MENURIGHT)) {
        placement = AttachmentMap.BOTTOMEND
246
      }
247
      return placement
248
249
    }

250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
    _detectNavbar() {
      return $(this._element).closest('.navbar').length > 0
    }

    _navbarPositioning() {
      const $parentNavbar = $(this._element).closest('.navbar')
      if ($(this._menu).hasClass(ClassName.MENURIGHT)) {
        if (!$parentNavbar.hasClass('navbar-expand')) {
          return {
            position: 'static',
            transform: '',
            float: 'none'
          }
        }
      }

      return {}
    }

    _getPopperConfig() {
      const popperConfig = {
        placement : this._getPlacement(),
        modifiers : {
          offset : {
            offset : this._config.offset
          },
          flip : {
            enabled : this._config.flip
          }
        }
      }

      if (this._inNavbar) {
        popperConfig.modifiers.AfterApplyStyle = {
          enabled: true,
          order: 901, // ApplyStyle order + 1
          fn: () => {
            // reset Popper styles
            $(this._menu).attr('style', '')
          }
        }
      }
      return popperConfig
    }

fat's avatar
fat committed
295
296
297
298
    // static

    static _jQueryInterface(config) {
      return this.each(function () {
299
        let data = $(this).data(DATA_KEY)
300
        const _config = typeof config === 'object' ? config : null
fat's avatar
fat committed
301
302

        if (!data) {
Johann-S's avatar
Johann-S committed
303
          data = new Dropdown(this, _config)
304
          $(this).data(DATA_KEY, data)
fat's avatar
fat committed
305
306
307
        }

        if (typeof config === 'string') {
308
309
310
          if (data[config] === undefined) {
            throw new Error(`No method named "${config}"`)
          }
311
          data[config]()
fat's avatar
fat committed
312
313
314
315
316
        }
      })
    }

    static _clearMenus(event) {
Pierre-Denis Vanduynslager's avatar
Pierre-Denis Vanduynslager committed
317
318
      if (event && (event.which === RIGHT_MOUSE_BUTTON_WHICH ||
        event.type === 'keyup' && event.which !== TAB_KEYCODE)) {
fat's avatar
fat committed
319
320
321
        return
      }

322
      const toggles = $.makeArray($(Selector.DATA_TOGGLE))
fat's avatar
fat committed
323
      for (let i = 0; i < toggles.length; i++) {
324
        const parent        = Dropdown._getParentFromElement(toggles[i])
325
        const context       = $(toggles[i]).data(DATA_KEY)
326
327
328
        const relatedTarget = {
          relatedTarget : toggles[i]
        }
fat's avatar
fat committed
329

Johann-S's avatar
Johann-S committed
330
331
332
333
        if (!context) {
          continue
        }

334
        const dropdownMenu = context._menu
Starsam80's avatar
Starsam80 committed
335
        if (!$(parent).hasClass(ClassName.SHOW)) {
fat's avatar
fat committed
336
337
338
          continue
        }

339
        if (event && (event.type === 'click' &&
Pierre-Denis Vanduynslager's avatar
Pierre-Denis Vanduynslager committed
340
            /input|textarea/i.test(event.target.tagName) || event.type === 'keyup' && event.which === TAB_KEYCODE)
341
            && $.contains(parent, event.target)) {
fat's avatar
fat committed
342
343
344
          continue
        }

345
        const hideEvent = $.Event(Event.HIDE, relatedTarget)
fat's avatar
fat committed
346
347
348
349
350
        $(parent).trigger(hideEvent)
        if (hideEvent.isDefaultPrevented()) {
          continue
        }

351
352
353
        // if this is a touch-enabled device we remove the extra
        // empty mouseover listeners we added for iOS support
        if ('ontouchstart' in document.documentElement) {
354
          $('body').children().off('mouseover', null, $.noop)
355
356
        }

fat's avatar
fat committed
357
358
        toggles[i].setAttribute('aria-expanded', 'false')

Johann-S's avatar
Johann-S committed
359
        $(dropdownMenu).removeClass(ClassName.SHOW)
fat's avatar
fat committed
360
        $(parent)
Starsam80's avatar
Starsam80 committed
361
          .removeClass(ClassName.SHOW)
Jacob Thornton's avatar
Jacob Thornton committed
362
          .trigger($.Event(Event.HIDDEN, relatedTarget))
fat's avatar
fat committed
363
364
365
366
367
      }
    }

    static _getParentFromElement(element) {
      let parent
368
      const selector = Util.getSelectorFromElement(element)
fat's avatar
fat committed
369
370
371
372
373
374
375
376
377

      if (selector) {
        parent = $(selector)[0]
      }

      return parent || element.parentNode
    }

    static _dataApiKeydownHandler(event) {
Pierre-Denis Vanduynslager's avatar
Pierre-Denis Vanduynslager committed
378
      if (!REGEXP_KEYDOWN.test(event.which) || /button/i.test(event.target.tagName) && event.which === SPACE_KEYCODE ||
fat's avatar
fat committed
379
380
381
382
383
384
385
386
387
388
389
         /input|textarea/i.test(event.target.tagName)) {
        return
      }

      event.preventDefault()
      event.stopPropagation()

      if (this.disabled || $(this).hasClass(ClassName.DISABLED)) {
        return
      }

390
      const parent   = Dropdown._getParentFromElement(this)
Starsam80's avatar
Starsam80 committed
391
      const isActive = $(parent).hasClass(ClassName.SHOW)
fat's avatar
fat committed
392

393
394
      if (!isActive && (event.which !== ESCAPE_KEYCODE || event.which !== SPACE_KEYCODE) ||
           isActive && (event.which === ESCAPE_KEYCODE || event.which === SPACE_KEYCODE)) {
fat's avatar
fat committed
395

396
        if (event.which === ESCAPE_KEYCODE) {
397
          const toggle = $(parent).find(Selector.DATA_TOGGLE)[0]
fat's avatar
fat committed
398
399
400
401
402
403
404
          $(toggle).trigger('focus')
        }

        $(this).trigger('click')
        return
      }

405
      const items = $(parent).find(Selector.VISIBLE_ITEMS).get()
fat's avatar
fat committed
406
407
408
409
410
411
412

      if (!items.length) {
        return
      }

      let index = items.indexOf(event.target)

413
      if (event.which === ARROW_UP_KEYCODE && index > 0) { // up
Jacob Thornton's avatar
Jacob Thornton committed
414
415
416
        index--
      }

417
      if (event.which === ARROW_DOWN_KEYCODE && index < items.length - 1) { // down
Jacob Thornton's avatar
Jacob Thornton committed
418
419
420
        index++
      }

421
      if (index < 0) {
Jacob Thornton's avatar
Jacob Thornton committed
422
423
        index = 0
      }
fat's avatar
fat committed
424
425
426
427
428
429
430
431
432
433
434
435
436
437

      items[index].focus()
    }

  }


  /**
   * ------------------------------------------------------------------------
   * Data Api implementation
   * ------------------------------------------------------------------------
   */

  $(document)
fat's avatar
fat committed
438
    .on(Event.KEYDOWN_DATA_API, Selector.DATA_TOGGLE,  Dropdown._dataApiKeydownHandler)
439
    .on(Event.KEYDOWN_DATA_API, Selector.MENU, Dropdown._dataApiKeydownHandler)
Pierre-Denis Vanduynslager's avatar
Pierre-Denis Vanduynslager committed
440
    .on(`${Event.CLICK_DATA_API} ${Event.KEYUP_DATA_API}`, Dropdown._clearMenus)
441
442
443
444
445
    .on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) {
      event.preventDefault()
      event.stopPropagation()
      Dropdown._jQueryInterface.call($(this), 'toggle')
    })
Jacob Thornton's avatar
Jacob Thornton committed
446
447
448
    .on(Event.CLICK_DATA_API, Selector.FORM_CHILD, (e) => {
      e.stopPropagation()
    })
fat's avatar
fat committed
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468


  /**
   * ------------------------------------------------------------------------
   * jQuery
   * ------------------------------------------------------------------------
   */

  $.fn[NAME]             = Dropdown._jQueryInterface
  $.fn[NAME].Constructor = Dropdown
  $.fn[NAME].noConflict  = function () {
    $.fn[NAME] = JQUERY_NO_CONFLICT
    return Dropdown._jQueryInterface
  }

  return Dropdown

})(jQuery)

export default Dropdown