Skip to content

OTP: fix click-to-focus and overwrite-on-retype#42524

Open
mdo wants to merge 4 commits into
v6-devfrom
mdo/otp-input-focus-fix
Open

OTP: fix click-to-focus and overwrite-on-retype#42524
mdo wants to merge 4 commits into
v6-devfrom
mdo/otp-input-focus-fix

Conversation

@mdo

@mdo mdo commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

The single-input OTP rewrite (#42500) improved accessibility but left two interaction gaps reported after merge:

  1. You couldn't click a slot to focus it. The slots had pointer-events: none and focus always jumped to the end of the value, so the click position was ignored.
  2. Retyping a digit shifted the others along. A native <input> inserts, so editing mid-value pushed the remaining digits down. OTP entry expects overwrite.

Both stem from the same thing: a single, full-width, centered input can't map a click to a slot, and native editing inserts rather than overwrites.

Approach

Keep the single accessible <input> (preserving the one-announced-field, autocomplete="one-time-code", SMS autofill, password-manager, and formatted-paste wins) and make its interaction faithful to the input-otp model:

  • Active slot = a selection range[i, i+1] on a filled slot so the next keystroke overwrites it, [i, i] on an empty one so it appends.
  • beforeinput handler intercepts single-character typing (overwrite + advance) and backspace (clear / step back). Paste, SMS autofill, and IME composition still fall through to the existing bulk input path.
  • Clickable slots — a pointerdown handler focuses the input and positions the caret on the clicked slot, clamped to the first empty slot.
  • Sane focus — Tab/focus() land on the first empty slot instead of the absolute end; a document selectionchange listener keeps the active-slot highlight in sync with the caret.

@mdo mdo requested review from a team as code owners June 18, 2026 17:07
@mdo mdo changed the title OtpInput: fix click-to-focus and overwrite-on-retype OTP: fix click-to-focus and overwrite-on-retype Jun 23, 2026
@mdo mdo added this to v6.0.0 Jun 23, 2026
@github-project-automation github-project-automation Bot moved this to Inbox in v6.0.0 Jun 23, 2026
The single-input rewrite left two interaction gaps: clicking a slot
didn't position the caret there (slots had pointer-events: none and
focus always jumped to the end), and retyping inserted instead of
overwriting, so preceding digits shifted along.

Keep the single accessible input but make its interaction faithful to
the input-otp model:

- Represent the active slot as a selection range so the next keystroke
  overwrites a filled slot or appends to an empty one
- Intercept single-char typing and backspace via beforeinput for
  overwrite semantics; paste/autofill/IME still flow through input
- Make slots clickable (pointerdown) to position the caret, clamped to
  the first empty slot
- Land focus on the first empty slot instead of the end; track the caret
  with a document selectionchange listener
@mdo mdo force-pushed the mdo/otp-input-focus-fix branch from c1fa103 to 0586af7 Compare June 23, 2026 16:39
@coliff

coliff commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

PREVIEW: https://deploy-preview-42524--twbs-bootstrap.netlify.app/docs/6.0/forms/otp-input/

Serious bug introduced in this PR on iPadOS 26 and iPadOS 27 (Developer Beta). Tapping in the input initiates the keyboard but auto-dismisses within a split-second.

Also tested this on Edge and Firefox (Windows 11) and all working well there.

mdo added 2 commits June 27, 2026 20:03
# Conflicts:
#	.bundlewatch.config.json
The slots overlaid the input with pointer-events: none and the JS called
input.focus() programmatically after preventDefault()-ing the tap. On
iPadOS that raises the on-screen keyboard then dismisses it instantly,
because the keyboard must be raised by a genuine, un-prevented gesture on
the input itself.

Let the input receive taps (pointer-events: auto) so focus — and the
keyboard — come from the native gesture. Map the tap's x-coordinate to a
slot and set the caret in the focus handler once focus settles; when
already focused, reposition immediately (preventDefault is safe then).

Reported on iPadOS 26/27 by @coliff.
@mdo

mdo commented Jun 28, 2026

Copy link
Copy Markdown
Member Author

@coliff thanks for catching this — pushed a fix.

The cause: the visual slots overlaid the real <input> with pointer-events: none, so the component called input.focus() programmatically after preventDefault()-ing the tap. iPadOS raises the keyboard for that focus and then dismisses it immediately, because the on-screen keyboard has to be raised by a genuine, un-prevented gesture on the input itself.

The fix lets the input receive the tap natively (pointer-events: auto), so focus — and the keyboard — come from the real gesture. The tap's x-coordinate is mapped to a slot and the caret is positioned in the focus handler once focus settles; when the field is already focused, it repositions immediately.

Could you re-test on iPadOS 26/27 (Developer Beta) once the preview rebuilds? I don't have a device to confirm the keyboard now stays up. Desktop click-to-slot, overwrite-on-retype, and the full JS suite all still pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Inbox

Development

Successfully merging this pull request may close these issues.

2 participants