blog / src /components /layout /DropdownMenu.astro
cacode's picture
Upload 434 files
96dd062 verified
---
import { Icon } from "astro-icon/components";
import DropdownItem from "@/components/common/DropdownItem.astro";
import DropdownPanel from "@/components/common/DropdownPanel.astro";
import { LinkPresets } from "@/constants/link-presets";
import { LinkPreset, type NavBarLink } from "@/types/config";
import { url } from "@/utils/url-utils";
interface Props {
link: NavBarLink;
class?: string;
}
const { link, class: className } = Astro.props;
// 检查 link 是否存在
if (!link) {
return null;
}
// 转换子菜单中的LinkPreset为NavBarLink
const processedLink = {
...link,
children: link.children
?.map((child: NavBarLink | LinkPreset): NavBarLink | null => {
if (typeof child === "number") {
// 检查 LinkPreset 是否存在于 LinkPresets 中
if (child in LinkPresets) {
return LinkPresets[child as LinkPreset];
}
return null;
}
return child;
})
.filter((child): child is NavBarLink => child !== null),
};
const hasChildren = processedLink.children && processedLink.children.length > 0;
---
<div class:list={["dropdown-container", className]} data-dropdown>
{hasChildren ? (
<button
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95 dropdown-trigger"
aria-expanded="false"
aria-haspopup="true"
data-dropdown-trigger
>
<div class="flex items-center">
{processedLink.icon && <Icon name={processedLink.icon} class="text-[1.1rem] mr-2 navbar-icon" />}
{processedLink.name}
<Icon name="material-symbols:keyboard-arrow-down-rounded" class="text-[1.25rem] transition-transform duration-200 dropdown-arrow ml-1" />
</div>
</button>
<div class="dropdown-menu" data-dropdown-menu>
<DropdownPanel class="dropdown-content">
{processedLink.children?.map((child, index) => (
<DropdownItem
href={child.external ? child.url : url(child.url)}
target={child.external ? "_blank" : undefined}
isLast={index === (processedLink.children?.length || 0) - 1}
class="dropdown-item"
>
{child.icon && <Icon name={child.icon} class="text-[1.25rem] mr-3 navbar-icon" />}
<span>{child.name}</span>
{child.external && (
<Icon name="fa7-solid:arrow-up-right-from-square" class="text-[0.75rem] text-black/25 dark:text-white/25 ml-auto" />
)}
</DropdownItem>
))}
</DropdownPanel>
</div>
) : (
<a
aria-label={processedLink.name}
href={processedLink.external ? processedLink.url : url(processedLink.url)}
target={processedLink.external ? "_blank" : null}
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
>
<div class="flex items-center">
{processedLink.icon && <Icon name={processedLink.icon} class="text-[1.1rem] mr-2 navbar-icon" />}
{processedLink.name}
{processedLink.external && <Icon name="fa7-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-px ml-1 text-black/20 dark:text-white/20" />}
</div>
</a>
)}
</div>
<style>
@reference "../../styles/main.css";
.dropdown-container {
@apply relative;
}
.dropdown-menu {
@apply absolute top-full left-0 pt-2 opacity-0 invisible pointer-events-none transition-all duration-200 ease-out transform translate-y-[-8px] z-50;
}
.dropdown-container:hover .dropdown-menu,
.dropdown-container:focus-within .dropdown-menu {
@apply opacity-100 visible pointer-events-auto translate-y-0;
}
.dropdown-container:hover .dropdown-arrow,
.dropdown-container:focus-within .dropdown-arrow {
@apply rotate-180;
}
.dropdown-content {
@apply min-w-[12rem];
}
/* 移动端隐藏下拉菜单 */
@media (max-width: 768px) {
.dropdown-container {
@apply hidden;
}
}
</style>
<script>
// 键盘导航支持
document.addEventListener('DOMContentLoaded', function() {
const dropdowns = document.querySelectorAll('[data-dropdown]');
dropdowns.forEach(dropdown => {
const trigger = dropdown.querySelector('[data-dropdown-trigger]') as HTMLElement | null;
const menu = dropdown.querySelector('[data-dropdown-menu]');
const items = dropdown.querySelectorAll('.dropdown-item') as NodeListOf<HTMLElement>;
if (!trigger || !menu) return;
// 键盘事件处理
trigger.addEventListener('keydown', function(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleDropdown(dropdown, trigger, menu);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
openDropdown(dropdown, trigger, menu);
if (items.length > 0) {
items[0].focus();
}
} else if (e.key === 'Escape') {
closeDropdown(dropdown, trigger, menu);
}
});
// 菜单项键盘导航
items.forEach((item, index) => {
item.addEventListener('keydown', function(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = (index + 1) % items.length;
items[nextIndex].focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = (index - 1 + items.length) % items.length;
items[prevIndex].focus();
} else if (e.key === 'Escape') {
closeDropdown(dropdown, trigger, menu);
trigger.focus();
}
});
});
});
// 点击外部关闭下拉菜单
document.addEventListener('click', function(e) {
dropdowns.forEach(dropdown => {
if (!dropdown.contains(e.target as Node)) {
const trigger = dropdown.querySelector('[data-dropdown-trigger]');
const menu = dropdown.querySelector('[data-dropdown-menu]');
if (trigger && menu) {
closeDropdown(dropdown, trigger, menu);
}
}
});
});
});
function toggleDropdown(_dropdown: Element, trigger: Element, menu: Element) {
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
if (isOpen) {
closeDropdown(_dropdown, trigger, menu);
} else {
openDropdown(_dropdown, trigger, menu);
}
}
function openDropdown(_dropdown: Element, trigger: Element, menu: Element) {
trigger.setAttribute('aria-expanded', 'true');
menu.classList.remove('opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]');
menu.classList.add('opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0');
}
function closeDropdown(_dropdown: Element, trigger: Element, menu: Element) {
trigger.setAttribute('aria-expanded', 'false');
menu.classList.add('opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]');
menu.classList.remove('opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0');
}
</script>