Input Pin
InputPinは、認証コードなど複数桁の数字を入力するためのコンポーネントです。
<script lang="ts">
import InputPin from '$lib/components/ui/atoms/InputPin.svelte';
let code = $state('');
</script>
<InputPin bind:value={code} autofocus={false} />
機能
- 任意の桁数に対応しています
- 入力すると自動で次の欄にフォーカスが移動します
- バックスペースで前の欄に戻ります
- 文字を貼り付けた場合は自動で分割して入力します
autocomplete="one-time-code"によりSMS認証コード等の自動入力も一部ブラウザでサポートされています
プロパティ
InputPinは、以下のプロパティをサポートしています。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
digits |
number |
6 |
入力する桁数を指定します。 |
value |
string |
'' |
入力値。bind:value で取得できます。 |
isError |
boolean |
false |
エラー状態を視覚的に示します。 |
autofocus |
boolean |
true |
最初のinput要素に自動でフォーカスします。 |
inputMode |
"text" | "numeric" |
"numeric" |
モバイルキーボードの種別を指定できます。 |
onInput |
(value: string, isComplete: boolean) => void |
入力時に呼ばれるコールバックです。 | |
onComplete |
(value: string) => void |
すべての桁が入力されたときに呼ばれるコールバックです。 |
補足
ドキュメント内のサンプルでは、複数のInputPINが並ぶためautofocus={false}を指定し、ページを開いたときに下部まで自動でスクロールされる現象を防いでいます。
実際の利用時にはデフォルトのautofocus={true}が推奨されます。
インストールの手順
以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新してください。
atoms/InputPin.svelte
<!--
@component
## 概要
- 認証コードなど複数桁の数字を入力するためのコンポーネントです
## 機能
- 桁数を指定して個別の入力欄を表示します
- 入力すると自動で次の欄にフォーカスが移動します
- バックスペースで前の欄に戻ります
- 数字を貼り付けた場合は自動で分割して入力されます
- エラー状態のスタイルを適用できます
- `autocomplete="one-time-code"` によりSMS認証コード等の自動入力も一部ブラウザでサポートされています
## Props
- digits: 入力欄の数を指定します
- value: 入力中の値を bind:value で取得できます
- isError: true の場合エラー時のスタイルを適用します
- autofocus: true の場合、コンポーネントがマウントされた時に最初の入力欄に自動でフォーカスします
- inputMode: モバイルキーボードの表示を制御するための
- 数字入力用のキーボードを表示する場合は "numeric"
- テキスト入力用のキーボードを表示する場合は "text
## Usage
```svelte
<InputPin bind:value={code}/>
```
-->
<script module lang="ts">
import type { ClassValue } from 'svelte/elements';
import { cva, type VariantProps } from 'class-variance-authority';
export const inputPinVariants = cva('size-10 px-1 py-2 border border-base-stroke-default rounded-md text-center text-base-foreground-default text-sm outline-primary transition-colors hover:bg-base-container-accent/90 placeholder:text-base-foreground-muted focus-visible:outline-0.5 focus-visible:outline-offset-0.5 focus-visible:outline-primary', {
variants: {
isError: {
true: ['border-destructive'],
false: [],
},
},
defaultVariants: {
isError: false,
},
});
export type InputPinVariants = VariantProps<typeof inputPinVariants>;
export interface InputPinProps extends InputPinVariants {
/** 桁数を指定して個別の入力欄を表示します */
digits?: number;
/** 入力中の値を bind:value で取得できます */
value?: string;
/** 自動でinputにフォーカスするか制御できます */
autofocus?: boolean;
/** モバイルキーボードの表示を制御するための属性です */
inputMode?: 'text' | 'numeric';
/** クラス */
class?: ClassValue;
/** 入力時のコールバックを登録できます */
onInput?: (value: string, isComplete: boolean) => void;
/** 入力完了時のコールバックを登録できます */
onComplete?: (value: string) => void;
}
</script>
<script lang="ts">
import { onMount, tick } from 'svelte';
let { digits = 6, value = $bindable(''), autofocus = true, inputMode = 'numeric', class: className, isError = false, onInput, onComplete }: InputPinProps = $props();
let inputElements: HTMLInputElement[] = [];
let values = $state(Array(digits).fill(''));
let isComposing = $state(false);
let inputPinVariantClass = $derived(inputPinVariants({ isError }));
onMount(() => {
if (autofocus) {
inputElements[0]?.focus();
}
});
$effect(() => {
if (value !== values.join('')) {
values = Array.from({ length: digits }, (_, i) => value[i] ?? '');
}
});
function updateValue() {
value = values.join('');
const is_complete = values.every((v) => v !== '');
onInput?.(value, is_complete);
if (is_complete) {
onComplete?.(value);
}
}
async function next(index: number) {
if (index + 1 < digits) {
await tick();
inputElements[index + 1]?.focus();
}
}
function prev(index: number) {
if (index > 0) {
inputElements[index - 1]?.focus();
}
}
function onInputEvent(e: Event, index: number) {
const el = e.target as HTMLInputElement;
const val = el.value.slice(0, 1);
values[index] = val;
if (!isComposing && val) {
next(index);
}
updateValue();
}
function onKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Backspace') {
e.preventDefault();
values[index] = '';
updateValue();
prev(index);
return;
}
if (e.key === 'Delete') {
if (index < digits - 1) {
values[index + 1] = '';
next(index);
updateValue();
}
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
prev(index);
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
next(index);
}
}
function onPaste(e: ClipboardEvent) {
e.preventDefault();
const text = (e.clipboardData?.getData('text') ?? '').trim();
for (let i = 0; i < digits; i++) {
values[i] = text[i] ?? '';
}
const last_pasted_index = Math.min(text.length, digits) - 1;
next(last_pasted_index);
updateValue();
}
function onCompositionEnd(index: number) {
isComposing = false;
if (values[index]) {
next(index);
}
updateValue();
}
function onFocus(index: number) {
const empty = values.findIndex((v) => v === '');
if (empty !== -1 && empty < index) {
inputElements[empty]?.focus();
}
else {
inputElements[index]?.select();
}
}
</script>
<div class={[className, 'flex gap-1.5']}>
{#each Array(digits) as _, index}
<input class={inputPinVariantClass} type="text" inputmode={inputMode} maxlength="1" autocomplete="one-time-code" bind:this={inputElements[index]} value={values[index]} oninput={(e) => onInputEvent(e, index)} onfocus={() => onFocus(index)} onkeydown={(e) => onKeydown(e, index)} onpaste={onPaste} oncompositionstart={() => (isComposing = true)} oncompositionend={() => onCompositionEnd(index)} />
{/each}
</div>
使い方
<script lang="ts">
import InputPin from '$lib/components/ui/atoms/InputPin.svelte';
let code = $state('');
</script>
<InputPin bind:value={code} autofocus={false} />
サンプル
Default
特に操作が行われていない、デフォルトの状態です。
<script lang="ts">
import InputPin from '$lib/components/ui/atoms/InputPin.svelte';
let code = $state('');
</script>
<InputPin bind:value={code} autofocus={false} />
Error
入力内容に問題があり、エラーが表示されている状態です。
<script lang="ts">
import InputPin from '$lib/components/ui/atoms/InputPin.svelte';
let code = $state('123457');
let isError = $derived(code.length === 6 && code !== '123456');
</script>
<div>
<InputPin digits={6} bind:value={code} {isError} autofocus={false} />
{#if isError}
<p class="text-destructive text-sm mt-2">ここにエラーメッセージが入ります。</p>
{/if}
</div>
With Label
Labelコンポーネントと組み合わせて、
InputPinをフォーム内でラベル付き・補足文付きで使う例です。
<script lang="ts">
import InputPin from '$lib/components/ui/atoms/InputPin.svelte';
import Label from '$lib/components/ui/atoms/Label.svelte';
let code = $state('');
</script>
<div>
<Label class="mb-3" for="pin-input">認証コード</Label>
<p class="text-base-foreground-muted text-sm mb-3">SMSで送信された認証コードを入力してください。</p>
<InputPin digits={6} bind:value={code} autofocus={false} />
</div>
Digits
digits プロパティを使うことで、
桁数を用途や要件に合わせて自由に変更できます。
<script lang="ts">
import InputPin from '$lib/components/ui/atoms/InputPin.svelte';
let code = $state('');
</script>
<InputPin bind:value={code} digits={4} autofocus={false} />
Events
イベントハンドラーの登録例です。
onInput・onCompleteイベントの内容をリアルタイムに確認できます。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import InputPin from '$lib/components/ui/atoms/InputPin.svelte';
let code = $state('');
let logs = $state<string[]>([]);
let isError = $state(false);
const EXPECTED_VALUE = '123456';
function onInput(value: string, isComplete: boolean) {
logs = [`[onInput] value="${value}" isComplete=${isComplete}`];
}
function onComplete(value: string) {
logs = [...logs, `[onComplete] value="${value}"`];
isError = code !== EXPECTED_VALUE;
if (!isError) {
logs = [...logs, '入力されたコードが期待値と一致しました'];
}
}
</script>
<div class="flex flex-col items-center w-full gap-2">
<div class="flex flex-col">
<p class="text-base-foreground-muted text-sm mb-2">「{EXPECTED_VALUE}」と入力してください</p>
<InputPin bind:value={code} digits={6} {onInput} {onComplete} {isError} autofocus={false} />
{#if isError}
<p class="w-full text-destructive text-sm mt-2">コードが一致しません。</p>
{:else if code === EXPECTED_VALUE}
<p class="w-full text-sm text-success mt-2">コードが一致しました。</p>
{/if}
</div>
<div class="w-full mt-4">
<DebugConsole data={{ code, logs }} />
</div>
</div>