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-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;
|
||||||
|
|||||||
@@ -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,20 +217,26 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
|||||||
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<div className="relative border-b border-line">
|
||||||
ref={inputRef}
|
<input
|
||||||
value={query}
|
ref={inputRef}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
onKeyDown={onInputKey}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
|
onKeyDown={onInputKey}
|
||||||
role="combobox"
|
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
|
||||||
aria-label="命令搜索"
|
role="combobox"
|
||||||
aria-expanded={true}
|
aria-label="命令搜索"
|
||||||
aria-controls={LIST_ID}
|
aria-expanded={true}
|
||||||
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
|
aria-controls={LIST_ID}
|
||||||
aria-autocomplete="list"
|
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
|
||||||
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`}
|
aria-autocomplete="list"
|
||||||
/>
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user