feat(ui): P4 无障碍与打磨收尾

- P4-1 对比度:muted-soft 提到 WCAG AA 小字(≥4.5:1)于两主题各面
- P4-3 ConflictCard 原文/建议加 Minus/Plus 图标 + sr-only 标签(不靠颜色)
- P4-4 动效:3 处 chevron transition-transform 加 motion-safe 门控
- P4-5 命令面板 ⌘K 键帽提示 + 键盘操作读数 + 空态引导 + mono 快捷键
- P2-8 SegmentedControl 升级 radiogroup(role=radio/aria-checked + 方向键 roving)
This commit is contained in:
Yaojia Wang
2026-07-11 08:12:36 +02:00
parent 5674158707
commit 45025c36bf
7 changed files with 123 additions and 26 deletions

View File

@@ -16,7 +16,8 @@
--color-body-strong: #3a342b; --color-body-strong: #3a342b;
--color-body: #4a4234; --color-body: #4a4234;
--color-ink-soft: #6b6356; --color-ink-soft: #6b6356;
--color-muted-soft: #8a8073; /* muted-soft 达 WCAG AA 小字≥4.5:1于 bg/card/panel/soft 各面P4-1。 */
--color-muted-soft: #6f6556;
/* 边框:主边框 + 同带内更弱分隔 */ /* 边框:主边框 + 同带内更弱分隔 */
--color-line: #e5ddcd; --color-line: #e5ddcd;
--color-line-soft: #ece4d5; --color-line-soft: #ece4d5;
@@ -54,7 +55,8 @@
--color-body-strong: #ddd2c0; --color-body-strong: #ddd2c0;
--color-body: #cdc0ab; --color-body: #cdc0ab;
--color-ink-soft: #b9ab96; --color-ink-soft: #b9ab96;
--color-muted-soft: #8f8474; /* muted-soft 达 AA 小字≥4.5:1于夜读 bg/card/soft 各面P4-1。 */
--color-muted-soft: #9a8e7c;
/* 边框 */ /* 边框 */
--color-line: #3c352c; --color-line: #3c352c;
--color-line-soft: #2f2a23; --color-line-soft: #2f2a23;

View File

@@ -66,6 +66,15 @@ export function useCommandPaletteOpen(): boolean {
const LIST_ID = "command-list"; const LIST_ID = "command-list";
const optionId = (cmd: Command): string => `command-option-${cmd.id}`; const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
// 统一的键帽kbd样式暖纸感描边 + 等宽小字,与 NavDrawer 的 ⌘K 提示一致。
function KeyCap({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-line bg-panel px-1 font-mono text-2xs text-ink-soft">
{children}
</kbd>
);
}
// 从 pathname 抽取当前 projectId/projects/<id>/...)。 // 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null { function projectIdFromPath(pathname: string): string | null {
const m = pathname.match(/^\/projects\/([^/]+)/); const m = pathname.match(/^\/projects\/([^/]+)/);
@@ -208,6 +217,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
if (dialogRef.current) handleTabTrap(dialogRef.current, e); if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}} }}
> >
<div className="relative border-b border-line">
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
@@ -220,8 +230,13 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
aria-controls={LIST_ID} aria-controls={LIST_ID}
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined} aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
aria-autocomplete="list" aria-autocomplete="list"
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`} className={`w-full bg-bg py-3 pl-4 pr-12 text-sm text-ink ${focusRing}`}
/> />
{/* 触发快捷键读数:随面板常驻,强化 ⌘K 的可发现性(纯装饰,不入 Tab 序)。 */}
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<KeyCap>K</KeyCap>
</span>
</div>
<ul <ul
id={LIST_ID} id={LIST_ID}
role="listbox" role="listbox"
@@ -229,7 +244,14 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
className="max-h-80 overflow-auto py-1 overscroll-contain" className="max-h-80 overflow-auto py-1 overscroll-contain"
> >
{results.length === 0 ? ( {results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li> <li className="px-4 py-6 text-center">
<p className="text-sm text-ink-soft">
{query.trim()}
</p>
<p className="mt-1 text-caption text-muted-soft">
稿
</p>
</li>
) : ( ) : (
results.map((cmd, i) => ( results.map((cmd, i) => (
<li <li
@@ -246,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
}`} }`}
> >
<span>{cmd.title}</span> <span>{cmd.title}</span>
<span className="text-xs text-ink-soft">{cmd.group}</span> <span className="text-caption text-ink-soft">{cmd.group}</span>
</li> </li>
)) ))
)} )}
</ul> </ul>
{/* 键盘操作读数:↑↓ 选择 · ↵ 打开 · esc 关闭(常驻引导,纯装饰)。 */}
<div
aria-hidden="true"
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-4 py-2 text-caption text-muted-soft"
>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap>esc</KeyCap>
</span>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react"; import { AlertTriangle, CheckCircle2, LocateFixed, Minus, Plus, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
@@ -80,10 +80,22 @@ export function ConflictCard({
{conflict.original && conflict.replacement ? ( {conflict.original && conflict.replacement ? (
<div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed"> <div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed">
<span className="mr-1 text-ink-soft"></span> <span className="mr-1 text-ink-soft"></span>
{/* 非颜色线索Minus/「原文」标签,色盲也可辨(不单靠红色 + 删除线) */}
<Minus
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-conflict"
aria-hidden="true"
/>
<span className="sr-only"></span>
<span className="text-conflict line-through">{conflict.original}</span> <span className="text-conflict line-through">{conflict.original}</span>
<span className="mx-1 text-ink-soft" aria-hidden="true"> <span className="mx-1 text-ink-soft" aria-hidden="true">
</span> </span>
{/* 非颜色线索Plus/「建议」标签,色盲也可辨(不单靠绿色) */}
<Plus
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-pass"
aria-hidden="true"
/>
<span className="sr-only"></span>
<span className="text-pass">{conflict.replacement}</span> <span className="text-pass">{conflict.replacement}</span>
<span className="ml-2 text-ink-soft">稿</span> <span className="ml-2 text-ink-soft">稿</span>
</div> </div>

View File

@@ -604,7 +604,7 @@ export function ReviewReport({
: "已处理"} : "已处理"}
</span> </span>
<ChevronDown <ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180" className="h-4 w-4 text-ink-soft motion-safe:transition-transform group-open:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>

View File

@@ -52,7 +52,7 @@ export function ReviewSectionPanel({
<Badge variant={statusVariant}>{statusLabel}</Badge> <Badge variant={statusVariant}>{statusLabel}</Badge>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 text-ink-soft transition-transform", "h-4 w-4 text-ink-soft motion-safe:transition-transform",
"group-open:rotate-180", "group-open:rotate-180",
)} )}
aria-hidden="true" aria-hidden="true"

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { KeyboardEvent, ReactNode } from "react";
import { useRef } from "react";
import { cn, focusRing, segmentedClass, transitionUi } from "@/lib/ui/variants"; import { cn, focusRing, segmentedClass, transitionUi } from "@/lib/ui/variants";
@@ -22,16 +23,57 @@ export function SegmentedControl<T extends string>({
ariaLabel, ariaLabel,
className, className,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const buttonsRef = useRef<Array<HTMLButtonElement | null>>([]);
const focusOption = (index: number) => {
const clamped = (index + options.length) % options.length;
const target = options[clamped];
if (!target) return;
onChange(target.value);
buttonsRef.current[clamped]?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
focusOption(index + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
focusOption(index - 1);
break;
case "Home":
event.preventDefault();
focusOption(0);
break;
case "End":
event.preventDefault();
focusOption(options.length - 1);
break;
default:
break;
}
};
return ( return (
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}> <div className={segmentedClass(className)} role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => { {options.map((option, index) => {
const selected = option.value === value; const selected = option.value === value;
return ( return (
<button <button
key={option.value} key={option.value}
ref={(node) => {
buttonsRef.current[index] = node;
}}
type="button" type="button"
aria-pressed={selected} role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn( className={cn(
"rounded-md px-3 py-1.5 text-sm", "rounded-md px-3 py-1.5 text-sm",
transitionUi, transitionUi,

View File

@@ -63,7 +63,7 @@ export function DirectivePanel({
className={`group flex min-w-0 cursor-pointer items-center gap-2 rounded text-left font-serif text-title-md text-ink transition-colors duration-fast ease-standard hover:text-cinnabar ${focusRing}`} className={`group flex min-w-0 cursor-pointer items-center gap-2 rounded text-left font-serif text-title-md text-ink transition-colors duration-fast ease-standard hover:text-cinnabar ${focusRing}`}
> >
<ChevronDown <ChevronDown
className={`h-4 w-4 shrink-0 text-muted-soft transition-transform duration-fast ease-standard group-hover:text-cinnabar ${ className={`h-4 w-4 shrink-0 text-muted-soft motion-safe:transition-transform duration-fast ease-standard group-hover:text-cinnabar ${
open ? "rotate-180" : "" open ? "rotate-180" : ""
}`} }`}
aria-hidden="true" aria-hidden="true"