Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 43 additions & 32 deletions components/otp/files/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
'type' => 'text',
'allowedPattern' => '[0-9]',
'autofocus' => false,
'required' => true,
])

@php
Expand Down Expand Up @@ -93,16 +94,22 @@ function() {
this._inputs.forEach((input, index) => {
input.setAttribute('data-order', index);
input.setAttribute('aria-label', `Digit ${index + 1} of ${this.length}`);

// Only the first box claims the code. Six boxes all answering to
// `one-time-code` leave a password manager no single field to fill.
input.setAttribute('autocomplete', index === 0 ? 'one-time-code' : 'off');
});
},

// Enable only the next valid input (lock the rest)
// Keep tabbing on the next valid input (skip the rest)
updateInputAvailability() {
// Inputs are enabled only up to (filled count + 1)
// Inputs are in play only up to (filled count + 1)
const enableCount = this._state.length < this.length ? this._state.length + 1 : this.length;

this._inputs.forEach((input, index) => {
input.disabled = index >= enableCount;
// Not `disabled`: a disabled input is one a password manager
// cannot write to, so a fill stops at the first box.
input.tabIndex = index >= enableCount ? -1 : 0;
});
},

Expand All @@ -116,10 +123,12 @@ function() {
const index = parseInt(el.dataset.order);
let value = el.value;

// Always keep last typed character (avoid multi-char paste in one box)
// A password manager fills the whole code into one box and dispatches
// `input`, never `paste` — so this is the only place that sees it.
if (value.length > 1) {
value = value.slice(-1);
el.value = value;
this.fillFrom(value, index);

return;
}

// Reject characters not matching the pattern
Expand All @@ -140,12 +149,16 @@ function() {

// Handle paste: distribute valid chars across remaining inputs
handlePaste(e) {
const pasted = e.clipboardData.getData('text');
this.fillFrom(e.clipboardData.getData('text'), parseInt(e.target.dataset.order));
},

// Spread a multi-character value across the boxes from one of them on.
// Both a paste and a password manager's fill arrive here.
fillFrom(text, startIndex) {
const regex = new RegExp(`^${this.allowedPattern}$`);
const validChars = Array.from(pasted).filter(char => regex.test(char));
const startIndex = parseInt(e.target.dataset.order);
const validChars = Array.from(text).filter(char => regex.test(char));

// Clear all inputs after paste start position
// Clear all inputs after the start position
for (let i = startIndex; i < this._inputs.length; i++) {
this._inputs[i].value = '';
}
Expand All @@ -165,7 +178,7 @@ function() {
if (next) {
this.focusAndSelect(next);
} else if (validChars.length + startIndex >= this.length) {
// If paste fills all boxes, focus last input for convenience
// If the fill covers all boxes, focus last input for convenience
const lastInput = this._inputs[this.length - 1];
if (lastInput) {
requestAnimationFrame(() => {
Expand Down Expand Up @@ -252,15 +265,18 @@ function() {

// Public method: Clear all inputs and reset focus
clear() {
const held = this._inputs.includes(document.activeElement);

this._inputs.forEach(input => {
input.value = '';
input.disabled = true;
});

if (this._inputs[0]) this._inputs[0].disabled = false;
// Resetting the state is what restores the tab order
this._state = '';

if (this.autofocus && this._inputs[0]) {
// The caret goes back to the first box rather than being left on
// one that is no longer in play
if ((this.autofocus || held) && this._inputs[0]) {
requestAnimationFrame(() => this._inputs[0].focus());
}
},
Expand All @@ -273,26 +289,14 @@ function() {

// Handle clicks anywhere inside the container (smart focus delegation)
handleClick(e) {
// Clamped to the boxes in play rather than gated on `disabled`, which
// nothing sets any more: a click past the caret lands on the first
// box still waiting for a digit.
const clickedInput = e.target.closest('[data-slot=otp-input]');
const furthest = Math.min(this._state.length, this.length - 1);
const order = clickedInput ? parseInt(clickedInput.dataset.order) : furthest;

// If clicked directly on an active input
if (clickedInput && !clickedInput.disabled) {
this.focusAndSelect(clickedInput);
return;
}

// Otherwise, find the best input to focus next
const firstEmpty = this._inputs.find(input => !input.value && !input.disabled);

if (firstEmpty) {
this.focusAndSelect(firstEmpty);
} else {
// All filled: focus last for easy editing
const lastInput = this._inputs[this.length - 1];
if (lastInput && !lastInput.disabled) {
this.focusAndSelect(lastInput);
}
}
this.focusAndSelect(this._inputs[Math.min(order, furthest)]);
}
}
}"
Expand Down Expand Up @@ -327,4 +331,11 @@ class="contents"
@endif
</div>
</div>

{{-- What a plain <form> submits. The boxes carry no name of their own: a name
on each of them posts one value per box and the last one wins. Livewire
binds through wire:model instead, and needs no field here. --}}
@if (filled($name) && ! $modelAttrs)
<input type="hidden" name="{{ $name }}" x-bind:value="_state" />
@endif
</div>
9 changes: 5 additions & 4 deletions components/otp/files/input.blade.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@aware(['type' => 'text','name'=> null])
@aware(['type' => 'text', 'required' => true])

@php
$classes = [
Expand All @@ -21,13 +21,12 @@
<input
{{ $attributes
->merge([
'name' => $name,
'type' => $type,
])
->class($classes)
}}

required
@if ($required) required @endif
maxlength="1"
data-slot="otp-input"
x-on:input="handleInput($el)"
Expand All @@ -39,7 +38,9 @@
x-on:keydown.backspace.prevent="await handleBackspace($event)"

{{-- accessibilty addons --}}
autocomplete="one-time-code"
{{-- index.blade.php gives the first box `one-time-code`; the rest stay off, so
a password manager has one field to aim at rather than six. --}}
autocomplete="off"
x-on:keydown.right="$focus.within($refs.inputsWrapper).next()"
x-on:keydown.up="$focus.within($refs.inputsWrapper).next()"
x-on:keydown.left="$focus.within($refs.inputsWrapper).prev()"
Expand Down
34 changes: 26 additions & 8 deletions components/otp/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ You can use it outside Livewire with just Alpine (with Blade):

> **Note:** The component uses `_state` and `_inputs` internally, so avoid using these variable names in your Alpine scope.

#### Submitting from a plain form

Outside Livewire the component submits through a hidden input, named after `name` (or after the `x-model` expression when no name is given):

```html
<form method="POST" action="/two-factor-challenge" x-data="{ code: null }">
@csrf

<x-ui.otp name="code" x-model="code" :length="6" />

<button type="submit">Continue</button>
</form>
```

> **Note:** If the OTP shares a form with another field and is hidden behind `x-show`, pass `:required="false"` or wrap it in a disabled `<fieldset>` — a `required` input that is `display: none` makes the browser refuse the submit.

## Customization

### Custom Length
Expand Down Expand Up @@ -288,29 +304,29 @@ When you delete a digit from the middle of the OTP, all subsequent digits automa
- Delete `2`: `[1][3][4][ ]` (values shift left)
- No gaps remain between digits

### Click-to-Focus on Disabled Inputs
### Click-to-Focus

Click anywhere in the OTP input container, even on disabled inputs, to automatically focus the appropriate input box.
Click anywhere in the OTP input container to automatically focus the appropriate input box.

@blade
<x-demo class="flex justify-center !text-start" x-data="{ clickCode: '12' }">
<div>
<label class="block text-sm font-medium mb-2">Click on any input (even disabled ones)</label>
<label class="block text-sm font-medium mb-2">Click on any input</label>
<x-ui.otp
x-model="clickCode"
:length="6"
/>
<p class="text-sm text-neutral-600 mt-2">Try clicking on neutraled-out (disabled) inputs - focus will jump to the next available input.</p>
<p class="text-sm text-neutral-600 mt-2">Try clicking past the digits you have typed - focus will jump to the next available input.</p>
</div>
</x-demo>
@endblade

**How it works:**
- Click on an enabled input → focuses that input
- Click on a disabled input → focuses the first empty input
- Click on an input at or before the caret → focuses that input
- Click on an input past the caret → focuses the first empty input
- Click on empty space → focuses the first empty input

> **Technical Note:** This feature uses a CSS `::after` pseudo-element overlay trick to capture click events on disabled inputs, which normally block all pointer events.
> **Technical Note:** Inputs past the caret are taken out of the tab order with `tabindex="-1"` rather than disabled, so a password manager can still fill them.

### Completion Events

Expand Down Expand Up @@ -479,7 +495,7 @@ The component exposes methods that can be called from outside:
The component includes comprehensive accessibility features:

- **ARIA labels**: Each input has a descriptive label (e.g., "Digit 1 of 4")
- **Autocomplete**: `autocomplete="one-time-code"` for better mobile support
- **Autocomplete**: `autocomplete="one-time-code"` on the first input, `off` on the rest, so mobile keyboards and password managers have a single field to fill
- **Keyboard navigation**: Arrow keys move between inputs
- **Screen reader friendly**: Proper roles and labels
- **Focus management**: Clear visual focus indicators
Expand All @@ -492,6 +508,8 @@ The component includes comprehensive accessibility features:
| `type` | string | `'text'` | No | HTML input type attribute |
| `allowedPattern` | string | `'[0-9]'` | No | Regex pattern for allowed characters |
| `autofocus` | boolean | `false` | No | Auto-focus first input on mount |
| `name` | string | `wire:model` / `x-model` value | No | Name the code is submitted under in a plain form |
| `required` | boolean | `true` | No | Mark the inputs required |
| `wire:model` | string | - | Yes* | Livewire property to bind to |
| `x-model` | string | - | Yes* | Alpine.js property to bind to |
| `class` | string | - | No | Additional CSS classes for container |
Expand Down