Skip to content
This repository was archived by the owner on Mar 27, 2025. It is now read-only.

Commit ff771c3

Browse files
committed
feat(BToggle): Add collapsed/not-collapsed classes (bootstrap-vue-next#1353)
1 parent 40e8e17 commit ff771c3

File tree

2 files changed

+325
-0
lines changed

2 files changed

+325
-0
lines changed

packages/bootstrap-vue-next/src/directives/BToggle.ts

+2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const checkVisibility = (binding: DirectiveBinding<string>, el: HTMLElement) =>
5555
}
5656
})
5757
el.setAttribute('aria-expanded', visible ? 'true' : 'false')
58+
el.classList.remove(visible ? 'collapsed' : 'not-collapsed')
59+
el.classList.add(visible ? 'not-collapsed' : 'collapsed')
5860
}
5961

6062
export interface WithToggle extends HTMLElement {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import {enableAutoUnmount, flushPromises, mount} from '@vue/test-utils'
2+
import {afterEach, describe, expect, it, vi} from 'vitest'
3+
import {nextTick} from 'vue'
4+
import VBToggle from './BToggle'
5+
6+
// Emitted control event for collapse (emitted to collapse)
7+
const EVENT_TOGGLE = 'bv-toggle'
8+
9+
describe('toggle directive', () => {
10+
enableAutoUnmount(afterEach)
11+
12+
it('works on buttons', async () => {
13+
const spy = vi.fn()
14+
const App = {
15+
directives: {
16+
bToggle: VBToggle,
17+
},
18+
mounted() {
19+
document.getElementById('test')?.addEventListener(EVENT_TOGGLE, spy)
20+
},
21+
destroy() {
22+
document.getElementById('test')?.removeEventListener(EVENT_TOGGLE, spy)
23+
},
24+
template: '<button v-b-toggle.test>button</button><div id="test"></div>',
25+
}
26+
27+
const wrapper = mount(App, {attachTo: document.body})
28+
29+
await flushPromises()
30+
await nextTick()
31+
32+
expect(wrapper.vm).toBeDefined()
33+
expect(spy).not.toHaveBeenCalled()
34+
35+
const $button = wrapper.find('button')
36+
expect($button.attributes('aria-controls')).toBe('test')
37+
expect($button.attributes('aria-expanded')).toBe('false')
38+
expect($button.attributes('tabindex')).toBeUndefined()
39+
expect($button.classes()).toContain('collapsed')
40+
expect($button.classes()).not.toContain('not-collapsed')
41+
42+
await $button.trigger('click')
43+
expect(spy).toHaveBeenCalledTimes(1)
44+
45+
// Since there is no target collapse to respond with the
46+
// current state, the classes and attrs remain the same
47+
expect($button.attributes('aria-controls')).toBe('test')
48+
expect($button.attributes('aria-expanded')).toBe('false')
49+
expect($button.attributes('tabindex')).toBeUndefined()
50+
expect($button.classes()).toContain('collapsed')
51+
expect($button.classes()).not.toContain('not-collapsed')
52+
})
53+
54+
it('works on passing ID as directive value', async () => {
55+
const spy = vi.fn()
56+
const App = {
57+
directives: {
58+
bToggle: VBToggle,
59+
},
60+
mounted() {
61+
document.getElementById('test')?.addEventListener(EVENT_TOGGLE, spy)
62+
},
63+
destroy() {
64+
document.getElementById('test')?.removeEventListener(EVENT_TOGGLE, spy)
65+
},
66+
template: `<button v-b-toggle="'test'">button</button><div id="test"></div>`,
67+
}
68+
69+
const wrapper = mount(App, {attachTo: document.body})
70+
71+
await flushPromises()
72+
await nextTick()
73+
74+
expect(wrapper.vm).toBeDefined()
75+
expect(spy).not.toHaveBeenCalled()
76+
77+
const $button = wrapper.find('button')
78+
expect($button.attributes('aria-controls')).toBe('test')
79+
expect($button.attributes('aria-expanded')).toBe('false')
80+
expect($button.classes()).toContain('collapsed')
81+
expect($button.classes()).not.toContain('not-collapsed')
82+
83+
await $button.trigger('click')
84+
expect(spy).toHaveBeenCalledTimes(1)
85+
86+
// Since there is no target collapse to respond with the
87+
// current state, the classes and attrs remain the same
88+
expect($button.attributes('aria-controls')).toBe('test')
89+
expect($button.attributes('aria-expanded')).toBe('false')
90+
expect($button.classes()).toContain('collapsed')
91+
expect($button.classes()).not.toContain('not-collapsed')
92+
})
93+
94+
it('works on passing ID as directive argument', async () => {
95+
const spy = vi.fn()
96+
const App = {
97+
directives: {
98+
bToggle: VBToggle,
99+
},
100+
mounted() {
101+
document.getElementById('test')?.addEventListener(EVENT_TOGGLE, spy)
102+
},
103+
destroy() {
104+
document.getElementById('test')?.removeEventListener(EVENT_TOGGLE, spy)
105+
},
106+
template: `<button v-b-toggle:test>button</button><div id="test"></div>`,
107+
}
108+
109+
const wrapper = mount(App, {attachTo: document.body})
110+
111+
await flushPromises()
112+
await nextTick()
113+
114+
expect(wrapper.vm).toBeDefined()
115+
expect(spy).not.toHaveBeenCalled()
116+
117+
const $button = wrapper.find('button')
118+
expect($button.attributes('aria-controls')).toBe('test')
119+
expect($button.attributes('aria-expanded')).toBe('false')
120+
expect($button.classes()).toContain('collapsed')
121+
expect($button.classes()).not.toContain('not-collapsed')
122+
123+
await $button.trigger('click')
124+
expect(spy).toHaveBeenCalledTimes(1)
125+
126+
// Since there is no target collapse to respond with the
127+
// current state, the classes and attrs remain the same
128+
expect($button.attributes('aria-controls')).toBe('test')
129+
expect($button.attributes('aria-expanded')).toBe('false')
130+
expect($button.classes()).toContain('collapsed')
131+
expect($button.classes()).not.toContain('not-collapsed')
132+
})
133+
134+
it('works on passing ID as href value on links', async () => {
135+
const spy = vi.fn()
136+
const App = {
137+
directives: {
138+
bToggle: VBToggle,
139+
},
140+
mounted() {
141+
document.getElementById('test')?.addEventListener(EVENT_TOGGLE, spy)
142+
},
143+
destroy() {
144+
document.getElementById('test')?.removeEventListener(EVENT_TOGGLE, spy)
145+
},
146+
template: '<a href="#test" v-b-toggle>link</a><div id="test"></div>',
147+
}
148+
149+
const wrapper = mount(App, {attachTo: document.body})
150+
151+
await flushPromises()
152+
await nextTick()
153+
154+
expect(wrapper.vm).toBeDefined()
155+
expect(spy).not.toHaveBeenCalled()
156+
157+
const $link = wrapper.find('a')
158+
expect($link.attributes('aria-controls')).toBe('test')
159+
expect($link.attributes('aria-expanded')).toBe('false')
160+
expect($link.attributes('tabindex')).toBeUndefined()
161+
expect($link.classes()).toContain('collapsed')
162+
expect($link.classes()).not.toContain('not-collapsed')
163+
164+
await $link.trigger('click')
165+
expect(spy).toHaveBeenCalledTimes(1)
166+
167+
// Since there is no target collapse to respond with the
168+
// current state, the classes and attrs remain the same
169+
expect($link.attributes('aria-controls')).toBe('test')
170+
expect($link.attributes('aria-expanded')).toBe('false')
171+
expect($link.attributes('tabindex')).toBeUndefined()
172+
expect($link.classes()).toContain('collapsed')
173+
expect($link.classes()).not.toContain('not-collapsed')
174+
})
175+
176+
// Does not currently support dynamic updates to target list (bootstrap-vue does)
177+
it.skip('works with multiple targets, and updates when targets change', async () => {
178+
const spy1 = vi.fn()
179+
const spy2 = vi.fn()
180+
const App = {
181+
directives: {
182+
bToggle: VBToggle,
183+
},
184+
props: {
185+
target: {
186+
type: [String, Array],
187+
default: null,
188+
},
189+
},
190+
mounted() {
191+
document.getElementById('test')?.addEventListener(EVENT_TOGGLE, spy1)
192+
document.getElementById('test')?.addEventListener(EVENT_TOGGLE, spy2)
193+
},
194+
destroy() {
195+
document.getElementById('test')?.removeEventListener(EVENT_TOGGLE, spy1)
196+
document.getElementById('test')?.removeEventListener(EVENT_TOGGLE, spy2)
197+
},
198+
template: `<button v-b-toggle="target">button</button><div id="test1"></div><div id="test2"></div>`,
199+
}
200+
201+
const wrapper = mount(App, {
202+
attachTo: document.body,
203+
props: {
204+
target: 'test1',
205+
},
206+
})
207+
208+
await flushPromises()
209+
await nextTick()
210+
211+
expect(wrapper.vm).toBeDefined()
212+
expect(spy1).not.toHaveBeenCalled()
213+
expect(spy2).not.toHaveBeenCalled()
214+
215+
const $button = wrapper.find('button')
216+
expect($button.attributes('aria-controls')).toBe('test1')
217+
expect($button.attributes('aria-expanded')).toBe('false')
218+
expect($button.classes()).toContain('collapsed')
219+
expect($button.classes()).not.toContain('not-collapsed')
220+
221+
await wrapper.setProps({target: ['test1', 'test2']})
222+
expect($button.attributes('aria-controls')).toBe('test1 test2')
223+
expect($button.attributes('aria-expanded')).toBe('false')
224+
expect($button.classes()).toContain('collapsed')
225+
expect($button.classes()).not.toContain('not-collapsed')
226+
expect(spy1).not.toHaveBeenCalled()
227+
expect(spy2).not.toHaveBeenCalled()
228+
229+
await $button.trigger('click')
230+
expect(spy1).toHaveBeenCalledTimes(1)
231+
expect(spy2).toHaveBeenCalledTimes(1)
232+
233+
// Since there is no target collapse to respond with the
234+
// current state, the classes and attrs remain the same
235+
expect($button.attributes('aria-controls')).toBe('test1 test2')
236+
expect($button.attributes('aria-expanded')).toBe('false')
237+
expect($button.classes()).toContain('collapsed')
238+
expect($button.classes()).not.toContain('not-collapsed')
239+
240+
await wrapper.setProps({target: ['test2']})
241+
expect($button.attributes('aria-controls')).toBe('test2')
242+
expect($button.attributes('aria-expanded')).toBe('false')
243+
expect($button.classes()).toContain('collapsed')
244+
expect($button.classes()).not.toContain('not-collapsed')
245+
expect(spy1).toHaveBeenCalledTimes(1)
246+
expect(spy2).toHaveBeenCalledTimes(1)
247+
248+
await $button.trigger('click')
249+
expect(spy1).toHaveBeenCalledTimes(1)
250+
expect(spy2).toHaveBeenCalledTimes(2)
251+
252+
// Since there is no target collapse to respond with the
253+
// current state, the classes and attrs remain the same
254+
expect($button.attributes('aria-controls')).toBe('test2')
255+
expect($button.attributes('aria-expanded')).toBe('false')
256+
expect($button.classes()).toContain('collapsed')
257+
expect($button.classes()).not.toContain('not-collapsed')
258+
})
259+
260+
it('works on non-buttons', async () => {
261+
const spy = vi.fn()
262+
const App = {
263+
directives: {
264+
bToggle: VBToggle,
265+
},
266+
data() {
267+
return {
268+
text: 'span',
269+
}
270+
},
271+
mounted() {
272+
document.getElementById('test')?.addEventListener(EVENT_TOGGLE, spy)
273+
},
274+
destroy() {
275+
document.getElementById('test')?.removeEventListener(EVENT_TOGGLE, spy)
276+
},
277+
template:
278+
'<span v-b-toggle.test role="button" tabindex="0">{{ text }}</span><div id="test"></div>',
279+
}
280+
281+
const wrapper = mount(App, {attachTo: document.body})
282+
283+
await flushPromises()
284+
await nextTick()
285+
286+
expect(wrapper.vm).toBeDefined()
287+
expect(spy).not.toHaveBeenCalled()
288+
289+
const $span = wrapper.find('span')
290+
expect($span.attributes('role')).toBe('button')
291+
expect($span.attributes('tabindex')).toBe('0')
292+
expect($span.attributes('aria-controls')).toBe('test')
293+
expect($span.attributes('aria-expanded')).toBe('false')
294+
expect($span.classes()).toContain('collapsed')
295+
expect($span.classes()).not.toContain('not-collapsed')
296+
expect($span.text()).toBe('span')
297+
298+
await $span.trigger('click')
299+
expect(spy).toHaveBeenCalledTimes(1)
300+
expect($span.attributes('role')).toBe('button')
301+
expect($span.attributes('tabindex')).toBe('0')
302+
303+
// Since there is no target collapse to respond with the
304+
// current state, the classes and attrs remain the same
305+
expect($span.attributes('aria-controls')).toBe('test')
306+
expect($span.attributes('aria-expanded')).toBe('false')
307+
expect($span.classes()).toContain('collapsed')
308+
expect($span.classes()).not.toContain('not-collapsed')
309+
310+
// Test updating component, should maintain role attribute
311+
await wrapper.setData({text: 'foobar'})
312+
expect($span.text()).toBe('foobar')
313+
expect($span.attributes('role')).toBe('button')
314+
expect($span.attributes('tabindex')).toBe('0')
315+
316+
// Since there is no target collapse to respond with the
317+
// current state, the classes and attrs remain the same
318+
expect($span.attributes('aria-controls')).toBe('test')
319+
expect($span.attributes('aria-expanded')).toBe('false')
320+
expect($span.classes()).toContain('collapsed')
321+
expect($span.classes()).not.toContain('not-collapsed')
322+
})
323+
})

0 commit comments

Comments
 (0)