better-forms
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBetter Forms Guide
优质表单构建指南
A collection of specific UX patterns, accessibility standards, and implementation techniques for modern web forms. This guide bridges the gap between raw HTML/CSS tips and component-based architectures (React, Tailwind, Headless UI).
本文集合了现代Web表单的特定UX模式、无障碍标准及实现技巧,填补了原生HTML/CSS技巧与组件化架构(React、Tailwind、Headless UI)之间的空白。
1. High-Impact UX Patterns (The "Why" & "How")
1. 高影响力UX模式(设计初衷与实现方案)
Avoid "Dead Zones" in Lists
避免列表中的「点击盲区」
Concept: Small gaps between clickable list items create frustration.
Implementation (Tailwind): Use a pseudo-element to expand the hit area without affecting layout.
tsx
// Do this for list items or radio groups
<div className="relative group">
<input type="radio" className="..." />
<label className="... after:absolute after:inset-y-[-10px] after:left-0 after:right-0 after:content-['']">
Option Label
</label>
</div>设计理念:可点击列表项之间的微小间隙会让用户产生挫败感。
Tailwind实现方案:使用伪元素扩展点击区域,同时不影响布局。
tsx
// 适用于列表项或单选组
<div className="relative group">
<input type="radio" className="..." />
<label className="... after:absolute after:inset-y-[-10px] after:left-0 after:right-0 after:content-['']">
选项标签
</label>
</div>Range Sliders > Min/Max Inputs
范围滑块优于最小/最大输入框
Concept: "From $10 to $1000" text inputs are tedious.
Implementation: Use a dual-thumb slider component (like Radix UI / Shadcn Slider) for ranges.
- Why: Cognitive load reduction and immediate visual feedback.
- A11y: Ensure the slider supports arrow key navigation.
设计理念:「从$10到$1000」这类文本输入框操作繁琐。
实现方案:使用双滑块组件(如Radix UI / Shadcn Slider)实现范围选择。
- 优势:降低认知负荷,提供即时视觉反馈。
- 无障碍要求:确保滑块支持方向键导航。
"Output-Inspired" Design
「输出导向」设计
Concept: The form inputs should visually resemble the final result card/page.
- Hierarchy: If the output title is , the input for it should be
text-2xl font-bold.text-2xl font-bold - Placement: If the image goes on the left in the listing, the upload button goes on the left in the form.
- Empty States: Preview what the empty card looks like while filling it.
设计理念:表单输入框的视觉样式应与最终结果卡片/页面保持一致。
- 层级一致:如果输出标题使用,对应输入框也应使用相同样式。
text-2xl font-bold - 布局匹配:如果列表中的图片位于左侧,表单中的上传按钮也应放在左侧。
- 空状态预览:在填写过程中展示空状态下的卡片样式。
Descriptive Action Buttons
描述性操作按钮
Concept: Never use "Submit" or "Send". The button should complete the sentence "I want to..."
- Avoid:
Submit - Prefer: ,
Create Account,Publish ListingTip: Update button text dynamically based on form state (e.g., "Saving..." vs "Save Changes").Update Profile
设计理念:绝不要使用「提交」或「发送」这类模糊文本,按钮文案应补全「我想要...」的语义。
- 避免:
提交 - 推荐:、
创建账号、发布列表提示:根据表单状态动态更新按钮文本(如「保存中...」与「保存更改」切换)。更新个人资料
"Optional" Label > Asterisks
「可选」标签优于星号标记
Concept: Red asterisks (*) are aggressive and ambiguous (sometimes meaning "error").
Implementation: Mark required fields by default (no indicator) and explicitly label optional ones.
tsx
<Label>
Phone Number{" "}
<span className="text-muted-foreground text-sm font-normal">(Optional)</span>
</Label>设计理念:红色星号(*)过于生硬且语义模糊(有时会被误认为「错误」标记)。
实现方案:默认标记必填字段(无额外标识),对可选字段进行明确标注。
tsx
<Label>
手机号码{" "}
<span className="text-muted-foreground text-sm font-normal">(可选)</span>
</Label>Show/Hide Password
密码显示/隐藏切换
Concept: Masking passwords by default prevents error correction.
Implementation: Always include a toggle button inside the input wrapper.
- A11y: The toggle button must have and
type="button".aria-label="Show password"
设计理念:默认隐藏密码会增加用户纠错难度。
实现方案:始终在输入框容器内包含切换按钮。
- 无障碍要求:切换按钮必须设置和
type="button"。aria-label="显示密码"
Field Sizing as Affordance
输入框尺寸暗示输入长度
Concept: The width of the input suggests the expected data length.
- Zip Code: or
w-20(not full width).w-24 - CVV: Small width.
- Street Address: Full width.
设计理念:输入框的宽度应暗示预期数据的长度。
- 邮政编码:使用或
w-20(而非全屏宽度)。w-24 - CVV码:使用小宽度。
- 街道地址:使用全屏宽度。
2. Advanced UX Patterns
2. 进阶UX模式
Input Masking & Formatting
输入掩码与自动格式化
Concept: Auto-format data as the user types to reduce errors and cognitive load.
tsx
// Phone number formatting with react-number-format
import { PatternFormat } from "react-number-format";
<PatternFormat
format="(###) ###-####"
mask="_"
allowEmptyFormatting
customInput={Input} // Your styled input component
onValueChange={(values) => {
// values.value = "1234567890" (raw)
// values.formattedValue = "(123) 456-7890"
form.setValue("phone", values.value);
}}
/>;
// Credit card with automatic spacing
<PatternFormat
format="#### #### #### ####"
customInput={Input}
onValueChange={(values) => form.setValue("cardNumber", values.value)}
/>;
// Currency input
import { NumericFormat } from "react-number-format";
<NumericFormat
thousandSeparator=","
prefix="$"
decimalScale={2}
fixedDecimalScale
customInput={Input}
onValueChange={(values) => form.setValue("amount", values.floatValue)}
/>;Key Principle: Store raw values, display formatted values. Never validate formatted strings.
设计理念:在用户输入时自动格式化数据,减少错误与认知负荷。
tsx
// 使用react-number-format格式化手机号码
import { PatternFormat } from "react-number-format";
<PatternFormat
format="(###) ###-####"
mask="_"
allowEmptyFormatting
customInput={Input} // 你的自定义样式输入组件
onValueChange={(values) => {
// values.value = "1234567890"(原始值)
// values.formattedValue = "(123) 456-7890"(格式化后的值)
form.setValue("phone", values.value);
}}
/>;
// 自动添加空格的信用卡号输入
<PatternFormat
format="#### #### #### ####"
customInput={Input}
onValueChange={(values) => form.setValue("cardNumber", values.value)}
/>;
// 货币输入框
import { NumericFormat } from "react-number-format";
<NumericFormat
thousandSeparator=","
prefix="$"
decimalScale={2}
fixedDecimalScale
customInput={Input}
onValueChange={(values) => form.setValue("amount", values.floatValue)}
/>;核心原则:存储原始值,展示格式化后的值。绝不要对格式化后的字符串进行验证。
OTP / 2FA Code Inputs
OTP/双因素验证码输入框
Concept: 6-digit verification codes need special handling for paste, auto-focus, and keyboard navigation.
tsx
import { useRef, useState, useCallback, ClipboardEvent, KeyboardEvent } from "react";
interface OTPInputProps {
length?: number;
onComplete: (code: string) => void;
}
export function OTPInput({ length = 6, onComplete }: OTPInputProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const focusInput = useCallback((index: number) => {
const clampedIndex = Math.max(0, Math.min(index, length - 1));
inputRefs.current[clampedIndex]?.focus();
}, [length]);
const handleChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return; // Only digits
const newValues = [...values];
newValues[index] = value.slice(-1); // Take last digit only
setValues(newValues);
if (value && index < length - 1) {
focusInput(index + 1);
}
const code = newValues.join("");
if (code.length === length) {
onComplete(code);
}
};
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Backspace":
if (!values[index] && index > 0) {
focusInput(index - 1);
}
break;
case "ArrowLeft":
e.preventDefault();
focusInput(index - 1);
break;
case "ArrowRight":
e.preventDefault();
focusInput(index + 1);
break;
}
};
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
if (pastedData) {
const newValues = [...values];
pastedData.split("").forEach((char, i) => {
newValues[i] = char;
});
setValues(newValues);
focusInput(pastedData.length - 1);
if (pastedData.length === length) {
onComplete(pastedData);
}
}
};
return (
<div className="flex gap-2" role="group" aria-label="Verification code">
{values.map((value, index) => (
<input
key={index}
ref={(el) => { inputRefs.current[index] = el; }}
type="text"
inputMode="numeric"
maxLength={1}
value={value}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
className="h-12 w-12 text-center text-lg font-semibold border rounded-md
focus:ring-2 focus:ring-ring focus:border-transparent"
aria-label={`Digit ${index + 1} of ${length}`}
/>
))}
</div>
);
}设计理念:6位验证码输入框需要支持粘贴、自动聚焦及键盘导航等特殊处理。
tsx
import { useRef, useState, useCallback, ClipboardEvent, KeyboardEvent } from "react";
interface OTPInputProps {
length?: number;
onComplete: (code: string) => void;
}
export function OTPInput({ length = 6, onComplete }: OTPInputProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const focusInput = useCallback((index: number) => {
const clampedIndex = Math.max(0, Math.min(index, length - 1));
inputRefs.current[clampedIndex]?.focus();
}, [length]);
const handleChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return; // 仅允许输入数字
const newValues = [...values];
newValues[index] = value.slice(-1); // 仅保留最后一位数字
setValues(newValues);
if (value && index < length - 1) {
focusInput(index + 1);
}
const code = newValues.join("");
if (code.length === length) {
onComplete(code);
}
};
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Backspace":
if (!values[index] && index > 0) {
focusInput(index - 1);
}
break;
case "ArrowLeft":
e.preventDefault();
focusInput(index - 1);
break;
case "ArrowRight":
e.preventDefault();
focusInput(index + 1);
break;
}
};
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
if (pastedData) {
const newValues = [...values];
pastedData.split("").forEach((char, i) => {
newValues[i] = char;
});
setValues(newValues);
focusInput(pastedData.length - 1);
if (pastedData.length === length) {
onComplete(pastedData);
}
}
};
return (
<div className="flex gap-2" role="group" aria-label="验证码">
{values.map((value, index) => (
<input
key={index}
ref={(el) => { inputRefs.current[index] = el; }}
type="text"
inputMode="numeric"
maxLength={1}
value={value}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
className="h-12 w-12 text-center text-lg font-semibold border rounded-md
focus:ring-2 focus:ring-ring focus:border-transparent"
aria-label={`第${index + 1}位,共${length}位`}
/>
))}
</div>
);
}Unsaved Changes Protection
未保存内容保护
Concept: Prevent accidental data loss when navigating away from a dirty form.
Note (React 19): Don't confuse from with React DOM's , which was renamed to in React 19.
useFormStatereact-hook-formuseFormStateuseActionStateWarning: Monkey-patching is fragile and may break across Next.js versions. There is no stable API for intercepting App Router navigation. The approach is the only reliable part. Consider using (Pages Router) or a route change event listener if your framework supports it.
router.pushbeforeunloadonBeforePopStatetsx
import { useEffect } from "react";
import { useFormState } from "react-hook-form";
export function useUnsavedChangesWarning(isDirty: boolean, message?: string) {
const warningMessage = message ?? "You have unsaved changes. Are you sure you want to leave?";
// Browser back/refresh — this is the reliable approach
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isDirty) return;
e.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isDirty]);
// For in-app navigation, consider a confirmation modal triggered
// from your navigation components rather than monkey-patching the router.
}
// Usage with React Hook Form
function EditProfileForm() {
const form = useForm<ProfileData>();
const { isDirty } = useFormState({ control: form.control });
useUnsavedChangesWarning(isDirty);
return <form>...</form>;
}设计理念:防止用户在表单内容未保存时意外导航离开。
注意(React 19):不要混淆中的与React DOM的,后者在React 19中已重命名为。
react-hook-formuseFormStateuseFormStateuseActionState警告:通过猴子补丁修改的方式不稳定,可能会在Next.js版本更新后失效。目前没有稳定的API可以拦截App Router的导航。是唯一可靠的实现方式。如果你的框架支持,可考虑使用(Pages Router)或路由变化事件监听器。
router.pushbeforeunloadonBeforePopStatetsx
import { useEffect } from "react";
import { useFormState } from "react-hook-form";
export function useUnsavedChangesWarning(isDirty: boolean, message?: string) {
const warningMessage = message ?? "你有未保存的更改,确定要离开吗?";
// 浏览器后退/刷新 —— 这是可靠的实现方式
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isDirty) return;
e.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isDirty]);
// 对于应用内导航,建议从导航组件触发确认模态框,而非通过猴子补丁修改路由。
}
// 结合React Hook Form使用
function EditProfileForm() {
const form = useForm<ProfileData>();
const { isDirty } = useFormState({ control: form.control });
useUnsavedChangesWarning(isDirty);
return <form>...</form>;
}Multi-Step Forms (Wizards)
多步骤表单(向导式)
Concept: Break complex forms into digestible steps with proper state persistence and focus management.
tsx
import { useState, useEffect, useRef, useCallback } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// Persist state to URL for refresh resilience
// Uses lazy state init to read from URL on first render (SSR-safe)
function useStepFromURL() {
const [step, setStep] = useState(() => {
if (typeof window === "undefined") return 1;
const params = new URLSearchParams(window.location.search);
return parseInt(params.get("step") ?? "1", 10);
});
const goToStep = useCallback((newStep: number) => {
setStep(newStep);
const url = new URL(window.location.href);
url.searchParams.set("step", String(newStep));
window.history.pushState({}, "", url);
}, []);
return { step, goToStep };
}
// Focus management on step change
function useStepFocus(step: number) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// Focus the step heading for screen reader announcement
headingRef.current?.focus();
}, [step]);
return headingRef;
}
// Example multi-step form
interface WizardFormData {
// Step 1
firstName: string;
lastName: string;
// Step 2
email: string;
phone: string;
// Step 3
address: string;
city: string;
}
const stepSchemas = {
1: z.object({ firstName: z.string().min(1), lastName: z.string().min(1) }),
2: z.object({ email: z.string().email(), phone: z.string().optional() }),
3: z.object({ address: z.string().min(1), city: z.string().min(1) }),
};
export function WizardForm() {
const { step, goToStep } = useStepFromURL();
const headingRef = useStepFocus(step);
const totalSteps = 3;
const form = useForm<WizardFormData>({
resolver: zodResolver(stepSchemas[step as keyof typeof stepSchemas]),
mode: "onBlur",
});
// Persist draft to localStorage
useEffect(() => {
const saved = localStorage.getItem("wizard-draft");
if (saved) {
form.reset(JSON.parse(saved));
}
}, []);
useEffect(() => {
const subscription = form.watch((data) => {
localStorage.setItem("wizard-draft", JSON.stringify(data));
});
return () => subscription.unsubscribe();
}, [form]);
const handleNext = async () => {
const isValid = await form.trigger();
if (isValid && step < totalSteps) {
goToStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) goToStep(step - 1);
};
return (
<FormProvider {...form}>
{/* Progress indicator */}
<div role="progressbar" aria-valuenow={step} aria-valuemin={1} aria-valuemax={totalSteps}>
Step {step} of {totalSteps}
</div>
{/* Step heading - focused on navigation */}
<h2 ref={headingRef} tabIndex={-1} className="outline-none">
{step === 1 && "Personal Information"}
{step === 2 && "Contact Details"}
{step === 3 && "Address"}
</h2>
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <StepOne />}
{step === 2 && <StepTwo />}
{step === 3 && <StepThree />}
<div className="flex gap-4 mt-6">
{step > 1 && (
<button type="button" onClick={handleBack}>
Back
</button>
)}
{step < totalSteps ? (
<button type="button" onClick={handleNext}>
Continue
</button>
) : (
<button type="submit">Complete Registration</button>
)}
</div>
</form>
</FormProvider>
);
}设计理念:将复杂表单拆分为易处理的步骤,同时保证状态持久化与焦点管理。
tsx
import { useState, useEffect, useRef, useCallback } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// 将状态持久化到URL,支持刷新恢复
// 使用惰性状态初始化,在首次渲染时从URL读取数据(支持SSR)
function useStepFromURL() {
const [step, setStep] = useState(() => {
if (typeof window === "undefined") return 1;
const params = new URLSearchParams(window.location.search);
return parseInt(params.get("step") ?? "1", 10);
});
const goToStep = useCallback((newStep: number) => {
setStep(newStep);
const url = new URL(window.location.href);
url.searchParams.set("step", String(newStep));
window.history.pushState({}, "", url);
}, []);
return { step, goToStep };
}
// 步骤切换时的焦点管理
function useStepFocus(step: number) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// 聚焦步骤标题,供屏幕阅读器朗读
headingRef.current?.focus();
}, [step]);
return headingRef;
}
// 示例多步骤表单
interface WizardFormData {
// 步骤1
firstName: string;
lastName: string;
// 步骤2
email: string;
phone: string;
// 步骤3
address: string;
city: string;
}
const stepSchemas = {
1: z.object({ firstName: z.string().min(1), lastName: z.string().min(1) }),
2: z.object({ email: z.string().email(), phone: z.string().optional() }),
3: z.object({ address: z.string().min(1), city: z.string().min(1) }),
};
export function WizardForm() {
const { step, goToStep } = useStepFromURL();
const headingRef = useStepFocus(step);
const totalSteps = 3;
const form = useForm<WizardFormData>({
resolver: zodResolver(stepSchemas[step as keyof typeof stepSchemas]),
mode: "onBlur",
});
// 将草稿持久化到localStorage
useEffect(() => {
const saved = localStorage.getItem("wizard-draft");
if (saved) {
form.reset(JSON.parse(saved));
}
}, []);
useEffect(() => {
const subscription = form.watch((data) => {
localStorage.setItem("wizard-draft", JSON.stringify(data));
});
return () => subscription.unsubscribe();
}, [form]);
const handleNext = async () => {
const isValid = await form.trigger();
if (isValid && step < totalSteps) {
goToStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) goToStep(step - 1);
};
return (
<FormProvider {...form}>
{/* 进度指示器 */}
<div role="progressbar" aria-valuenow={step} aria-valuemin={1} aria-valuemax={totalSteps}>
第{step}步,共{totalSteps}步
</div>
{/* 步骤标题 - 切换步骤时自动聚焦 */}
<h2 ref={headingRef} tabIndex={-1} className="outline-none">
{step === 1 && "个人信息"}
{step === 2 && "联系方式"}
{step === 3 && "地址信息"}
</h2>
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <StepOne />}
{step === 2 && <StepTwo />}
{step === 3 && <StepThree />}
<div className="flex gap-4 mt-6">
{step > 1 && (
<button type="button" onClick={handleBack}>
返回
</button>
)}
{step < totalSteps ? (
<button type="button" onClick={handleNext}>
继续
</button>
) : (
<button type="submit">完成注册</button>
)}
</div>
</form>
</FormProvider>
);
}3. Backend Integration Patterns
3. 后端集成模式
Server-Side Error Mapping
服务端错误映射
Concept: Map API validation errors back to specific form fields.
tsx
import { useForm, UseFormReturn } from "react-hook-form";
interface APIError {
field: string;
message: string;
}
interface APIResponse {
success: boolean;
errors?: APIError[];
}
// Usage: pass form instance and call inside component
function useServerErrorHandler<T extends Record<string, unknown>>(form: UseFormReturn<T>) {
return async (data: T) => {
const response = await fetch("/api/register", {
method: "POST",
body: JSON.stringify(data),
});
const result: APIResponse = await response.json();
if (!result.success && result.errors) {
// Map server errors to form fields
result.errors.forEach((error) => {
form.setError(error.field as keyof T & string, {
type: "server",
message: error.message,
});
});
// Focus the first errored field
const firstErrorField = result.errors[0]?.field;
if (firstErrorField) {
form.setFocus(firstErrorField as keyof T & string);
}
return;
}
// Success handling
};
}
// For nested errors (e.g., "address.city")
function mapNestedError(form: UseFormReturn, path: string, message: string) {
form.setError(path as any, { type: "server", message });
}设计理念:将API验证错误映射到对应的表单字段。
tsx
import { useForm, UseFormReturn } from "react-hook-form";
interface APIError {
field: string;
message: string;
}
interface APIResponse {
success: boolean;
errors?: APIError[];
}
// 使用方式:传入form实例,在组件内部调用
function useServerErrorHandler<T extends Record<string, unknown>>(form: UseFormReturn<T>) {
return async (data: T) => {
const response = await fetch("/api/register", {
method: "POST",
body: JSON.stringify(data),
});
const result: APIResponse = await response.json();
if (!result.success && result.errors) {
// 将服务端错误映射到表单字段
result.errors.forEach((error) => {
form.setError(error.field as keyof T & string, {
type: "server",
message: error.message,
});
});
// 聚焦第一个错误字段
const firstErrorField = result.errors[0]?.field;
if (firstErrorField) {
form.setFocus(firstErrorField as keyof T & string);
}
return;
}
// 成功处理逻辑
};
}
// 处理嵌套错误(如"address.city")
function mapNestedError(form: UseFormReturn, path: string, message: string) {
form.setError(path as any, { type: "server", message });
}Debounced Async Validation
防抖异步验证
Concept: Validate expensive fields (username availability) without API overload.
tsx
import { useEffect, useMemo, useRef, useState } from "react";
import debounce from "lodash.debounce";
// Custom hook for async field validation
// Uses ref for validateFn to keep debounce stable and avoid timer resets.
// Cancels in-flight debounced calls on unmount to prevent memory leaks.
function useAsyncValidation<T>(
validateFn: (value: T) => Promise<string | null>,
delay = 500
) {
const [isValidating, setIsValidating] = useState(false);
const [error, setError] = useState<string | null>(null);
const validateFnRef = useRef(validateFn);
validateFnRef.current = validateFn;
const debouncedValidate = useMemo(
() =>
debounce(async (value: T) => {
setIsValidating(true);
try {
const result = await validateFnRef.current(value);
setError(result);
} finally {
setIsValidating(false);
}
}, delay),
[delay]
);
// Cleanup debounce timer on unmount
useEffect(() => () => debouncedValidate.cancel(), [debouncedValidate]);
return { validate: debouncedValidate, isValidating, error };
}
// Usage with React Hook Form
// Validation runs in the onChange handler (not via effects) to avoid
// race conditions and unnecessary re-renders.
function UsernameField() {
const { register, setError, clearErrors } = useFormContext();
const [isChecking, setIsChecking] = useState(false);
const checkUsername = async (value: string): Promise<string | null> => {
if (!value || value.length < 3) return null;
const response = await fetch(
`/api/check-username?username=${encodeURIComponent(value)}`
);
const { available } = await response.json();
return available ? null : "This username is already taken";
};
const { validate, isValidating } = useAsyncValidation(checkUsername);
// Derive combined checking state
const showChecking = isChecking || isValidating;
const { onChange: rhfOnChange, ...rest } = register("username", {
onChange: (e) => {
const value = e.target.value;
if (value && value.length >= 3) {
setIsChecking(true);
validate(value);
} else {
clearErrors("username");
}
},
// Also validate via RHF's built-in async validate for submit-time
validate: async (value) => {
const result = await checkUsername(value);
return result ?? true;
},
});
return (
<div>
<input onChange={rhfOnChange} {...rest} />
{showChecking && <span className="text-muted-foreground">Checking...</span>}
</div>
);
}设计理念:对高开销字段(如用户名可用性)进行验证时,避免API请求过载。
tsx
import { useEffect, useMemo, useRef, useState } from "react";
import debounce from "lodash.debounce";
// 用于异步字段验证的自定义Hook
// 使用ref存储validateFn,保证防抖函数稳定,避免计时器重置。
// 在组件卸载时取消待处理的防抖调用,防止内存泄漏。
function useAsyncValidation<T>(
validateFn: (value: T) => Promise<string | null>,
delay = 500
) {
const [isValidating, setIsValidating] = useState(false);
const [error, setError] = useState<string | null>(null);
const validateFnRef = useRef(validateFn);
validateFnRef.current = validateFn;
const debouncedValidate = useMemo(
() =>
debounce(async (value: T) => {
setIsValidating(true);
try {
const result = await validateFnRef.current(value);
setError(result);
} finally {
setIsValidating(false);
}
}, delay),
[delay]
);
// 组件卸载时清理防抖计时器
useEffect(() => () => debouncedValidate.cancel(), [debouncedValidate]);
return { validate: debouncedValidate, isValidating, error };
}
// 结合React Hook Form使用
// 验证逻辑在onChange事件中触发,避免竞态条件和不必要的重渲染。
function UsernameField() {
const { register, setError, clearErrors } = useFormContext();
const [isChecking, setIsChecking] = useState(false);
const checkUsername = async (value: string): Promise<string | null> => {
if (!value || value.length < 3) return null;
const response = await fetch(
`/api/check-username?username=${encodeURIComponent(value)}`
);
const { available } = await response.json();
return available ? null : "该用户名已被占用";
};
const { validate, isValidating } = useAsyncValidation(checkUsername);
// 合并检查状态
const showChecking = isChecking || isValidating;
const { onChange: rhfOnChange, ...rest } = register("username", {
onChange: (e) => {
const value = e.target.value;
if (value && value.length >= 3) {
setIsChecking(true);
validate(value);
} else {
clearErrors("username");
}
},
// 同时通过RHF内置的异步验证在提交时进行校验
validate: async (value) => {
const result = await checkUsername(value);
return result ?? true;
},
});
return (
<div>
<input onChange={rhfOnChange} {...rest} />
{showChecking && <span className="text-muted-foreground">检查中...</span>}
</div>
);
}Optimistic Updates
乐观更新
Concept: Show immediate feedback while the request is in flight.
tsx
import { useTransition, useState } from "react";
type SubmitState = "idle" | "submitting" | "success" | "error";
function ProfileForm() {
const [isPending, startTransition] = useTransition();
const [submitState, setSubmitState] = useState<SubmitState>("idle");
const [optimisticData, setOptimisticData] = useState<ProfileData | null>(null);
async function handleSubmit(data: ProfileData) {
// Immediately show optimistic update
setOptimisticData(data);
setSubmitState("submitting");
startTransition(async () => {
try {
await updateProfile(data);
setSubmitState("success");
// Clear success state after delay
setTimeout(() => setSubmitState("idle"), 2000);
} catch (error) {
// Revert optimistic update
setOptimisticData(null);
setSubmitState("error");
}
});
}
return (
<form onSubmit={form.handleSubmit(handleSubmit)}>
{/* Show optimistic preview */}
{optimisticData && (
<div className="opacity-70">
Preview: {optimisticData.name}
</div>
)}
<button type="submit" disabled={isPending}>
{submitState === "submitting" && "Saving..."}
{submitState === "success" && "Saved!"}
{submitState === "error" && "Try Again"}
{submitState === "idle" && "Save Changes"}
</button>
</form>
);
}设计理念:在请求处理过程中立即给用户反馈。
tsx
import { useTransition, useState } from "react";
type SubmitState = "idle" | "submitting" | "success" | "error";
function ProfileForm() {
const [isPending, startTransition] = useTransition();
const [submitState, setSubmitState] = useState<SubmitState>("idle");
const [optimisticData, setOptimisticData] = useState<ProfileData | null>(null);
async function handleSubmit(data: ProfileData) {
// 立即展示乐观更新结果
setOptimisticData(data);
setSubmitState("submitting");
startTransition(async () => {
try {
await updateProfile(data);
setSubmitState("success");
// 延迟后清除成功状态
setTimeout(() => setSubmitState("idle"), 2000);
} catch (error) {
// 回滚乐观更新
setOptimisticData(null);
setSubmitState("error");
}
});
}
return (
<form onSubmit={form.handleSubmit(handleSubmit)}>
{/* 展示乐观更新预览 */}
{optimisticData && (
<div className="opacity-70">
预览:{optimisticData.name}
</div>
)}
<button type="submit" disabled={isPending}>
{submitState === "submitting" && "保存中..."}
{submitState === "success" && "保存成功!"}
{submitState === "error" && "重试"}
{submitState === "idle" && "保存更改"}
</button>
</form>
);
}4. Complex Component Patterns
4. 复杂组件模式
Accessible File Upload (Drag & Drop)
无障碍文件上传(拖拽上传)
Concept: Drag-and-drop zones are often inaccessible. Ensure keyboard and screen reader support.
tsx
import { useCallback, useId, useState, useRef } from "react";
interface FileUploadProps {
accept?: string;
maxSize?: number; // bytes
onUpload: (files: File[]) => void;
}
export function AccessibleFileUpload({ accept, maxSize, onUpload }: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropzoneId = useId();
const errorId = `${dropzoneId}-error`;
const handleFiles = useCallback((files: FileList | null) => {
setError(null);
if (!files?.length) return;
const validFiles: File[] = [];
Array.from(files).forEach((file) => {
if (maxSize && file.size > maxSize) {
setError(`${file.name} exceeds maximum size`);
return;
}
validFiles.push(file);
});
if (validFiles.length) {
onUpload(validFiles);
}
}, [maxSize, onUpload]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
handleFiles(e.dataTransfer.files);
}, [handleFiles]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
};
return (
<div>
{/* Hidden but accessible file input */}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={(e) => handleFiles(e.target.files)}
className="sr-only"
id={dropzoneId}
aria-describedby={error ? errorId : undefined}
/>
{/* Clickable and keyboard-accessible dropzone */}
<label
htmlFor={dropzoneId}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg cursor-pointer",
"hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring",
isDragOver && "border-primary bg-primary/5",
error && "border-destructive"
)}
>
<UploadIcon className="h-10 w-10 text-muted-foreground mb-2" />
<span className="text-sm font-medium">
Drop files here or click to browse
</span>
<span className="text-xs text-muted-foreground mt-1">
{accept && `Accepted: ${accept}`}
{maxSize && ` (Max: ${formatBytes(maxSize)})`}
</span>
</label>
{error && (
<p id={errorId} role="alert" className="text-sm text-destructive mt-2">
{error}
</p>
)}
</div>
);
}设计理念:拖拽上传区域通常缺乏无障碍支持,需确保键盘和屏幕阅读器可正常使用。
tsx
import { useCallback, useId, useState, useRef } from "react";
interface FileUploadProps {
accept?: string;
maxSize?: number; // 字节
onUpload: (files: File[]) => void;
}
export function AccessibleFileUpload({ accept, maxSize, onUpload }: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropzoneId = useId();
const errorId = `${dropzoneId}-error`;
const handleFiles = useCallback((files: FileList | null) => {
setError(null);
if (!files?.length) return;
const validFiles: File[] = [];
Array.from(files).forEach((file) => {
if (maxSize && file.size > maxSize) {
setError(`${file.name}超过最大文件大小限制`);
return;
}
validFiles.push(file);
});
if (validFiles.length) {
onUpload(validFiles);
}
}, [maxSize, onUpload]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
handleFiles(e.dataTransfer.files);
}, [handleFiles]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
};
return (
<div>
{/* 隐藏但可访问的文件输入框 */}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={(e) => handleFiles(e.target.files)}
className="sr-only"
id={dropzoneId}
aria-describedby={error ? errorId : undefined}
/>
{/* 可点击且支持键盘操作的拖拽区域 */}
<label
htmlFor={dropzoneId}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg cursor-pointer",
"hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring",
isDragOver && "border-primary bg-primary/5",
error && "border-destructive"
)}
>
<UploadIcon className="h-10 w-10 text-muted-foreground mb-2" />
<span className="text-sm font-medium">
拖拽文件到此处,或点击浏览
</span>
<span className="text-xs text-muted-foreground mt-1">
{accept && `支持格式:${accept}`}
{maxSize && ` (最大:${formatBytes(maxSize)})`}
</span>
</label>
{error && (
<p id={errorId} role="alert" className="text-sm text-destructive mt-2">
{error}
</p>
)}
</div>
);
}Accessible Combobox (Searchable Select)
无障碍组合框(可搜索选择器)
Concept: Native is limited. Use a proper combobox pattern for search/filter.
<select>tsx
import { useState, useRef, useId, KeyboardEvent } from "react";
interface ComboboxOption {
value: string;
label: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function Combobox({ options, value, onChange, placeholder }: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const inputId = useId();
const listboxId = `${inputId}-listbox`;
const filteredOptions = options.filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
const selectedOption = options.find((opt) => opt.value === value);
const handleSelect = (option: ComboboxOption) => {
onChange(option.value);
setQuery("");
setIsOpen(false);
inputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
}
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && filteredOptions[activeIndex]) {
handleSelect(filteredOptions[activeIndex]);
}
break;
case "Escape":
setIsOpen(false);
setQuery("");
break;
}
};
return (
<div className="relative">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined}
aria-autocomplete="list"
value={query || selectedOption?.label || ""}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 150)}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border rounded-md"
/>
{isOpen && filteredOptions.length > 0 && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="absolute z-10 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-auto"
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={option.value === value}
className={cn(
"px-3 py-2 cursor-pointer",
index === activeIndex && "bg-accent",
option.value === value && "font-medium"
)}
onClick={() => handleSelect(option)}
onMouseEnter={() => setActiveIndex(index)}
>
{option.label}
</li>
))}
</ul>
)}
{isOpen && filteredOptions.length === 0 && (
<div className="absolute z-10 w-full mt-1 px-3 py-2 bg-background border rounded-md">
No results found
</div>
)}
</div>
);
}设计理念:原生功能有限,使用标准组合框模式实现搜索/筛选功能。
<select>tsx
import { useState, useRef, useId, KeyboardEvent } from "react";
interface ComboboxOption {
value: string;
label: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function Combobox({ options, value, onChange, placeholder }: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const inputId = useId();
const listboxId = `${inputId}-listbox`;
const filteredOptions = options.filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
const selectedOption = options.find((opt) => opt.value === value);
const handleSelect = (option: ComboboxOption) => {
onChange(option.value);
setQuery("");
setIsOpen(false);
inputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
}
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && filteredOptions[activeIndex]) {
handleSelect(filteredOptions[activeIndex]);
}
break;
case "Escape":
setIsOpen(false);
setQuery("");
break;
}
};
return (
<div className="relative">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined}
aria-autocomplete="list"
value={query || selectedOption?.label || ""}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 150)}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border rounded-md"
/>
{isOpen && filteredOptions.length > 0 && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="absolute z-10 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-auto"
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={option.value === value}
className={cn(
"px-3 py-2 cursor-pointer",
index === activeIndex && "bg-accent",
option.value === value && "font-medium"
)}
onClick={() => handleSelect(option)}
onMouseEnter={() => setActiveIndex(index)}
>
{option.label}
</li>
))}
</ul>
)}
{isOpen && filteredOptions.length === 0 && (
<div className="absolute z-10 w-full mt-1 px-3 py-2 bg-background border rounded-md">
未找到匹配结果
</div>
)}
</div>
);
}Date Picker Strategy
日期选择器策略
Concept: Choose the right approach based on use case and accessibility needs.
tsx
// OPTION 1: Native input (Best for mobile, simple use cases)
// Pros: Native a11y, mobile keyboards, no JS
// Cons: Limited styling, inconsistent across browsers
<input
type="date"
min="2024-01-01"
max="2025-12-31"
className="px-3 py-2 border rounded-md"
/>
// OPTION 2: Three separate selects (Best for birthdays, fixed ranges)
// Pros: Accessible, no calendar needed for known dates
// Cons: More inputs to manage
function BirthdatePicker({ value, onChange }: DatePickerProps) {
const [month, day, year] = value ? value.split("-") : ["", "", ""];
return (
<fieldset>
<legend className="text-sm font-medium mb-2">Date of Birth</legend>
<div className="flex gap-2">
<select
aria-label="Month"
value={month}
onChange={(e) => onChange(`${e.target.value}-${day}-${year}`)}
>
<option value="">Month</option>
{months.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
<select aria-label="Day" value={day} onChange={...}>
<option value="">Day</option>
{Array.from({ length: 31 }, (_, i) => (
<option key={i + 1} value={String(i + 1).padStart(2, "0")}>{i + 1}</option>
))}
</select>
<select aria-label="Year" value={year} onChange={...}>
<option value="">Year</option>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
</fieldset>
);
}
// OPTION 3: Calendar picker (For date ranges, scheduling)
// Use a tested library: react-day-picker, @radix-ui/react-calendar
// Key a11y requirements:
// - Arrow key navigation
// - Announce selected date to screen readers
// - Trap focus within calendar when open
// - Close on Escape
// See: https://react-day-picker.js.org/guides/accessibility设计理念:根据使用场景和无障碍需求选择合适的实现方案。
tsx
// 方案1:原生输入框(最适合移动端、简单场景)
// 优点:原生无障碍支持、适配移动端键盘、无需JS
// 缺点:样式定制有限、浏览器间表现不一致
<input
type="date"
min="2024-01-01"
max="2025-12-31"
className="px-3 py-2 border rounded-md"
/>
// 方案2:三个独立选择器(最适合生日、固定范围场景)
// 优点:无障碍支持完善、无需日历组件
// 缺点:需要管理更多输入框
function BirthdatePicker({ value, onChange }: DatePickerProps) {
const [month, day, year] = value ? value.split("-") : ["", "", ""];
return (
<fieldset>
<legend className="text-sm font-medium mb-2">出生日期</legend>
<div className="flex gap-2">
<select
aria-label="月份"
value={month}
onChange={(e) => onChange(`${e.target.value}-${day}-${year}`)}
>
<option value="">月份</option>
{months.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
<select aria-label="日期" value={day} onChange={...}>
<option value="">日期</option>
{Array.from({ length: 31 }, (_, i) => (
<option key={i + 1} value={String(i + 1).padStart(2, "0")}>{i + 1}</option>
))}
</select>
<select aria-label="年份" value={year} onChange={...}>
<option value="">年份</option>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
</fieldset>
);
}
// 方案3:日历选择器(适合日期范围、日程安排场景)
// 使用经过测试的库:react-day-picker, @radix-ui/react-calendar
// 核心无障碍要求:
// - 方向键导航
// - 向屏幕阅读器朗读选中日期
// - 打开日历后锁定焦点在内部
// - 按Escape键关闭
// 参考:https://react-day-picker.js.org/guides/accessibility5. Accessibility Deep Dive
5. 无障碍设计深度解析
Reduced Motion Support
减少动画支持
Concept: Respect user preferences for reduced animations.
tsx
// Hook to detect preference
function usePrefersReducedMotion() {
// Lazy init: read actual value on first render to avoid animation flash
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
query.addEventListener("change", handler);
return () => query.removeEventListener("change", handler);
}, []);
return prefersReducedMotion;
}
// Usage in validation animations
function ErrorMessage({ message }: { message: string }) {
const prefersReducedMotion = usePrefersReducedMotion();
return (
<p
role="alert"
className={cn(
"text-sm text-destructive",
!prefersReducedMotion && "animate-shake" // Only animate if allowed
)}
>
{message}
</p>
);
}
// Tailwind config for reduced motion
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
shake: {
"0%, 100%": { transform: "translateX(0)" },
"25%": { transform: "translateX(-4px)" },
"75%": { transform: "translateX(4px)" },
},
},
animation: {
shake: "shake 0.3s ease-in-out",
},
},
},
};
// In CSS, use motion-safe/motion-reduce variants
// <div className="motion-safe:animate-shake motion-reduce:animate-none">设计理念:尊重用户对减少动画的偏好设置。
tsx
// 检测用户偏好的Hook
function usePrefersReducedMotion() {
// 惰性初始化:在首次渲染时读取实际值,避免动画闪烁
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
query.addEventListener("change", handler);
return () => query.removeEventListener("change", handler);
}, []);
return prefersReducedMotion;
}
// 在验证动画中使用
function ErrorMessage({ message }: { message: string }) {
const prefersReducedMotion = usePrefersReducedMotion();
return (
<p
role="alert"
className={cn(
"text-sm text-destructive",
!prefersReducedMotion && "animate-shake" // 仅在允许时展示动画
)}
>
{message}
</p>
);
}
// Tailwind配置减少动画
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
shake: {
"0%, 100%": { transform: "translateX(0)" },
"25%": { transform: "translateX(-4px)" },
"75%": { transform: "translateX(4px)" },
},
},
animation: {
shake: "shake 0.3s ease-in-out",
},
},
},
};
// 在CSS中使用motion-safe/motion-reduce变体
// <div className="motion-safe:animate-shake motion-reduce:animate-none">Forced Colors (High Contrast Mode)
强制颜色模式(高对比度模式)
Concept: Windows High Contrast mode removes background colors. Borders become critical.
tsx
// Problem: Red border for errors disappears in High Contrast mode
// Solution: Use forced-colors variant and ensure visible borders
<input
className={cn(
"border rounded-md",
error && "border-destructive",
// High contrast fallback - ensure border is always visible
"forced-colors:border-[CanvasText]",
error && "forced-colors:border-[Mark]" // System highlight color
)}
/>
// For icons that convey meaning, ensure they have forced-colors support
<CheckIcon
className="text-success forced-colors:text-[Highlight]"
aria-hidden="true"
/>
// Error indicators need text backup, not just color
{error && (
<span className="flex items-center gap-1 text-destructive">
<AlertIcon className="h-4 w-4 forced-colors:text-[Mark]" aria-hidden="true" />
<span>{error}</span> {/* Text is always readable */}
</span>
)}设计理念:Windows高对比度模式会移除背景色,边框成为关键的视觉标识。
tsx
// 问题:错误提示的红色边框在高对比度模式下会消失
// 解决方案:使用forced-colors变体,确保边框始终可见
<input
className={cn(
"border rounded-md",
error && "border-destructive",
// 高对比度回退方案 - 确保边框始终可见
"forced-colors:border-[CanvasText]",
error && "forced-colors:border-[Mark]" // 系统高亮色
)}
/>
// 对于传达语义的图标,确保支持forced-colors模式
<CheckIcon
className="text-success forced-colors:text-[Highlight]"
aria-hidden="true"
/>
// 错误提示需要文本备份,不能仅依赖颜色
{error && (
<span className="flex items-center gap-1 text-destructive">
<AlertIcon className="h-4 w-4 forced-colors:text-[Mark]" aria-hidden="true" />
<span>{error}</span> {/* 文本始终可读 */}
</span>
)}Live Regions for Global Feedback
实时区域用于全局反馈
Concept: Announce form success/error to screen readers using aria-live regions.
tsx
import { createContext, useContext, useState, useCallback } from "react";
interface Announcement {
message: string;
type: "polite" | "assertive";
}
const AnnouncerContext = createContext<{
announce: (message: string, type?: "polite" | "assertive") => void;
} | null>(null);
// Provider component - add to app root
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
const announce = useCallback((message: string, type: "polite" | "assertive" = "polite") => {
// Clear first to ensure re-announcement of same message
setAnnouncement(null);
requestAnimationFrame(() => {
setAnnouncement({ message, type });
});
}, []);
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* Visually hidden live regions */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement?.type === "polite" && announcement.message}
</div>
<div className="sr-only" aria-live="assertive" aria-atomic="true">
{announcement?.type === "assertive" && announcement.message}
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnounce() {
const context = useContext(AnnouncerContext);
if (!context) throw new Error("useAnnounce must be used within AnnouncerProvider");
return context.announce;
}
// Usage in form submission
function ContactForm() {
const announce = useAnnounce();
async function onSubmit(data: FormData) {
try {
await submitForm(data);
announce("Form submitted successfully. We'll be in touch soon.", "polite");
} catch (error) {
announce("Form submission failed. Please check the errors and try again.", "assertive");
}
}
}
// Integration with Sonner/Radix Toast (ensure a11y)
import { toast } from "sonner";
// Sonner automatically handles aria-live, but verify:
toast.success("Profile updated", {
description: "Your changes have been saved.",
// Sonner uses role="status" which is aria-live="polite" by default
});
toast.error("Upload failed", {
description: "The file was too large.",
// For errors, consider if assertive is needed
});设计理念:使用aria-live区域向屏幕阅读器播报表单成功/错误信息。
tsx
import { createContext, useContext, useState, useCallback } from "react";
interface Announcement {
message: string;
type: "polite" | "assertive";
}
const AnnouncerContext = createContext<{
announce: (message: string, type?: "polite" | "assertive") => void;
} | null>(null);
// 提供者组件 - 添加到应用根节点
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
const announce = useCallback((message: string, type: "polite" | "assertive" = "polite") => {
// 先清除现有消息,确保相同消息可重复播报
setAnnouncement(null);
requestAnimationFrame(() => {
setAnnouncement({ message, type });
});
}, []);
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* 视觉隐藏的实时区域 */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement?.type === "polite" && announcement.message}
</div>
<div className="sr-only" aria-live="assertive" aria-atomic="true">
{announcement?.type === "assertive" && announcement.message}
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnounce() {
const context = useContext(AnnouncerContext);
if (!context) throw new Error("useAnnounce必须在AnnouncerProvider内部使用");
return context.announce;
}
// 在表单提交中使用
function ContactForm() {
const announce = useAnnounce();
async function onSubmit(data: FormData) {
try {
await submitForm(data);
announce("表单提交成功,我们将尽快与您联系。", "polite");
} catch (error) {
announce("表单提交失败,请检查错误信息后重试。", "assertive");
}
}
}
// 与Sonner/Radix Toast集成(确保无障碍支持)
import { toast } from "sonner";
// Sonner自动处理aria-live,但需验证:
toast.success("个人资料已更新", {
description: "您的更改已保存。",
// Sonner默认使用role="status",对应aria-live="polite"
});
toast.error("上传失败", {
description: "文件大小超出限制。",
// 对于错误,考虑是否需要使用assertive模式
});6. Testing & Documentation
6. 测试与文档
Unit Testing with React Testing Library
使用React Testing Library进行单元测试
Concept: Test user interactions, not implementation details.
tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContactForm } from "./ContactForm";
describe("ContactForm", () => {
it("shows validation errors on submit with empty fields", async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.click(screen.getByRole("button", { name: /send message/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/email is required/i);
});
it("submits successfully with valid data", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/message/i), "Hello world");
await user.click(screen.getByRole("button", { name: /send message/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
message: "Hello world",
});
});
});
it("disables submit button while loading", async () => {
const user = userEvent.setup();
const slowSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)));
render(<ContactForm onSubmit={slowSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/message/i), "Hello");
const submitButton = screen.getByRole("button", { name: /send message/i });
await user.click(submitButton);
expect(submitButton).toBeDisabled();
expect(submitButton).toHaveTextContent(/sending/i);
});
it("handles server errors gracefully", async () => {
const user = userEvent.setup();
const failingSubmit = vi.fn().mockRejectedValue(new Error("Server error"));
render(<ContactForm onSubmit={failingSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/message/i), "Hello");
await user.click(screen.getByRole("button", { name: /send message/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/something went wrong/i);
});
it("is keyboard accessible", async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Tab through all fields
await user.tab();
expect(screen.getByLabelText(/email/i)).toHaveFocus();
await user.tab();
expect(screen.getByLabelText(/message/i)).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: /send message/i })).toHaveFocus();
});
});设计理念:测试用户交互,而非实现细节。
tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContactForm } from "./ContactForm";
describe("ContactForm", () => {
it("提交空字段时展示验证错误", async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.click(screen.getByRole("button", { name: /发送消息/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/邮箱为必填项/i);
});
it("输入有效数据时提交成功", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/邮箱/i), "test@example.com");
await user.type(screen.getByLabelText(/消息/i), "你好,世界");
await user.click(screen.getByRole("button", { name: /发送消息/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
message: "你好,世界",
});
});
});
it("提交过程中禁用提交按钮", async () => {
const user = userEvent.setup();
const slowSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)));
render(<ContactForm onSubmit={slowSubmit} />);
await user.type(screen.getByLabelText(/邮箱/i), "test@example.com");
await user.type(screen.getByLabelText(/消息/i), "你好");
const submitButton = screen.getByRole("button", { name: /发送消息/i });
await user.click(submitButton);
expect(submitButton).toBeDisabled();
expect(submitButton).toHaveTextContent(/发送中/i);
});
it("优雅处理服务端错误", async () => {
const user = userEvent.setup();
const failingSubmit = vi.fn().mockRejectedValue(new Error("服务端错误"));
render(<ContactForm onSubmit={failingSubmit} />);
await user.type(screen.getByLabelText(/邮箱/i), "test@example.com");
await user.type(screen.getByLabelText(/消息/i), "你好");
await user.click(screen.getByRole("button", { name: /发送消息/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/出现错误,请重试/i);
});
it("支持键盘操作", async () => {
const user = userEvent.setup();
render(<ContactForm />);
// 按Tab键遍历所有字段
await user.tab();
expect(screen.getByLabelText(/邮箱/i)).toHaveFocus();
await user.tab();
expect(screen.getByLabelText(/消息/i)).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: /发送消息/i })).toHaveFocus();
});
});Storybook Documentation
Storybook文档
Concept: Document all form component states for design system consistency.
tsx
// SmartInput.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { SmartInput } from "./SmartInput";
const meta: Meta<typeof SmartInput> = {
title: "Forms/SmartInput",
component: SmartInput,
parameters: {
docs: {
description: {
component: "Accessible input component with built-in label, description, error handling, and password toggle.",
},
},
},
argTypes: {
type: {
control: "select",
options: ["text", "email", "password", "tel", "url"],
},
error: { control: "text" },
description: { control: "text" },
isOptional: { control: "boolean" },
disabled: { control: "boolean" },
},
};
export default meta;
type Story = StoryObj<typeof SmartInput>;
export const Default: Story = {
args: {
label: "Email Address",
placeholder: "you@example.com",
},
};
export const WithDescription: Story = {
args: {
label: "Username",
description: "This will be your public display name",
placeholder: "johndoe",
},
};
export const WithError: Story = {
args: {
label: "Email Address",
error: "Please enter a valid email address",
defaultValue: "invalid-email",
},
};
export const Optional: Story = {
args: {
label: "Phone Number",
isOptional: true,
placeholder: "(555) 123-4567",
},
};
export const Password: Story = {
args: {
label: "Password",
type: "password",
description: "Must be at least 8 characters",
},
};
export const Disabled: Story = {
args: {
label: "Email Address",
disabled: true,
defaultValue: "disabled@example.com",
},
};
export const Loading: Story = {
render: () => (
<div className="space-y-4">
<SmartInput label="Username" />
<p className="text-sm text-muted-foreground">Checking availability...</p>
</div>
),
};
// Sizing variations for "Field Sizing as Affordance"
export const FieldSizes: Story = {
render: () => (
<div className="flex gap-4">
<SmartInput label="CVV" widthClass="w-20" maxLength={4} />
<SmartInput label="Zip Code" widthClass="w-28" />
<SmartInput label="City" widthClass="w-48" />
</div>
),
};
// Accessibility testing story
export const AccessibilityDemo: Story = {
render: () => (
<form className="space-y-4 max-w-md">
<SmartInput label="Full Name" autoComplete="name" />
<SmartInput label="Email" type="email" autoComplete="email" />
<SmartInput
label="Password"
type="password"
autoComplete="new-password"
description="Minimum 8 characters"
/>
<SmartInput
label="Confirm Password"
type="password"
autoComplete="new-password"
error="Passwords do not match"
/>
<button type="submit" className="btn-primary">Create Account</button>
</form>
),
parameters: {
a11y: {
// Axe accessibility checks
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "label", enabled: true },
],
},
},
},
};设计理念:记录所有表单组件状态,确保设计系统一致性。
tsx
// SmartInput.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { SmartInput } from "./SmartInput";
const meta: Meta<typeof SmartInput> = {
title: "表单/SmartInput",
component: SmartInput,
parameters: {
docs: {
description: {
component: "内置标签、描述、错误处理和密码切换功能的无障碍输入组件。",
},
},
},
argTypes: {
type: {
control: "select",
options: ["text", "email", "password", "tel", "url"],
},
error: { control: "text" },
description: { control: "text" },
isOptional: { control: "boolean" },
disabled: { control: "boolean" },
},
};
export default meta;
type Story = StoryObj<typeof SmartInput>;
export const 默认状态: Story = {
args: {
label: "邮箱地址",
placeholder: "you@example.com",
},
};
export const 带描述信息: Story = {
args: {
label: "用户名",
description: "这将是您的公开显示名称",
placeholder: "johndoe",
},
};
export const 带错误提示: Story = {
args: {
label: "邮箱地址",
error: "请输入有效的邮箱地址",
defaultValue: "invalid-email",
},
};
export const 可选字段: Story = {
args: {
label: "手机号码",
isOptional: true,
placeholder: "(555) 123-4567",
},
};
export const 密码输入框: Story = {
args: {
label: "密码",
type: "password",
description: "至少8个字符",
},
};
export const 禁用状态: Story = {
args: {
label: "邮箱地址",
disabled: true,
defaultValue: "disabled@example.com",
},
};
export const 加载状态: Story = {
render: () => (
<div className="space-y-4">
<SmartInput label="用户名" />
<p className="text-sm text-muted-foreground">检查可用性中...</p>
</div>
),
};
// 「输入框尺寸暗示输入长度」的尺寸变体
export const 字段尺寸示例: Story = {
render: () => (
<div className="flex gap-4">
<SmartInput label="CVV码" widthClass="w-20" maxLength={4} />
<SmartInput label="邮政编码" widthClass="w-28" />
<SmartInput label="城市" widthClass="w-48" />
</div>
),
};
// 无障碍测试示例
export const 无障碍演示: Story = {
render: () => (
<form className="space-y-4 max-w-md">
<SmartInput label="全名" autoComplete="name" />
<SmartInput label="邮箱" type="email" autoComplete="email" />
<SmartInput
label="密码"
type="password"
autoComplete="new-password"
description="至少8个字符"
/>
<SmartInput
label="确认密码"
type="password"
autoComplete="new-password"
error="两次输入的密码不一致"
/>
<button type="submit" className="btn-primary">创建账号</button>
</form>
),
parameters: {
a11y: {
// Axe无障碍检查
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "label", enabled: true },
],
},
},
},
};7. Accessibility & Validation (Modern Stack)
7. 无障碍与验证(现代技术栈)
Integration with React Hook Form & Zod
与React Hook Form & Zod集成
Don't rely on browser defaults alone. Connect library state to ARIA attributes.
tsx
// Example of connecting RHF errors to ARIA
<input
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>;
{
errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
);
}不要仅依赖浏览器默认行为,将库状态与ARIA属性关联。
tsx
// 将RHF错误与ARIA属性关联的示例
<input
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>;
{
errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
);
}Mobile Optimization
移动端优化
- Input Modes: Critical for triggering the right keyboard on iOS/Android.
- Numbers (codes): pattern="[0-9]*"
inputMode="numeric" - Email:
inputMode="email" - Search: (adds "Go" button)
inputMode="search"
- Numbers (codes):
- Touch Targets: Min height (
44pxin Tailwind default config usually works well).h-11
- 输入模式:对于触发iOS/Android正确键盘至关重要。
- 数字(验证码):pattern="[0-9]*"
inputMode="numeric" - 邮箱:
inputMode="email" - 搜索:(添加「前往」按钮)
inputMode="search"
- 数字(验证码):
- 触摸目标:最小高度为(Tailwind默认配置中的
44px通常适用)。h-11
8. Component Implementation Recipe
8. 组件实现参考
Here is a style Field component that implements these principles automatically.
shadcn/uitsx
import { useId, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
// React 19: ref is a regular prop, no forwardRef needed
interface SmartInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
ref?: React.Ref<HTMLInputElement>;
label: string;
error?: string;
description?: string;
isOptional?: boolean;
widthClass?: string; // For "Field Sizing as Affordance"
}
export const SmartInput = ({
ref,
label,
error,
description,
isOptional,
widthClass = "w-full",
className,
type = "text",
...props
}: SmartInputProps) => {
const id = useId();
const descriptionId = `${id}-desc`;
const errorId = `${id}-error`;
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
return (
<div className={cn("space-y-2", widthClass)}>
<div className="flex justify-between items-baseline">
<label
htmlFor={id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
{isOptional && (
<span className="ml-2 text-muted-foreground font-normal text-xs">
(Optional)
</span>
)}
</label>
</div>
<div className="relative">
<input
ref={ref}
id={id}
type={inputType}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus-visible:ring-destructive",
className,
)}
aria-invalid={!!error}
aria-describedby={
[description && descriptionId, error && errorId]
.filter(Boolean)
.join(" ") || undefined
}
{...props}
/>
{/* Password Toggle Pattern */}
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
{/* Description linked via ARIA */}
{description && !error && (
<p id={descriptionId} className="text-sm text-muted-foreground">
{description}
</p>
)}
{/* Error Message with role="alert" */}
{error && (
<p
id={errorId}
role="alert"
className="text-sm font-medium text-destructive"
>
{error}
</p>
)}
</div>
);
};以下是一个风格的Field组件,自动实现上述设计原则。
shadcn/uitsx
import { useId, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
// React 19: ref是常规props,无需forwardRef
interface SmartInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
ref?: React.Ref<HTMLInputElement>;
label: string;
error?: string;
description?: string;
isOptional?: boolean;
widthClass?: string; // 用于「输入框尺寸暗示输入长度」
}
export const SmartInput = ({
ref,
label,
error,
description,
isOptional,
widthClass = "w-full",
className,
type = "text",
...props
}: SmartInputProps) => {
const id = useId();
const descriptionId = `${id}-desc`;
const errorId = `${id}-error`;
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === "password";
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
return (
<div className={cn("space-y-2", widthClass)}>
<div className="flex justify-between items-baseline">
<label
htmlFor={id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
{isOptional && (
<span className="ml-2 text-muted-foreground font-normal text-xs">
(可选)
</span>
)}
</label>
</div>
<div className="relative">
<input
ref={ref}
id={id}
type={inputType}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus-visible:ring-destructive",
className,
)}
aria-invalid={!!error}
aria-describedby={
[description && descriptionId, error && errorId]
.filter(Boolean)
.join(" ") || undefined
}
{...props}
/>
{/* 密码切换模式 */}
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
{/* 通过ARIA关联的描述信息 */}
{description && !error && (
<p id={descriptionId} className="text-sm text-muted-foreground">
{description}
</p>
)}
{/* 带role="alert"的错误提示 */}
{error && (
<p
id={errorId}
role="alert"
className="text-sm font-medium text-destructive"
>
{error}
</p>
)}
</div>
);
};Checklist for Review
评审检查清单
Layout & UX
布局与UX
- Single Column: Is the form mostly single column? (Exceptions: City/Zip, First/Last Name)
- Labels: Are placeholder labels avoided? (Labels must remain visible while typing)
- Buttons: Does the submit button say what it does? (e.g. "Create Account" vs "Submit")
- Affordance: Are short inputs (CVV) actually short on screen?
- Input Masking: Are formatted fields (phone, credit card, currency) using proper masking?
- Unsaved Changes: Is navigation blocked when form has unsaved changes?
- 单列布局:表单是否以单列布局为主?(例外:城市/邮编、名/姓)
- 标签设计:是否避免了占位符作为标签?(输入时标签必须保持可见)
- 按钮文案:提交按钮是否明确说明操作内容?(如「创建账号」而非「提交」)
- 视觉暗示:短输入框(如CVV码)是否在屏幕上显示为短宽度?
- 输入掩码:格式化字段(电话、信用卡、货币)是否使用了正确的掩码?
- 未保存内容保护:表单有未保存更改时,是否会阻止导航?
Accessibility & Code
无障碍与代码
- Focus: Is the focus ring visible? (Tailwind )
focus-visible:ring - Click Areas: Do lists/radios have expanded hit areas? (tricks)
inset - Semantics: Are errors linked with ?
aria-describedby - Keyboard: Can you fill the form without a mouse? (Check password toggles and custom sliders)
- Reduced Motion: Are animations disabled for ?
prefers-reduced-motion - High Contrast: Do error states work in Windows High Contrast mode? ()
forced-colors: - Live Regions: Are success/error messages announced to screen readers?
- File Uploads: Is the drag-and-drop zone keyboard accessible?
- 焦点可见:焦点环是否可见?(Tailwind )
focus-visible:ring - 点击区域:列表/单选框是否有扩展的点击区域?(技巧)
inset - 语义化:错误信息是否通过关联?
aria-describedby - 键盘操作:是否可以不使用鼠标完成表单填写?(检查密码切换和自定义滑块)
- 减少动画:在模式下是否禁用了动画?
prefers-reduced-motion - 高对比度:错误状态在Windows高对比度模式下是否正常显示?()
forced-colors: - 实时区域:成功/错误消息是否会被屏幕阅读器朗读?
- 文件上传:拖拽上传区域是否支持键盘操作?
Performance & Backend
性能与后端
- Validation: Is heavy validation (API checks) debounced?
- Submission: Does the button show a loading state and disable to prevent double-submit?
- Server Errors: Are backend validation errors mapped to specific form fields?
- Optimistic UI: Does the form show immediate feedback during submission?
- 验证优化:高开销验证(API检查)是否使用了防抖?
- 提交状态:按钮是否会显示加载状态并禁用,防止重复提交?
- 服务端错误映射:后端验证错误是否映射到了具体表单字段?
- 乐观UI:表单提交过程中是否会展示即时反馈?
Testing & Documentation
测试与文档
- Unit Tests: Are critical paths tested with React Testing Library?
- Storybook: Are all component states documented (default, error, loading, disabled)?
- A11y Tests: Are axe or similar tools integrated in tests/Storybook?
- 单元测试:关键流程是否使用React Testing Library进行了测试?
- Storybook文档:所有组件状态是否都已记录(默认、错误、加载、禁用)?
- 无障碍测试:是否在测试/Storybook中集成了axe等工具?