优化Dropdown
All checks were successful
Go CI / test-and-build (push) Successful in 10s
Web CI / lint-test-build (push) Successful in 22s

This commit is contained in:
2026-04-06 15:44:09 +08:00
parent 76e3e3c99f
commit fcb5732b40
5 changed files with 878 additions and 66 deletions

View File

@@ -1,46 +1,335 @@
<template> <template>
<div ref="rootRef" class="dropdown" :class="{ open: visible }"> <div ref="rootRef" class="dropdown" :class="[openClass, placementClass]">
<div class="dropdown-trigger" @click="visible = !visible"> <div
ref="triggerRef"
class="dropdown-trigger"
role="button"
tabindex="0"
:aria-expanded="isOpen"
:aria-haspopup="true"
@click="toggle"
@keydown="onTriggerKeydown"
>
<slot name="trigger"></slot> <slot name="trigger"></slot>
</div> </div>
<Transition name="dropdown"> <Teleport to="body" :disabled="!teleport">
<div v-if="visible" class="dropdown-menu"> <Transition name="dropdown" @after-leave="onTransitionLeave">
<slot></slot> <div
</div> v-if="isOpen"
</Transition> ref="menuRef"
class="dropdown-menu"
role="menu"
:aria-hidden="!isOpen"
@click="onMenuClick"
>
<div class="dropdown-arrow"></div>
<div class="dropdown-menu-inner">
<slot></slot>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
const visible = ref(false) const props = defineProps({
modelValue: {
type: Boolean,
default: undefined,
},
placement: {
type: String,
default: 'bottom-end',
validator: (v) =>
[
'top-start',
'top-end',
'bottom-start',
'bottom-end',
'left-start',
'left-end',
'right-start',
'right-end',
].includes(v),
},
teleport: {
type: Boolean,
default: true,
},
closeOnSelect: {
type: Boolean,
default: true,
},
closeOnOutsideClick: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue', 'open', 'close'])
const internalVisible = ref(false)
const rootRef = ref(null) const rootRef = ref(null)
const triggerRef = ref(null)
const menuRef = ref(null)
const focusedIndex = ref(-1)
let resizeObserver = null
const isOpen = computed(() => props.modelValue ?? internalVisible.value)
const openClass = computed(() => (isOpen.value ? 'open' : ''))
const placementClass = computed(() => `placement-${props.placement}`)
function setOpen(val) {
if (val === isOpen.value) return
internalVisible.value = val
if (props.modelValue === undefined) {
emit('update:modelValue', val)
}
emit(val ? 'open' : 'close')
if (val) {
focusedIndex.value = -1
nextTick(() => focusFirstItem())
}
}
function toggle() {
setOpen(!isOpen.value)
}
function onTransitionLeave() {
focusedIndex.value = -1
}
function updatePosition() {
const menuEl = menuRef.value
const triggerEl = triggerRef.value
if (!menuEl || !triggerEl) return
const triggerRect = triggerEl.getBoundingClientRect()
const menuRect = menuEl.getBoundingClientRect()
const arrowGap = 6
const arrowOffset = 12
const { placement } = props
let top, left, arrowTop, arrowLeft
switch (placement) {
case 'bottom-end':
top = triggerRect.bottom + arrowGap
left = triggerRect.right - menuRect.width
arrowTop = -arrowGap + 1
arrowLeft = menuRect.width - arrowOffset
break
case 'bottom-start':
top = triggerRect.bottom + arrowGap
left = triggerRect.left
arrowTop = -arrowGap + 1
arrowLeft = arrowOffset
break
case 'top-end':
top = triggerRect.top - menuRect.height - arrowGap
left = triggerRect.right - menuRect.width
arrowTop = menuRect.height - arrowGap - 1
arrowLeft = menuRect.width - arrowOffset
break
case 'top-start':
top = triggerRect.top - menuRect.height - arrowGap
left = triggerRect.left
arrowTop = menuRect.height - arrowGap - 1
arrowLeft = arrowOffset
break
case 'right-end':
top = triggerRect.bottom - menuRect.height
left = triggerRect.right + arrowGap
arrowTop = 'auto'
arrowLeft = -arrowGap + 1
break
case 'right-start':
top = triggerRect.top
left = triggerRect.right + arrowGap
arrowTop = arrowOffset
arrowLeft = -arrowGap + 1
break
case 'left-end':
top = triggerRect.bottom - menuRect.height
left = triggerRect.left - menuRect.width - arrowGap
arrowTop = 'auto'
arrowLeft = menuRect.width - arrowGap - 1
break
case 'left-start':
top = triggerRect.top
left = triggerRect.left - menuRect.width - arrowGap
arrowTop = arrowOffset
arrowLeft = menuRect.width - arrowGap - 1
break
default:
top = triggerRect.bottom + arrowGap
left = triggerRect.right - menuRect.width
arrowTop = -arrowGap + 1
arrowLeft = menuRect.width - arrowOffset
}
const padding = 8
if (top < padding) top = triggerRect.bottom + arrowGap
if (left < padding) left = padding
if (left + menuRect.width > window.innerWidth - padding) {
left = window.innerWidth - menuRect.width - padding
}
if (top + menuRect.height > window.innerHeight - padding) {
top = triggerRect.top - menuRect.height - arrowGap
}
menuEl.style.top = `${top}px`
menuEl.style.left = `${left}px`
const arrowEl = menuEl.querySelector('.dropdown-arrow')
if (arrowEl) {
if (arrowTop !== 'auto') arrowEl.style.top = `${arrowTop}px`
else arrowEl.style.top = 'auto'
if (arrowLeft !== 'auto') arrowEl.style.left = `${arrowLeft}px`
else arrowEl.style.left = 'auto'
}
}
function handleClickOutside(e) { function handleClickOutside(e) {
if (rootRef.value && !rootRef.value.contains(e.target)) { if (!props.closeOnOutsideClick) return
visible.value = false if (rootRef.value?.contains(e.target)) return
if (menuRef.value?.contains(e.target)) return
if (isOpen.value) {
setOpen(false)
} }
} }
function handleKeyDown(e) { function handleKeyDown(e) {
if (e.key === 'Escape') { if (e.key === 'Escape' && isOpen.value) {
visible.value = false e.preventDefault()
setOpen(false)
triggerRef.value?.focus()
} }
} }
function onTriggerKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle()
} else if (['ArrowDown', 'ArrowUp'].includes(e.key) && isOpen.value) {
e.preventDefault()
if (e.key === 'ArrowDown') focusNextItem()
else focusPrevItem()
} else if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key) && !isOpen.value) {
e.preventDefault()
setOpen(true)
nextTick(() => {
if (e.key === 'ArrowUp') focusLastItem()
else focusFirstItem()
})
}
}
function onMenuClick(e) {
const item = e.target.closest('button, .dropdown-item, [role="menuitem"], a')
if (!item) return
if (item.disabled || item.getAttribute('aria-disabled') === 'true') {
e.stopPropagation()
return
}
if (props.closeOnSelect) {
setOpen(false)
triggerRef.value?.focus()
}
}
function getMenuItems() {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll(
'button:not([disabled]):not(.dropdown-divider), .dropdown-item:not([disabled]):not([aria-disabled="true"]), [role="menuitem"]:not([aria-disabled="true"]), a',
),
)
}
function focusItem(index) {
const items = getMenuItems()
if (items.length === 0) return
const clampedIndex = ((index % items.length) + items.length) % items.length
items[clampedIndex]?.focus()
focusedIndex.value = clampedIndex
}
function focusFirstItem() { focusItem(0) }
function focusLastItem() { focusItem(getMenuItems().length - 1) }
function focusNextItem() { focusItem(focusedIndex.value + 1) }
function focusPrevItem() { focusItem(focusedIndex.value - 1) }
function handleMenuKeydown(e) {
if (!isOpen.value) return
const items = getMenuItems()
const currentIdx = items.indexOf(document.activeElement)
if (e.key === 'ArrowDown') {
e.preventDefault()
focusItem(currentIdx + 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
focusItem(currentIdx - 1)
} else if (e.key === 'Home') {
e.preventDefault()
focusFirstItem()
} else if (e.key === 'End') {
e.preventDefault()
focusLastItem()
} else if (e.key === 'Tab') {
setOpen(false)
}
}
watch(isOpen, (val) => {
if (val) nextTick(() => updatePosition())
})
watch(
() => props.modelValue,
(val) => {
if (val !== undefined && val !== internalVisible.value) {
internalVisible.value = val
if (val) {
emit('open')
nextTick(() => focusFirstItem())
} else {
emit('close')
}
}
},
)
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside, true)
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
menuRef.value?.addEventListener('keydown', handleMenuKeydown)
resizeObserver = new ResizeObserver(() => {
if (isOpen.value) updatePosition()
})
if (rootRef.value?.parentElement) {
resizeObserver.observe(rootRef.value.parentElement)
}
window.addEventListener('scroll', () => {
if (isOpen.value) updatePosition()
}, true)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside, true)
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
menuRef.value?.removeEventListener('keydown', handleMenuKeydown)
resizeObserver?.disconnect()
}) })
</script> </script>
<style scoped> <style scoped>
/* ====== Container ====== */
.dropdown { .dropdown {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -51,25 +340,168 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
outline: none;
border-radius: var(--radius-2);
transition: background-color 0.2s ease, transform 0.15s ease;
} }
.dropdown-trigger:hover { transform: scale(1.05); }
.dropdown-trigger:active { transform: scale(0.95); }
.dropdown-trigger:focus-visible {
box-shadow: 0 0 0 2px var(--brand-light), 0 0 0 4px var(--brand);
}
/* ====== Menu Shell ====== */
.dropdown-menu { .dropdown-menu {
position: absolute; position: fixed;
top: calc(100% + 4px); z-index: 9999;
right: 0; min-width: 160px;
z-index: 1000; filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.07))
drop-shadow(0 10px 15px rgba(0, 0, 0, 0.1));
}
.dropdown-menu-inner {
background-color: var(--gray-0); background-color: var(--gray-0);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-2); border-radius: var(--radius-2);
box-shadow: var(--shadow-3);
padding: var(--size-1) 0; padding: var(--size-1) 0;
min-width: 150px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
position: relative;
} }
.dropdown-menu button { /* Arrow */
.dropdown-arrow {
position: absolute;
width: 10px;
height: 10px;
background: var(--gray-0);
border: 1px solid var(--border);
transform: rotate(45deg);
z-index: 1;
pointer-events: none;
}
.dropdown.placement-bottom-end .dropdown-arrow,
.dropdown.placement-bottom-start .dropdown-arrow {
border-right: none;
border-bottom: none;
}
.dropdown.placement-top-end .dropdown-arrow,
.dropdown.placement-top-start .dropdown-arrow {
border-left: none;
border-top: none;
}
/* ====== .dropdown-item — Primary item system (any element) ====== */
/* When <button class="dropdown-item">, ONLY .dropdown-item styles apply */
.dropdown-item {
position: relative;
width: 100%;
padding: var(--size-2) var(--size-3);
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: var(--font-size-1);
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: var(--size-3);
outline: none;
letter-spacing: -0.01em;
}
.dropdown-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) scaleX(0);
width: 3px;
height: 60%;
background: var(--brand);
border-radius: 0 2px 2px 0;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-item:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.dropdown-item:hover::before {
transform: translateY(-50%) scaleX(1);
}
.dropdown-item:focus-visible {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.dropdown-item:focus-visible::before {
transform: translateY(-50%) scaleX(1);
}
.dropdown-item:active {
transform: scale(0.98);
}
.dropdown-item[disabled],
.dropdown-item[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
/* Danger variant */
.dropdown-item.danger { color: var(--red-7); }
.dropdown-item.danger::before { background: var(--red-7); }
.dropdown-item.danger:hover {
background-color: var(--red-1, #fef2f2);
color: var(--red-7);
}
/* Item sub-elements */
.dropdown-item .dropdown-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
transition: color 0.2s ease;
}
.dropdown-item:hover .dropdown-icon,
.dropdown-item:focus-visible .dropdown-icon {
color: var(--brand);
}
.dropdown-item.danger .dropdown-icon { color: var(--red-7); }
.dropdown-item .dropdown-text {
flex: 1;
white-space: nowrap;
}
.dropdown-item .dropdown-hint {
font-size: var(--font-size-0);
color: var(--text-muted);
font-weight: 400;
}
/* ====== Bare <button> / <a> — fallback for simple usage ====== */
/* All selectors use :not(.dropdown-item) to avoid conflict */
.dropdown-menu button:not(.dropdown-item),
.dropdown-menu a {
position: relative;
width: 100%; width: 100%;
padding: var(--size-2) var(--size-4); padding: var(--size-2) var(--size-4);
border: none; border: none;
@@ -79,37 +511,160 @@ onBeforeUnmount(() => {
font-size: var(--font-size-0); font-size: var(--font-size-0);
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.15s; transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--size-2); gap: var(--size-3);
outline: none;
letter-spacing: 0.01em;
} }
.dropdown-menu button:hover { .dropdown-menu button:not(.dropdown-item)::before,
background-color: var(--surface); .dropdown-menu a::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) scaleX(0);
width: 3px;
height: 60%;
background: var(--brand);
border-radius: 0 2px 2px 0;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-menu button:not(.dropdown-item):hover,
.dropdown-menu a:hover {
background-color: var(--surface-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.dropdown-menu button[type="reset"] { .dropdown-menu button:not(.dropdown-item):hover::before,
.dropdown-menu a:hover::before {
transform: translateY(-50%) scaleX(1);
}
.dropdown-menu button:not(.dropdown-item):focus-visible,
.dropdown-menu a:focus-visible {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.dropdown-menu button:not(.dropdown-item):focus-visible::before,
.dropdown-menu a:focus-visible::before {
transform: translateY(-50%) scaleX(1);
}
.dropdown-menu button:not(.dropdown-item):active,
.dropdown-menu a:active {
transform: scale(0.98);
}
.dropdown-menu button:not(.dropdown-item)[disabled],
.dropdown-menu a[disabled] {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
/* Destructive via type="reset" — only on bare buttons */
.dropdown-menu button:not(.dropdown-item)[type="reset"] { color: var(--red-7); }
.dropdown-menu button:not(.dropdown-item)[type="reset"]::before { background: var(--red-7); }
.dropdown-menu button:not(.dropdown-item)[type="reset"]:hover {
background-color: var(--red-1, #fef2f2);
color: var(--red-7); color: var(--red-7);
} }
.dropdown-menu button[type="reset"]:hover { /* Divider */
background-color: var(--red-1, #fef2f2); .dropdown-divider,
.dropdown-menu hr {
height: 1px;
margin: var(--size-1) var(--size-2);
padding: 0;
border: none;
background: linear-gradient(90deg, transparent, var(--border), transparent);
pointer-events: none;
} }
/* 过渡动画 */ /* ====== Transitions ====== */
.dropdown-enter-active, .dropdown-enter-active,
.dropdown-leave-active { .dropdown-leave-active {
transition: transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
opacity 0.15s ease,
transform 0.15s ease;
transform-origin: top right;
} }
.dropdown-enter-from, .dropdown-enter-from,
.dropdown-leave-to { .dropdown-leave-to { opacity: 0; }
opacity: 0;
transform: scale(0.96) translateY(-4px); .dropdown-enter-active .dropdown-menu-inner {
animation: dropdown-pop-in 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.dropdown-leave-active .dropdown-menu-inner {
animation: dropdown-pop-out 0.15s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.dropdown-enter-active .dropdown-arrow {
animation: dropdown-arrow-in 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.dropdown-leave-active .dropdown-arrow {
animation: dropdown-arrow-out 0.15s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes dropdown-pop-in {
from { opacity: 0; transform: scale(0.92) translateY(-6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes dropdown-pop-out {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.96) translateY(-4px); }
}
@keyframes dropdown-arrow-in {
from { opacity: 0; transform: rotate(45deg) scale(0); }
to { opacity: 1; transform: rotate(45deg) scale(1); }
}
@keyframes dropdown-arrow-out {
from { opacity: 1; transform: rotate(45deg) scale(1); }
to { opacity: 0; transform: rotate(45deg) scale(0); }
}
/* Staggered items */
.dropdown-enter-active .dropdown-item,
.dropdown-enter-active button:not(.dropdown-item),
.dropdown-enter-active a {
animation: dropdown-item-in 0.25s cubic-bezier(0.4, 0, 0.2, 1) backwards;
}
.dropdown-enter-active .dropdown-item:nth-child(1),
.dropdown-enter-active button:not(.dropdown-item):nth-child(1),
.dropdown-enter-active a:nth-child(1) { animation-delay: 0.03s; }
.dropdown-enter-active .dropdown-item:nth-child(2),
.dropdown-enter-active button:not(.dropdown-item):nth-child(2),
.dropdown-enter-active a:nth-child(2) { animation-delay: 0.06s; }
.dropdown-enter-active .dropdown-item:nth-child(3),
.dropdown-enter-active button:not(.dropdown-item):nth-child(3),
.dropdown-enter-active a:nth-child(3) { animation-delay: 0.09s; }
.dropdown-enter-active .dropdown-item:nth-child(4),
.dropdown-enter-active button:not(.dropdown-item):nth-child(4),
.dropdown-enter-active a:nth-child(4) { animation-delay: 0.12s; }
.dropdown-enter-active .dropdown-item:nth-child(5),
.dropdown-enter-active button:not(.dropdown-item):nth-child(5),
.dropdown-enter-active a:nth-child(5) { animation-delay: 0.15s; }
.dropdown-enter-active .dropdown-item:nth-child(6),
.dropdown-enter-active button:not(.dropdown-item):nth-child(6),
.dropdown-enter-active a:nth-child(6) { animation-delay: 0.18s; }
@keyframes dropdown-item-in {
from { opacity: 0; transform: translateY(4px) translateX(-4px); }
to { opacity: 1; transform: translateY(0) translateX(0); }
} }
</style> </style>

View File

@@ -8,15 +8,24 @@
<template v-slot:trigger> <template v-slot:trigger>
<button class="card-menu-btn" aria-label="更多操作"><EllipsisVertical :size="16" /></button> <button class="card-menu-btn" aria-label="更多操作"><EllipsisVertical :size="16" /></button>
</template> </template>
<button @click="viewSongs">查看歌曲</button> <button class="dropdown-item" @click="viewSongs">
<button @click="scanCard">扫描</button> <ListMusic class="dropdown-icon" :size="16" />
<button type="reset" @click="deleteCard">删除</button> <span class="dropdown-text">查看歌曲</span>
</button>
<button class="dropdown-item" @click="scanCard">
<RotateCw class="dropdown-icon" :size="16" />
<span class="dropdown-text">扫描</span>
</button>
<button class="dropdown-item danger" type="button" @click="deleteCard">
<Trash2 class="dropdown-icon" :size="16" />
<span class="dropdown-text">删除</span>
</button>
</DropDown> </DropDown>
</div> </div>
</template> </template>
<script setup> <script setup>
import { EllipsisVertical, FolderOpen } from 'lucide-vue-next' import { EllipsisVertical, FolderOpen, ListMusic, RotateCw, Trash2 } from 'lucide-vue-next'
import DropDown from './DropDown.vue' import DropDown from './DropDown.vue'
const props = defineProps({ const props = defineProps({

View File

@@ -1,18 +1,37 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import DropDown from '../DropDown.vue' import DropDown from '../DropDown.vue'
describe('DropDown.vue', () => { describe('DropDown.vue', () => {
function createWrapper() { beforeEach(() => {
document.body.innerHTML = ''
})
function createWrapper(props = {}, slots = {}) {
return mount(DropDown, { return mount(DropDown, {
props: {
...props,
},
slots: { slots: {
trigger: '<button class="my-trigger">Open</button>', trigger: slots.trigger || '<button class="my-trigger">Open</button>',
default: '<button class="item">Item 1</button>', default:
slots.default ||
`<button class="item-1">Item 1</button><button class="item-2">Item 2</button>`,
}, },
attachTo: document.body, attachTo: document.body,
global: {
stubs: {
Teleport: false,
},
},
}) })
} }
function getMenu() {
return document.querySelector('.dropdown-menu')
}
// Basic rendering
it('should render trigger slot content', () => { it('should render trigger slot content', () => {
const wrapper = createWrapper() const wrapper = createWrapper()
expect(wrapper.find('.my-trigger').exists()).toBe(true) expect(wrapper.find('.my-trigger').exists()).toBe(true)
@@ -23,29 +42,75 @@ describe('DropDown.vue', () => {
it('should render default slot content in dropdown menu', async () => { it('should render default slot content in dropdown menu', async () => {
const wrapper = createWrapper() const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click') await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu .item').exists()).toBe(true) await flushPromises()
expect(wrapper.find('.dropdown-menu .item').text()).toBe('Item 1') const menu = getMenu()
expect(menu).not.toBeNull()
expect(menu.querySelector('.item-1')).not.toBeNull()
expect(menu.querySelector('.item-2')).not.toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
// Toggle behavior
it('should toggle dropdown when trigger is clicked', async () => { it('should toggle dropdown when trigger is clicked', async () => {
const wrapper = createWrapper() const wrapper = createWrapper()
expect(wrapper.find('.dropdown-menu').exists()).toBe(false) expect(getMenu()).toBeNull()
await wrapper.find('.dropdown-trigger').trigger('click') await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true) await flushPromises()
expect(getMenu()).not.toBeNull()
await wrapper.find('.dropdown-trigger').trigger('click') await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(false) await flushPromises()
expect(getMenu()).toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
// v-model support
it('should support v-model (modelValue prop)', async () => {
const wrapper = createWrapper({ modelValue: false })
expect(getMenu()).toBeNull()
await wrapper.setProps({ modelValue: true })
await flushPromises()
expect(getMenu()).not.toBeNull()
await wrapper.setProps({ modelValue: false })
await flushPromises()
expect(getMenu()).toBeNull()
wrapper.unmount()
})
it('should emit update:modelValue when toggled internally', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue').slice(-1)[0]).toEqual([true])
wrapper.unmount()
})
// Open/Close events
it('should emit open and close events', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(wrapper.emitted('open')).toBeTruthy()
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(wrapper.emitted('close')).toBeTruthy()
wrapper.unmount()
})
// Outside click
it('should close dropdown when clicking outside', async () => { it('should close dropdown when clicking outside', async () => {
const wrapper = createWrapper() const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click') await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true) await flushPromises()
expect(getMenu()).not.toBeNull()
const outsideEvent = new MouseEvent('click', { const outsideEvent = new MouseEvent('click', {
bubbles: true, bubbles: true,
@@ -54,20 +119,36 @@ describe('DropDown.vue', () => {
document.dispatchEvent(outsideEvent) document.dispatchEvent(outsideEvent)
await flushPromises() await flushPromises()
expect(wrapper.find('.dropdown-menu').exists()).toBe(false) expect(getMenu()).toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
it('should not close when closeOnOutsideClick is false', async () => {
const wrapper = createWrapper({ closeOnOutsideClick: false })
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(getMenu()).not.toBeNull()
document.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(getMenu()).not.toBeNull()
wrapper.unmount()
})
// Escape key
it('should close dropdown on Escape key', async () => { it('should close dropdown on Escape key', async () => {
const wrapper = createWrapper() const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click') await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true) await flushPromises()
expect(getMenu()).not.toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises() await flushPromises()
expect(wrapper.find('.dropdown-menu').exists()).toBe(false) expect(getMenu()).toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
@@ -75,21 +156,151 @@ describe('DropDown.vue', () => {
const wrapper = createWrapper() const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click') await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true) await flushPromises()
expect(getMenu()).not.toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(wrapper.find('.dropdown-menu').exists()).toBe(true) expect(getMenu()).not.toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
// Close on select
it('should close dropdown when clicking a menu item', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(getMenu()).not.toBeNull()
const item = getMenu().querySelector('.item-1')
item.click()
await flushPromises()
expect(getMenu()).toBeNull()
wrapper.unmount()
})
it('should not close on select when closeOnSelect is false', async () => {
const wrapper = createWrapper({ closeOnSelect: false })
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(getMenu()).not.toBeNull()
const item = getMenu().querySelector('.item-1')
item.click()
await flushPromises()
expect(getMenu()).not.toBeNull()
wrapper.unmount()
})
// Keyboard navigation on trigger
it('should open dropdown on Enter key on trigger', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(getMenu()).not.toBeNull()
wrapper.unmount()
})
it('should open dropdown on Space key on trigger', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('keydown', { key: ' ' })
await flushPromises()
expect(getMenu()).not.toBeNull()
wrapper.unmount()
})
it('should open dropdown on ArrowDown key and focus first item', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(getMenu()).not.toBeNull()
const items = getMenu().querySelectorAll('button:not([disabled])')
expect(items.length).toBeGreaterThan(0)
expect(document.activeElement).toBe(items[0])
wrapper.unmount()
})
// ARIA attributes
it('should have correct ARIA attributes on trigger', () => {
const wrapper = createWrapper()
const trigger = wrapper.find('.dropdown-trigger')
expect(trigger.attributes('role')).toBe('button')
expect(trigger.attributes('tabindex')).toBe('0')
expect(trigger.attributes('aria-haspopup')).toBe('true')
expect(trigger.attributes('aria-expanded')).toBe('false')
wrapper.unmount()
})
it('should update aria-expanded when opened', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(wrapper.find('.dropdown-trigger').attributes('aria-expanded')).toBe('true')
wrapper.unmount()
})
it('should have role="menu" and aria-hidden on menu', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
const menu = getMenu()
expect(menu.getAttribute('role')).toBe('menu')
expect(menu.getAttribute('aria-hidden')).toBe('false')
wrapper.unmount()
})
// Placement class
it('should apply placement class', () => {
const wrapper = createWrapper({ placement: 'top-start' })
expect(wrapper.find('.dropdown').classes()).toContain('placement-top-start')
wrapper.unmount()
})
// Disabled item handling
it('should not close when clicking disabled item', async () => {
const wrapper = mount(DropDown, {
slots: {
trigger: '<button class="my-trigger">Open</button>',
default: '<button disabled class="disabled-item">Disabled</button><button class="item-1">Enabled</button>',
},
attachTo: document.body,
global: {
stubs: {
Teleport: false,
},
},
})
await wrapper.find('.dropdown-trigger').trigger('click')
await flushPromises()
expect(getMenu()).not.toBeNull()
const disabledItem = getMenu().querySelector('.disabled-item')
expect(disabledItem).not.toBeNull()
disabledItem.click()
await flushPromises()
expect(getMenu()).not.toBeNull()
wrapper.unmount()
})
// Cleanup
it('should clean up event listeners on unmount', () => { it('should clean up event listeners on unmount', () => {
const removeSpy = vi.spyOn(document, 'removeEventListener') const removeSpy = vi.spyOn(document, 'removeEventListener')
const wrapper = createWrapper() const wrapper = createWrapper()
wrapper.unmount() wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function)) expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function), true)
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
}) })
}) })

View File

@@ -1,8 +1,12 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import LibraryCard from '../LibraryCard.vue' import LibraryCard from '../LibraryCard.vue'
describe('LibraryCard.vue', () => { describe('LibraryCard.vue', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
function setup(props = {}) { function setup(props = {}) {
return mount(LibraryCard, { return mount(LibraryCard, {
props: { props: {
@@ -10,61 +14,87 @@ describe('LibraryCard.vue', () => {
name: 'My Library', name: 'My Library',
...props, ...props,
}, },
attachTo: document.body,
global: {
stubs: {
Teleport: false,
},
},
}) })
} }
function getMenuButtons() {
const menu = document.querySelector('.dropdown-menu')
return menu ? Array.from(menu.querySelectorAll('button')) : []
}
it('should render library name', () => { it('should render library name', () => {
const wrapper = setup() const wrapper = setup()
expect(wrapper.find('.card-name').text()).toBe('My Library') expect(wrapper.find('.card-name').text()).toBe('My Library')
wrapper.unmount()
}) })
it('should render the folder icon', () => { it('should render the folder icon', () => {
const wrapper = setup() const wrapper = setup()
expect(wrapper.find('.card-icon').exists()).toBe(true) expect(wrapper.find('.card-icon').exists()).toBe(true)
wrapper.unmount()
}) })
it('should emit scan event with id when scan button clicked', async () => { it('should emit scan event with id when scan button clicked', async () => {
const wrapper = setup() const wrapper = setup()
// Need to open dropdown first to reveal buttons // Open dropdown first
await wrapper.find('.card-menu-btn').trigger('click') await wrapper.find('.card-menu-btn').trigger('click')
await flushPromises()
const buttons = wrapper.findAll('.dropdown-menu button') const buttons = getMenuButtons()
// Second button is "扫描" // Second button is "扫描"
await buttons[1].trigger('click') expect(buttons.length).toBeGreaterThanOrEqual(2)
buttons[1].click()
await flushPromises()
expect(wrapper.emitted('scan')).toBeTruthy() expect(wrapper.emitted('scan')).toBeTruthy()
expect(wrapper.emitted('scan')[0]).toEqual([1]) expect(wrapper.emitted('scan')[0]).toEqual([1])
wrapper.unmount()
}) })
it('should emit delete event with id when delete button clicked', async () => { it('should emit delete event with id when delete button clicked', async () => {
const wrapper = setup() const wrapper = setup()
await wrapper.find('.card-menu-btn').trigger('click') await wrapper.find('.card-menu-btn').trigger('click')
await flushPromises()
const buttons = wrapper.findAll('.dropdown-menu button') const buttons = getMenuButtons()
// Third button (type=reset) is "删除" // Third button (type=reset) is "删除"
await buttons[2].trigger('click') expect(buttons.length).toBeGreaterThanOrEqual(3)
buttons[2].click()
await flushPromises()
expect(wrapper.emitted('delete')).toBeTruthy() expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')[0]).toEqual([1]) expect(wrapper.emitted('delete')[0]).toEqual([1])
wrapper.unmount()
}) })
it('should emit viewSongs event with id when view button clicked', async () => { it('should emit viewSongs event with id when view button clicked', async () => {
const wrapper = setup() const wrapper = setup()
await wrapper.find('.card-menu-btn').trigger('click') await wrapper.find('.card-menu-btn').trigger('click')
await flushPromises()
const buttons = wrapper.findAll('.dropdown-menu button') const buttons = getMenuButtons()
// First button is "查看歌曲" // First button is "查看歌曲"
await buttons[0].trigger('click') expect(buttons.length).toBeGreaterThanOrEqual(1)
buttons[0].click()
await flushPromises()
expect(wrapper.emitted('viewSongs')).toBeTruthy() expect(wrapper.emitted('viewSongs')).toBeTruthy()
expect(wrapper.emitted('viewSongs')[0]).toEqual([1]) expect(wrapper.emitted('viewSongs')[0]).toEqual([1])
wrapper.unmount()
}) })
it('should render with correct id prop', () => { it('should render with correct id prop', () => {
const wrapper = setup({ id: 42 }) const wrapper = setup({ id: 42 })
expect(wrapper.props('id')).toBe(42) expect(wrapper.props('id')).toBe(42)
wrapper.unmount()
}) })
}) })

View File

@@ -54,6 +54,13 @@ class MockAudio {
globalThis.Audio = MockAudio globalThis.Audio = MockAudio
// Mock ResizeObserver for jsdom
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
localStorageMock.clear() localStorageMock.clear()