mcpField
B
better-forms

better-forms

github.com/alexeira设计与 UI043 次安装25 周前更新
代码仓库

关于 better-forms

better-forms 是关于现代 Web 表单体验模式、无障碍设计和实现技巧的实用指南,适用于使用 HTML/CSS 或 React、Tailwind 等组件方案的设计师和开发者,内容涵盖从验证到多步骤流程的表单行为。

主要功能

  1. 01表单布局与交互模式
  2. 02键盘和屏幕阅读器无障碍支持
  3. 03输入格式化与异步验证
  4. 04未保存更改和多步骤表单指引
  5. 05后端错误处理与乐观反馈

适用场景

  1. 01检查表单的无障碍问题
  2. 02设计多步骤数据录入流程
  3. 03添加响应式输入验证
  4. 04改进表单提交反馈

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/alexeira/skills --skill better-forms

安装后在 Claude Code 中输入/better-forms确认已加载。

详细介绍

更好表单指南

汇集了适用于现代 Web 表单的具体 UX 模式、无障碍标准和实现技巧。本指南弥合了原始 HTML/CSS 技巧与基于组件的架构(React、Tailwind、Headless UI)之间的差距。

1. 高影响力 UX 模式(“为什么”与“如何做”)

避免列表中的“死区”

概念:可点击列表项之间的小间隙会令人沮丧。 实现(Tailwind):使用伪元素扩大点击区域,同时不影响布局。

// 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>
范围滑块 > 最小值/最大值输入框

概念:填写“$10 至 $1000”这类文本输入框很繁琐。 实现:使用双滑块组件(如 Radix UI / Shadcn Slider)设置范围。

  • 原因:降低认知负担并提供即时视觉反馈。
  • 无障碍:确保滑块支持方向键导航。
“受输出启发”的设计

概念:表单输入项的视觉效果应与最终结果卡片/页面相似。

  • 层级:如果输出标题使用 text-2xl font-bold,对应的输入项也应使用 text-2xl font-bold。
  • 位置:如果列表中的图片放在左侧,表单中的上传按钮也应放在左侧。
  • 空状态:填写表单时,预览空卡片的样子。
描述明确的操作按钮

概念:永远不要使用“提交”或“发送”。按钮应补全“我想要……”这句话。

  • 避免:Submit
  • 推荐:Create Account、Publish Listing、Update Profile 提示:根据表单状态动态更新按钮文本(例如,“正在保存……”与“保存更改”)。
“可选”标签 > 星号

概念:红色星号(*)显得咄咄逼人且含义模糊(有时表示“错误”)。 实现:默认将字段标记为必填(不加标记),并明确标注可选字段。

<Label>
  Phone Number{" "}
  <span className="text-muted-foreground text-sm font-normal">(Optional)</span>
</Label>
显示/隐藏密码

概念:默认隐藏密码会妨碍用户纠正输入错误。 实现:始终在输入框容器内加入切换按钮。

  • 无障碍:切换按钮必须具有 type="button" 和 aria-label="Show password"。
将字段尺寸作为提示

概念:输入框的宽度暗示了预期数据的长度。

  • 邮政编码:w-20 或 w-24(不要使用全宽)。
  • CVV:使用较窄的宽度。
  • 街道地址:使用全宽。

2. 高级 UX 模式

输入掩码与格式化

概念:随着用户输入自动格式化数据,以减少错误和认知负担。

// 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)}
/>;

关键原则:存储原始值,显示格式化后的值。绝不要校验格式化后的字符串。

OTP / 2FA 验证码输入

概念:6 位验证码需要特别处理粘贴、自动聚焦和键盘导航。

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>
  );
}
未保存更改保护

概念:用户离开脏表单时,防止意外丢失数据。

说明(React 19):不要将 react-hook-form 中的 useFormState 与 React DOM 的 useFormState 混淆;后者在 React 19 中已更名为 useActionState。

警告:猴子补丁 router.push 很脆弱,可能会在不同版本的 Next.js 中失效。拦截 App Router 导航没有稳定的 API。beforeunload 方法是唯一可靠的部分。如果你的框架支持,可以考虑使用 onBeforePopState(Pages Router)或路由更改事件监听器。

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>;
}
多步骤表单(向导)

概念:将复杂表单拆分为易于处理的步骤,并妥善持久化状态和管理焦点。

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>
  );
}

3. 后端集成模式

服务端错误映射

概念:将 API 校验错误映射回对应的表单字段。

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 过载。

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>
  );
}
乐观更新

概念:请求进行中时立即显示反馈。

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>
  );
}

4. 复杂组件模式

无障碍文件上传(拖放)

概念:拖放区域通常不具备无障碍性。请确保支持键盘和屏幕阅读器。

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>
  );
}
无障碍组合框(可搜索选择框)

概念:原生 <select> 功能有限。请采用规范的组合框模式来实现搜索/筛选。

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>
  );
}
日期选择器策略

概念:根据使用场景和无障碍需求选择合适的方案。

// 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

5. 深入了解无障碍设计

支持减少动态效果

概念:尊重用户减少动画的偏好。

// 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">
强制颜色(高对比度模式)

概念:Windows 高对比度模式会移除背景颜色。因此,边框至关重要。

// 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>
)}
用于全局反馈的实时区域

概念:使用 aria-live 区域向屏幕阅读器播报表单成功或错误信息。

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
});

6. 测试与文档

使用 React Testing Library 进行单元测试

概念:测试用户交互,而非实现细节。

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), "[email protected]");
    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: "[email protected]",
        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), "[email protected]");
    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), "[email protected]");
    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();
  });
});
Storybook 文档

概念:记录表单组件的所有状态,确保设计系统的一致性。

// 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: "[email protected]",
  },
};

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: "[email protected]",
  },
};

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 },
        ],
      },
    },
  },
};

7. 无障碍设计与验证(现代技术栈)

集成 React Hook Form 和 Zod

不要只依赖浏览器默认行为。将库的状态与 ARIA 属性关联起来。

// 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>
  );
}
移动端优化
  • 输入模式:这对在 iOS/Android 上触发正确的键盘至关重要。
    • 数字(代码):inputMode="numeric" pattern="[0-9]*"
    • 电子邮件:inputMode="email"
    • 搜索:inputMode="search"(添加“前往”按钮)
  • 触控目标:高度至少为 44px(Tailwind 默认配置中的 h-11 通常很合适)。

8. 组件实现方案

下面是一个 shadcn/ui 风格的 Field 组件,可自动实现这些原则。

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>
  );
};

审查清单

布局与用户体验
  • 单列布局:表单是否大多采用单列布局?(例外:城市/邮编、名字/姓氏)
  • 标签:是否避免使用占位符作为标签?(输入时标签必须保持可见)
  • 按钮:提交按钮的文案是否明确说明其功能?(例如,“创建账户”而非“提交”)
  • 可操作性:短输入项(CVV)在屏幕上是否确实较短?
  • 输入格式化:格式化字段(电话、信用卡、货币)是否使用了适当的格式化方式?
  • 未保存的更改:表单有未保存的更改时,是否会阻止导航?
无障碍设计与代码
  • 焦点:焦点环是否清晰可见?(Tailwind focus-visible:ring)
  • 点击区域:列表项/单选按钮是否扩大了可点击区域?(inset 技巧)
  • 语义:错误是否通过 aria-describedby 关联?
  • 键盘:不使用鼠标能否填写表单?(检查密码切换按钮和自定义滑块)
  • 减少动态效果:是否为 prefers-reduced-motion 禁用了动画?
  • 高对比度:错误状态在 Windows 高对比度模式下是否正常?(forced-colors:)
  • 实时区域:屏幕阅读器是否会播报成功/错误消息?
  • 文件上传:拖放区域是否支持键盘操作?
性能与后端
  • 验证:是否对耗时较长的验证(API 检查)进行了防抖?
  • 提交:按钮是否显示加载状态并被禁用,以防止重复提交?
  • 服务端错误:后端验证错误是否映射到具体的表单字段?
  • 乐观 UI:提交过程中,表单是否会立即提供反馈?
测试与文档
  • 单元测试:是否使用 React Testing Library 测试了关键流程?
  • Storybook:是否记录了所有组件状态(默认、错误、加载中、禁用)?
  • 无障碍测试:是否在测试/Storybook 中集成了 axe 或类似工具?

评论

支持 Markdown