Skip to main content

General

Pin Input code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/pin-input.blade.php

@props([
    'name' => 'code',
    'length' => 6,
    'type' => 'text',
    'invalid' => false,
    'disabled' => false,
])

@php
    /*
    | One-time-code / PIN input: a row of single-character boxes that auto-advance
    | as you type, support backspace navigation and paste of the full code. The
    | combined value is submitted via a hidden input bound to `name`.
    |
    | type: text, password
    |
    | Usage:
    |   <x-ui.pin-input name="otp" :length="6" />
    |   <x-ui.pin-input name="pin" :length="4" type="password" />
    */
    $length = (int) $length;

    $ring = $invalid
        ? 'border-red-400 dark:border-red-500 focus:border-red-500 focus:ring-red-500'
        : 'border-gray-300 dark:border-gray-700 focus:border-brand-500 focus:ring-brand-500';
    $providedAriaLabel = $attributes->get('aria-label');
    $providedAriaLabelledBy = $attributes->get('aria-labelledby');
    $normalizedName = $name
        ? trim((string) preg_replace('/[^A-Za-z0-9\-_:.]+/', '-', str_replace(['[', ']'], '', (string) $name)), '-')
        : null;
    $groupLabel = $providedAriaLabel
        ?: ($name ? Str::headline(str_replace(['-', '_'], ' ', $normalizedName ?? (string) $name)) : 'Verification code');
    $idPrefix = $attributes->get('id')
        ?: ($normalizedName ? $normalizedName.'-digit' : 'pin-digit-'.substr(md5($name.$length.$type), 0, 8));
@endphp

<div
    x-data="{
        length: @js($length),
        digits: Array(@js($length)).fill(''),
        disabled: @js((bool) $disabled),
        get code() { return this.digits.join(''); },
        focusBox(i) {
            const box = this.$refs['box' + i];
            if (box) { box.focus(); box.select(); }
        },
        onInput(i, e) {
            const val = e.target.value.replace(/[^0-9a-zA-Z]/g, '').slice(-1);
            this.digits[i] = val;
            if (val && i < this.length - 1) { this.focusBox(i + 1); }
        },
        onKeydown(i, e) {
            if (e.key === 'Backspace' && this.digits[i] === '' && i > 0) {
                this.focusBox(i - 1);
            } else if (e.key === 'ArrowLeft' && i > 0) {
                e.preventDefault(); this.focusBox(i - 1);
            } else if (e.key === 'ArrowRight' && i < this.length - 1) {
                e.preventDefault(); this.focusBox(i + 1);
            }
        },
        onPaste(e) {
            e.preventDefault();
            const chars = (e.clipboardData.getData('text') || '').replace(/[^0-9a-zA-Z]/g, '').split('');
            for (let i = 0; i < this.length; i++) { this.digits[i] = chars[i] || ''; }
            this.focusBox(Math.min(chars.length, this.length - 1));
        },
    }"
    role="group"
    @if ($providedAriaLabelledBy) aria-labelledby="{{ $providedAriaLabelledBy }}" @endif
    @if (! $providedAriaLabelledBy) aria-label="{{ $groupLabel }}" @endif
    {{ $attributes->except(['class', 'id', 'aria-label', 'aria-labelledby'])->merge(['class' => 'inline-flex items-center gap-2']) }}
    @paste="onPaste($event)"
>
    <input type="hidden" name="{{ $name }}" :value="code">

    @for ($i = 0; $i < $length; $i++)
        <input
            x-ref="box{{ $i }}"
            id="{{ $idPrefix }}-{{ $i + 1 }}"
            type="{{ $type }}"
            inputmode="numeric"
            maxlength="1"
            autocomplete="{{ $i === 0 ? 'one-time-code' : 'off' }}"
            @disabled($disabled)
            aria-label="{{ $groupLabel }} digit {{ $i + 1 }} of {{ $length }}"
            @if ($invalid) aria-invalid="true" @endif
            x-model="digits[{{ $i }}]"
            @input="onInput({{ $i }}, $event)"
            @keydown="onKeydown({{ $i }}, $event)"
            @focus="$event.target.select()"
            class="h-12 w-11 rounded-lg border bg-white dark:bg-gray-900 text-center text-lg font-semibold text-gray-900 dark:text-gray-100 shadow-sm transition focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed {{ $ring }}"
        >
    @endfor
</div>