feat(web): 命令面板 combobox+可见入口 + sticky 导航壳 + 焦点环统一 + 抽屉锁滚动/关闭按钮 + 主题首帧修复 + 设置徽标/datalist

This commit is contained in:
Yaojia Wang
2026-06-30 08:56:23 +02:00
parent 164e98887e
commit 6854dac98f
11 changed files with 251 additions and 77 deletions

View File

@@ -1,8 +1,12 @@
"use client";
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { overlayScrim } from "@/lib/ui/variants";
interface DrawerProps {
open: boolean;
@@ -15,7 +19,7 @@ interface DrawerProps {
children: ReactNode;
}
// 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦面板)。
// 移动端侧滑抽屉(<lg遮罩点击 / Esc 关闭 + 打开聚焦具名关闭按钮)。
// 复用命令面板的 a11y 模式CommandPalette桌面用静态布局不渲染本抽屉。
export function Drawer({
open,
@@ -26,8 +30,11 @@ export function Drawer({
children,
}: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
useBodyScrollLock(open);
useEffect(() => {
if (!open) {
if (wasOpenRef.current) {
@@ -44,7 +51,8 @@ export function Drawer({
}
};
window.addEventListener("keydown", onKey);
panelRef.current?.focus();
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose, triggerRef]);
@@ -53,10 +61,9 @@ export function Drawer({
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
return (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden" onClick={onClose}>
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={label}
@@ -65,8 +72,19 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
>
<div className="mb-2 flex justify-end px-2">
<Button
ref={closeRef}
onClick={onClose}
aria-label={`关闭${label}`}
variant="ghost"
size="icon"
>
<X className="h-5 w-5" aria-hidden="true" />
</Button>
</div>
{children}
</div>
</div>