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>