import { mount } from '@vue/test-utils'
import { defineComponent, nextTick, useTemplateRef } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
useDropdownState,
type UseDropdownStateOptions,
type UseDropdownStateReturn,
} from '../../composables/useDropdownState'
function mountHarness(options: UseDropdownStateOptions = {}) {
let state: UseDropdownStateReturn | undefined
const wrapper = mount(defineComponent({
setup() {
state = useDropdownState(options)
return state
},
template: '
',
}), { attachTo: document.body })
if (!state) {
throw new Error('Dropdown harness did not initialize.')
}
return { wrapper, state }
}
describe('useDropdownState', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('opens, closes, and toggles with callbacks only on state changes', () => {
const onOpen = vi.fn()
const onClose = vi.fn()
const { wrapper, state } = mountHarness({ onOpen, onClose })
state.open()
state.open()
expect(state.isOpen.value).toBe(true)
expect(onOpen).toHaveBeenCalledTimes(1)
state.toggle()
state.close()
expect(state.isOpen.value).toBe(false)
expect(onClose).toHaveBeenCalledTimes(1)
state.toggle()
expect(state.isOpen.value).toBe(true)
expect(onOpen).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('closes on outside click but keeps inside clicks open', async () => {
const { wrapper, state } = mountHarness()
state.open()
await nextTick()
await wrapper.find('.inside').trigger('click')
await nextTick()
expect(state.isOpen.value).toBe(true)
document.body.click()
await nextTick()
expect(state.isOpen.value).toBe(false)
wrapper.unmount()
})
it('closes on Escape by default', async () => {
const { wrapper, state } = mountHarness()
state.open()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(state.isOpen.value).toBe(false)
wrapper.unmount()
})
it('returns focus to the trigger only when asked', async () => {
const { wrapper, state } = mountHarness()
const trigger = wrapper.get('.trigger').element
const inside = wrapper.get('.inside').element
state.open()
inside.focus()
state.close()
await nextTick()
expect(document.activeElement).toBe(inside)
state.open()
inside.focus()
state.close(true)
await nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
})
it('returns focus to the trigger when Escape closes the dropdown', async () => {
const { wrapper, state } = mountHarness()
const trigger = wrapper.get('.trigger').element
state.open()
wrapper.get('.inside').element.focus()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
await nextTick()
expect(state.isOpen.value).toBe(false)
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
})
it('closes on scroll and resize only when closeOnScroll is set', async () => {
const plain = mountHarness()
plain.state.open()
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(plain.state.isOpen.value).toBe(true)
plain.wrapper.unmount()
const scrolling = mountHarness({ closeOnScroll: true })
scrolling.state.open()
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(scrolling.state.isOpen.value).toBe(false)
scrolling.state.open()
window.dispatchEvent(new Event('resize'))
await nextTick()
expect(scrolling.state.isOpen.value).toBe(false)
scrolling.wrapper.unmount()
})
it('should detect outside clicks against a caller-provided root template ref', async () => {
let state: UseDropdownStateReturn | undefined
const wrapper = mount(defineComponent({
setup() {
state = useDropdownState({ rootRef: useTemplateRef('root') })
return {}
},
template: '',
}), { attachTo: document.body })
state!.open()
await nextTick()
document.body.click()
await nextTick()
expect(state!.isOpen.value).toBe(false)
wrapper.unmount()
})
it('can opt out of outside-click and Escape closing', async () => {
const { wrapper, state } = mountHarness({
closeOnOutsideClick: false,
closeOnEscape: false,
})
state.open()
document.body.click()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(state.isOpen.value).toBe(true)
wrapper.unmount()
})
})