feat(web): Field 标签自动关联控件 + 全局壳无障碍(skip link/设置可访问名/抽屉焦点归还)

This commit is contained in:
Yaojia Wang
2026-06-29 16:52:43 +02:00
parent dd80b6815d
commit a2849bf6b7
4 changed files with 52 additions and 6 deletions

View File

@@ -33,6 +33,12 @@ export function AppShell({
className="min-h-screen" className="min-h-screen"
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }} style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
> >
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:border focus:border-line focus:bg-panel focus:px-3 focus:py-2 focus:text-sm focus:text-ink focus:shadow-paper"
>
</a>
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6"> <header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
<NavDrawer projectId={projectId} activeNav={activeNav} /> <NavDrawer projectId={projectId} activeNav={activeNav} />
<Link <Link
@@ -52,10 +58,15 @@ export function AppShell({
{subtitle} {subtitle}
</span> </span>
) : null} ) : null}
<nav className="ml-auto flex shrink-0 items-center gap-2 text-sm"> <nav
aria-label="快捷操作"
className="ml-auto flex shrink-0 items-center gap-2 text-sm"
>
<ThemeToggle /> <ThemeToggle />
<Link <Link
href="/settings/providers" href="/settings/providers"
aria-label="设置"
title="设置"
className={buttonClass({ className={buttonClass({
variant: "ghost", variant: "ghost",
size: "sm", size: "sm",
@@ -72,7 +83,11 @@ export function AppShell({
) : null} ) : null}
<div className="flex"> <div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} /> <LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg"> <main
id="main"
tabIndex={-1}
className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg"
>
{children} {children}
</main> </main>
</div> </div>

View File

@@ -0,0 +1,19 @@
import { StatusNote } from "./ui/StatusNote";
interface BackendDownNoticeProps {
className?: string;
}
// 后端不可达时的统一提示(多个页面 backend-down 分支复用)。
export function BackendDownNotice({ className }: BackendDownNoticeProps) {
return (
<StatusNote
variant="danger"
title="无法连接后端服务"
role="alert"
className={className}
>
API
</StatusNote>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@@ -16,10 +16,12 @@ interface NavDrawerProps {
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。 // 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg桌面用 LeftNav 静态侧栏。UX §5.1。
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) { export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
return ( return (
<> <>
<Button <Button
ref={triggerRef}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="打开导航" aria-label="打开导航"
aria-expanded={open} aria-expanded={open}
@@ -34,6 +36,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
side="left" side="left"
label="主导航" label="主导航"
triggerRef={triggerRef}
> >
<NavItems <NavItems
projectId={projectId} projectId={projectId}

View File

@@ -1,4 +1,6 @@
import type { ReactNode } from "react"; "use client";
import { cloneElement, isValidElement, useId, type ReactNode } from "react";
import { import {
cn, cn,
@@ -26,13 +28,20 @@ export function Field({
required = false, required = false,
className, className,
}: FieldProps) { }: FieldProps) {
const autoId = useId();
const id = htmlFor ?? autoId;
const labelText = required ? `${label} *` : label; const labelText = required ? `${label} *` : label;
// 自动把 id 注入唯一子控件,建立 label↔控件关联仅当子控件自身未带 id
const control =
isValidElement<{ id?: string }>(children) && children.props.id == null
? cloneElement(children, { id })
: children;
return ( return (
<div className={cn("space-y-1.5", className)}> <div className={cn("space-y-1.5", className)}>
<label htmlFor={htmlFor} className={fieldLabelClass()}> <label htmlFor={id} className={fieldLabelClass()}>
{labelText} {labelText}
</label> </label>
{children} {control}
{error ? <p className={fieldErrorClass()}>{error}</p> : null} {error ? <p className={fieldErrorClass()}>{error}</p> : null}
{!error && help ? <p className={fieldHelpClass()}>{help}</p> : null} {!error && help ? <p className={fieldHelpClass()}>{help}</p> : null}
</div> </div>