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:
@@ -16,7 +16,8 @@
|
||||
--color-body-strong: #3a342b;
|
||||
--color-body: #4a4234;
|
||||
--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-soft: #ece4d5;
|
||||
@@ -54,7 +55,8 @@
|
||||
--color-body-strong: #ddd2c0;
|
||||
--color-body: #cdc0ab;
|
||||
--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-soft: #2f2a23;
|
||||
|
||||
@@ -66,6 +66,15 @@ export function useCommandPaletteOpen(): boolean {
|
||||
const LIST_ID = "command-list";
|
||||
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>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
const m = pathname.match(/^\/projects\/([^/]+)/);
|
||||
@@ -208,6 +217,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
||||
}}
|
||||
>
|
||||
<div className="relative border-b border-line">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
@@ -220,8 +230,13 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
aria-controls={LIST_ID}
|
||||
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
|
||||
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
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
@@ -229,7 +244,14 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
className="max-h-80 overflow-auto py-1 overscroll-contain"
|
||||
>
|
||||
{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) => (
|
||||
<li
|
||||
@@ -246,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { TextInput } from "@/components/ui/TextInput";
|
||||
@@ -80,10 +80,22 @@ export function ConflictCard({
|
||||
{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">
|
||||
<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="mx-1 text-ink-soft" aria-hidden="true">
|
||||
→
|
||||
</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="ml-2 text-ink-soft">(点「采纳改法」改入终稿)</span>
|
||||
</div>
|
||||
|
||||
@@ -604,7 +604,7 @@ export function ReviewReport({
|
||||
: "已处理"}
|
||||
</span>
|
||||
<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"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ReviewSectionPanel({
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
<ChevronDown
|
||||
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",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -22,16 +23,57 @@ export function SegmentedControl<T extends string>({
|
||||
ariaLabel,
|
||||
className,
|
||||
}: 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 (
|
||||
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}>
|
||||
{options.map((option) => {
|
||||
<div className={segmentedClass(className)} role="radiogroup" aria-label={ariaLabel}>
|
||||
{options.map((option, index) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
buttonsRef.current[index] = node;
|
||||
}}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm",
|
||||
transitionUi,
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
<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" : ""
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
|
||||
Reference in New Issue
Block a user