OTP 入力
stimeo--otp
自動フォーカス移動、Backspace戻り、ペースト文字分配に対応した、アクセシブルなワンタイムパスワード / PIN 入力フィールド。
stimeo--otp コントローラは、多国籍セキュリティ認証や多要素認証(MFA)で標準的な複数セルの暗証番号(PIN)入力欄をアクセシブルに構築します。各入力フィールドにおける数字・文字のみの制限(pattern正規表現)、値入力時の自動フォーカス進出、空欄での Backspace 打鍵時の自動フォーカス後退&前セル消去、およびクリップボードからのペースト時に複数文字を各セルへ自動分配して充填する機能を、外部の重量級ライブラリに一切依存せず軽量に実装しています。すべての値が埋まった段階で自動的に stimeo--otp:complete イベントが発火します。
キーボード操作
| キー | 動作 |
|---|---|
| Backspace | 現在フィールドが空の場合、自動的に前方のフィールドにフォーカスを戻し、その値を消去する。 |
| ← | フォーカスを左側の入力フィールドに移動する。 |
| → | フォーカスを右側の入力フィールドに移動する。 |
| Home | フォーカスを最初の入力フィールドに移動する。 |
| End | フォーカスを最後の入力フィールドに移動する。 |
<%# Markup for the otp (OTP PIN passcode) demo.
Provides auto-advance on entry in each field, Backspace to go back, paste
distribution, and value sync. %>
<div
class="otp-demo"
id="otp-passcode"
data-controller="stimeo--otp"
data-stimeo--otp-length-value="6"
data-stimeo--otp-pattern-value="[0-9]"
role="group"
aria-label="<%= t("components.otp.demo.label") %>"
>
<div class="otp-demo__label"><%= t("components.otp.demo.label") %></div>
<div class="otp-demo__fields">
<% 6.times do |i| %>
<input
class="otp-demo__field"
type="text"
inputmode="numeric"
maxlength="1"
data-stimeo--otp-target="field"
data-action="
input->stimeo--otp#onInput
keydown->stimeo--otp#onKeydown
paste->stimeo--otp#onPaste
"
aria-label="<%= t("components.otp.demo.digit_label", position: i + 1) %>"
/>
<% end %>
</div>
<input
type="hidden"
data-stimeo--otp-target="value"
name="otp"
/>
<%# Region for invalid-input / full-width-character warning messages. %>
<div
data-stimeo--otp-target="error"
id="otp-error-message"
class="otp-demo__error"
role="alert"
aria-live="polite"
hidden
>
<span class="otp-demo__error-badge">!</span>
<span class="otp-demo__error-text">
<%= t("components.otp.demo.invalid_msg") %>
</span>
</div>
<%# Placeholder for the completion celebration message. %>
<div id="otp-complete-message" class="otp-demo__message" style="display: none;">
<span class="otp-demo__success-badge">✓</span>
<span class="otp-demo__success-text">
<%= t("components.otp.demo.complete_msg") %>
<strong id="otp-success-code"></strong>
</span>
</div>
</div>
/*
* Presentation-only styles for the otp demo.
* Whether a field is filled switches on data-filled (set by the library).
*/
.otp-demo {
display: inline-block;
font-family: inherit;
}
.otp-demo__label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.otp-demo__fields {
display: flex;
gap: 0.5rem;
}
.otp-demo__field {
width: 3rem;
height: 3.5rem;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
color: var(--fg);
background: var(--surface-card);
border: 1px solid var(--border-strong);
border-radius: 8px;
outline: none;
transition: all 0.15s ease;
}
/* Filled state (data-filled). */
.otp-demo__field[data-filled="true"] {
border-color: var(--border-interactive);
}
/* Focus state. */
.otp-demo__field:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15);
}
.otp-demo__message {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--leaf-50);
border: 1px solid var(--leaf-50);
border-radius: 8px;
animation: otpFadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.otp-demo__success-badge {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--leaf-500);
color: var(--white);
font-size: 0.75rem;
font-weight: bold;
}
.otp-demo__success-text {
font-size: 0.875rem;
color: var(--leaf-500);
}
.otp-demo__error {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--danger-50);
border: 1px solid var(--ruby-200);
border-radius: 8px;
animation: otpFadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Stimeo controls visibility only via the hidden attribute (behavior only); the styling reads
that state. */
.otp-demo__error[hidden] {
display: none;
}
.otp-demo__error-badge {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--danger-500);
color: var(--white);
font-size: 0.75rem;
font-weight: bold;
}
.otp-demo__error-text {
font-size: 0.875rem;
color: var(--color-accent);
}
@keyframes otpFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Demo script that catches the complete event and shows a celebration message.
document.getElementById('otp-passcode').addEventListener('stimeo--otp:complete', function (e) {
const msg = document.getElementById('otp-complete-message');
const code = document.getElementById('otp-success-code');
if (msg && code) {
code.textContent = e.detail.value;
msg.style.display = 'flex';
}
});
// Hide the message when the value changes.
// Backspace clears values programmatically in the controller, so the native input
// event doesn't fire — subscribe to stimeo--otp:change instead.
document.getElementById('otp-passcode').addEventListener('stimeo--otp:change', function (e) {
const fields = Array.from(document.querySelectorAll('.otp-demo__field'));
const filled = fields.every((f) => f.value.length > 0);
if (!filled) {
const msg = document.getElementById('otp-complete-message');
if (msg) msg.style.display = 'none';
}
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--otp"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
field
必須
|
1文字ずつの入力欄。スロットごとに1つで、フォーカスが自動で進む。 | data-stimeo--otp-target="field" |
value
|
連結したコードを保持する隠しフィールド。フォーム送信用。 | data-stimeo--otp-target="value" |
error
|
不正入力時に表示・非表示を切り替えるエラー要素。 | data-stimeo--otp-target="error" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
length
|
コードの桁数・フィールド数(既定6)。 | data-stimeo--otp-length-value |
pattern
|
1文字ごとの検証用正規表現(既定 [0-9])。 | data-stimeo--otp-pattern-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onInput
|
入力文字を検証し、欄を filled にしてフォーカスを次へ進める。 | stimeo--otp#onInput |
onKeydown
|
Backspace のクリア・後退と矢印・Home/End のナビを処理する。 | stimeo--otp#onKeydown |
onPaste
|
貼り付けを横取りし、有効な文字を各欄へ分配する。 | stimeo--otp#onPaste |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
連結値が変わるたびに発火。detail にその値を載せる。 | stimeo--otp:change |
complete
|
全欄が埋まると発火。detail に完成値を載せる。 | stimeo--otp:complete |
invalid
|
文字や貼り付けが拒否されると発火。detail に pattern を載せる。 | stimeo--otp:invalid |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-filled |
個々の入力フィールド (field) | 文字が入力されている場合に "true"、空の場合には属性自体を削除。 |
value |
隠し input 要素 (value) | 各フィールドの値を連結した最終的な PIN 文字列が自動同期される。 |