Skip to content

Commit

Permalink
feat(b-tabs): emit cancelable BvEvent before changing tabs via new `a…
Browse files Browse the repository at this point in the history
…ctivate-tab` event (closes #4273) (#4274)
  • Loading branch information
tmorehouse committed Oct 18, 2019
1 parent 009431e commit 9b195dd
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 11 deletions.
17 changes: 17 additions & 0 deletions src/components/tabs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,23 @@ methods are `.activate()` and `.deactivate()`, respectively. If activation or de
will remain active and the method will return `false`. You will need a reference to the `<b-tab>` in
order to use these methods.

## Preventing a `<b-tab>` from being activated

To prevent a tab from activating, simply set the `disabled` prop on the `<b-tab>` component.

Alternatively, you can listen for the `activate-tab` event, which provides an option to prevent the
tab from activating. The `activate-tab` event is emitted with three arguments:

- `newTabIndex`: The index of the tab that is going to be activated
- `prevTabIndex`: The index of the currently active tab
- `bvEvent`: The `BvEvent` object. Call `bvEvt.preventDefault()` to prevent `newTabIndex` from being
activated

For accessibility reasons, when using the `activate-tab` event to prevent a tab from activating, you
should provide some means of notification to the user as to why the tab is not able to be activated.
It is recommended to use the `disabled` attribute on the `<b-tab>` component instead of using the
`activate-tab` event (as `disabled` is more intuitive for screen reader users).

## Advanced examples

### External controls using `v-model`
Expand Down
22 changes: 22 additions & 0 deletions src/components/tabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@
}
]
},
{
"event": "activate-tab",
"version": "2.1.0",
"description": "Emitted just before a tab is shown/activated. Cancelable",
"args": [
{
"arg": "newTabIndex",
"type": "Number",
"description": "Tab being activated (0-based index)"
},
{
"arg": "prevTabIndex",
"type": "Number",
"description": "Tab that is currently active (0-based index). Will be -1 if no current active tab"
},
{
"arg": "bvEvt",
"type": "BvEvent",
"description": "BvEvent object. Call bvEvt.preventDefault() to cancel"
}
]
},
{
"event": "changed",
"description": "Emitted when a tab is added, removed, or tabs are re-ordered",
Expand Down
29 changes: 18 additions & 11 deletions src/components/tabs/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import looseEqual from '../../utils/loose-equal'
import observeDom from '../../utils/observe-dom'
import stableSort from '../../utils/stable-sort'
import { arrayIncludes, concat } from '../../utils/array'
import { BvEvent } from '../../utils/bv-event.class'
import { requestAF, selectAll } from '../../utils/dom'
import { isEvent } from '../../utils/inspect'
import { omit } from '../../utils/object'
Expand Down Expand Up @@ -262,7 +263,7 @@ export const BTabs = /*#__PURE__*/ Vue.extend({
old = parseInt(old, 10) || 0
const tabs = this.tabs
if (tabs[val] && !tabs[val].disabled) {
this.currentTab = val
this.activateTab(tabs[val])
} else {
// Try next or prev tabs
if (val < old) {
Expand Down Expand Up @@ -481,14 +482,22 @@ export const BTabs = /*#__PURE__*/ Vue.extend({
let result = false
if (tab) {
const index = this.tabs.indexOf(tab)
if (!tab.disabled && index > -1) {
result = true
this.currentTab = index
if (!tab.disabled && index > -1 && index !== this.currentTab) {
const tabEvt = new BvEvent('activate-tab', {
cancelable: true,
vueTarget: this,
componentId: this.safeId()
})
this.$emit(tabEvt.type, index, this.currentTab, tabEvt)
if (!tabEvt.defaultPrevented) {
result = true
this.currentTab = index
}
}
}
if (!result) {
// Couldn't set tab, so ensure v-model is set to `this.currentTab`
/* istanbul ignore next: should rarely happen */
// Couldn't set tab, so ensure v-model is set to `this.currentTab`
/* istanbul ignore next: should rarely happen */
if (!result && this.currentTab !== this.value) {
this.$emit('input', this.currentTab)
}
return result
Expand All @@ -500,11 +509,9 @@ export const BTabs = /*#__PURE__*/ Vue.extend({
// Find first non-disabled tab that isn't the one being deactivated
// If no tabs are available, then don't deactivate current tab
return this.activateTab(this.tabs.filter(t => t !== tab).find(notDisabled))
} else {
// No tab specified
/* istanbul ignore next: should never happen */
return false
}
/* istanbul ignore next: should never/rarely happen */
return false
},
// Focus a tab button given it's <b-tab> instance
focusButton(tab) {
Expand Down
66 changes: 66 additions & 0 deletions src/components/tabs/tabs.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,72 @@ describe('tabs', () => {
wrapper.destroy()
})

it('`activate-tab` event works', async () => {
const App = Vue.extend({
methods: {
preventTab(next, prev, bvEvt) {
// Prevent 3rd tab (index === 2) from activating
if (next === 2) {
bvEvt.preventDefault()
}
}
},
render(h) {
return h(BTabs, { props: { value: 0 }, on: { 'activate-tab': this.preventTab } }, [
h(BTab, { props: {} }, 'tab 0'),
h(BTab, { props: {} }, 'tab 1'),
h(BTab, { props: {} }, 'tab 2')
])
}
})
const wrapper = mount(App)
expect(wrapper).toBeDefined()

await waitNT(wrapper.vm)
await waitRAF()
const tabs = wrapper.find(BTabs)
expect(tabs).toBeDefined()
expect(tabs.findAll(BTab).length).toBe(3)

// Expect 1st tab (index 0) to be active
expect(tabs.vm.currentTab).toBe(0)
expect(tabs.vm.tabs[0].localActive).toBe(true)
expect(tabs.emitted('input')).not.toBeDefined()
expect(tabs.emitted('activate-tab')).not.toBeDefined()

// Set 2nd BTab to be active
tabs.setProps({ value: 1 })
await waitNT(wrapper.vm)
await waitRAF()
expect(tabs.vm.currentTab).toBe(1)
expect(tabs.emitted('input')).toBeDefined()
expect(tabs.emitted('input').length).toBe(1)
expect(tabs.emitted('input')[0][0]).toBe(1)
expect(tabs.emitted('activate-tab')).toBeDefined()
expect(tabs.emitted('activate-tab').length).toBe(1)
expect(tabs.emitted('activate-tab')[0][0]).toBe(1)
expect(tabs.emitted('activate-tab')[0][1]).toBe(0)
expect(tabs.emitted('activate-tab')[0][2]).toBeDefined()
expect(tabs.emitted('activate-tab')[0][2].vueTarget).toBe(tabs.vm)

// Attempt to set 3rd BTab to be active
tabs.setProps({ value: 2 })
await waitNT(wrapper.vm)
await waitRAF()
expect(tabs.vm.currentTab).toBe(1)
expect(tabs.emitted('input')).toBeDefined()
expect(tabs.emitted('input').length).toBe(2)
expect(tabs.emitted('input')[1][0]).toBe(1)
expect(tabs.emitted('activate-tab').length).toBe(2)
expect(tabs.emitted('activate-tab')[1][0]).toBe(2)
expect(tabs.emitted('activate-tab')[1][1]).toBe(1)
expect(tabs.emitted('activate-tab')[1][2]).toBeDefined()
expect(tabs.emitted('activate-tab')[1][2].vueTarget).toBe(tabs.vm)
expect(tabs.emitted('activate-tab')[1][2].defaultPrevented).toBe(true)

wrapper.destroy()
})

it('clicking on tab activates the tab, and tab emits click event', async () => {
const App = Vue.extend({
render(h) {
Expand Down

0 comments on commit 9b195dd

Please sign in to comment.