forked from bootstrap-vue/bootstrap-vue
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathdropdown.js
436 lines (427 loc) · 11.4 KB
/
dropdown.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
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
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
import Popper from 'popper.js'
import BvEvent from '../utils/bv-event.class'
import KeyCodes from '../utils/key-codes'
import warn from '../utils/warn'
import { closest, contains, isVisible, requestAF, selectAll } from '../utils/dom'
import { isNull } from '../utils/inspect'
import clickOutMixin from './click-out'
import focusInMixin from './focus-in'
// Return an array of visible items
const filterVisibles = els => (els || []).filter(isVisible)
// Dropdown item CSS selectors
const Selector = {
FORM_CHILD: '.dropdown form',
ITEM_SELECTOR: ['.dropdown-item', '.b-dropdown-form']
.map(selector => `${selector}:not(.disabled):not([disabled])`)
.join(', ')
}
// Popper attachment positions
const AttachmentMap = {
// Dropup left align
TOP: 'top-start',
// Dropup right align
TOPEND: 'top-end',
// Dropdown left align
BOTTOM: 'bottom-start',
// Dropdown right align
BOTTOMEND: 'bottom-end',
// Dropright left align
RIGHT: 'right-start',
// Dropright right align
RIGHTEND: 'right-end',
// Dropleft left align
LEFT: 'left-start',
// Dropleft right align
LEFTEND: 'left-end'
}
// @vue/component
export default {
mixins: [clickOutMixin, focusInMixin],
provide() {
return {
bvDropdown: this
}
},
props: {
disabled: {
type: Boolean,
default: false
},
text: {
// Button label
type: String,
default: ''
},
html: {
// Button label
type: String
},
dropup: {
// place on top if possible
type: Boolean,
default: false
},
dropright: {
// place right if possible
type: Boolean,
default: false
},
dropleft: {
// place left if possible
type: Boolean,
default: false
},
right: {
// Right align menu (default is left align)
type: Boolean,
default: false
},
offset: {
// Number of pixels to offset menu, or a CSS unit value (i.e. 1px, 1rem, etc)
type: [Number, String],
default: 0
},
noFlip: {
// Disable auto-flipping of menu from bottom<=>top
type: Boolean,
default: false
},
lazy: {
// If true, only render menu contents when open
type: Boolean,
default: false
},
popperOpts: {
// type: Object,
default: () => {}
}
},
data() {
return {
visible: false,
inNavbar: null,
visibleChangePrevented: false
}
},
computed: {
toggler() {
const toggle = this.$refs.toggle
return toggle ? toggle.$el || toggle : null
},
directionClass() {
if (this.dropup) {
return 'dropup'
} else if (this.dropright) {
return 'dropright'
} else if (this.dropleft) {
return 'dropleft'
}
return ''
}
},
watch: {
visible(newValue, oldValue) {
if (this.visibleChangePrevented) {
this.visibleChangePrevented = false
return
}
if (newValue !== oldValue) {
const evtName = newValue ? 'show' : 'hide'
const bvEvt = new BvEvent(evtName, {
cancelable: true,
vueTarget: this,
target: this.$refs.menu,
relatedTarget: null
})
this.emitEvent(bvEvt)
if (bvEvt.defaultPrevented) {
// Reset value and exit if canceled
this.visibleChangePrevented = true
this.visible = oldValue
// Just in case a child element triggered this.hide(true)
this.$off('hidden', this.focusToggler)
return
}
if (evtName === 'show') {
this.showMenu()
} else {
this.hideMenu()
}
}
},
disabled(newValue, oldValue) {
if (newValue !== oldValue && newValue && this.visible) {
// Hide dropdown if disabled changes to true
this.visible = false
}
}
},
created() {
// Create non-reactive property
this._popper = null
},
deactivated() /* istanbul ignore next: not easy to test */ {
// In case we are inside a `<keep-alive>`
this.visible = false
this.whileOpenListen(false)
this.removePopper()
},
beforeDestroy() {
this.visible = false
this.whileOpenListen(false)
this.removePopper()
},
methods: {
// Event emitter
emitEvent(bvEvt) {
const type = bvEvt.type
this.$emit(type, bvEvt)
this.$root.$emit(`bv::dropdown::${type}`, bvEvt)
},
showMenu() {
if (this.disabled) {
/* istanbul ignore next */
return
}
// Ensure other menus are closed
this.$root.$emit('bv::dropdown::shown', this)
// Are we in a navbar ?
if (isNull(this.inNavbar) && this.isNav) {
// We should use an injection for this
/* istanbul ignore next */
this.inNavbar = Boolean(closest('.navbar', this.$el))
}
// Disable totally Popper.js for Dropdown in Navbar
if (!this.inNavbar) {
if (typeof Popper === 'undefined') {
/* istanbul ignore next */
warn('b-dropdown: Popper.js not found. Falling back to CSS positioning.')
} else {
// for dropup with alignment we use the parent element as popper container
let element = (this.dropup && this.right) || this.split ? this.$el : this.$refs.toggle
// Make sure we have a reference to an element, not a component!
element = element.$el || element
// Instantiate popper.js
this.createPopper(element)
}
}
this.whileOpenListen(true)
// Wrap in nextTick to ensure menu is fully rendered/shown
this.$nextTick(() => {
// Focus on the menu container on show
this.focusMenu()
// Emit the shown event
this.$emit('shown')
})
},
hideMenu() {
this.whileOpenListen(false)
this.$root.$emit('bv::dropdown::hidden', this)
this.$emit('hidden')
this.removePopper()
},
createPopper(element) {
this.removePopper()
this._popper = new Popper(element, this.$refs.menu, this.getPopperConfig())
},
removePopper() {
if (this._popper) {
// Ensure popper event listeners are removed cleanly
this._popper.destroy()
}
this._popper = null
},
getPopperConfig() {
let placement = AttachmentMap.BOTTOM
if (this.dropup) {
placement = this.right ? AttachmentMap.TOPEND : AttachmentMap.TOP
} else if (this.dropright) {
placement = AttachmentMap.RIGHT
} else if (this.dropleft) {
placement = AttachmentMap.LEFT
} else if (this.right) {
placement = AttachmentMap.BOTTOMEND
}
const popperConfig = {
placement,
modifiers: {
offset: { offset: this.offset || 0 },
flip: { enabled: !this.noFlip }
}
}
if (this.boundary) {
popperConfig.modifiers.preventOverflow = { boundariesElement: this.boundary }
}
return { ...popperConfig, ...(this.popperOpts || {}) }
},
whileOpenListen(open) {
// turn listeners on/off while open
if (open) {
// If another dropdown is opened
this.$root.$on('bv::dropdown::shown', this.rootCloseListener)
// Hide the dropdown when clicked outside
this.listenForClickOut = true
// Hide the dropdown when it loses focus
this.listenForFocusIn = true
} else {
this.$root.$off('bv::dropdown::shown', this.rootCloseListener)
this.listenForClickOut = false
this.listenForFocusIn = false
}
},
rootCloseListener(vm) {
if (vm !== this) {
this.visible = false
}
},
show() {
// Public method to show dropdown
if (this.disabled) {
return
}
// Wrap in a requestAnimationFrame to allow any previous
// click handling to occur first
requestAF(() => {
this.visible = true
})
},
hide(refocus = false) {
// Public method to hide dropdown
if (this.disabled) {
/* istanbul ignore next */
return
}
this.visible = false
if (refocus) {
// Child element is closing the dropdown on click
this.$once('hidden', this.focusToggler)
}
},
// Called only by a button that toggles the menu
toggle(evt) {
evt = evt || {}
const type = evt.type
const key = evt.keyCode
if (
type !== 'click' &&
!(
type === 'keydown' &&
(key === KeyCodes.ENTER || key === KeyCodes.SPACE || key === KeyCodes.DOWN)
)
) {
// We only toggle on Click, Enter, Space, and Arrow Down
/* istanbul ignore next */
return
}
/* istanbul ignore next */
if (this.disabled) {
this.visible = false
return
}
this.$emit('toggle', evt)
evt.preventDefault()
evt.stopPropagation()
// Toggle visibility
if (this.visible) {
this.hide(true)
} else {
this.show()
}
},
// Called only in split button mode, for the split button
click(evt) {
/* istanbul ignore next */
if (this.disabled) {
this.visible = false
return
}
this.$emit('click', evt)
},
// Called from dropdown menu context
onKeydown(evt) {
const key = evt.keyCode
if (key === KeyCodes.ESC) {
// Close on ESC
this.onEsc(evt)
} else if (key === KeyCodes.DOWN) {
// Down Arrow
this.focusNext(evt, false)
} else if (key === KeyCodes.UP) {
// Up Arrow
this.focusNext(evt, true)
}
},
onEsc(evt) {
if (this.visible) {
this.visible = false
evt.preventDefault()
evt.stopPropagation()
// Return focus to original trigger button
this.$once('hidden', this.focusToggler)
}
},
// Document click out listener
clickOutHandler() {
if (this.visible) {
this.visible = false
}
},
// Document focusin listener
focusInHandler(evt) {
const target = evt.target
// If focus leaves dropdown, hide it
if (this.visible && !contains(this.$refs.menu, target) && !contains(this.toggler, target)) {
this.visible = false
}
},
// Keyboard nav
focusNext(evt, up) {
// Ignore key up/down on form elements
if (!this.visible || (evt && closest(Selector.FORM_CHILD, evt.target))) {
/* istanbul ignore next: should never happen */
return
}
evt.preventDefault()
evt.stopPropagation()
this.$nextTick(() => {
const items = this.getItems()
if (items.length < 1) {
/* istanbul ignore next: should never happen */
return
}
let index = items.indexOf(evt.target)
if (up && index > 0) {
index--
} else if (!up && index < items.length - 1) {
index++
}
if (index < 0) {
/* istanbul ignore next: should never happen */
index = 0
}
this.focusItem(index, items)
})
},
focusItem(idx, items) {
const el = items.find((el, i) => i === idx)
if (el && el.focus) {
el.focus()
}
},
getItems() {
// Get all items
return filterVisibles(selectAll(Selector.ITEM_SELECTOR, this.$refs.menu))
},
focusMenu() {
this.$refs.menu.focus && this.$refs.menu.focus()
},
focusToggler() {
this.$nextTick(() => {
const toggler = this.toggler
if (toggler && toggler.focus) {
toggler.focus()
}
})
}
}
}