diff --git a/components/otp/files/index.blade.php b/components/otp/files/index.blade.php index c91d61b..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 @@ -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; }); }, @@ -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 @@ -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 = ''; } @@ -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(() => { @@ -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()); } }, @@ -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)]); } } }" @@ -327,4 +331,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 903e892..57b56e9 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', 'required' => true]) @php $classes = [ @@ -21,13 +21,12 @@ merge([ - 'name' => $name, 'type' => $type, ]) ->class($classes) }} - required + @if ($required) required @endif maxlength="1" data-slot="otp-input" x-on:input="handleInput($el)" @@ -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()" 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 |