优化Dropdown
This commit is contained in:
@@ -1,46 +1,335 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="dropdown" :class="{ open: visible }">
|
||||
<div class="dropdown-trigger" @click="visible = !visible">
|
||||
<div ref="rootRef" class="dropdown" :class="[openClass, placementClass]">
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="dropdown-trigger"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
@click="toggle"
|
||||
@keydown="onTriggerKeydown"
|
||||
>
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
<Transition name="dropdown">
|
||||
<div v-if="visible" class="dropdown-menu">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<Teleport to="body" :disabled="!teleport">
|
||||
<Transition name="dropdown" @after-leave="onTransitionLeave">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<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 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) {
|
||||
if (rootRef.value && !rootRef.value.contains(e.target)) {
|
||||
visible.value = false
|
||||
if (!props.closeOnOutsideClick) return
|
||||
if (rootRef.value?.contains(e.target)) return
|
||||
if (menuRef.value?.contains(e.target)) return
|
||||
if (isOpen.value) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
visible.value = false
|
||||
if (e.key === 'Escape' && isOpen.value) {
|
||||
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(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('click', handleClickOutside, true)
|
||||
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(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('click', handleClickOutside, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
menuRef.value?.removeEventListener('keydown', handleMenuKeydown)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ====== Container ====== */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -51,25 +340,168 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
min-width: 160px;
|
||||
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);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-3);
|
||||
padding: var(--size-1) 0;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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%;
|
||||
padding: var(--size-2) var(--size-4);
|
||||
border: none;
|
||||
@@ -79,37 +511,160 @@ onBeforeUnmount(() => {
|
||||
font-size: var(--font-size-0);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
gap: var(--size-3);
|
||||
outline: none;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.dropdown-menu button:hover {
|
||||
background-color: var(--surface);
|
||||
.dropdown-menu button:not(.dropdown-item)::before,
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.dropdown-menu button[type="reset"]:hover {
|
||||
background-color: var(--red-1, #fef2f2);
|
||||
/* Divider */
|
||||
.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-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
transform-origin: top right;
|
||||
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-4px);
|
||||
.dropdown-leave-to { opacity: 0; }
|
||||
|
||||
.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>
|
||||
|
||||
@@ -8,15 +8,24 @@
|
||||
<template v-slot:trigger>
|
||||
<button class="card-menu-btn" aria-label="更多操作"><EllipsisVertical :size="16" /></button>
|
||||
</template>
|
||||
<button @click="viewSongs">查看歌曲</button>
|
||||
<button @click="scanCard">扫描</button>
|
||||
<button type="reset" @click="deleteCard">删除</button>
|
||||
<button class="dropdown-item" @click="viewSongs">
|
||||
<ListMusic class="dropdown-icon" :size="16" />
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { EllipsisVertical, FolderOpen } from 'lucide-vue-next'
|
||||
import { EllipsisVertical, FolderOpen, ListMusic, RotateCw, Trash2 } from 'lucide-vue-next'
|
||||
import DropDown from './DropDown.vue'
|
||||
|
||||
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 DropDown from '../DropDown.vue'
|
||||
|
||||
describe('DropDown.vue', () => {
|
||||
function createWrapper() {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function createWrapper(props = {}, slots = {}) {
|
||||
return mount(DropDown, {
|
||||
props: {
|
||||
...props,
|
||||
},
|
||||
slots: {
|
||||
trigger: '<button class="my-trigger">Open</button>',
|
||||
default: '<button class="item">Item 1</button>',
|
||||
trigger: slots.trigger || '<button class="my-trigger">Open</button>',
|
||||
default:
|
||||
slots.default ||
|
||||
`<button class="item-1">Item 1</button><button class="item-2">Item 2</button>`,
|
||||
},
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getMenu() {
|
||||
return document.querySelector('.dropdown-menu')
|
||||
}
|
||||
|
||||
// Basic rendering
|
||||
it('should render trigger slot content', () => {
|
||||
const wrapper = createWrapper()
|
||||
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 () => {
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('.dropdown-trigger').trigger('click')
|
||||
expect(wrapper.find('.dropdown-menu .item').exists()).toBe(true)
|
||||
expect(wrapper.find('.dropdown-menu .item').text()).toBe('Item 1')
|
||||
await flushPromises()
|
||||
const menu = getMenu()
|
||||
expect(menu).not.toBeNull()
|
||||
expect(menu.querySelector('.item-1')).not.toBeNull()
|
||||
expect(menu.querySelector('.item-2')).not.toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Toggle behavior
|
||||
it('should toggle dropdown when trigger is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
|
||||
expect(getMenu()).toBeNull()
|
||||
|
||||
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')
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
|
||||
await flushPromises()
|
||||
expect(getMenu()).toBeNull()
|
||||
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 () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
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', {
|
||||
bubbles: true,
|
||||
@@ -54,20 +119,36 @@ describe('DropDown.vue', () => {
|
||||
document.dispatchEvent(outsideEvent)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
|
||||
expect(getMenu()).toBeNull()
|
||||
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 () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
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' }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
|
||||
expect(getMenu()).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -75,21 +156,151 @@ describe('DropDown.vue', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
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' }))
|
||||
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
|
||||
expect(getMenu()).not.toBeNull()
|
||||
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', () => {
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
const wrapper = createWrapper()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function))
|
||||
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function), true)
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import LibraryCard from '../LibraryCard.vue'
|
||||
|
||||
describe('LibraryCard.vue', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function setup(props = {}) {
|
||||
return mount(LibraryCard, {
|
||||
props: {
|
||||
@@ -10,61 +14,87 @@ describe('LibraryCard.vue', () => {
|
||||
name: 'My Library',
|
||||
...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', () => {
|
||||
const wrapper = setup()
|
||||
expect(wrapper.find('.card-name').text()).toBe('My Library')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should render the folder icon', () => {
|
||||
const wrapper = setup()
|
||||
expect(wrapper.find('.card-icon').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should emit scan event with id when scan button clicked', async () => {
|
||||
const wrapper = setup()
|
||||
|
||||
// Need to open dropdown first to reveal buttons
|
||||
// Open dropdown first
|
||||
await wrapper.find('.card-menu-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('.dropdown-menu button')
|
||||
const buttons = getMenuButtons()
|
||||
// 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')[0]).toEqual([1])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should emit delete event with id when delete button clicked', async () => {
|
||||
const wrapper = setup()
|
||||
|
||||
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 "删除"
|
||||
await buttons[2].trigger('click')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(3)
|
||||
buttons[2].click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('delete')).toBeTruthy()
|
||||
expect(wrapper.emitted('delete')[0]).toEqual([1])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should emit viewSongs event with id when view button clicked', async () => {
|
||||
const wrapper = setup()
|
||||
|
||||
await wrapper.find('.card-menu-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('.dropdown-menu button')
|
||||
const buttons = getMenuButtons()
|
||||
// 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')[0]).toEqual([1])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should render with correct id prop', () => {
|
||||
const wrapper = setup({ id: 42 })
|
||||
expect(wrapper.props('id')).toBe(42)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,13 @@ class MockAudio {
|
||||
|
||||
globalThis.Audio = MockAudio
|
||||
|
||||
// Mock ResizeObserver for jsdom
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorageMock.clear()
|
||||
|
||||
Reference in New Issue
Block a user