Skip to content

Commit c462e0a

Browse files
authored
fix(tabs): prevent double input event on mount, and add additional tests (#2748)
1 parent 9a9f39f commit c462e0a

File tree

6 files changed

+198
-74
lines changed

6 files changed

+198
-74
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@
147147
"^.+\\.js$": "babel-jest",
148148
".*\\.(vue)$": "vue-jest"
149149
},
150-
"coverageDirectory": "./coverage/"
150+
"coverageDirectory": "./coverage/",
151+
"testEnvironmentOptions": {
152+
"pretendToBeVisual": true
153+
}
151154
},
152155
"keywords": [
153156
"Bootstrap",

src/components/tabs/tab.js

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import idMixin from '../../mixins/id'
2+
import { requestAF } from '../../utils/dom'
23

34
// @vue/component
45
export default {
@@ -137,26 +138,14 @@ export default {
137138
},
138139
methods: {
139140
// Transition handlers
140-
beforeEnter() /* instanbul ignore next: difficult to test rAF in JSDOM */ {
141+
beforeEnter() {
141142
// change opacity (add 'show' class) 1 frame after display
142143
// otherwise css transition won't happen
143-
// TODO: Move raf method into utils/dom.js
144-
const raf =
145-
window.requestAnimationFrame ||
146-
window.webkitRequestAnimationFrame ||
147-
window.mozRequestAnimationFrame ||
148-
window.msRequestAnimationFrame ||
149-
window.oRequestAnimationFrame ||
150-
/* istanbul ignore next */
151-
function(cb) {
152-
setTimeout(cb, 16)
153-
}
154-
155-
raf(() => {
144+
requestAF(() => {
156145
this.show = true
157146
})
158147
},
159-
beforeLeave() /* instanbul ignore next: difficult to test rAF in JSDOM */ {
148+
beforeLeave() {
160149
// Remove the 'show' class
161150
this.show = false
162151
},
@@ -185,7 +174,15 @@ export default {
185174
ref: 'panel',
186175
staticClass: 'tab-pane',
187176
class: this.tabClasses,
188-
directives: [{ name: 'show', value: this.localActive }],
177+
directives: [
178+
// TODO: convert to style object in render
179+
{
180+
name: 'show',
181+
rawName: 'v-show',
182+
value: this.localActive,
183+
expression: 'localActive'
184+
}
185+
],
189186
attrs: {
190187
role: 'tabpanel',
191188
id: this.safeId(),
@@ -201,7 +198,16 @@ export default {
201198
return h(
202199
'transition',
203200
{
204-
props: { mode: 'out-in' },
201+
props: {
202+
mode: 'out-in',
203+
// Disable use of built-in transition classes
204+
'enter-class': '',
205+
'enter-active-class': '',
206+
'enter-to-class': '',
207+
'leave-class': '',
208+
'leave-active-class': '',
209+
'leave-to-class': ''
210+
},
205211
on: {
206212
beforeEnter: this.beforeEnter,
207213
beforeLeave: this.beforeLeave

src/components/tabs/tab.spec.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import Tab from './tab'
22
import { mount } from '@vue/test-utils'
33

4-
jest.useFakeTimers()
5-
64
describe('tab', async () => {
75
it('default has expected classes, attributes and structure', async () => {
86
const wrapper = mount(Tab)
@@ -75,8 +73,15 @@ describe('tab', async () => {
7573
expect(wrapper.classes()).not.toContain('card-body')
7674
})
7775

78-
it('has class active when localActive becomes true', async () => {
79-
const wrapper = mount(Tab)
76+
it('has class active and show when localActive becomes true', async () => {
77+
const wrapper = mount(Tab, {
78+
mountToDocument: true,
79+
stubs: {
80+
// the builtin stub doesn't execute the transition hooks
81+
// so we let it use the real transition component
82+
transition: false
83+
}
84+
})
8085

8186
expect(wrapper.classes()).not.toContain('active')
8287
expect(wrapper.classes()).not.toContain('disabled')
@@ -85,10 +90,22 @@ describe('tab', async () => {
8590
expect(wrapper.classes()).not.toContain('card-body')
8691

8792
wrapper.setData({ localActive: true })
93+
await wrapper.vm.$nextTick()
94+
await new Promise(resolve => requestAnimationFrame(resolve))
8895

8996
expect(wrapper.classes()).toContain('active')
97+
expect(wrapper.classes()).toContain('show')
9098
expect(wrapper.classes()).not.toContain('disabled')
99+
expect(wrapper.classes()).not.toContain('fade')
100+
expect(wrapper.classes()).not.toContain('card-body')
101+
102+
wrapper.setData({ localActive: false })
103+
await wrapper.vm.$nextTick()
104+
await new Promise(resolve => requestAnimationFrame(resolve))
105+
106+
expect(wrapper.classes()).not.toContain('active')
91107
expect(wrapper.classes()).not.toContain('show')
108+
expect(wrapper.classes()).not.toContain('disabled')
92109
expect(wrapper.classes()).not.toContain('fade')
93110
expect(wrapper.classes()).not.toContain('card-body')
94111
})

src/components/tabs/tabs.js

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,25 @@ import observeDom from '../../utils/observe-dom'
44
import idMixin from '../../mixins/id'
55

66
// Private Helper component
7+
// @vue/component
78
const BTabButtonHelper = {
89
name: 'BTabButtonHelper',
10+
inject: {
11+
bvTabs: {
12+
default() /* istanbul ignore next */ {
13+
return {}
14+
}
15+
}
16+
},
917
props: {
1018
// Reference to the child b-tab instance
11-
tab: { default: null, required: true },
19+
tab: { default: null },
20+
tabs: {
21+
type: Array,
22+
default() {
23+
return []
24+
}
25+
},
1226
id: { type: String, default: null },
1327
controls: { type: String, default: null },
1428
tabIndex: { type: Number, default: null },
@@ -28,6 +42,7 @@ const BTabButtonHelper = {
2842
evt.stopPropagation()
2943
}
3044
if (this.tab.disabled) {
45+
/* istanbul ignore next */
3146
return
3247
}
3348
const type = evt.type
@@ -186,9 +201,11 @@ export default {
186201
}
187202
},
188203
data() {
204+
let tabIdx = parseInt(this.value, 10)
205+
tabIdx = isNaN(tabIdx) ? -1 : tabIdx
189206
return {
190207
// Index of current tab
191-
currentTab: parseInt(this.value, 10) || -1,
208+
currentTab: tabIdx,
192209
// Array of direct child b-tab instances
193210
tabs: []
194211
}
@@ -220,6 +237,7 @@ export default {
220237
value(val, old) {
221238
if (val !== old) {
222239
val = parseInt(val, 10)
240+
val = isNaN(val) ? -1 : val
223241
old = parseInt(old, 10) || 0
224242
const tabs = this.tabs
225243
if (tabs[val] && !tabs[val].disabled) {
@@ -236,27 +254,66 @@ export default {
236254
}
237255
},
238256
created() {
257+
let tabIdx = parseInt(this.value, 10)
258+
this.currentTab = isNaN(tabIdx) ? -1 : tabIdx
259+
// Create private non-reactive prop
260+
this._bvObserver = null
239261
// For SSR and to make sure only a single tab is shown on mount
240-
// We wrap this in a `$nextTick()` to ensure the tabs have been created
262+
// We wrap this in a `$nextTick()` to ensure the child tabs have been created
241263
this.$nextTick(() => {
242264
this.updateTabs()
243265
})
244266
},
245267
mounted() {
246-
// In case tabs have changed before mount
247-
this.updateTabs()
248-
// Observe Child changes so we can update list of tabs
249-
observeDom(this.$refs.tabsContainer, this.updateTabs.bind(this), {
250-
subtree: false
268+
this.$nextTick(() => {
269+
// Call updateTabs jsut in case....
270+
this.updateTabs()
271+
// Observe Child changes so we can update list of tabs
272+
this.setObserver(true)
273+
})
274+
},
275+
deactivated() /* istanbul ignore next */ {
276+
this.setObserver(false)
277+
},
278+
activated() /* istanbul ignore next */ {
279+
let tabIdx = parseInt(this.value, 10)
280+
this.currentTab = isNaN(tabIdx) ? -1 : tabIdx
281+
this.$nextTick(() => {
282+
this.updateTabs()
283+
this.setObserver(true)
251284
})
252285
},
286+
beforeDestroy() /* istanbul ignore next */ {
287+
this.setObserver(false)
288+
},
253289
methods: {
290+
setObserver(on) {
291+
if (on) {
292+
// Make sure no existing observer running
293+
this.setObserver(false)
294+
// Watch for changes to b-tab sub components
295+
this._bvObserver = observeDom(this.$refs.tabsContainer, this.updateTabs.bind(this), {
296+
childList: true,
297+
subtree: false,
298+
attributes: true,
299+
attributeFilter: ['style', 'class']
300+
})
301+
} else {
302+
if (this._bvObserver && this._bvObserver.disconnect) {
303+
this._bvObserver.disconnect()
304+
}
305+
this._bvObserver = null
306+
}
307+
},
308+
getTabs() {
309+
return (this.$slots.default || [])
310+
.map(vnode => vnode.componentInstance)
311+
.filter(tab => tab && tab._isTab)
312+
},
254313
// Update list of b-tab children
255314
updateTabs() {
256315
// Probe tabs
257-
const tabs = (this.$slots.default || [])
258-
.map(vnode => vnode.componentInstance)
259-
.filter(tab => tab && tab._isTab)
316+
const tabs = this.getTabs()
260317

261318
// Find *last* active non-disabled tab in current tabs
262319
// We trust tab state over currentTab, in case tabs were added/removed/re-ordered
@@ -291,8 +348,12 @@ export default {
291348

292349
// Set the current tab state to active
293350
tabs.forEach((tab, idx) => {
294-
tab.localActive = idx === tabIndex && !tab.disabled
351+
// tab.localActive = idx === tabIndex && !tab.disabled
352+
tab.localActive = false
295353
})
354+
if (tabs[tabIndex]) {
355+
tabs[tabIndex].localActive = true
356+
}
296357

297358
// Update the array of tab children
298359
this.tabs = tabs
@@ -325,6 +386,7 @@ export default {
325386
}
326387
if (!result) {
327388
// Couldn't set tab, so ensure v-model is set to this.currentTab
389+
/* istanbul ignore next: should rarely happen */
328390
this.$emit('input', this.currentTab)
329391
}
330392
return result
@@ -338,6 +400,7 @@ export default {
338400
return this.activateTab(this.tabs.filter(t => t !== tab).find(notDisabled))
339401
} else {
340402
// No tab specified
403+
/* istanbull ignore next: should never happen */
341404
return false
342405
}
343406
},
@@ -405,14 +468,15 @@ export default {
405468
},
406469
render(h) {
407470
const tabs = this.tabs
471+
408472
// Currently active tab
409473
let activeTab = tabs.find(tab => tab.localActive && !tab.disabled)
474+
410475
// Tab button to allow focusing when no active tab found (keynav only)
411476
const fallbackTab = tabs.find(tab => !tab.disabled)
412477

413478
// For each <b-tab> found create the tab buttons
414479
const buttons = tabs.map((tab, index) => {
415-
const buttonId = tab.controlledBy || this.safeId(`_BV_tab_${index + 1}_`)
416480
let tabIndex = null
417481
// Ensure at least one tab button is focusable when keynav enabled (if possible)
418482
if (!this.noKeyNav) {
@@ -424,14 +488,17 @@ export default {
424488
}
425489
}
426490
return h(BTabButtonHelper, {
427-
key: tab._uid || buttonId || index,
491+
key: tab._uid || index,
428492
ref: 'buttons',
429493
// Needed to make this.$refs.buttons an array
430494
refInFor: true,
431495
props: {
432496
tab: tab,
433-
id: buttonId,
434-
controls: this.safeId('_BV_tab_container_'),
497+
tabs: tabs,
498+
id:
499+
tab.controlledBy ||
500+
(this.tab && this.tab.safeId ? this.tab.safeId(`_BV_tab_button_`) : null),
501+
controls: this.tab && this.tab.safeId ? this.tab.safeId() : null,
435502
tabIndex,
436503
setSize: tabs.length,
437504
posInSet: index + 1,
@@ -453,6 +520,7 @@ export default {
453520
let navs = h(
454521
'ul',
455522
{
523+
ref: 'navs',
456524
class: [
457525
'nav',
458526
{
@@ -477,6 +545,7 @@ export default {
477545
navs = h(
478546
'div',
479547
{
548+
key: 'bv-tabs-navs',
480549
class: [
481550
{
482551
'card-header': this.card && !this.vertical && !(this.end || this.bottom),
@@ -499,10 +568,12 @@ export default {
499568
}
500569

501570
// Main content section
571+
// TODO: This container should be a helper component
502572
const content = h(
503573
'div',
504574
{
505575
ref: 'tabsContainer',
576+
key: 'bv-tabs-container',
506577
staticClass: 'tab-content',
507578
class: [{ col: this.vertical }, this.contentClass],
508579
attrs: { id: this.safeId('_BV_tab_container_') }

0 commit comments

Comments
 (0)