- 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)
93 lines
2.4 KiB
TypeScript
93 lines
2.4 KiB
TypeScript
import type { KeyboardEvent, ReactNode } from "react";
|
|
import { useRef } from "react";
|
|
|
|
import { cn, focusRing, segmentedClass, transitionUi } from "@/lib/ui/variants";
|
|
|
|
export interface SegmentOption<T extends string> {
|
|
value: T;
|
|
label: ReactNode;
|
|
}
|
|
|
|
interface SegmentedControlProps<T extends string> {
|
|
options: Array<SegmentOption<T>>;
|
|
value: T;
|
|
onChange: (value: T) => void;
|
|
ariaLabel: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function SegmentedControl<T extends string>({
|
|
options,
|
|
value,
|
|
onChange,
|
|
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="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"
|
|
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,
|
|
focusRing,
|
|
selected
|
|
? "bg-panel text-cinnabar shadow-paper"
|
|
: "text-ink-soft hover:text-cinnabar",
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|