feat(web): polish responsive accessible interface
This commit is contained in:
@@ -9,6 +9,7 @@ import { SkillsPanel } from "./components/skills/SkillsPanel.js";
|
||||
import { EventTimeline } from "./components/timeline/EventTimeline.js";
|
||||
import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js";
|
||||
import { ModelSettings, type ModelView } from "./components/settings/ModelSettings.js";
|
||||
import { CompletionAnnouncer } from "./components/common/CompletionAnnouncer.js";
|
||||
import { HarnessClientStore } from "./store/harness-client-store.js";
|
||||
|
||||
type AppStore = HarnessRuntimeStore & {
|
||||
@@ -60,6 +61,10 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
|
||||
const busy = Object.values(snapshot.runs).some((run) => run.status === "running");
|
||||
const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending");
|
||||
const pendingApproval = Object.values(snapshot.approvals).find((approval) => approval.status === "pending");
|
||||
const latestAssistantStatus = [...snapshot.messageOrder]
|
||||
.reverse()
|
||||
.map((messageId) => snapshot.messages[messageId])
|
||||
.find((message) => message?.role === "assistant")?.status;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoStart) return undefined;
|
||||
@@ -127,6 +132,7 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CompletionAnnouncer status={latestAssistantStatus} />
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { MessageStatus } from "../../store/reducer.js";
|
||||
|
||||
export function CompletionAnnouncer({ status }: { status: MessageStatus | undefined }) {
|
||||
return <div className="sr-only" role="status" aria-live="polite">{status === "complete" ? "回答已完成" : ""}</div>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { TopBar, type TopBarProps } from "../topbar/TopBar.js";
|
||||
|
||||
type WorkspaceTab = "chat" | "skills" | "events";
|
||||
@@ -17,13 +17,25 @@ export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarPro
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
const selectAdjacentTab = (current: WorkspaceTab, event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight" && event.key !== "Home" && event.key !== "End") return;
|
||||
event.preventDefault();
|
||||
const order: WorkspaceTab[] = ["chat", "skills", "events"];
|
||||
const currentIndex = order.indexOf(current);
|
||||
const targetIndex = event.key === "Home" ? 0 : event.key === "End" ? order.length - 1 : (currentIndex + (event.key === "ArrowRight" ? 1 : -1) + order.length) % order.length;
|
||||
const target = order[targetIndex];
|
||||
if (target === undefined) return;
|
||||
setActiveTab(target);
|
||||
document.getElementById(`${target}-tab`)?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TopBar {...topBar} />
|
||||
<nav className="mobile-tabs" aria-label="工作区导航" role="tablist">
|
||||
<TabButton id="chat" active={activeTab === "chat"} onSelect={setActiveTab}><MessageSquare size={17} />Chat</TabButton>
|
||||
<TabButton id="skills" active={activeTab === "skills"} onSelect={setActiveTab}><PanelsTopLeft size={17} />Skills</TabButton>
|
||||
<TabButton id="events" active={activeTab === "events"} onSelect={setActiveTab}><Activity size={17} />Events</TabButton>
|
||||
<TabButton id="chat" active={activeTab === "chat"} onSelect={setActiveTab} onKeyDown={selectAdjacentTab}><MessageSquare size={17} aria-hidden="true" />Chat</TabButton>
|
||||
<TabButton id="skills" active={activeTab === "skills"} onSelect={setActiveTab} onKeyDown={selectAdjacentTab}><PanelsTopLeft size={17} aria-hidden="true" />Skills</TabButton>
|
||||
<TabButton id="events" active={activeTab === "events"} onSelect={setActiveTab} onKeyDown={selectAdjacentTab}><Activity size={17} aria-hidden="true" />Events</TabButton>
|
||||
</nav>
|
||||
<div className="workspace-grid">
|
||||
<div id="skills-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "skills"}>{skills}</div>
|
||||
@@ -34,9 +46,15 @@ export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarPro
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ id, active, onSelect, children }: { id: WorkspaceTab; active: boolean; onSelect: (tab: WorkspaceTab) => void; children: ReactNode }) {
|
||||
function TabButton({ id, active, onSelect, onKeyDown, children }: {
|
||||
id: WorkspaceTab;
|
||||
active: boolean;
|
||||
onSelect: (tab: WorkspaceTab) => void;
|
||||
onKeyDown: (tab: WorkspaceTab, event: KeyboardEvent<HTMLButtonElement>) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" role="tab" aria-selected={active} aria-controls={`${id}-panel`} tabIndex={active ? 0 : -1} onClick={() => onSelect(id)}>
|
||||
<button id={`${id}-tab`} type="button" role="tab" aria-selected={active} aria-controls={`${id}-panel`} tabIndex={active ? 0 : -1} onClick={() => onSelect(id)} onKeyDown={(event) => onKeyDown(id, event)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -32,9 +32,13 @@ export function ModelSettings({ open, busy, current, onClose, onSave, onTest, on
|
||||
const [saved, setSaved] = useState<ModelView | undefined>(current);
|
||||
const [message, setMessage] = useState("");
|
||||
const firstInput = useRef<HTMLSelectElement>(null);
|
||||
const returnFocus = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) firstInput.current?.focus();
|
||||
if (!open) return undefined;
|
||||
returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
firstInput.current?.focus();
|
||||
return () => returnFocus.current?.focus();
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
setSaved(current);
|
||||
@@ -70,7 +74,10 @@ export function ModelSettings({ open, busy, current, onClose, onSave, onTest, on
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<section className="dialog settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title" onKeyDown={(event) => { if (event.key === "Escape") onClose(); }}>
|
||||
<section className="dialog settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title" onKeyDown={(event) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key === "Tab") trapSettingsFocus(event);
|
||||
}}>
|
||||
<div className="dialog-title-row">
|
||||
<div><span className="eyebrow">SESSION MODEL</span><h2 id="settings-title">模型连接</h2></div>
|
||||
<button className="icon-button" type="button" aria-label="关闭模型设置" onClick={onClose}><X size={19} /></button>
|
||||
@@ -106,3 +113,16 @@ function translateModelError(error: unknown): string {
|
||||
if ("code" in error && error.code === "BASE_URL_NOT_ALLOWED") return "该 Base URL 不符合安全策略";
|
||||
return error.message;
|
||||
}
|
||||
|
||||
function trapSettingsFocus(event: React.KeyboardEvent<HTMLElement>): void {
|
||||
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled), select:not(:disabled)")];
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; }
|
||||
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; }
|
||||
button, textarea, select, input { font: inherit; }
|
||||
button, select { color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
@@ -9,3 +9,4 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
code { font-family: "SFMono-Regular", Consolas, monospace; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
.composer-hint { padding: 0 3px 8px; color: var(--warning); }
|
||||
.composer-footnote { padding-top: 8px; text-align: center; }
|
||||
.filter-label { display: grid; gap: 7px; padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.filter-label select { height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); }
|
||||
.filter-label select { height: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); }
|
||||
.timeline { overflow-y: auto; padding: 6px 0 24px; }
|
||||
.event-row { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 9px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||
.event-row > svg { margin-top: 3px; color: #4e7573; }
|
||||
@@ -133,5 +133,5 @@
|
||||
.image-preview-dialog img { display: block; width: 100%; max-height: calc(100dvh - 66px); border-radius: 11px; object-fit: contain; }
|
||||
.spin { animation: spin 1.2s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } }
|
||||
@media (max-width: 720px) { .topbar { grid-template-columns: 1fr auto; padding: 0 12px; } .topbar__actions { grid-column: 2; } .thread-viewport { padding: 20px 16px; } .composer-area { padding: 0 10px 10px; } .tool-summary { grid-template-columns: repeat(2, 1fr); } .tool-summary > div + div { border-left: 0; } .settings-form { grid-template-columns: 1fr; } .settings-form > * { grid-column: 1; } .settings-actions { flex-wrap: wrap; } }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CompletionAnnouncer } from "../src/components/common/CompletionAnnouncer.js";
|
||||
import { TopBar } from "../src/components/topbar/TopBar.js";
|
||||
|
||||
describe("accessibility contracts", () => {
|
||||
it("gives icon-only actions accessible names", () => {
|
||||
render(<TopBar sessionId="ses_1" connection="open" modelLabel="gpt-test" supportsImages onOpenSettings={vi.fn()} onReset={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "打开模型设置" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "重置训练会话" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("announces completion but not every streaming token", () => {
|
||||
const { rerender } = render(<CompletionAnnouncer status="running" />);
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
rerender(<CompletionAnnouncer status="complete" />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("回答已完成");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user