Formについて学ぶ(少しだけアクセシビリティ)
よく考えたらFormのこと何もしらなかったので
注意
想像で書いてる部分もあるので、間違ってたら直します
見つけたら教えていただければと思います
検証用環境
以下のリポジトリ、環境を使います
https://github.com/stopod/rr-chakra-conform-sample
https://qiita.com/stopod/items/42ac91a67037e849e97f
※HTMLの基礎みたいなことなのでここはなんでもいいです
やっていくこと
- これを読む
https://developer.mozilla.org/ja/docs/Learn/Forms
適宜以下も読みます
https://developer.mozilla.org/ja/docs/Learn/Accessibility - 記載されてるコードを真似る
- ChakraUIで代替してみる
やらないこと
- css関連(例外はある)
- わからなさすぎることの深堀り
本題(Formについて)
ここからはまず以下を読みます
しばらくは下記の章ごとに進めます
https://developer.mozilla.org/ja/docs/Learn/Forms
初めてのフォーム
ひとまずサンプルのフォームを書きました
import { Form } from "react-router";
import { Route } from "./+types/sample-form";
import { Input, Stack, Textarea, Button } from "@chakra-ui/react";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
console.log(formData);
return formData;
}
export default function FormTry() {
return (
<Form action="/sample-form" method="post">
<Stack gap={4} m={8}>
<label htmlFor="name">Name:</label>
<Input type="text" id="name" name="user_name" />
<label htmlFor="mail">Email:</label>
<Input type="email" id="mail" name="user_email" />
<label htmlFor="msg">Message:</label>
<Textarea id="msg" name="user_message" />
</Stack>
<Button type="submit">メッセージを送信</Button>
</Form>
);
}
適宜コンポーネントはreact-routerのだったりChakraのだったりに置き換えていますが、似たような挙動(だと思っている)だと思っています
以下がまとめです
-
<Form>
のaction
に設定されたURLへデータが送信される
(このURLにあるスクリプトがデータを受け取る) -
name
を設定しないと送信されない -
<labl>
のfor
(reactならhtmlFor
)と<input>
のid
を同一にしておくと、ラベルをクリックしてもinput
がアクティブになる
<label>
の子要素にしても同様のことが出来るが、id
は紐づけたほうが良い
https://developer.mozilla.org/ja/docs/Web/HTML/Attributes/for
フォームの構築方法
(ここから<Form>
内のコードを書くときは<Form>
タグ以下を書いていきます)
<Stack gap={8}>
<Box>
<Heading>Payment form</Heading>
<Text>
Required fields are followed by
<Mark aria-label="required" variant={"plain"} fontWeight={"bold"}>
*
</Mark>
.
</Text>
</Box>
<section>
<Heading>Contact information</Heading>
<fieldset>
<legend>Title</legend>
<RadioGroup name="title">
<HStack gap={4}>
<Radio id="title_1" value="A">
Ace
</Radio>
<Radio id="title_2" value="K">
King
</Radio>
<Radio id="title_3" value="Q">
Queen
</Radio>
</HStack>
</RadioGroup>
</fieldset>
<Box>
<label htmlFor="name">
<Text>
Name:
<Mark aria-label="required" variant={"plain"} fontWeight={"bold"}>
*
</Mark>
</Text>
</label>
<Input type="text" id="name" name="username" required />
</Box>
<Box>
<label htmlFor="mail">
<Text>
Email:
<Mark aria-label="required" variant={"plain"} fontWeight={"bold"}>
*
</Mark>
</Text>
</label>
<Input type="email" id="mail" name="usermail" required />
</Box>
<Box>
<label htmlFor="pwd">
<Text>
Password:
<Mark aria-label="required" variant={"plain"} fontWeight={"bold"}>
*
</Mark>
</Text>
</label>
<Input type="password" id="pwd" name="password" required />
</Box>
</section>
<section>
<Heading>Payment information</Heading>
<Box>
<label htmlFor="card">
<Text>Card type: </Text>
</label>
<NativeSelectRoot>
<NativeSelectField id="card" name="usercard">
<option value="visa">Visa</option>
<option value="mc">Mastercard</option>
<option value="amex">American Express</option>
</NativeSelectField>
</NativeSelectRoot>
</Box>
<Box>
<label htmlFor="number">
<Text>
Card number:
<Mark aria-label="required" variant={"plain"} fontWeight={"bold"}>
*
</Mark>
</Text>
</label>
<Input type="tel" id="number" name="cardnumber" required />
</Box>
<Box>
<label htmlFor="expiration">
<Text>
Expiration date:
<Mark aria-label="required" variant={"plain"} fontWeight={"bold"}>
*
</Mark>
</Text>
</label>
<Input
type="text"
id="expiration"
name="expiration"
required
placeholder="MM/YY"
pattern="^(0[1-9]|1[0-2])\/([0-9]{2})$"
/>
</Box>
</section>
<section>
<Button type="submit">Validate the payment</Button>
</section>
</Stack>
前の章と同様に適宜Chakraのコンポーネントを使用しています
<Field
, <Fieldset>
がChakraにも用意されてますがこの段階ではいったん無視します
-
<fieldset>
で<Form>
の要素をグループ化することができる、
この際のラベル的な役割を果たしているのが<legend>
https://developer.mozilla.org/ja/docs/Learn/Forms/How_to_structure_a_web_form#fieldset_%E3%81%8A%E3%82%88%E3%81%B3_legend_%E8%A6%81%E7%B4%A0
でもそもそも<fieldset>
が複数出てくるようなページにするべきではないと読み取った -
aria-label
をつけることで読み上げ時の音を決めてる(*となっているが、これを「必須」と読み上げさせる的な感じの理解) -
<section>
は区切りの意味、Chakraの<Vstack>
で代用しようとしたけどブラウザの開発者ツールで見たら別に<section>
とかになってるわけじゃなかった(それはそう、あくまでChakraはUIコンポーネントなので)
https://developer.mozilla.org/ja/docs/Web/HTML/Element/section -
<section>
には見出しを付けるのが好ましいらしい
Chakraには<Heading>
が用意されてるので、これが見出し(<h2>
とか)の代わりになる
-
<label>
の中にコンポーネントを入れることもできる
https://developer.mozilla.org/ja/docs/Learn/Forms/How_to_structure_a_web_form#label_%E8%A6%81%E7%B4%A0
基本的なネイティブフォームコントロール
-
readonly
は送信される、disabled
は送信されない
https://developer.mozilla.org/ja/docs/Learn/Forms/Basic_native_form_controls#%E3%83%86%E3%82%AD%E3%82%B9%E3%83%88%E5%85%A5%E5%8A%9B%E3%83%95%E3%82%A3%E3%83%BC%E3%83%AB%E3%83%89 -
<fieldset>
を多用しないほうが良いのかもしれないと前述したが、
typeがradio
,checkbox
はまとめるために使う必要がある
(こいつらはそれぞれが<input>
要素なので、1つの塊にする必要があるイメージを持った)
https://developer.mozilla.org/ja/docs/Learn/Forms/Basic_native_form_controls#%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%E5%8F%AF%E8%83%BD%E9%A0%85%E7%9B%AE_%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%E3%83%9C%E3%83%83%E3%82%AF%E3%82%B9%E3%81%A8%E3%83%A9%E3%82%B8%E3%82%AA%E3%83%9C%E3%82%BF%E3%83%B3
↓こんな感じでラジオボタンは複数のinputをくっつけて1つの入力要素としてる
HTML5 の入力型
<Stack gap={4} m={8}>
<label htmlFor="email">Email:</label>
<Input type="email" id="email" name="email" />
<label htmlFor="search">Search:</label>
<Input type="search" id="search" name="search" />
<label htmlFor="tel">Tel:</label>
<Input type="tel" id="tel" name="tel" />
<label htmlFor="url">Url:</label>
<Input type="url" id="url" name="url" />
<label htmlFor="datetime-local">Datetime-local:</label>
<Input type="datetime-local" name="datetime" id="datetime" />
<label htmlFor="month">Month:</label>
<Input type="month" name="month" id="month" />
<label htmlFor="time">Time:</label>
<Input type="time" name="time" id="time" />
<label htmlFor="week">Week:</label>
<Input type="week" name="week" id="week" />
<label htmlFor="color">Color:</label>
<Input type="color" name="color" id="color" />
<Button type="submit">Enter</Button>
</Stack>
<input>
にはたくさんのtype
があるって話ですね
送信されるデータは以下です
weekは1週間目を基準としてそこからひたすらに加算していく感じみたいですね、
いつ使うんでしょう?でも標準に存在しているということは一定の需要はあるということなんでしょうね
tel
はテンキーが出てくるだけで特に入力制御はないみたいですね、スマホなどでは入力の仮想キーボードが数値だけとかになるっぽいです キーボード入力してるとちょっとよくわからないですね
日付関連は意外と細かく用意されています、週まであるのは驚きです
見た目さえ気にしなければ標準のものを使うのが良い気持ちです
その他のフォームコントロール
<Stack gap={4} m={8}>
{/* テキストエリア */}
<label htmlFor="textarea">Textarea:</label>
<Textarea id="textarea" name="textarea" />
{/* いつものセレクトボックス */}
<label htmlFor="simple">SimpleSelect:</label>
<NativeSelectRoot>
<NativeSelectField id="simple" name="simple" defaultValue={"さくらんぼ"}>
<option>バナナ</option>
<option>さくらんぼ</option>
<option>レモン</option>
</NativeSelectField>
</NativeSelectRoot>
{/* グループ化されたセレクトボックス */}
<label htmlFor="groups">GroupSelect:</label>
<NativeSelectRoot>
<NativeSelectField id="groups" name="groups" defaultValue={"さくらんぼ"}>
<optgroup label="果物">
<option>バナナ</option>
<option>さくらんぼ</option>
<option>レモン</option>
</optgroup>
<optgroup label="野菜">
<option>人参</option>
<option>茄子</option>
<option>馬鈴薯</option>
</optgroup>
</NativeSelectField>
</NativeSelectRoot>
{/* 複数選択可能なセレクトボックス */}
<label htmlFor="multi">MultiSelect:</label>
<NativeSelectRoot>
<NativeSelectField id="multi" name="multi" multiple>
<optgroup label="果物">
<option>バナナ</option>
<option>さくらんぼ</option>
<option>レモン</option>
</optgroup>
<optgroup label="野菜">
<option>人参</option>
<option>茄子</option>
<option>馬鈴薯</option>
</optgroup>
</NativeSelectField>
</NativeSelectRoot>
{/* 自動補完されるセレクトボックス、入力が可能 */}
<label htmlFor="myFruit">好きな果物は何ですか?</label>
<Input type="text" name="myFruit" id="myFruit" list="mySuggestion" />
<datalist id="mySuggestion">
<option>リンゴ</option>
<option>バナナ</option>
<option>ブラックベリー</option>
<option>ブルーベリー</option>
<option>レモン</option>
<option>ライチ</option>
<option>桃</option>
<option>梨</option>
</datalist>
{/* 自動補完されるセレクトボックス、古いブラウザ対応版 */}
<label htmlFor="myFruit2">好きな果物は何ですか?(代替手段付き)</label>
<Input type="text" id="myFruit2" name="fruit" list="fruitList" />
<datalist id="fruitList">
<label htmlFor="suggestion">または果物を選択</label>
<NativeSelectRoot>
<NativeSelectField id="suggestion" name="altFruit">
<option>リンゴ</option>
<option>バナナ</option>
<option>ブラックベリー</option>
<option>ブルーベリー</option>
<option>レモン</option>
<option>ライチ</option>
<option>桃</option>
<option>梨</option>
</NativeSelectField>
</NativeSelectRoot>
</datalist>
<label htmlFor="meter">meter</label>
<meter min="0" max="100" value="75" low={33} high={66} optimum={0} id="meter">
75
</meter>
<label htmlFor="progress">progress</label>
<progress max="100" value="75" id="progress">
75/100
</progress>
<Button type="submit">Submit</Button>
</Stack>
テキストエリア
素だとchildrenの位置にvalue
を持つけどChakraだと違うみたいです
(こっちの方がわざわざ閉じタグ付けなくていいので便利)
あとcols
、rows
で縦幅横幅を行、文字数で管理できるっぽいです
ただ<Stack>
配下にてcols
を設定しても<Stack>
のfullサイズになってるような気がします
(<Stack>
から外れた場所で設定したらcols
の設定通りに幅が変わった)
wrap
の設定で折り返しの方法を指定できるらしい(改行コードを入れるかどうかとか)
セレクトボックス
標準にグループ化できるタグがあったんですね...
この例ではvalue
を指定していないのでchildrenの値が送信されますが、value
を設定してたらvalue
の値が送信されます (valueを明示的に設定しなかったらchildrenがvalueの値される、が正しい?)
複数選択可能なセレクトボックス
<select>
タグにmultiple
を付けると出来ます、見える量はsize
にて変更可能
multiple
を付けると見え方が若干変わって、クリックしたら一覧がばって出てくるわけじゃないみたいです
ChakraのNativeSelectを使うとsize
が大きさ変更で枠を埋められてるので実質的に使用不可(使えるけど見た目的にアレ)
Chakraでこれを使うならNativeSelectじゃない方のSelectを使う必要があります
自動補完のボックス
一番意味がわからないです、Edgeだと最初はそれっぽく表示されましたが、リストにない文字列を一度入力して送信すると、それ以降リストが表示されなくなりました どういうこと
Chromeだと表示されました、でもちょっと見た目がアレ
見た目に関してはCSS関係を何もしてないだとも思いますが、おとなしくreact-selectとか違うライブラリを使った方が良い気持ちになりました、ただ標準で<datalist>
なるタグがあることに気づけたのは良かったです
メーターとプログレスバーは割愛します、プログレスバーはChakraにもありました
この辺は適宜思い出して使えば良いと思います
ウェブフォームへのスタイル設定
今回は学びたいことから逸れるので省きます
フォームへの高度なスタイル設定
今回は学びたいことから逸れるので省きます
UI疑似クラス
今回は学びたいことから逸れるので省きます
省きますが読んだ感想としてはreactとかChakraとかconformとかって外部ライブラリって便利だなと
これを読んだあとだと一層ありがたみを感じますね
クライアント側のフォーム検証
フォーム検証についてをまとめます
ここではクライアント側の検証について述べられてます
※組込みフォーム検証の下に置いたスクリーンショットは「組み込みフォーム検証の利用」から引っ張りました
クライアント側の検証はユーザーのためにやっておいてね、サーバー側の検証は自分たちのためにやりなねってことですね(超要約)
組み込みフォーム検証
ひとまず組み込みフォーム検証を種類ごとに見ていきます
required
必須項目にするやつです
<Stack gap={4} m={8}>
<label htmlFor="choose">banana と cherry のどちらが好き?</label>
<Input id="choose" name="i-like" required />
<Button type="submit">Submit</Button>
</Stack>
input:invalid {
border: 2px dashed red;
}
input:valid {
border: 2px solid black;
}
こんな感じで試しました
required
を付与すると、見た目に変化はありませんが空でsubmitをしても送信されません
つまり元から<input>
に備わってる機能(= 組み込み)だということなのでしょう
また、cssをあてることで空の時(= バリデーションに引っかかってる状態)では赤枠となり、それ以外では黒枠となりました
Chakra等のUIライブラリを使ってると当たり前のようにこの辺りをぽいぽいやってますが、自分で書くと地味に面倒ですね
ちなみにvalidが有効という意味で、invalidが無効という意味です
なのでcssは有効の時は黒枠で無効の時は赤枠にしてねってことですね
追加で下記のようにします
input:invalid {
border: 2px dashed red;
}
/* ↓追加 */
input:invalid:required {
background-image: linear-gradient(to right, pink, lightgreen);
}
input:valid {
border: 2px solid black;
}
このように<input>
が無効の時のrequired
が付いてるやつという指定も出来るみたいです
pattern
入力された文字が求めてるものかをチェックするやつです
required
に加えてpattern
を追加します
<Stack gap={4} m={8}>
<label htmlFor="choose">banana と cherry のどちらが好き?</label>
<Input id="choose" name="i-like" required pattern="[Bb]anana|[Cc]herry" />
<Button type="submit">Submit</Button>
</Stack>
入力された内容に応じてバリデーションが出来るようになりました
minLength, maxLengtn
入力文字数制限です
2文字以上、10文字以下制限にしました
<Stack gap={4} m={8}>
<label htmlFor="len">len:</label>
<Input id="len" name="len" minLength={2} maxLength={10} />
<Button type="submit">Submit</Button>
</Stack>
10文字以上入力しようとしている状態
ここから確定(Enter)すると10文字に入力が切られます
でもMDNを読んでると、これより良い実装方法があるらしく -> ここ
ブラウザーはよくテキストフィールドに期待している以上に入力させないことがあります。単に maxlength を使うよりも良いユーザーエクスペリエンスは、入力文字数のフィードバックを提供してサイズ以下でコンテンツを編集できるようにもしておくことです。 この例のひとつが、ソーシャルメディアに投稿する際の文字数制限です。これは JavaScript と maxlength を使った解決策の組み合わせ実現できます。
X(旧Twitter)にあるようなあの140文字以上入力できるけれど、140文字以上は赤字になって送信できなくなる機能のことですかね
たしかにこっちの方が「コピペ->整形->投稿」がしやすいかもしれません
min, max
<input>
のtype
がnumber
の際の最小値、最大値を制限するやつです
2以上10以下に設定します
<Stack gap={4} m={8}>
<label htmlFor="num">num:</label>
<Input type="number" id="num" name="num" min={2} max={10}/>
<Button type="submit">Submit</Button>
</Stack>
なんか途中で気づいたんですけれども、マウスのホバー時もエラーが表示されています
minLentgth, maxLength, patternでも確認したら同様でした
入力した直後にも検証されているのと、submit時にも検証しており、
エラーを出力する箇所が2つあると見られますね
ちなみnumber
にすると数値を上下するカチカチが右側につくのですが、min, maxの値を超えて変化することはなさそうです ただキーボード入力だと入力できます
ちなみに3.4など、値の範囲内の数値でも小数点ありですとエラーにみなされます
これはここのメモに書いてあります
ちなみになんですが、Chakraには<NumberInput>
というコンポーネントが用意されています
Webで確認した感じこちらは特に何もしなくても小数点も範囲内であれば良さそうに見えます
(実際に書いてないので不明瞭ではある)
JavaScript を使用したフォーム検証
制約検証API
以下があるよと紹介されています
HTMLButtonElement
HTMLFieldSetElement
HTMLInputElement
HTMLOutputElement
HTMLSelectElement
HTMLTextAreaElement
正確にはHTML DOM APIと呼ぶ方が正しいのでしょうか?
上記各種に以下のようなプロパティが存在してます
-
validationMessage
: エラーの時のメッセージを返す -
validity
: 各種バリデーションチェックの状態やメッセージの更新等が出来る
詳細はこちらを読んでください
https://developer.mozilla.org/ja/docs/Learn/Forms/Form_validation#%E5%88%B6%E7%B4%84%E6%A4%9C%E8%A8%BC_api
エラーメッセージの書き換え
標準で出力されるエラーメッセージを変更してみます
以下がコードです
useEffect(() => {
const email = document.getElementById("mail") as HTMLInputElement;
if (email) {
email.addEventListener("input", (event) => {
if (email.validity.typeMismatch) {
email.setCustomValidity("メールアドレスを入力してください。");
} else {
email.setCustomValidity("");
}
});
}
}, []);
return (
<Form action="/sample-form" method="post">
<Stack gap={4} m={8}>
<label htmlFor="mail">メールアドレスを教えてください:</label>
<Input type="email" id="mail" name="mail" />
<Button type="submit">Submit</Button>
</Stack>
</Form>
);
(※このコードだとレンダリングのたびにaddEventListener
がはしって追加しまくるかも
unmount?のタイミングでremoveEventListener
をしたほうが良いのでしょうね クリーンアップとかって呼ばれてるやつだと思います)
これはMDNのサンプルをReact用に書き換えたのですが、メッセージを見るとホバーの時のメッセージとsubmitした後のメッセージが異なるようにみえます
console.log
を入れてブラウザから確認してみると、メッセージは次のようになっていました
(まったくわからない)
ちょっと調べたくらいだと分からなかったのでスルーします、ひとまずエラーメッセージはこのように書き換えられるということです
でもちょっとこの方法で変えるのは、美しくはないかもしれません
エラーメッセージを要素に出力する
useEffect(() => {
const form = document.querySelector("form") as HTMLFormElement;
const email = document.getElementById("mail") as HTMLInputElement;
const emailError = document.querySelector("#mail + span.error")!;
const handleInput = (event: Event) => {
if (email.validity.valid) {
emailError.textContent = "";
emailError.className = "error";
} else {
showError();
}
};
const handleSubmit = (event: Event) => {
if (!email.validity.valid) {
showError();
event.preventDefault();
}
};
const showError = () => {
if (email.validity.valueMissing) {
emailError.textContent = "You need to enter an email address.";
} else if (email.validity.typeMismatch) {
emailError.textContent = "Entered value needs to be an email address.";
} else if (email.validity.tooShort) {
emailError.textContent = `Email should be at least ${email.minLength} characters; you entered ${email.value.length}.`;
}
emailError.className = "error active";
};
email.addEventListener("input", handleInput);
form.addEventListener("submit", handleSubmit);
return () => {
email.removeEventListener("input", handleInput);
form.removeEventListener("submit", handleSubmit);
};
}, []);
return (
<Form action="/sample-form" method="post" noValidate>
<Stack gap={4} m={8}>
<label htmlFor="mail">
<span>メールアドレスを入力してください。</span>
<Input type="email" id="mail" name="mail" required minLength={8} />
<span className="error" aria-live="polite"></span>
</label>
<Button type="submit">Submit</Button>
</Stack>
</Form>
);
input[type="email"] {
appearance: none;
width: 100%;
border: 1px solid #333;
margin: 0;
font-family: inherit;
font-size: 90%;
box-sizing: border-box;
}
input:invalid {
border-color: #900;
background-color: #fdd;
}
input:focus:invalid {
outline: none;
}
.error {
width: 100%;
padding: 0;
font-size: 80%;
color: white;
background-color: #900;
border-radius: 0 0 5px 5px;
box-sizing: border-box;
}
.error.active {
padding: 0.3em;
}
(※今回はクリーンアップをしてみました)
初期状態だと<span>
に文字は表示されていませんが、<input>
の背景が赤になってますね
これは<input>
にrequired
が付与されており、初期状態は入力がない(= エラー)ためcssで背景色等を変えてるからですかね
とはいえ初期状態は入力がないのは当たり前な気がするので、これは不自然とかもしれません
1つずつ見ていきます
<Form>
にnoValidate
が付きました
これで<Form>
のsubmit時の検証をOFFにしています
ONにすると標準の検証が走ります(当たり前ですね)
OFFだとメッセージは出力されないので、この2箇所のメッセージはsubmitに関連しているということでしょうか?
ただ小さな方のメッセージがちょっとよくわかりませんね、こちらはONのときマウスカーソルをホバーするだけで表示されます うーん... やはりホバーした際のエラーメッセージの挙動がいまいちつかめませんね
エラーメッセージ出力用の<span>
要素を追加
classにerror
と付けて、errorの要素をとれるようにすることが目的だと思います
また、aria-live
というオプションが付きました これは要素が更新されることを意味するそうです
https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Attributes/aria-live
javascriptによるエラーメッセージの出力
const emailError = document.querySelector("#mail + span.error")!;
useEffect
でイベントを追加しています
ここで該当span要素のElementを取得して、制約検証APIのプロパティを参照してそれによってtextに値をいれてる感じですね やはりちょっと面倒に感じます
標準の制約のみで対応すると細かいエラーメッセージや出力箇所をいじれず、かといっていじる際はsubmitまでの一連の流れの手順の面倒を見る必要があるように思えますね
制約検証APIを使用しない例
https://developer.mozilla.org/ja/docs/Learn/Forms/Form_validation#%E7%B5%84%E3%81%BF%E8%BE%BC%E3%81%BF_api_%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%AA%E3%81%84%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0%E6%A4%9C%E8%A8%BC
この部分ですが、面倒なのでコードを実際に書いたりはしません
素直にバリデーション用のライブラリを使った方が良さそうですね
フォームデータの送信
今回は学びたいことから逸れるので省きます
でもaction
とmethod
, enctype
を頭に入れておけば良さそうです
読み終えて
いろいろと考えるのが難しいので簡単にしたい気持ちがありますね
ちょっと自分の認識を言語化していきます
自分の考えるフォームの構成
フォームには大雑把に以下の要素を持つと認識しました
- ラベル
- 入力フィールド
- エラーメッセージ(標準の出力、もしくは自分で要素を作る)
また、Formの要素としてはfieldsetも存在していました
こんな感じでしょうか
- Field -> 単一のinput等から構成されるFormの入力要素の塊
- Fieldset -> 複数のinputから構成されるFormの入力要素(というかradio, checkbox)の塊、または任意でグループ化したい場合
こうするとこんな感じの実装をしておけば間違いないでしょうか
<Form action="/sample-form" method="post">
<Stack gap={4} m={8}>
<label htmlFor="name">
<Text>
Name:
<Mark aria-label="required" variant={"plain"} fontWeight={"bold"}>
*
</Mark>
</Text>
</label>
<Input type="text" id="name" name="username" required />
<fieldset>
<legend>Title</legend>
<RadioGroup name="title">
<HStack gap={4}>
<Radio id="title_1" value="A">
Ace
</Radio>
<Radio id="title_2" value="K">
King
</Radio>
<Radio id="title_3" value="Q">
Queen
</Radio>
</HStack>
</RadioGroup>
</fieldset>
<Button type="submit">submit</Button>
</Stack>
</Form>
今までの章から切り貼りしただけですが、シンプルなFiledとFieldsetの想定です
これでフォームの最小限を持ってると言えます
ただこれですと、エラーメッセージは標準ですしユーザーには優しくないかもしれませんね
ChakraUIを使用してFormを作る
ChakraUIでこんなものを見つけました
https://www.chakra-ui.com/docs/components/field
https://www.chakra-ui.com/docs/components/fieldset
書き換えてみましょう
<Form action="/sample-form" method="post">
<Stack gap={4} m={8}>
<Field label={"name"} required>
<Input type="text" name="username" />
</Field>
<Fieldset.Root>
<Fieldset.Legend>Title</Fieldset.Legend>
<Fieldset.Content>
<RadioGroup name="title">
<HStack gap={4}>
<Radio value="A">
Ace
</Radio>
<Radio value="K">
King
</Radio>
<Radio value="Q">
Queen
</Radio>
</HStack>
</RadioGroup>
</Fieldset.Content>
</Fieldset.Root>
<Button type="submit">submit</Button>
</Stack>
</Form>
開発者ツールからHTMLを確認してみます
<label>
のfor
と<input>
のid
が何もしなくても同一になっています
さらにrequired
を<Field>
につけたら<input>
へ反映されていますね
必須の際はaria-label
で音を書き換えてましたが、こちらはaria-hidden
を使ってそもそも隠してるようです
ここは用途によって分かれそうです でも変な挙動でもないのでこれはこれでアリな気がしています
https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Attributes/aria-hidden
<Feildset>
のラジオボタンも<label>
と<input>
のfor
とid
が一致しています
これは<Radio>
の力でしょうね
エラーメッセージを考える
このままだとエラーメッセージは標準のままですが、この<Field>
はerrorText
があれば出力するようになっています
こんな感じで無理やり出力させてみます
ちゃんと出てきました
さらにはaria-live
まで設定されています、このMDNのFormに出てきたaria-hogeというモノに関しては網羅できていそうですね さらにはちゃんと赤枠になっており、cssを改めて記述する必要がなくなりました
エラーメッセージもちゃんと変えられるようにしましょう
ただ素直にJavaScriptを書いていくのはちょっと憚られるので、便利なFormライブラリとしてConformを使用します
書き換えたものがこちらです
// zodで定義したスキーマ ここで検証を設定
const schema = z.object({
name: z.string({ required_error: "必須です-1" }),
title: z.string({ required_error: "必須です-2" }),
});
// Hooks これで各種属性の設定や検証用のjavascriptを生成してるんじゃないかなって思った
const [form, fields] = useForm({
constraint: getZodConstraint(schema),
shouldRevalidate: "onInput",
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
});
// 本体 Conformの公式をみて書いた fieldsetの書き方はちょっとあってるかいまいちだけど
<Form action="/sample-form" method="post" {...getFormProps(form)}>
<Stack gap={4} m={8}>
<Field label={"name"} required invalid={!fields.name.valid} errorText={fields.name.errors}>
<Input {...getInputProps(fields.name, { type: "text" })} />
</Field>
<Fieldset.Root invalid={!fields.title.valid} {...getFieldsetProps(fields.title)}>
<Fieldset.Legend>Title</Fieldset.Legend>
<Fieldset.Content>
<RadioGroup name="title">
<HStack gap={4}>
<Radio value="A" invalid={!fields.title.valid}>Ace</Radio>
<Radio value="K" invalid={!fields.title.valid}>King</Radio>
<Radio value="Q" invalid={!fields.title.valid}>Queen</Radio>
</HStack>
</RadioGroup>
</Fieldset.Content>
<Fieldset.ErrorText>{fields.title.errors}</Fieldset.ErrorText>
</Fieldset.Root>
<Button type="submit">submit</Button>
</Stack>
</Form>
バリデーションもちゃんと問題なさそうで、エラーの文言も変更できています
またマウスカーソルをホバーしたときのエラー、submitしたときに出るエラーの2種類でることもなくなって良くなっています これはForm
に設定しているgetFormPropsの返り値にnoValidation: true
が存在してるからですね
組み込みの検証についてもzodのスキーマ定義で任せてしまえば良さそうです
最後にまた確認
一応開発者ツールからHTMLを確認してみます
<Field>
の方は<label>
のfor
と<input>
のid
が異なってますね
これはConformのヘルパーで<input>
のid
を上書きしちゃってるからでしょうか
ここは実際使うときにはChakraのを加工する必要がありそうです
スニペットをいじるだけなのでたいした労力ではないと思います
多少いじることはあるとはいえ、ライブラリの力を借りるととんでもなくありがたみを感じますね
よっぽどのことがない限りはUIライブラリやFormのライブラリを公式ドキュメントをよく読んで、
最終確認で開発者ツール等にてHTMLを一応見ておくってのが良さそうな感じでしょうか
特に複数のライブラリ(UIライブラリとFormのライブラリとか)を組み合わせて作るときはちゃんとHTMLを見ておいた方が良いよってとこですね
ちなみにConformのヘルパーからは以下のそれっぽいものが渡されます
-
aria-invalid
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid -
aria-describedby
https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Attributes/aria-describedby
たびたびaria-hogeみたいなものが登場していましたが、これらはWAI-ARIAと呼ばれるものです
https://developer.mozilla.org/ja/docs/Learn/Accessibility/WAI-ARIA_basics
(読み方は「うぇいありあ」っぽいです)
アクセシビリティのためにHTML要素へ追加で意味を提供する一連の属性、という認識をしました
MDNにも記載されていますが、「WAI-ARIAは必要な場合のみ使用する」が重要っぽいです
基本はHTMLの要素を適切に使うことが優先でしょうね
アクセシビリティの該当欄にも以下のように記載されています
既定では、HTML は正しく使用すればアクセシブルです。
ではなぜaria-invalid
とaria-describedby
はConformのヘルパーから返されるのでしょうね?(設定で返さなくするようにも可能)
どうやらここで一応会話されてるようです、もともとは標準ではOFFだったようです
https://github.com/edmundhung/conform/issues/213
aria-invalid
は状態を説明する属性であり、標準では状態をもっていないので追加しているといった感じでしょうか?
aria-describedby
は何かと関連付ける属性であり、ConfromではerrorIdと紐づいてます
(↓こんな感じです、getInputPropsからerrorIdが込められたaria-describedby
が渡される)
このスクリーンショットはConformのチュートリアルから拝借しました
https://ja.conform.guide/tutorial
エラーメッセージのid
と<input>
のaria-describedby
が同一のid
なので紐づいています
且つ、このaria-describedby
は入力でエラーが発生したときのみConformのヘルパーから返ってきます
なのでエラーが発生してメッセージが出力されたら紐づく、みたいな感じですかね
たしかに標準のHTMLだけではちょっと表現が難しいのかもしれません
ちょっと見返すと、先ほど書いたコードもエラーの時の<input>
のaria-describedby
とメッセージを出力している<span>
のid
が異なっています
まぁaria-describedby
の値はConformのfield.hoge.errorIdとなるのですが、これを<input>
にしか渡していないせいですね
ここを加工すれば一致させられるかと思います、たいした労力ではないです
ちなみに前回書いた記事で<Field>
を加工した<ConformFiled>
は<label>
のfor
と<ErrorText>
のid
を受け取れるようになってるので対応できてそうです
https://qiita.com/stopod/items/42ac91a67037e849e97f#%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0%E7%94%BB%E9%9D%A2%E3%81%AE%E4%BD%9C%E6%88%90
よくわからなくても公式ドキュメントを真似ておくのは良かったみたいです
(ちゃんと理解してから真似るべきではありますが、自戒です)
終わりに
知らないことがてんこ盛りでした
他にも付けておいた方が良いアクセシビリティがあるのかもしれませんが、適宜考えて使っていくことにします
ライブラリの力は偉大ですが、使わずに進めてライブラリはどうやって実現させてるのかを想像するのはためになりました
なんか読み返すとChakraとConformの回し者みたいになった気がします
他のライブラリを使う際も同様に確認しながら進めたいと思います
検証で使ったリポジトリはここです
https://github.com/stopod/rr-chakra-conform-sample/blob/form-test/app/routes/sample-form.tsx