From 23800a6abe73180f14592ba97c3f84f7fd423d42 Mon Sep 17 00:00:00 2001 From: Oneleggedswede Date: Wed, 19 Aug 2026 01:05:28 +0200 Subject: [PATCH 1/4] fix(otp): let a password manager fill the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A password manager sets `.value` and dispatches `input`; it never dispatches `paste`. Three things then stop it filling the code: - all six inputs claim `autocomplete="one-time-code"`, so there is no single field to aim at; - `handleInput()` keeps only the last character of a multi-character value, and `handlePaste()` — the only code that spreads a code across the boxes — never runs, so a filled `123456` becomes `6`; - every box ahead of the caret is `disabled`, and a disabled input cannot be written to at all, so a fill that goes box by box stops after the first. `setupInputs()` now assigns `one-time-code` to the first box and `off` to the rest, the distribution logic moves out of `handlePaste()` into a shared `fillFrom()` that `handleInput()` also calls, and the caret is held with `tabIndex` instead of `disabled`. `clear()` and `handleClick()` stop reading the flag back; `handleClick()` clamps to the boxes in play instead, which is what `disabled` was standing in for. --- components/otp/files/index.blade.php | 67 +++++++++++++++------------- components/otp/files/input.blade.php | 4 +- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/components/otp/files/index.blade.php b/components/otp/files/index.blade.php index c91d61b..8d26f7d 100644 --- a/components/otp/files/index.blade.php +++ b/components/otp/files/index.blade.php @@ -93,16 +93,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; }); }, @@ -116,10 +122,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 @@ -140,12 +148,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 = ''; } @@ -165,7 +177,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(() => { @@ -252,15 +264,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()); } }, @@ -273,26 +288,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)]); } } }" diff --git a/components/otp/files/input.blade.php b/components/otp/files/input.blade.php index 903e892..96a7aa8 100644 --- a/components/otp/files/input.blade.php +++ b/components/otp/files/input.blade.php @@ -39,7 +39,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()" From 2fbca4492d9a1da61f4792b52c122c738dae6d9f Mon Sep 17 00:00:00 2001 From: Oneleggedswede Date: Wed, 19 Aug 2026 01:05:42 +0200 Subject: [PATCH 2/4] fix(otp): submit the joined code from a plain form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component renders nothing carrying the joined value. `name` is consumed by `@props` and read back by `otp.input` through `@aware`, so it lands on every digit box: `` renders six ``, the browser posts all six, and the server keeps one of them. That is invisible under `wire:model`, where Livewire is the transport. But Laravel's Livewire starter kit posts its two-factor challenge as an ordinary form to `two-factor.login.store` with the digits held in Alpine via `x-model`, and there the code never reaches the server at all — every login is rejected. The boxes now carry no name of their own, and the component renders a single hidden input tracking `_state` under `name`, the way `` does. Nothing is rendered when `wire:model` is present, so the Livewire path is untouched. --- components/otp/files/index.blade.php | 7 +++++++ components/otp/files/input.blade.php | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/components/otp/files/index.blade.php b/components/otp/files/index.blade.php index 8d26f7d..c230dac 100644 --- a/components/otp/files/index.blade.php +++ b/components/otp/files/index.blade.php @@ -330,4 +330,11 @@ class="contents" @endif + + {{-- What a plain
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) + + @endif diff --git a/components/otp/files/input.blade.php b/components/otp/files/input.blade.php index 96a7aa8..ba01425 100644 --- a/components/otp/files/input.blade.php +++ b/components/otp/files/input.blade.php @@ -1,4 +1,4 @@ -@aware(['type' => 'text','name'=> null]) +@aware(['type' => 'text']) @php $classes = [ @@ -21,7 +21,6 @@ merge([ - 'name' => $name, 'type' => $type, ]) ->class($classes) From 26fb071e5013d3021f9db7d380f602bd4409720b Mon Sep 17 00:00:00 2001 From: Oneleggedswede Date: Wed, 19 Aug 2026 01:05:52 +0200 Subject: [PATCH 3/4] feat(otp): make `required` opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inputs are unconditionally `required`. A `required` control that is `display: none` is still validated, so an OTP kept in the same `` as an alternative field and swapped with `x-show` makes the browser refuse the submit outright — "An invalid form control with name='' is not focusable" — and the other field can never be sent. That is exactly the shape of the starter kit's two-factor challenge, where the recovery-code path becomes unusable. `required` becomes a prop defaulting to `true`, so nothing changes unless a page passes `:required="false"`. --- components/otp/files/index.blade.php | 1 + components/otp/files/input.blade.php | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/components/otp/files/index.blade.php b/components/otp/files/index.blade.php index c230dac..7bf9578 100644 --- a/components/otp/files/index.blade.php +++ b/components/otp/files/index.blade.php @@ -4,6 +4,7 @@ 'type' => 'text', 'allowedPattern' => '[0-9]', 'autofocus' => false, + 'required' => true, ]) @php diff --git a/components/otp/files/input.blade.php b/components/otp/files/input.blade.php index ba01425..57b56e9 100644 --- a/components/otp/files/input.blade.php +++ b/components/otp/files/input.blade.php @@ -1,4 +1,4 @@ -@aware(['type' => 'text']) +@aware(['type' => 'text', 'required' => true]) @php $classes = [ @@ -26,7 +26,7 @@ ->class($classes) }} - required + @if ($required) required @endif maxlength="1" data-slot="otp-input" x-on:input="handleInput($el)" From 0191cdf812a7b9192e1166c0314c3027387ddef3 Mon Sep 17 00:00:00 2001 From: Oneleggedswede Date: Wed, 19 Aug 2026 01:05:52 +0200 Subject: [PATCH 4/4] docs(otp): document naming, required, and the tab-order change The click-to-focus section described disabled inputs and the `::after` overlay that worked around them; neither exists now. Adds `name` and `required` to the props table, a plain-form example, and a note about `required` on an OTP hidden behind `x-show`. --- components/otp/usage.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/components/otp/usage.md b/components/otp/usage.md index ea17b7d..6882778 100644 --- a/components/otp/usage.md +++ b/components/otp/usage.md @@ -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 + + @csrf + + + + + +``` + +> **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 `
` — a `required` input that is `display: none` makes the browser refuse the submit. + ## Customization ### Custom Length @@ -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
- + -

Try clicking on neutraled-out (disabled) inputs - focus will jump to the next available input.

+

Try clicking past the digits you have typed - focus will jump to the next available input.

@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 @@ -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 @@ -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 |