マルチステップフォーム
:LiTarget: 用途
複数ステップに分けた長いフォームを管理するパターン。
:LiSparkle: 特徴
- ステップ管理
- バリデーション
- プログレスバー
- 前後移動
:LiCode: コード(コピペ用)
import { useState, ReactNode } from 'react';
type Step<T> = {
title: string;
validate?: (data: T) => string | null;
render: (data: T, set: (patch: Partial<T>) => void) => ReactNode;
};
export function MultiStepForm<T extends Record<string, any>>({
initial, steps, onSubmit,
}: {
initial: T;
steps: Step<T>[];
onSubmit: (data: T) => Promise<void>;
}) {
const [data, setData] = useState<T>(initial);
const [step, setStep] = useState(0);
const [error, setError] = useState<string | null>(null);
const set = (patch: Partial<T>) => setData((d) => ({ ...d, ...patch }));
const current = steps[step];
const progress = ((step + 1) / steps.length) * 100;
const next = async () => {
const err = current.validate?.(data) ?? null;
if (err) { setError(err); return; }
setError(null);
if (step === steps.length - 1) {
await onSubmit(data);
} else {
setStep(step + 1);
}
};
return (
<div className="max-w-xl mx-auto">
<div className="h-1 bg-zinc-800 rounded-full mb-2">
<div className="h-full bg-indigo-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-xs text-zinc-400 mb-4">Step {step + 1} / {steps.length}: {current.title}</p>
{current.render(data, set)}
{error && <p className="text-rose-400 text-sm mt-2">{error}</p>}
<div className="flex justify-between mt-6">
<button disabled={step === 0} onClick={() => setStep(step - 1)} className="px-4 py-2 rounded-lg bg-zinc-800 disabled:opacity-50">戻る</button>
<button onClick={next} className="px-4 py-2 rounded-lg bg-indigo-500 text-white">
{step === steps.length - 1 ? '送信' : '次へ'}
</button>
</div>
</div>
);
}
:LiHandPointer: 使い方
対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。
:LiAlertCircle: 注意事項
- 依存パッケージを忘れず追加