优化Dropdown
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user