diff --git a/src/components/collapse/collapse.js b/src/components/collapse/collapse.js index 93779958a17..556a51cd6ba 100644 --- a/src/components/collapse/collapse.js +++ b/src/components/collapse/collapse.js @@ -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' @@ -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) { @@ -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 diff --git a/src/components/navbar/navbar-toggle.js b/src/components/navbar/navbar-toggle.js index c03c0213e2d..f8e121ad1ac 100644 --- a/src/components/navbar/navbar-toggle.js +++ b/src/components/navbar/navbar-toggle.js @@ -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, @@ -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) { diff --git a/src/components/navbar/navbar-toggle.spec.js b/src/components/navbar/navbar-toggle.spec.js index 09f40152f5a..dd119d3600f 100644 --- a/src/components/navbar/navbar-toggle.spec.js +++ b/src/components/navbar/navbar-toggle.spec.js @@ -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') }) }) diff --git a/src/directives/toggle/toggle.js b/src/directives/toggle/toggle.js index b8a0d066d72..d971c1f748b 100644 --- a/src/directives/toggle/toggle.js +++ b/src/directives/toggle/toggle.js @@ -9,6 +9,7 @@ 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' @@ -16,6 +17,11 @@ 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 @@ -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 @@ -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') @@ -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, @@ -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') diff --git a/src/directives/toggle/toggle.spec.js b/src/directives/toggle/toggle.spec.js index cb3bdf32190..a42ce23b661 100644 --- a/src/directives/toggle/toggle.spec.js +++ b/src/directives/toggle/toggle.spec.js @@ -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() @@ -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() @@ -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: '' + }) + + 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() + }) }) diff --git a/src/utils/config.js b/src/utils/config.js index 47493a509b5..ea8bf2fa164 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -81,6 +81,9 @@ const DEFAULTS = { okTitle: 'OK', okVariant: 'primary', headerCloseLabel: 'Close' + }, + BNavbarToggle: { + label: 'Toggle navigation' } }