feat: add generic ToolbarMenuButton dropdown component
This commit is contained in:
72
src/components/ToolbarMenuButton.test.ts
Normal file
72
src/components/ToolbarMenuButton.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import ToolbarMenuButton from './ToolbarMenuButton.vue'
|
||||||
|
|
||||||
|
function mountMenu(props: { label: string; toggleTestid: string; disabled?: boolean }) {
|
||||||
|
return mount(ToolbarMenuButton, {
|
||||||
|
props,
|
||||||
|
attachTo: document.body,
|
||||||
|
slots: {
|
||||||
|
default: `<template #default="{ close }">
|
||||||
|
<li role="menuitem"><button data-testid="item-a" @click="close">Item A</button></li>
|
||||||
|
<li role="menuitem"><button data-testid="item-b" @click="close">Item B</button></li>
|
||||||
|
</template>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ToolbarMenuButton', () => {
|
||||||
|
it('renders the toggle button with the given label and closed menu by default', () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').text()).toBe('导出 ▾')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the menu when the toggle button is clicked', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.get('[data-testid="item-a"]').isVisible()).toBe(true)
|
||||||
|
expect(wrapper.get('[data-testid="item-b"]').isVisible()).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when a slot item calls close', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
await wrapper.get('button[data-testid="item-a"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when clicking outside the component', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
document.body.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when Escape is pressed', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle' })
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.get('div.toolbar-menu').trigger('keydown', { key: 'Escape' })
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the toggle button and never opens the menu when disabled is true', async () => {
|
||||||
|
const wrapper = mountMenu({ label: '导出 ▾', toggleTestid: 'export-menu-toggle', disabled: true })
|
||||||
|
expect(wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-testid="item-a"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
58
src/components/ToolbarMenuButton.vue
Normal file
58
src/components/ToolbarMenuButton.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
toggleTestid: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!rootRef.value) return
|
||||||
|
if (!rootRef.value.contains(event.target as Node)) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="toolbar-menu" @keydown="handleKeydown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:data-testid="toggleTestid"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
<ul v-if="open" class="toolbar-menu-list" role="menu">
|
||||||
|
<slot :close="close" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user