mcpField
B
better-forms

better-forms

github.com/alexeiraデザイン・UI043 回インストール25 週間前に更新
リポジトリ

better-forms について

better-formsは、現代的なWebフォームのUXパターン、アクセシビリティ、実装手法をまとめた実用ガイドです。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を入力して読み込まれたか確認します。

詳細

より良いフォームのためのガイド

最新のウェブフォーム向けの、具体的な 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」は使わないでください。ボタンの文言は「〜したい」という文を完成させるものにします。

  • 避ける: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"(「Go」ボタンが追加されます)
  • タッチターゲット:高さは最低 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>
  );
};

レビュー用チェックリスト

レイアウトと UX
  • 単一カラム:フォームはおおむね単一カラムになっていますか?(例外:市区町村/郵便番号、名/姓)
  • ラベル:プレースホルダーをラベル代わりにしていませんか?(入力中もラベルが表示されている必要があります)
  • ボタン:送信ボタンには動作が明記されていますか?(例:「Submit」ではなく「Create Account」)
  • アフォーダンス:短い入力項目(CVV)は画面上でも実際に短くなっていますか?
  • 入力マスキング:書式設定されたフィールド(電話番号、クレジットカード、通貨)に適切なマスキングを使用していますか?
  • 未保存の変更:フォームに未保存の変更がある場合、ページ遷移をブロックしますか?
アクセシビリティとコード
  • フォーカス:フォーカスリングは表示されていますか?(Tailwind の focus-visible:ring)
  • クリック領域:リスト/ラジオボタンのヒット領域は拡張されていますか?(inset による工夫)
  • セマンティクス:エラーは aria-describedby で関連付けられていますか?
  • キーボード:マウスを使わずにフォームへ入力できますか?(パスワードの表示切り替えやカスタムスライダーを確認してください)
  • モーションの軽減:prefers-reduced-motion の設定時にアニメーションは無効になりますか?
  • ハイコントラスト:Windows のハイコントラストモードでもエラー状態が機能しますか?(forced-colors:)
  • ライブリージョン:成功/エラーメッセージはスクリーンリーダーに通知されますか?
  • ファイルアップロード:ドラッグ&ドロップ領域はキーボードで操作できますか?
パフォーマンスとバックエンド
  • バリデーション:負荷の高いバリデーション(API チェック)はデバウンスされていますか?
  • 送信:二重送信を防ぐため、ボタンに読み込み状態が表示され、無効になりますか?
  • サーバーエラー:バックエンドのバリデーションエラーは、フォームの特定のフィールドに対応付けられていますか?
  • 楽観的 UI:送信中にフォームからすぐにフィードバックが表示されますか?
テストとドキュメント
  • ユニットテスト:重要な処理フローは React Testing Library でテストされていますか?
  • Storybook:すべてのコンポーネントの状態(デフォルト、エラー、読み込み中、無効)がドキュメント化されていますか?
  • A11y テスト:axe などのツールはテスト/Storybook に統合されていますか?

コメント