@k8ordo/form

パターン

よくある組み立て方をまとめます。扱うのは、複数ステップのフォーム、@k8ordo/state と組む GET フォーム、@k8ordo/ui のコンポーネントとの組み合わせ、そしてこのパッケージがまだできないことです。

複数ステップのフォーム

すべてのステップを描いたまま、今のステップ以外を隠します。値は DOM にあるので、ステップを行き来しても何も失われず、送信は最後に 1 回で済みます。

  • ステップを隠すのは、ハイドレーションが終わってからにします。サーバーが返す HTML の時点で隠すと、JavaScript の無い人は後のステップにたどり着けません。隠さずにおけば、JavaScript が無いときは 1 枚の長いフォームになり、1 回のリクエストで送信されます。それが正しい動きです。
  • 次に進む前に、今のステップの中の入力要素だけを checkValidity() で確かめます。まだ入力していない後のステップまで含めると、必ず失敗するからです。
  • useForm がメッセージを出すのは欄を離れたときなので、触っていない欄は checkValidity() が失敗しても何も表示しません。最初の無効な欄にフォーカスを移すと、その欄を離れたときにメッセージが出ます。
  • 送信ボタンは、ハイドレーションが終わったあとは最後のステップでだけ描きます。テキスト欄で Enter を押すと、ブラウザはフォームの最初の送信ボタンを押したものとして扱い、そのボタンが隠れていても送信することがあります。前のステップから、ステップの確認を飛ばしてフォーム全体が送られてしまいます。
  • 送信に失敗すると、useFormstate.errors の先頭の欄にフォーカスを移しますが、隠れたステップの欄はフォーカスを受け取れません。新しい結果が届いたら、描画の中で先頭のエラーを含むステップに戻します。
// src/routes/join/_parts/join-schema.ts
import * as z from 'zod';

export const joinSchema = z.object({
  email: z.email('Enter your email address'),
  password: z
    .string()
    .min(8, 'Use at least 8 characters')
    .meta({ input: 'password' }),
  name: z.string().min(1, 'Enter your name'),
  city: z.string().max(80, 'Use 80 characters or fewer'),
});
// src/routes/join/_parts/join-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState, useRef, useState, useSyncExternalStore } from 'react';

import { join } from './actions';

const ACCOUNT_FIELDS: readonly string[] = ['email', 'password'];

const subscribe = () => () => {};

type Props = {
  fields: FormFields<'email' | 'password' | 'name' | 'city', never>;
};

export function JoinForm({ fields }: Props) {
  const [state, formAction] = useActionState(join, {});
  const form = useForm(fields, state);
  const hydrated = useSyncExternalStore(
    subscribe,
    () => true,
    () => false,
  );
  const [step, setStep] = useState(0);
  const [answered, setAnswered] = useState(state);
  const account = useRef<HTMLFieldSetElement>(null);

  if (answered !== state) {
    setAnswered(state);
    const first = Object.keys(state.errors ?? {})[0];
    if (first !== undefined) {
      setStep(ACCOUNT_FIELDS.includes(first) ? 0 : 1);
    }
  }

  const next = () => {
    const invalid = [
      ...(account.current?.querySelectorAll('input') ?? []),
    ].find((control) => !control.checkValidity());
    if (invalid === undefined) {
      setStep(1);
    } else {
      invalid.focus();
    }
  };

  const email = form.field('email');
  const password = form.field('password');
  const name = form.field('name');
  const city = form.field('city');

  return (
    <form {...form.props} action={formAction}>
      <fieldset hidden={hydrated && step !== 0} ref={account}>
        <legend>Account</legend>
        <input {...email.input} aria-label="Email" />
        {email.error !== undefined && <p>{email.error}</p>}
        <input {...password.input} aria-label="Password" />
        {password.error !== undefined && <p>{password.error}</p>}
        {hydrated && (
          <button onClick={next} type="button">
            Next
          </button>
        )}
      </fieldset>

      <fieldset hidden={hydrated && step !== 1}>
        <legend>Profile</legend>
        <input {...name.input} aria-label="Name" />
        {name.error !== undefined && <p>{name.error}</p>}
        <input {...city.input} aria-label="City" />
        {city.error !== undefined && <p>{city.error}</p>}
        {hydrated && (
          <button
            onClick={() => {
              setStep(0);
            }}
            type="button"
          >
            Back
          </button>
        )}
        {(!hydrated || step === 1) && <button type="submit">Join</button>}
      </fieldset>
    </form>
  );
}

@k8ordo/state と組む GET フォーム

検索や絞り込みのフォームは GET フォームです。@k8ordo/state の definePageStateurl スキーマを formFields に渡せば、同じスキーマがフォームの制約と URL の状態の両方の出所になります。

// src/routes/products/_parts/list-state.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const listState = definePageState('product-list', {
  url: z.object({
    q: z._default(z.string(), ''),
    min: z._default(z.coerce.number().check(z.int(), z.gte(0)), 0),
  }),
});

このスキーマは useAppState がブラウザで使うので、クライアントのバンドルに入ります。zod/mini を選ぶのはこういうときです。

// src/routes/products/page.tsx
import { formFields } from '@k8ordo/form/server';

import { FilterForm } from './_parts/filter-form';
import { listState } from './_parts/list-state';

const filterFields = formFields(listState.url);

export default function ProductsPage() {
  return <FilterForm fields={filterFields} />;
}

受け取る Server Action が無いので、state を省いて useForm(fields) と呼びます。送信すると値は URL の search params に着地し、useAppState が同じスキーマで読み返します。欄の初期値には、今の状態を defaultValue で渡します。

// src/routes/products/_parts/filter-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useAppState } from '@k8ordo/state';

import { listState } from './list-state';

type Props = {
  fields: FormFields<'q' | 'min', never>;
};

export function FilterForm({ fields }: Props) {
  const form = useForm(fields);
  const q = form.field('q');
  const min = form.field('min');
  const [current] = useAppState(listState);

  return (
    <form {...form.props} method="get">
      <input {...q.input} aria-label="Keyword" defaultValue={current.q} />
      <input {...min.input} aria-label="Minimum" defaultValue={current.min} />
      {min.error !== undefined && <p>{min.error}</p>}
      <button type="submit">Filter</button>
    </form>
  );
}

@k8ordo/router の下では、同じ pathname への GET フォームの送信はルーターが intercept し、ページの読み込みではなく状態の更新として扱います。JavaScript が無くても、同じフォームは同じ URL に着きます。

@k8ordo/ui と組み合わせる

@k8ordo/form@k8ordo/ui に依存しません。属性は入力要素に、エラーは FormControl に渡します。FormControlidaria-* の結び付けを自分で作るので、渡すのは labelerrorTextinvalidrequired だけです。

// src/routes/settings/_parts/account-schema.ts
import * as z from 'zod';

export const accountSchema = z.object({
  displayName: z
    .string()
    .min(1, 'Enter a display name')
    .max(50, 'Use 50 characters or fewer'),
  website: z.url('Enter a URL'),
  plan: z.enum(['personal', 'business'], 'Choose a plan'),
  bio: z.string().max(1000, 'Use 1000 characters or fewer'),
  password: z
    .string()
    .min(8, 'Use at least 8 characters')
    .meta({ input: 'password' }),
});
// src/routes/settings/_parts/account-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import {
  Button,
  FormControl,
  PasswordInput,
  Select,
  TextField,
  Textarea,
} from '@k8ordo/ui';
import { useActionState } from 'react';

import { saveAccount } from './actions';

const PLANS = [
  { value: 'personal', label: 'Personal' },
  { value: 'business', label: 'Business' },
];

type Props = {
  fields: FormFields<
    'displayName' | 'website' | 'plan' | 'bio' | 'password',
    never
  >;
};

export function AccountForm({ fields }: Props) {
  const [state, formAction] = useActionState(saveAccount, {});
  const form = useForm(fields, state);
  const displayName = form.field('displayName');
  const website = form.field('website');
  const plan = form.field('plan');
  const bio = form.field('bio');
  const password = form.field('password');
  const { type: _displayNameType, ...displayNameInput } = displayName.input;
  const { type: _websiteType, ...websiteInput } = website.input;
  const { type: _bioType, ...bioInput } = bio.input;
  const { type: _passwordType, ...passwordInput } = password.input;

  return (
    <form {...form.props} action={formAction}>
      <FormControl
        errorText={displayName.error}
        invalid={displayName.invalid}
        label="Display name"
        renderInput={(props) => <TextField {...props} {...displayNameInput} />}
        required={displayName.required}
      />
      <FormControl
        errorText={website.error}
        invalid={website.invalid}
        label="Website"
        renderInput={(props) => (
          <TextField {...props} {...websiteInput} type="url" />
        )}
        required={website.required}
      />
      <FormControl
        errorText={plan.error}
        invalid={plan.invalid}
        label="Plan"
        renderInput={(props) => (
          <Select {...props} {...plan.input} options={PLANS} />
        )}
        required={plan.required}
      />
      <FormControl
        errorText={bio.error}
        invalid={bio.invalid}
        label="Bio"
        renderInput={(props) => <Textarea {...props} {...bioInput} />}
        required={bio.required}
      />
      <FormControl
        errorText={password.error}
        invalid={password.invalid}
        label="Current password"
        renderInput={(props) => (
          <PasswordInput {...props} {...passwordInput} />
        )}
        required={password.required}
      />
      <Button type="submit">Save</Button>
    </form>
  );
}
  • TextField が受け付ける typetextemailtelurlsearch だけで、input.typestring なので、そのまま広げると型エラーになります。type を外して残りを広げ、type は自分で書きます。
  • PasswordInput は表示を切り替えるために type を自分で書き換えます。導かれた type を広げると切り替えが効かなくなり、しかも型エラーにはならないので、必ず外します。<textarea> にも type 属性は無いので、Textarea でも外します。
  • z.enum() から導かれた inputtype を持たないので、Select にはそのまま広げられます。選択肢は options で渡します。
  • NumberField は値を React の state に持ち、type="text" の入力を描いて、触られていなくても 0 を送ります。空の数値欄を未入力として扱う約束も、導かれた required も働かないので、数値には素の <input> に属性を広げます。
// src/routes/settings/_parts/number-control.tsx
'use client';

import type { FieldView } from '@k8ordo/form';
import { FormControl } from '@k8ordo/ui';

type Props = {
  field: FieldView;
  label: string;
};

export function NumberControl({ field, label }: Props) {
  return (
    <FormControl
      errorText={field.error}
      invalid={field.invalid}
      label={label}
      renderInput={({ invalid, ...props }) => (
        <input {...props} {...field.input} aria-invalid={invalid} />
      )}
      required={field.required}
    />
  );
}

この節で扱ったのは TextFieldPasswordInputTextareaSelectNumberField だけです。チェックボックス、ラジオボタン、ファイルは、フィールドのページにある素の要素で描きます。たとえば Radiorequired を中の入力要素に渡しません。

チェックボックス・ラジオボタン・ファイルの描き方(フィールド)

まだできないこと

現在のバージョンでは、次のことはできません。

  • 1 つの欄で複数のファイルを受けること。z.array(z.file())multiple の付いた 1 つの欄ではなく 1 ファイルずつの繰り返し行になり、parseForm は 1 つの名前に 1 つのファイルを期待します。
  • .transform() の後ろにある制約や、出力を JSON Schema で表せない .pipe() の制約を導くこと。スキーマが送信される値を語らなくなるので、欄は素のテキスト欄になり dropped に載ります。検証はすべてサーバーで行われます。
  • 文字列を拒む z.custom() を導出時に拒否すること。中身を読めないので、テキスト欄として dropped に載り、サーバーで失敗します。
  • 1 つの欄、ネストしたオブジェクト、繰り返し行に付けた .refine() / .superRefine()dropped で報告すること。報告されるのはルートのオブジェクトのチェックの件数だけで、それ以外はクライアントに何も知らせずサーバーでだけ走ります。
  • 繰り返し行の中の欄を対象にしたルール。ルールの欄名はスキーマのパスで型付けされ、行の中の欄はそこに含まれません。
  • 入力のマスク。DOM を正とする設計でも el.value を書き換えれば実現できますが、キャレットの管理はフォームの結線とは別の問題です。