📋

Svelte + SMUI + Felte + yup でマテリアルデザインのフォームを作成してみた。(バリデーション付き)

2022/11/15に公開

Svelte でマテリアルデザインのフォームを作って、Felte + yup でバリデーションを導入してみたログです。
このやり方でやってる記事が見つからなかったので書きます。
(※ドキュメントが少ない状態で実装してるので、マズイ書き方してるかも)

やったこと

  1. Sveltekit で静的サイト構築(GitHub Pages にデプロイ)
  2. Svelte Material UI でマテリアルデザインのフォームを作成
  3. Felte + yup でフォームにバリデーションを導入

書いたコード(わかる人はコード読む方が早いかも)
https://github.com/eteeeeeerminal/svelte-form-sample

フォームのサンプル
https://eteeeeeerminal.github.io/svelte-form-sample/

環境構築

基本的に↓のブログの内容を参考にした。
https://zenn.dev/mktu/articles/29eab3ac780f13

Svelte + SvelteKit で SvelteKit の Static adapter を設定すれば SvelteKit を静的サイトジェネレータとして使える。
ただし、SvelteKit は現状絶賛開発中のフレームワークなので、更新が多く、バージョンによって設定の仕方が変わったりする。
↑の記事では、svelte.config.js に prepender の設定をしていたが、今回実装したバージョンでは最上位の+layout.tsで、export const prerender=true;としている。

Svelte Material UI でフォームを作る

Svelte Material UI(SMUI)
https://sveltematerialui.com/

Svelte でマテリアルデザインができるライブラリ。

本当は Sveltestrap を使いたかったけど、エラーが解決できなかったので、SMUIにした。
Sveltestrap のエラー → https://github.com/bestguy/sveltestrap/issues/463

マテリアルデザインのためのコンポーネントが一通り揃っているが、カスタマイズ性が低い。あと、ドキュメントがあまり親切じゃない。

(今回はやらないが、カスタムテーマも導入できる。が、アクセントカラーや背景色くらいしか変えられない。がんばれば、もっとCSSをいじれるっぽいが……)

導入方法

npm i -D svelte-material-ui@6.1.4
npm i -D @smui/button@6.1.4

みたいに入れてやる。@smuiからはじまるやつは、使うコンポーネントだけ選択して入れる。

次に、コンポーネントにデザインを適用するために、+layout.svelte の、<svelte:head> に↓を書く。

<!-- Material Icons -->
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<!-- Roboto -->
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,600,700"
/>
<!-- Roboto Mono -->
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css?family=Roboto+Mono"
/>
<!-- css -->
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/svelte-material-ui@6.0.0/bare.min.css"
/>

SMUI でマテリアルデザインのフォームをざっくり作る

こんな感じのフォームが作成できる → https://eteeeeeerminal.github.io/svelte-form-sample/smui-only
ソースコード → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/routes/smui-only/%2Bpage.svelte

サンプルフォーム

詳細は、GitHub のコードを読んでもらうとして、以下では使用したコンポーネントをざっくり説明する。

Textfield: テキスト入力

<script lang="ts">
    import Textfield from '@smui/textfield';
    import HelperText from '@smui/textfield/helper-text';
</script>
<Textfield bind:value={textValue} label="プレースホルダー">
    <HelperText slot="helper">細かい説明</HelperText>
</Textfield>

value に bind した textValue に入力されたテキストが入る。テキストが変更されるたびに、適宜 textValue も変わる。Svelte なのでこれだけで reactive なコードになる。フォームに初期値を設定したり、好きなテキストを入れたい場合、textValue に値を代入してやればいい。

labelTextfield 上に灰色で表示される文字列。
HelperTextTextfield にフォーカスした際に下に小さく表示される文字列。

より詳しいデモは → https://sveltematerialui.com/demo/textfield/

なお、Textfield は html の <input> タグを内部に持っているので、<form></form> の中にある状態で、Enter が押されると、submit イベントが発火する。
submit ボタンがなくても、type=button と明示されていない、button タグや Button コンポーネントのイベントが発火する。(html の仕様)

Select: プルダウンボックス

<script lang="ts">
    import Select, { Option } from '@smui/select';
</script>
<Select bind:value={selectSelected} label="選んでね" variant="standard">
    <Option value="" />
    {#each selectOptions as value}
        <Option {value}>{value}</Option>
    {/each}
</Select>

プルダウンで出てくる要素を復数の Option で並べてやって、それを Select で挟む。
value に bind した selectSelected に選択された Option コンポーネントの value が入る。
Option コンポーネントで表示する内容と、Optionvalue に設定する値は別のものでも良い。
これも、初期値を設定したい場合は、selectSelected に代入するだけでいい。

詳しいデモは → https://sveltematerialui.com/demo/select/

Radio: ラジオボタン

<script lang="ts">
    import FormField from '@smui/form-field';
    import Radio from '@smui/radio';
</script>
{#each radioOptions as value}
    <FormField>
        <Radio bind:group={radioSelected} {value} />
        <span slot="label">{value}</span>
    </FormField>
{/each}

RadioFormField で包む必要がある。npm で radio だけでなく、form-field も入れる必要がある。
選択されている Radio コンポーネントの value が、group に bind された、radioSelected に入る。
復数の Radio コンポーネントをグループとして扱いたい場合は、 group に同じ変数を bind すればいい。
これも、radioSelected に代入することで選択されているコンポーネントを変更できる。

詳しいデモ → https://sveltematerialui.com/demo/radio/

Checkbox: チェックボックス

<script lang="ts">
    import FormField from '@smui/form-field';
    import CheckBox from '@smui/checkbox';
</script>
{#each checkBoxOptions as value}
    <FormField>
        <CheckBox bind:group={checkBoxChecked} {value} />
        <span slot="label">{value}</span>
    </FormField>
{/each}

Radio によく似ているが、複数選択できるやつ。
複数選択できるので、checkBoxChecked には配列が代入される。

詳しくは → https://sveltematerialui.com/demo/checkbox/

コンポーネントの切り出し

次に、Felte を導入するための下ごしらえとして、さっき書いた SMUI のコンポーネントたちを切り出しておく。
Felte は独自のフォーム用コンポーネントもサポートしているので、SMUI を包んで、独自のコンポーネントっぽくする。

見た目はまったく同じ → https://eteeeeeerminal.github.io/svelte-form-sample/smui-only-refactored
ソースコード → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/routes/smui-only-refactored/%2Bpage.svelte

切り出したコンポーネントたち → https://github.com/eteeeeeerminal/svelte-form-sample/tree/main/src/lib/smui-only

本当に切り出しただけなのでコードの説明は省略。

Felte の導入

https://felte.dev/

Felte は Svelte, Solid, React で使えるフォーム周りをよしなにまとめてくれるライブラリ。コンポーネントを提供していたり、デザインを提供するライブラリではない。
フォームの submit イベントやバリデーションをやりやすくしてくれるやつ。

npm i -D felte

以下公式サイトより

<script>
  import { createForm } from 'felte';

  const { form } = createForm({
    onSubmit: (values) => {
      // ...
    },
  })
</script>

<form use:form>
  <input type="text" name="email">
  <input type="password" name="password">
  <button type="submit">Sign In</button>
</form>

フォームの submit イベントが発火すると、onSubmitvalues にフォームの入力内容が

{email: "hoge@hoge.com", password: "password"}

みたいなふうに代入される。

Felte を導入した後のコード

Felte 導入後のフォームは → https://eteeeeeerminal.github.io/svelte-form-sample/smui-felte
フォーム全体のコードは → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/routes/smui-felte/%2Bpage.svelte

まずは、フォーム全体のコードから要点だけ抜き出して説明する。

<script lang="ts">
    import { createForm } from 'felte';

    const { form, data } = createForm({
        initialValues,
        onSubmit: (values) => {
            // ここの onSubmit は text input で enter するだけで呼ばれる。
            // ボタンを押したときだけ送信してほしいので今回は無し。
        },
    });
</script>

createForm にはさっき言った onSubmit 以外にも、initialValues でフォームの初期値を設定できる。
この initialValuesonSubmitvalues で取得できるような object とまったく同じ形が望ましい(違うやつを渡しても怒られはしないが……)。
ただし、initialValues はあくまで、Felte の方で得られるデータの初期値であって、独自コンポーネントに設定される初期値ではない。
独自コンポーネントを使う場合は、createForminitialValues と各独自コンポーネントの両方にそれぞれ初期値を渡す必要がある。
(標準のinput要素を使っていればそんなにめんどくさくはならない……)

また、onSubmit を設定しなくても、フォームの内容は逐一 data store から取得できる。こっちは submit が発火しなくても、リアルタイムでフォームの内容が取得できる。

Felte 導入後のコンポーネントは以下のような感じ。相違点として、引数に name を増やしている。
それから、submit イベントには反応せずに、Button を押したときだけ値を取得したいときがあるので、Buttonon:click には別途 onSubmit 関数を定義して渡している。
各コンポーネントの説明は後述。

<form use:form>
    <TextField name={textboxName} textValue={initialValues[textboxName]} />
    <Select name={selectName} selectSelected={initialValues[selectName]} {selectOptions} />
    <Radio name={radioName} radioSelected={initialValues[radioName]} {radioOptions} />
    <CheckBoxList name={checkBoxName} checkBoxChecked={initialValues[checkBoxName]} checkBoxOptions={checkBoxOptions} />

    <Button color="primary" variant="raised" type="button" on:click={onSubmit}>
        <Label>送信</Label>
    </Button>
</form>

独自コンポーネントを Felte に対応させる

今回は、createField を使って対応させていく。
公式ドキュメントは → https://felte.dev/docs/svelte/custom-form-controls#using-createfield
以下各コンポーネントを例にして説明する。

TextField を Felte で使う

コード → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/lib/smui-felte/TextField.svelte

TextField に Felte を導入すると以下のようになる。

<script lang="ts">
    import Textfield from '@smui/textfield';
    import HelperText from '@smui/textfield/helper-text';
    import { createField } from 'felte';

    export let name: string;
    export let textValue: string;

    const { field, onInput, onBlur } = createField(name);

    $: onInput(textValue);
</script>

<div use:field on:blur={onBlur} role="textbox">
    <Textfield bind:value={textValue} label="プレースホルダー">
        <HelperText slot="helper">細かい説明</HelperText>
    </Textfield>
    <p>入力しているテキスト: {textValue}</p>
</div>

やってることは以下の3点

  1. 独自コンポーネントを、<div use:field></div> で包む。
  2. 包んでる div に on:blur={onBlur} を設定する。Felte は各フォームのコンポーネントのフォーカスが外れたときなどに、バリデーションを行う。
    なので、onBlur を設定することで、Felte 側に、コンポーネントが触られたかどうか知らせている。Textfield の場合大丈夫だが、タブでフォーカスできない要素の場合、onBlur がうまく動かない。
  3. 入力を受けて onInput を呼び出す。onInput に渡した値がなんであれ、Felte 側で{[name]: value}の形で受け取れるようになる。

Select を Felte で使う

コード → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/lib/smui-felte/Select.svelte

Select に Felte を導入すると以下のとおり。
Textfield と違うところは、Select がタブでフォーカスされず、onBlur がうまく動かないので、tabindex="0" を指定している。

<script lang="ts">
    import Select, { Option } from '@smui/select';
    import { createField } from 'felte';

    export let name: string;
    export let selectSelected: string;
    export let selectOptions: string[];

    const { field, onInput, onBlur } = createField(name);

    $: onInput(selectSelected);
</script>

<div use:field on:blur={onBlur} role="listbox" tabindex="0">
    <Select bind:value={selectSelected} label="選んでね" variant="standard">
        <Option value="" />
        {#each selectOptions as value}
            <Option {value}>{value}</Option>
        {/each}
    </Select>
    <p>選択された要素: {selectSelected}</p>
</div>

Radio を Felte で使う

コード → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/lib/smui-felte/Radio.svelte

特に説明すべきところはない。

<script lang="ts">
    import Radio from '@smui/radio';
	import FormField from '@smui/form-field';
    import { createField } from 'felte';

    export let name: string;
    export let radioSelected: string;
    export let radioOptions: string[];

    const { field, onInput, onBlur } = createField(name);

    $: onInput(radioSelected);
</script>

<!--
    aria-checked 読み上げ等でラジオボタンの状態を知らせるためにあるやつ。
    ほんとは、状態に応じて true とか false とか入れないといけないけど、めんどくさいので適当に埋める。
-->
<div use:field on:blur={onBlur} role="radio" aria-checked="mixed" tabindex="0">
    {#each radioOptions as value}
        <FormField>
            <Radio bind:group={radioSelected} {value} />
            <span slot="label">{value}</span>
        </FormField>
    {/each}
    <p>選択された要素: {radioSelected}</p>
</div>

CheckBoxList を Felte で使う

コード

CheckBox に Felte を導入すると以下のとおり。

CheckBoxList.svelte
<script lang="ts">
    import { createField } from 'felte';
    import CheckBox from './CheckBox.svelte';

    export let name: string;
    export let checkBoxChecked: string[];
    export let checkBoxOptions: string[];

    const { field, onBlur } = createField(name);
</script>

<!--
    これ自体は checkbox を集めたもので、checkbox ではないので、aria-checked は適当に
-->
<div use:field on:blur={onBlur} role="checkbox" aria-checked={undefined} tabindex="0">
    {#each checkBoxOptions as value, i }
        <CheckBox
            name={name+'.'+i}
            checkBoxChecked={checkBoxChecked[i]}
            checkBoxOption={value}
        />
        <br/>
    {/each}
</div>
CheckBox.svelte
<script lang="ts">
    import FormField from '@smui/form-field';
    import CheckBox from '@smui/checkbox';
    import { createField } from 'felte';

    export let name: string;
    export let checkBoxChecked: string;
    export let checkBoxOption: string;

    const { field, onInput } = createField(name);

    let checked = checkBoxChecked ? [checkBoxChecked] : [];
    $: onInput(checked);
</script>

<div use:field role="checkbox" aria-checked={Boolean(checked)} tabindex="0">
    <FormField>
        <CheckBox bind:group={checked} value={checkBoxOption} />
        <span slot="label">{checkBoxOption}</span>
    </FormField>
    <p>選択された要素: {checked}</p>
</div>

要素が一つだけの CheckBox をコンポーネントとして切り出し、それを CheckBoxList で1つにまとめている。
Felte でうまいこと配列の入力を扱おうとしているうちに、こんな形になってしまった。
Felte には、hoge.0, hoge.1, hoge.2 みたいな name のフォームが並んでいると、hoge[] として、まとめて値を取得できるという機能があってそれを使っている。
また、validation メッセージを、この CheckBox 全体で、まとめて受け取りたいので、個々の CheckBox には onBlur を設定せずに、CheckBoxList の方で、onBlur を設定している。
validation も、個々の CheckBoxhoge.0, hoge.1, hoge.2 ではなく、hoge[] に対して行う。

yup による validation の導入

https://github.com/jquense/yup

yup はテキストが、email のフォーマットを満たしてるかどうかとかをチェックしてくれるパッケージ。
Felte は公式で yup をサポートしている → https://felte.dev/docs/svelte/validators#using-yup

npm i -D @felte/validator-yup yup

以下公式サイトより

<script lang="ts">
import { validator } from '@felte/validator-yup';
import * as yup from 'yup';

const schema = yup.object({
  email: yup.string().email().required(),
  password: yup.string().required(),
});

const { form } = createForm({
  // ...
  extend: validator({ schema }), // OR `extend: [validator({ schema })],`
  // ...
});
</script>

yup.object で validation の内容を定義する。引数に渡す object は、createForm で取得できる object と同じ形にする。
上の例は、nameemail のフォームで、文字列かつ email の書式かつ空欄でないという条件で validation している。

yup を導入した後のコード

フォーム → https://eteeeeeerminal.github.io/svelte-form-sample/smui-felte-yup
コード → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/routes/smui-felte-yup/%2Bpage.svelte

間違った値を入力すると↓のような感じに
バリデーション付きフォーム

以下 validation に関係のある部分だけ抜粋

<script lang="ts">
    import { createForm } from 'felte';
    import { validator } from '@felte/validator-yup';
    import * as yup from 'yup';

    const schema = yup.object({
        [textboxName]: yup.string().required(),
        [selectName]: yup.string().required(),
        [radioName]: yup.string().equals(['Svelte']).required(),
        [checkBoxName]: yup.array().test("", (v) => {
            if (v == null) return false;
            console.log(v);
            v = v.filter((v) => Boolean(v));
            return v.length >= 1;
        }).required()
    })

    const { form, data, isValid } = createForm({
        initialValues,
        onSubmit: (values) => {
            // ここの onSubmit は text input で enter するだけで呼ばれる。
            // ボタンを押したときだけ送信してほしいので今回は無し。
        },
        extend: [validator( {schema} )]
    })

    let submitted: object | null = null;
    const onSubmit = () => {
        if ($isValid) {
            submitted = $data
        } else {
            submitted = {
                message: "回答が間違っています"
            }
        }
    };
</script>

validation の詳細は、yup のドキュメントを参照。test メソッドを使うと、かなり柔軟な validation ができる。

他に変更点として、createForm の返り値から isValid を取り出している。
isValid store は、フォームの値が条件を満たしているかどうかを、リアルタイムに教えてくれる。

Validation メッセージの表示

前述のコードだけだと、条件で入力を弾くことはできても、何が悪いのかユーザーにフィードバックできていない。
最後に、validation メッセージが出るようにする。

Felte が Reporters という形で、いくつかのライブラリに対応している → https://felte.dev/docs/svelte/reporters

今回は一番シンプルな reporter-svelte を使う。

npm i -D @felte/reporter-svelte

以下公式より

<script>
  import { reporter, ValidationMessage } from '@felte/reporter-svelte';
  import { createForm } from 'felte';

  const { form } = createForm({
      // ...
      extend: reporter,
      // ...
    },
  })
</script>

<form use:form>
  <input id="email" type="text" name="email">
  <ValidationMessage for="email" let:messages={message}>
    <!-- We assume a single string will be passed as a validation message -->
    <!-- This can be an array of strings depending on your validation strategy -->
    <span>{message}</span>
    <span slot="placeholder">Please type a valid email.</span>
  </ValidationMessage>
  <input type="password" name="password">
  <ValidationMessage for="password" let:messages={message}>
    <span>{message || ''}</span>
  </ValidationMessage>
  <input type="submit" value="Sign in">
</form>

createFormextend に reporter-svelte を渡してやる。
すると、validation でエラーが出たときに、ValidationMessage コンポーネントにメッセージが送られる。

今回作ったフォームへの実装では、デザインを調整するために、ValidationMessageコンポーネントを外出ししてある。
コード → https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/lib/smui-felte-valid-message/ValidationMessage.svelte

<script lang="ts">
    import { ValidationMessage } from '@felte/reporter-svelte';

    export let name: string;
</script>

<ValidationMessage for={name} let:messages>
    <ul class="error-list">
        {#if Array.isArray(messages)}
            {#each messages as message}
                {#if message != null}
                    <li>{message}</li>
                {/if}
            {/each}
        {:else if messages != null}
            <li>{messages}</li>
        {/if}
    </ul>
</ValidationMessage>

<style>
    .error-list {
        list-style: '* ';
        color: red;
        font-weight: bold;
    }
</style>

ここで、ValidationMessage から上がってくる、messagesは、null のときもあるし、string[] のときも、string のときもある。
あとはこのコンポーネントを、さっき作った Textfield などのすぐ下に組み込んでやると、validation メッセージの表示は完成。

以上で、Svelte Material UI + Felte + yup でバリデーション付きのフォームの作成は完了。

さいごに

ここまで読んでくださってありがとうございました。
気づけば長くなってしまっていた……
自分で書いていて、あまりいい方法ではないのでは?って何回も思ったんですが、他にやってる人いないのでとりあえず記事に残してみました。
もっといい方法とか、こうしたらいいとかありましたら教えていただけると助かります。

Discussion