Input Pin

InputPinは、認証コードなど複数桁の数字を入力するためのコンポーネントです。

機能

  • 任意の桁数に対応しています
  • 入力すると自動で次の欄にフォーカスが移動します
  • バックスペースで前の欄に戻ります
  • 文字を貼り付けた場合は自動で分割して入力します
  • 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>

      

使い方


サンプル

Default

特に操作が行われていない、デフォルトの状態です。

Error

入力内容に問題があり、エラーが表示されている状態です。

With Label

Labelコンポーネントと組み合わせて、
InputPinをフォーム内でラベル付き・補足文付きで使う例です。

Digits

digits プロパティを使うことで、
桁数を用途や要件に合わせて自由に変更できます。

Events

イベントハンドラーの登録例です。
onInputonCompleteイベントの内容をリアルタイムに確認できます。