feat(web): Field 标签自动关联控件 + 全局壳无障碍(skip link/设置可访问名/抽屉焦点归还)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
19
apps/web/components/BackendDownNotice.tsx
Normal file
19
apps/web/components/BackendDownNotice.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user