📋
悪いフォームUIを作った話
TL;DR
こういうUIを作ってみました
いかがでしたか?
経緯
世の中の「良いUIを作ろう」という風潮に身をかわし、頭のネジを外してUIデザインをしてみましょう。 新しいなにかが見えるかもしれないし、見えないかもしれません。
こちらの「イカれたUIを作ろうの会」というタイトルに惹かれて、そこでの発表用になんか作ってみようと思いました
以前にこういうのを作ったことがあるのでまあそういうノリで考えていたんですが…
今回のお題は「とあるセミナーのアンケートフォーム」
指定のタスクは「ユーザーがアンケートを送信できること」です。
お題が指定されていたのでフォームUIに向き合うことにしました
悪いフォームとは?
大きく分けて3つあると思います
- a. デザイン原則から外れたもの
- b. なんらかの偶発的な要素で生まれた天然バッドUI
- c. ネタ
これらについて考えてみると…
- a はおそらく普通に作ってるとやりがちな感じなもので、なんとなく使いづらいなという状況になるものだと思います(見づらいとかタブで移動しづらいとか不快なだけでおもしろみが少ない)
- b はさまざまなしがらみや思い込み、因習、タイミング、組織体制、やる気、予算、実装技術などが巡り合ったときに稀に生じるもので、多くの人の心を惹きつけてやみません。しかしこれは天然物であるので狙って再現するのは難度が高いです
- c は技術的な挑戦や社会的な提言を伴ったもので、代表的なのが
input type="range"
でクレジットカード番号を入力するようなものだと思っています
以前どっかでbadUIまとめみたいな海外サイトのページを見たのですが、その中でもやはりこれが一番シンプルで完成されていて気高く美しいので心に残り続けています。バッドUI界のオイラーの等式と言えるでしょう
- あとは User Inyerface - A worst-practice UI experiment とか見てください
フォームに向き合う
- というわけでできるだけシンプルに、そして標準的なフォーム機能を使ったものを考えたいと思いました
- フォームといえばチェックボックス、ラジオボタン、コンボボックス、日付入力などがあります
- そもそもinput要素がtypeによって全く振る舞いを変えるのですが、その種類は実に20種類以上となっているのでここを掘ってみるだけでも十分ヤバさがあるでしょう
checkboxの可能性
- とりあえず可能性を感じるのはチェックボックスです
- シンプルにbooleanで二値の状態(厳密には三値あります)をバインドしやすく、チェックボックスでライフゲームやテトリスを作ったり、動画を表示するということも盛んに行われています(盛んに?)
- そしてこれを上述のrangeのような数値入力に使いたいなと思いました
checkboxをgroupとして評価できるようにする
- radioボタンは複数のradioボタンから一つだけ選択するという性質上同じname要素全体で評価され、グループの中のいずれかにrequired属性がついていればグループとしてのrequired評価になります
- しかしcheckboxにはそういうのがありません
- このため「複数チェックボックスの中から一つ以上の選択を必須にする」というようなことが実は標準的にはできません
- しかし私の所属しているSTUDIOではそうした機能が欲しいなと思ったので独自に実装しました
- 全部が未選択状態だとグループ全体にrequired属性が入っていてバリデーションが行われます(今思うと1個で十分だった)
- 選択が一つでもされると動的に全てのrequiredが外され、バリデーションが通るようになります
- 選択が全解除されると再びrequiredが付加されます
checkbox groupを数値化
- というわけでcheckbox単独ではON/OFFの値しか表せませんが、groupとして協調させればいろいろできます
- そういうわけで今回作ったのが、CheckBoxNumberInputというコンポーネントです
- このコンポーネントは
input type="number"
のようにmin
,max
の値をプロパティとして指定することができ、maxを表すのに必要になる分のチェックボックスを生成して並べます
- たとえばこれはコンポーネントにmin:1, max:5を指定しています
- max:5までを表すのに必要な3bit分のチェックボックスが生成されています
- 二値のチェックボックスを数値化するので、横に並んだチェックボックスを2進数の桁と見立てて処理することになります(上記は 0b001, 0b010, 0b100 となります)
- 配列各桁のチェック状態を0/1化した上で連結し2進数とすることで、最終的に10進数の値として表すことができるようになります
10進の数値を2進数化したときの桁数を求める
- 最近は関数単位でChatGPTに実装を移譲してみるというのを試しているんですが、この関数を使ってみたところまあ良さそうだったので採用しました
- しかしこの実装だと後述のMAX_SAFE_INTEGER付近でおかしくなることに気づきました
- 修正版がgetBitLength2です(最初から自分で書けという感じですが…)
- 比較するとぱっと見同じなのですが 2^49 あたりから違いが出てくるということが分かりました
- こういう細かい違いだとなかなかテストも難しいなと思いました
group単位でのバリデーション
- 先程のmax:5の場合だと3bitの入力欄となり最大では7まで表すことができますが、maxを超えるので範囲外となります
- mix, maxの範囲外のときにはsubmitできないようにするためにバリデーションを実装していきます
- 標準ではrequired属性などのバリデーションが行われますが、それ以外に独自のバリデーションを実装する場合には
setCustomValidity()
というメソッドが利用できます - 数値が更新されたタイミングでmin,maxと比較し、input要素のsetCustomValidity()に文言をセットします(文字列がセットされるとinvalid、空文字列をセットするとvalidになります)
const validate = () => {
if (!inputEl.value) return;
const getErrorMsg = () => {
if (decimalValue.value < min.value) {
return `${min.value}以上の値で入力してください`;
}
if (decimalValue.value > max.value) {
return `${max.value}以下の値で入力してください`;
}
// custom validation
return props.validator?.() ?? "";
};
errorMsg.value = getErrorMsg();
inputEl.value.setCustomValidity(errorMsg.value);
};
- このメソッドを使うことでformのsubmit時にバリデーションが行われ、invalidになっている要素がハイライトされるようになります
- 今回はグループとして扱っているためinput要素が複数ありますがsetCustomValidity()するのは一つで十分です
- 見栄え的に中央に警告を出したかったので配列中央のinput要素を算出し、その要素に対してsetCustomValidity()しています
group外からのカスタムバリデーション
年月日入力は3つのcheckbox groupとして構成されています
- 一つ一つにはmin,maxによるバリデーションがありますが、年月日を総合したときに有効な日付かどうかというバリデーションも必要になってきます
- これはこの3つのコンポーネントの親側で処理しています
- https://github.com/miyaoka/bad-form/blob/main/src/views/HomeView.vue
const dateValidator = () => {
const date = new Date(year.value, month.value - 1, day.value);
// Invalid date、または2/30->3/2のように日付が変わってしまう場合にエラーにする
const isValid =
date.getFullYear() === year.value &&
date.getMonth() === month.value - 1 &&
date.getDate() === day.value;
if (!isValid) {
return "存在しない日付です";
}
if (new Date().getTime() < date.getTime()) {
return "未来の日付です";
}
return "";
};
- 各checkbox groupの値は双方向バインドされて親側で参照できるようになっているので、3つ合わせて有効な日付かどうかを返すカスタムバリデーターをpropとして各checkbox groupにセットしています
<CheckBoxNumberInput
:min="1900"
:max="2100"
v-model="year"
:validator="dateValidator"
/>
CSSによるinvalid表示
- setCustomValidityするだけではsubmit時にしかinvalid要素がハイライトされず、invalid要素が複数あっても先頭要素しか警告されません
- これだと全体でどこを修正すればいいのかユーザーが分かりづらいです
- なのでCSSを使ってinvalid状態であるという表示を加えます
- これはけっこう簡単で、setCustomValidityでinvalidにした要素は
:invalid
という疑似要素として評価されるようになります - これと
:has
を組み合わせるとinvalidになっている要素を持つコンテナーに対してスタイルを当てるということが手軽にできます
checkbox group
.checkBoxContainer:has(:invalid)
- グループ内がinvalidである場合にグループに赤線
submit button
.surveyForm:has(:invalid) button[type=submit]
- form内がinvalidになっているときのsubmitボタンの表示をグレーに変更
- style変更するだけでdisabledにしないのでフォーカス可能
max値の制限
- JSの整数の精度は53bitまでなのでこのcheckbox groupで扱える最大値もそこまでに制限します
-
2^53 - 1
を表すNumber.MAX_SAFE_INTEGER
という定数があるのでそれを利用します
const max = computed(() => Math.min(Number.MAX_SAFE_INTEGER, props.max ?? 10));
- propsで53bit以上の数値が渡されても上限を超えないよう制限します
- (maxがmin未満やマイナスであるケースについては今回実装してません)
- 最大だと53個のチェックボックスが並び、9,007,199,254,740,991となります
- これを超えると下位bitの精度が失われるのが分かります
- それかBitIntを使いましょう
読み上げ処理
- めっちゃでかい数になると分かりにくいので読み上げをつけました
- Windows/Macともに日本語での読み上げは兆の単位までで京はサポートされていないようです
- Windows環境だと日本語で1000兆まで読んでくれたけど、iOS環境だと100兆までで1000兆になると読んでくれないという妙な挙動を見つけました
- これは英語だとtrillion(兆)単位までが限界で、英語だと1000ずつ単位が上がるから1000 trillionになるともう読み上げサポート外になって日本語もそれに準じてるのかな? と思ったけど英語で試したら普通にtrillionの上のquadrillionとして読み上げてました
- Appleのバグでは?
フィードバック
…という感じで作りつつツイートをしてたらけっこう拡散されてました
いろいろ感想がつぶやかれていたのでリプライ、引リツ、リツイート直後のツイートなどを拾ってフィードバックとしてまとめます
悪い
- 悪すぎる
- これは極悪
- 草
- 電話番号無理すぎ
- 使い勝手最悪で草
- 邪悪!
- 最早ただのマークシートだ……
それなりにマシ
- 意外と入力できる
- 「全選択」「全解除」もほしいと思ったが、悪いUIなので無いのが最適解。
- ドロップダウンリストから選ばせるやつよりはかなりマシという悲しさ
- 上のビットから二分探索して詰めていけばいいので、実は10進法キーボード入力と同じ手間なのかもです!
- 日数がちょうど5桁に収まるの草w
- 4桁毎に隙間開けてくれると嬉しいなぁ。正直、ドロップダウンリストよりこっちのほうがイイ。
- ノーミスで入力しろと言われると詰むけど、二進数はある程度わかってるとざっくり詰めることは出来る。
桁の表し方がおかしい
- 2進数の桁がズレてる気がする
- ビットを表す上の10進数が1から始まってる時点で気分が悪い
- 0始まりじゃないのめちゃくちゃタチ悪くて草
→最初のツイートの画像ではズレてました
電話番号の区切り方
市外局番2桁の場所って、東京03と大阪06と、埼玉千葉の旧04A0番の地区の「04」くらいしかないので、基本は市外局番3桁で区切っていただけると助かります。
- 電話番号の区切りは総務省|電気通信番号制度|電気通信番号指定状況 (電気通信番号計画(令和元年総務省告示第6号)第1第4項による公表)を見て解釈したもので実装しています
- 固定電話に関しては市外局番の一覧を日本の市外局番 - Wikipediaからリスト化し、それとマッチさせて合計9桁になれば有効という処理です
// 固定電話: 市外局番-市内局番-4桁番号
const homePhoneReg = new RegExp(
`^(?<area>${areaCodeList.join("|")})(?<localArea>.+)(?<rest>.{4})$`
);
// 携帯電話: 0x0-市内局番-4桁番号
const mobilePhoneReg = new RegExp(
`^(?<area>[^0]0)(?<localArea>.+)(?<rest>.{4})$`
);
// 電話番号の形式が正しい場合にハイフン区切りにして返す
const formatPhone = (num: number) => {
if (num === 0) return "0";
const strNum = String(num);
const homePhoneMatchedGroups = strNum.match(homePhoneReg)?.groups;
if (homePhoneMatchedGroups) {
const { area, localArea, rest } = homePhoneMatchedGroups;
// 固定電話は市外局番と市内局番が合計5桁
if (area.length + localArea.length === 5)
return `0${area}-${localArea}-${rest}`;
}
const mobilePhoneMatchedGroups = strNum.match(mobilePhoneReg)?.groups;
if (mobilePhoneMatchedGroups) {
const { area, localArea, rest } = mobilePhoneMatchedGroups;
return `0${area}-${localArea}-${rest}`;
}
// マッチしない場合はハイフン無しで返す
return `0${strNum}`;
};
- この処理だと同じ数値の並びでも、04-xxxx-xxxxと042-xxx-xxxxのどちらでもありうることになります(けど現実にはダブらないようになってるはず)
- どっちを優先すればよいのか?
- …まあ今回の本質ではないので見送り
まとめ
- わりといろいろと学びがありました
- checkboxをグループ化するのを学んだ
- ChatGPTとの付き合い方を学んだ
- setCustomValidityを学んだ
- 日付バリデーションを学んだ
- :has, :invalidを学んだ
- MAX_SAFE_INTEGERを学んだ
- 読み上げ可能な数値を学んだ
- 電話番号体系を学んだ
- みんな2進数が好きっぽい
- マウスクリックするだけ OR タブとスペースだけで入力できるという利点はある
Discussion