Skip to content

Commit 6b36d0d

Browse files
authored
fix(v-b-toggle/b-collapse): ensure toggle remains in sync with collapse (Closes #3020) (#3021)
1 parent aee64ae commit 6b36d0d

File tree

6 files changed

+104
-8
lines changed

6 files changed

+104
-8
lines changed

src/components/collapse/collapse.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { closest, matches, reflow, getCS, getBCR, eventOn, eventOff } from '../.
55
// Events we emit on $root
66
const EVENT_STATE = 'bv::collapse::state'
77
const EVENT_ACCORDION = 'bv::collapse::accordion'
8+
// Private event we emit on $root to ensure the toggle state is always synced
9+
// Gets emited even if the state has not changed!
10+
// This event is NOT to be documented as people should not be using it.
11+
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'
812
// Events we listen to on $root
913
const EVENT_TOGGLE = 'bv::toggle::collapse'
1014

@@ -69,18 +73,28 @@ export default {
6973
}
7074
},
7175
created() {
76+
this.show = this.visible
7277
// Listen for toggle events to open/close us
7378
this.listenOnRoot(EVENT_TOGGLE, this.handleToggleEvt)
7479
// Listen to other collapses for accordion events
7580
this.listenOnRoot(EVENT_ACCORDION, this.handleAccordionEvt)
7681
},
7782
mounted() {
83+
this.show = this.visible
7884
if (this.isNav && inBrowser) {
7985
// Set up handlers
8086
this.setWindowEvents(true)
8187
this.handleResize()
8288
}
83-
this.emitState()
89+
this.$nextTick(() => {
90+
this.emitState()
91+
})
92+
},
93+
updated() {
94+
// Emit a private event every time this component updates
95+
// to ensure the toggle button is in sync with the collapse's state.
96+
// It is emitted regardless if the visible state changes.
97+
this.$root.$emit(EVENT_STATE_SYNC, this.id, this.show)
8498
},
8599
deactivated() /* istanbul ignore next */ {
86100
if (this.isNav && inBrowser) {
@@ -91,6 +105,7 @@ export default {
91105
if (this.isNav && inBrowser) {
92106
this.setWindowEvents(true)
93107
}
108+
this.$root.$emit(EVENT_STATE_SYNC, this.id, this.show)
94109
},
95110
beforeDestroy() {
96111
// Trigger state emit if needed

src/components/navbar/navbar-toggle.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import listenOnRootMixin from '../../mixins/listen-on-root'
2+
import { getComponentConfig } from '../../utils/config'
3+
4+
const NAME = 'BNavbarToggle'
5+
6+
// Events we emit on $root
7+
const EVENT_TOGGLE = 'bv::toggle::collapse'
8+
9+
// Events we listen to on $root
10+
const EVENT_STATE = 'bv::collapse::state'
11+
// This private event is NOT to be documented as people should not be using it.
12+
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'
213

314
// @vue/component
415
export default {
5-
name: 'BNavbarToggle',
16+
name: NAME,
617
mixins: [listenOnRootMixin],
718
props: {
819
label: {
920
type: String,
10-
default: 'Toggle navigation'
21+
default: () => String(getComponentConfig(NAME, 'label') || '')
1122
},
1223
target: {
1324
type: String,
@@ -20,14 +31,14 @@ export default {
2031
}
2132
},
2233
created() {
23-
this.listenOnRoot('bv::collapse::state', this.handleStateEvt)
34+
this.listenOnRoot(EVENT_STATE, this.handleStateEvt)
35+
this.listenOnRoot(EVENT_STATE_SYNC, this.handleStateEvt)
2436
},
2537
methods: {
2638
onClick(evt) {
2739
this.$emit('click', evt)
28-
/* istanbul ignore next */
2940
if (!evt.defaultPrevented) {
30-
this.$root.$emit('bv::toggle::collapse', this.target)
41+
this.$root.$emit(EVENT_TOGGLE, this.target)
3142
}
3243
},
3344
handleStateEvt(id, state) {

src/components/navbar/navbar-toggle.spec.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,21 @@ describe('navbar-toggle', () => {
8080
target: 'target'
8181
}
8282
})
83+
84+
// Private state event
8385
wrapper.vm.$root.$emit('bv::collapse::state', 'target', true)
8486
expect(wrapper.attributes('aria-expanded')).toBe('true')
8587
wrapper.vm.$root.$emit('bv::collapse::state', 'target', false)
8688
expect(wrapper.attributes('aria-expanded')).toBe('false')
8789
wrapper.vm.$root.$emit('bv::collapse::state', 'foo', true)
8890
expect(wrapper.attributes('aria-expanded')).toBe('false')
91+
92+
// Private sync event
93+
wrapper.vm.$root.$emit('bv::collapse::sync::state', 'target', true)
94+
expect(wrapper.attributes('aria-expanded')).toBe('true')
95+
wrapper.vm.$root.$emit('bv::collapse::sync::state', 'target', false)
96+
expect(wrapper.attributes('aria-expanded')).toBe('false')
97+
wrapper.vm.$root.$emit('bv::collapse::sync::state', 'foo', true)
98+
expect(wrapper.attributes('aria-expanded')).toBe('false')
8999
})
90100
})

src/directives/toggle/toggle.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@ const listenTypes = { click: true }
99
const BV_TOGGLE = '__BV_toggle__'
1010
const BV_TOGGLE_STATE = '__BV_toggle_STATE__'
1111
const BV_TOGGLE_CONTROLS = '__BV_toggle_CONTROLS__'
12+
const BV_TOGGLE_TARGETS = '__BV_toggle_TARGETS__'
1213

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

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

20+
// Private event emitted on $root to ensure the toggle state is always synced.
21+
// Gets emitted even if the state of b-collapse has not changed.
22+
// This event is NOT to be documented as people should not be using it.
23+
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'
24+
1925
// Reset and remove a property from the provided element
2026
const resetProp = (el, prop) => {
2127
el[prop] = null
@@ -53,6 +59,8 @@ export default {
5359
})
5460

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

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

82-
// Listen for toggle state changes
91+
// Listen for toggle state changes (public)
8392
vnode.context.$root.$on(EVENT_STATE, el[BV_TOGGLE])
93+
// Listen for toggle state sync (private)
94+
vnode.context.$root.$on(EVENT_STATE_SYNC, el[BV_TOGGLE])
8495
}
8596
},
8697
componentUpdated: handleUpdate,
@@ -90,11 +101,13 @@ export default {
90101
// Remove our $root listener
91102
if (el[BV_TOGGLE]) {
92103
vnode.context.$root.$off(EVENT_STATE, el[BV_TOGGLE])
104+
vnode.context.$root.$off(EVENT_STATE_SYNC, el[BV_TOGGLE])
93105
}
94106
// Reset custom props
95107
resetProp(el, BV_TOGGLE)
96108
resetProp(el, BV_TOGGLE_STATE)
97109
resetProp(el, BV_TOGGLE_CONTROLS)
110+
resetProp(el, BV_TOGGLE_TARGETS)
98111
// Reset classes/attrs
99112
removeClass(el, 'collapsed')
100113
removeAttr(el, 'aria-expanded')

src/directives/toggle/toggle.spec.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const EVENT_TOGGLE = 'bv::toggle::collapse'
77
// Listen to event for toggle state update (emitted by collapse)
88
const EVENT_STATE = 'bv::collapse::state'
99

10+
// Listen to event for toggle sync state update (emitted by collapse)
11+
const EVENT_STATE_SYNC = 'bv::collapse::sync::state'
12+
1013
describe('v-b-toggle directive', () => {
1114
it('works on buttons', async () => {
1215
const localVue = new CreateLocalVue()
@@ -49,7 +52,6 @@ describe('v-b-toggle directive', () => {
4952

5053
wrapper.destroy()
5154
})
52-
5355
it('works on passing ID as directive value', async () => {
5456
const localVue = new CreateLocalVue()
5557
const spy = jest.fn()
@@ -190,4 +192,46 @@ describe('v-b-toggle directive', () => {
190192

191193
wrapper.destroy()
192194
})
195+
196+
it('responds to private sync state update events', async () => {
197+
const localVue = new CreateLocalVue()
198+
199+
const App = localVue.extend({
200+
directives: {
201+
bToggle: toggleDirective
202+
},
203+
data() {
204+
return {}
205+
},
206+
template: '<button v-b-toggle.test>button</button>'
207+
})
208+
209+
const wrapper = mount(App, {
210+
localVue: localVue
211+
})
212+
213+
expect(wrapper.isVueInstance()).toBe(true)
214+
expect(wrapper.is('button')).toBe(true)
215+
expect(wrapper.find('button').attributes('aria-controls')).toBe('test')
216+
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
217+
expect(wrapper.find('button').classes()).not.toContain('collapsed')
218+
219+
const $root = wrapper.vm.$root
220+
221+
$root.$emit(EVENT_STATE_SYNC, 'test', true)
222+
await wrapper.vm.$nextTick()
223+
224+
expect(wrapper.find('button').attributes('aria-controls')).toBe('test')
225+
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true')
226+
expect(wrapper.find('button').classes()).not.toContain('collapsed')
227+
228+
$root.$emit(EVENT_STATE_SYNC, 'test', false)
229+
await wrapper.vm.$nextTick()
230+
231+
expect(wrapper.find('button').attributes('aria-controls')).toBe('test')
232+
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
233+
expect(wrapper.find('button').classes()).toContain('collapsed')
234+
235+
wrapper.destroy()
236+
})
193237
})

src/utils/config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ const DEFAULTS = {
8181
okTitle: 'OK',
8282
okVariant: 'primary',
8383
headerCloseLabel: 'Close'
84+
},
85+
BNavbarToggle: {
86+
label: 'Toggle navigation'
8487
}
8588
}
8689

0 commit comments

Comments
 (0)