Rails: Tailwind + Stimulusで2FA/OTP/SMS認証用の6桁フィールドを作る(翻訳)
というわけで、今日はBeckyの新アプリでメールベースの認証フローの一部となるささやかなUIを構築しました。
(まだリリースしておらず、スクリーンキャストを録画するのもおっくうなので、ひとまずこのフィールドが完璧に動いているものとお考えください、どうぞよろしく)
何らかのサイトにログインすると、認証アプリからTOTP(タイムベースのワンタイムパスワード)1を自動入力したり、SMSやメールで送信されたコードをコピーして貼り付けたりするときに、(私のように)こんな感じの6桁のフォームの美しさに魅了されたことのある方なら、「こういう"いい感じの外観を備えたフィールド"はどうやって実現するのだろう?」と疑問に思ったことがおありでしょう。
私も実際にその方法をあれこれ調べてみたのですが、見つけたものはどれもこれも私のお気に召すものではありませんでした。
そういうフォームに取り組んでいる私は、ひとまずいろいろな有名サービスのフォームで使われている、可愛らしいワンタイムパスワード(OTP)フィールドをリバースエンジニアリングしてみました。しばらくの間、ソリューションをググって調べては、ChatGPTの回答に腹を立てる日々でした。
皆さんには想像もつかないでしょうが、この作業を通じて、現実のフォームがどのように設計されているか、有名なコンポーネントライブラリの背後で何が行われているかが見えてくるようになり、世の中のできの悪いチュートリアル系記事が読者にどんなことを吹き込んでいるかもわかってきました。恐ろしいことに、外見上は美しく見えるOTPフォームフィールドのほぼすべてが、目も当てられないほどひどい、箸にも棒にもかからない、言語道断な方法で、勝手気ままに、てんでバラバラに実装されていたのです。
- 1.
<input>
タグをわざわざ6個作ってレンダリング・スタイル設定している- 2.
- 必要そうなキーボード操作やマウス操作を変換する適当なJavaScriptを思いつきで書いている(当然エッジケースを取りこぼしまくる)
- 3.
- ユーザーの90%は認証コードを手入力せずに貼り付けるので、コピペ操作を適切に処理するためのJavaScriptコードも追加している
- 4.
- Appleの実に快適な"ワンタイムコード"のオートコンプリートフローを壊さないためのJavaScriptコードも追加している
- 5.
- バラバラな6つの
<input>
タグを無理やり連携させて、値をhiddenフォームフィールドに代入し、すべてが適切に送信されるようにするJavaScriptコードも追加している - 6.
- ステップ1〜5で犯したあらゆる罪をアクセシビリティの神にお赦しいただくために、
aria-*
属性をたっぷり散りばめている
<input>
ごとに別々の不自然なコードを書いていない実装は「たった1つ」しかありませんでしたが、コードは醜悪、CSSも理解不能でした。
私が代わりに選んだ方法は、いたって普通の、変更を最小限にとどめた<input>
フィールドです(そうやって実際に構築したのが上のスクショです)。丸みを帯びた枠線や桁の間の枠線など、それ以外のものはすべて、ユーザー操作に反応しないスタイルで個別に実装しました。
というわけで、ご興味がおありの方に向けて、今週の私のエピソード「こんなこと どうして自分がやるのかと 独りぼやいて 自分で作る」の締めくくりとして、私の最終成果をお目にかけたいと思います。
最初はマークアップです(RailsとTailwindの出来が素晴らしいのでERBで実装しました)。
<!-- app/views/logins/_otp_input.html.erb -->
<div class="relative w-[320px] h-[64px] mt-[16px] sm:w-[368px]">
<%= text_field_tag :otp_code, params[:otp_code],
maxlength: 6,
inputmode: "numeric",
pattern: "\\d{6}",
class: "absolute inset-[16px] focus:ring-0 focus:outline-none border-none font-mono text-[26px] sm:text-[32px] tracking-[26px] sm:tracking-[32px]",
autocomplete: "one-time-code",
title: "six-digit code that was emailed to you",
data: {controller: "otp", action: "input->otp#caret click->otp#caret keydown->otp#caret keyup->otp#caret"} %>
<div class="absolute flex sm:w-[336px] w-[284px] h-full border rounded-lg pointer-events-none bg-none">
<% 5.times do %>
<div class="w-[40px] border-r border-dashed first:ml-[16px] ml-[2px] sm:w-[48px] sm:first:ml-[14px] sm:ml-[4px]" ></div>
<% end %>
</div>
</div>
上のマークアップはいろいろ盛り沢山なので、じっくり読みつつ不明な点があればググって調べるのがベストでしょう。なお、data-*
属性はStimulusが撒き散らしたものです。
お次はJavaScriptです。
// app/javascript/controllers/otp_controller.js
import { Controller } from '@hotwired/stimulus'
import { useWindowFocus } from 'stimulus-use'
export default class extends Controller {
connect () {
useWindowFocus(this)
}
async focus() {
this.pastePotentialCode()
}
caret () {
const shouldHideCaret = this.element.maxLength == this.element.selectionStart
&& this.element.maxLength == this.element.selectionEnd
this.element.classList.toggle('caret-transparent', shouldHideCaret)
}
async pastePotentialCode () {
try {
const text = (await navigator.clipboard.readText()).trim()
if (text.match(/^\d{6}$/) && text !== this.alreadyPasted) {
this.element.value = text
this.alreadyPasted = text
}
} catch { }
}
}
このStimulusコントローラは、以下の2つの作業だけに専念しています。
- ユーザーの選択範囲が末尾の桁より右に移動したら、Iビームキャレットを隠し、何もない場所ではキャレットが点滅しないようにする(ただしこれを直接制御するcaret-shapeプロパティが使えることが前提)。
-
ユーザーがウィンドウをアクティブにしたときに(stimulus-useの
useWindowFocus
をインポートすることで検出)、クリップボードに6桁の認証コードが存在していれば、フォームフィールドにそのコードを自動入力する(ただしAppleプラットフォームではプライバシー制約のせいで動かない)
以上でおしまいです。皆さんが遊べる実行可能なサンプルも作りたいところでしたが、そこまでの気力はありません。皆さんもこれだけ得るものがあれば十分ラッキーと言えますよね、それとも皆さんは最近私に何かしてくれましたっけ?
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。