Svelte + SMUI + Felte + yup でマテリアルデザインのフォームを作成してみた。(バリデーション付き)
Svelte でマテリアルデザインのフォームを作って、Felte + yup でバリデーションを導入してみたログです。
このやり方でやってる記事が見つからなかったので書きます。
(※ドキュメントが少ない状態で実装してるので、マズイ書き方してるかも)
やったこと
- Sveltekit で静的サイト構築(GitHub Pages にデプロイ)
- Svelte Material UI でマテリアルデザインのフォームを作成
- Felte + yup でフォームにバリデーションを導入
書いたコード(わかる人はコード読む方が早いかも)
フォームのサンプル
環境構築
基本的に↓のブログの内容を参考にした。
Svelte + SvelteKit で SvelteKit の Static adapter を設定すれば SvelteKit を静的サイトジェネレータとして使える。
ただし、SvelteKit は現状絶賛開発中のフレームワークなので、更新が多く、バージョンによって設定の仕方が変わったりする。
↑の記事では、svelte.config.js
に prepender の設定をしていたが、今回実装したバージョンでは最上位の+layout.ts
で、export const prerender=true;
としている。
Svelte Material UI でフォームを作る
Svelte Material UI(SMUI)
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
に値を代入してやればいい。
label
は Textfield
上に灰色で表示される文字列。
HelperText
は Textfield
にフォーカスした際に下に小さく表示される文字列。
より詳しいデモは → 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
コンポーネントで表示する内容と、Option
の value
に設定する値は別のものでも良い。
これも、初期値を設定したい場合は、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}
Radio
は FormField
で包む必要がある。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 の導入
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 イベントが発火すると、onSubmit
の values
にフォームの入力内容が
{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
でフォームの初期値を設定できる。
この initialValues
は onSubmit
の values
で取得できるような object とまったく同じ形が望ましい(違うやつを渡しても怒られはしないが……)。
ただし、initialValues
はあくまで、Felte の方で得られるデータの初期値であって、独自コンポーネントに設定される初期値ではない。
独自コンポーネントを使う場合は、createForm
の initialValues
と各独自コンポーネントの両方にそれぞれ初期値を渡す必要がある。
(標準のinput要素を使っていればそんなにめんどくさくはならない……)
また、onSubmit
を設定しなくても、フォームの内容は逐一 data
store から取得できる。こっちは submit が発火しなくても、リアルタイムでフォームの内容が取得できる。
Felte 導入後のコンポーネントは以下のような感じ。相違点として、引数に name
を増やしている。
それから、submit イベントには反応せずに、Button を押したときだけ値を取得したいときがあるので、Button
の on: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点
- 独自コンポーネントを、
<div use:field></div>
で包む。 - 包んでる div に
on:blur={onBlur}
を設定する。Felte は各フォームのコンポーネントのフォーカスが外れたときなどに、バリデーションを行う。
なので、onBlur
を設定することで、Felte 側に、コンポーネントが触られたかどうか知らせている。Textfield
の場合大丈夫だが、タブでフォーカスできない要素の場合、onBlur
がうまく動かない。 - 入力を受けて
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 で使う
コード
- CheckBoxList: https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/lib/smui-felte/CheckBoxList.svelte
- CheckBox: https://github.com/eteeeeeerminal/svelte-form-sample/blob/main/src/lib/smui-felte/CheckBox.svelte
CheckBox
に Felte を導入すると以下のとおり。
<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>
<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 も、個々の CheckBox
の hoge.0, hoge.1, hoge.2
ではなく、hoge[]
に対して行う。
yup による validation の導入
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 と同じ形にする。
上の例は、name
が email
のフォームで、文字列かつ 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>
createForm
の extend
に 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