Skip to content

fix(v-b-toggle/b-collapse): ensure toggle remains in sync with collapse (Closes #3020) #3021

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 5, 2019
Merged
17 changes: 16 additions & 1 deletion src/components/collapse/collapse.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { closest, matches, reflow, getCS, getBCR, eventOn, eventOff } from '../.
// Events we emit on $root
const EVENT_STATE = 'bv::collapse::state'
const EVENT_ACCORDION = 'bv::collapse::accordion'
// Private event we emit on $root to ensure the toggle state is always synced
// Gets emited even if the state has not changed!
// This event is NOT to be documented as people should not be using it.
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'
// Events we listen to on $root
const EVENT_TOGGLE = 'bv::toggle::collapse'

Expand Down Expand Up @@ -69,18 +73,28 @@ export default {
}
},
created() {
this.show = this.visible
// Listen for toggle events to open/close us
this.listenOnRoot(EVENT_TOGGLE, this.handleToggleEvt)
// Listen to other collapses for accordion events
this.listenOnRoot(EVENT_ACCORDION, this.handleAccordionEvt)
},
mounted() {
this.show = this.visible
if (this.isNav && inBrowser) {
// Set up handlers
this.setWindowEvents(true)
this.handleResize()
}
this.emitState()
this.$nextTick(() => {
this.emitState()
})
},
updated() {
// Emit a private event every time this component updates
// to ensure the toggle button is in sync with the collapse's state.
// It is emitted regardless if the visible state changes.
this.$root.$emit(EVENT_STATE_SYNC, this.id, this.show)
},
deactivated() /* istanbul ignore next */ {
if (this.isNav && inBrowser) {
Expand All @@ -91,6 +105,7 @@ export default {
if (this.isNav && inBrowser) {
this.setWindowEvents(true)
}
this.$root.$emit(EVENT_STATE_SYNC, this.id, this.show)
},
beforeDestroy() {
// Trigger state emit if needed
Expand Down
21 changes: 16 additions & 5 deletions src/components/navbar/navbar-toggle.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import listenOnRootMixin from '../../mixins/listen-on-root'
import { getComponentConfig } from '../../utils/config'

const NAME = 'BNavbarToggle'

// Events we emit on $root
const EVENT_TOGGLE = 'bv::toggle::collapse'

// Events we listen to on $root
const EVENT_STATE = 'bv::collapse::state'
// This private event is NOT to be documented as people should not be using it.
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'

// @vue/component
export default {
name: 'BNavbarToggle',
name: NAME,
mixins: [listenOnRootMixin],
props: {
label: {
type: String,
default: 'Toggle navigation'
default: () => String(getComponentConfig(NAME, 'label') || '')
},
target: {
type: String,
Expand All @@ -20,14 +31,14 @@ export default {
}
},
created() {
this.listenOnRoot('bv::collapse::state', this.handleStateEvt)
this.listenOnRoot(EVENT_STATE, this.handleStateEvt)
this.listenOnRoot(EVENT_STATE_SYNC, this.handleStateEvt)
},
methods: {
onClick(evt) {
this.$emit('click', evt)
/* istanbul ignore next */
if (!evt.defaultPrevented) {
this.$root.$emit('bv::toggle::collapse', this.target)
this.$root.$emit(EVENT_TOGGLE, this.target)
}
},
handleStateEvt(id, state) {
Expand Down
10 changes: 10 additions & 0 deletions src/components/navbar/navbar-toggle.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,21 @@ describe('navbar-toggle', () => {
target: 'target'
}
})

// Private state event
wrapper.vm.$root.$emit('bv::collapse::state', 'target', true)
expect(wrapper.attributes('aria-expanded')).toBe('true')
wrapper.vm.$root.$emit('bv::collapse::state', 'target', false)
expect(wrapper.attributes('aria-expanded')).toBe('false')
wrapper.vm.$root.$emit('bv::collapse::state', 'foo', true)
expect(wrapper.attributes('aria-expanded')).toBe('false')

// Private sync event
wrapper.vm.$root.$emit('bv::collapse::sync::state', 'target', true)
expect(wrapper.attributes('aria-expanded')).toBe('true')
wrapper.vm.$root.$emit('bv::collapse::sync::state', 'target', false)
expect(wrapper.attributes('aria-expanded')).toBe('false')
wrapper.vm.$root.$emit('bv::collapse::sync::state', 'foo', true)
expect(wrapper.attributes('aria-expanded')).toBe('false')
})
})
15 changes: 14 additions & 1 deletion src/directives/toggle/toggle.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ const listenTypes = { click: true }
const BV_TOGGLE = '__BV_toggle__'
const BV_TOGGLE_STATE = '__BV_toggle_STATE__'
const BV_TOGGLE_CONTROLS = '__BV_toggle_CONTROLS__'
const BV_TOGGLE_TARGETS = '__BV_toggle_TARGETS__'

// Emitted control event for collapse (emitted to collapse)
const EVENT_TOGGLE = 'bv::toggle::collapse'

// Listen to event for toggle state update (emitted by collapse)
const EVENT_STATE = 'bv::collapse::state'

// Private event emitted on $root to ensure the toggle state is always synced.
// Gets emitted even if the state of b-collapse has not changed.
// This event is NOT to be documented as people should not be using it.
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'

// Reset and remove a property from the provided element
const resetProp = (el, prop) => {
el[prop] = null
Expand Down Expand Up @@ -53,6 +59,8 @@ export default {
})

if (inBrowser && vnode.context && targets.length > 0) {
// Add targets array to element
el[BV_TOGGLE_TARGETS] = targets
// Add aria attributes to element
el[BV_TOGGLE_CONTROLS] = targets.join(' ')
// State is initially collapsed until we receive a state event
Expand All @@ -66,6 +74,7 @@ export default {

// Toggle state handler, stored on element
el[BV_TOGGLE] = function toggleDirectiveHandler(id, state) {
const targets = el[BV_TOGGLE_TARGETS] || []
if (targets.indexOf(id) !== -1) {
// Set aria-expanded state
setAttr(el, 'aria-expanded', state ? 'true' : 'false')
Expand All @@ -79,8 +88,10 @@ export default {
}
}

// Listen for toggle state changes
// Listen for toggle state changes (public)
vnode.context.$root.$on(EVENT_STATE, el[BV_TOGGLE])
// Listen for toggle state sync (private)
vnode.context.$root.$on(EVENT_STATE_SYNC, el[BV_TOGGLE])
}
},
componentUpdated: handleUpdate,
Expand All @@ -90,11 +101,13 @@ export default {
// Remove our $root listener
if (el[BV_TOGGLE]) {
vnode.context.$root.$off(EVENT_STATE, el[BV_TOGGLE])
vnode.context.$root.$off(EVENT_STATE_SYNC, el[BV_TOGGLE])
}
// Reset custom props
resetProp(el, BV_TOGGLE)
resetProp(el, BV_TOGGLE_STATE)
resetProp(el, BV_TOGGLE_CONTROLS)
resetProp(el, BV_TOGGLE_TARGETS)
// Reset classes/attrs
removeClass(el, 'collapsed')
removeAttr(el, 'aria-expanded')
Expand Down
46 changes: 45 additions & 1 deletion src/directives/toggle/toggle.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ const EVENT_TOGGLE = 'bv::toggle::collapse'
// Listen to event for toggle state update (emitted by collapse)
const EVENT_STATE = 'bv::collapse::state'

// Listen to event for toggle sync state update (emitted by collapse)
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'

describe('v-b-toggle directive', () => {
it('works on buttons', async () => {
const localVue = new CreateLocalVue()
Expand Down Expand Up @@ -49,7 +52,6 @@ describe('v-b-toggle directive', () => {

wrapper.destroy()
})

it('works on passing ID as directive value', async () => {
const localVue = new CreateLocalVue()
const spy = jest.fn()
Expand Down Expand Up @@ -190,4 +192,46 @@ describe('v-b-toggle directive', () => {

wrapper.destroy()
})

it('responds to private sync state update events', async () => {
const localVue = new CreateLocalVue()

const App = localVue.extend({
directives: {
bToggle: toggleDirective
},
data() {
return {}
},
template: '<button v-b-toggle.test>button</button>'
})

const wrapper = mount(App, {
localVue: localVue
})

expect(wrapper.isVueInstance()).toBe(true)
expect(wrapper.is('button')).toBe(true)
expect(wrapper.find('button').attributes('aria-controls')).toBe('test')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
expect(wrapper.find('button').classes()).not.toContain('collapsed')

const $root = wrapper.vm.$root

$root.$emit(EVENT_STATE_SYNC, 'test', true)
await wrapper.vm.$nextTick()

expect(wrapper.find('button').attributes('aria-controls')).toBe('test')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true')
expect(wrapper.find('button').classes()).not.toContain('collapsed')

$root.$emit(EVENT_STATE_SYNC, 'test', false)
await wrapper.vm.$nextTick()

expect(wrapper.find('button').attributes('aria-controls')).toBe('test')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
expect(wrapper.find('button').classes()).toContain('collapsed')

wrapper.destroy()
})
})
3 changes: 3 additions & 0 deletions src/utils/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ const DEFAULTS = {
okTitle: 'OK',
okVariant: 'primary',
headerCloseLabel: 'Close'
},
BNavbarToggle: {
label: 'Toggle navigation'
}
}

Expand Down