Skip to content

Commit 866fe7d

Browse files
committed
[Bug Fix] Normalize selection after every input so replace-and-advance chains
After replacing a filled slot, the browser leaves a collapsed caret right after the edited character. Since the value is at (or stays at) maxlength, further typing with a collapsed caret and no selection is a no-op — the user has to press an arrow key again between every replacement. Fix: after onInput, re-select the character at the new caret position as a 1-char range whenever one exists there (i.e. we're not in true insert-mode at the end of a not-yet-full value). This mirrors the onKeydown/onPaste selection logic so typing keeps replacing forward without needing arrow keys in between.
1 parent 6c543b3 commit 866fe7d

2 files changed

Lines changed: 40 additions & 0 deletions

File tree

docs/app/javascript/controllers/ruby_ui/input_otp_controller.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default class extends Controller {
1919
const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)
2020
if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered
2121

22+
this.normalizeSelection()
2223
this.paint()
2324
this.dispatch("input", { detail: { value: filtered } })
2425
if (filtered.length === this.lengthValue) {
@@ -73,6 +74,25 @@ export default class extends Controller {
7374
this.paint()
7475
}
7576

77+
// After typing, replacing, or deleting, the browser leaves a collapsed
78+
// caret. If it landed on a slot that already has a character (not the
79+
// true insert-mode end of the value), re-select that character as a
80+
// 1-char range so the next keystroke replaces it instead of being
81+
// silently dropped by the native maxlength/no-selection behavior.
82+
normalizeSelection() {
83+
const input = this.inputTarget
84+
const value = input.value
85+
const s = input.selectionStart
86+
const e = input.selectionEnd
87+
if (s === null || e === null || s !== e) return
88+
89+
const isInsertMode = s === value.length && value.length < this.lengthValue
90+
if (isInsertMode) return
91+
92+
const index = Math.min(s, this.lengthValue - 1)
93+
input.setSelectionRange(index, index < value.length ? index + 1 : index)
94+
}
95+
7696
filter(raw) {
7797
const re = new RegExp(this.charClassValue)
7898
return raw.split("").filter((char) => re.test(char)).join("")

gem/lib/ruby_ui/input_otp/input_otp_controller.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default class extends Controller {
1919
const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)
2020
if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered
2121

22+
this.normalizeSelection()
2223
this.paint()
2324
this.dispatch("input", { detail: { value: filtered } })
2425
if (filtered.length === this.lengthValue) {
@@ -73,6 +74,25 @@ export default class extends Controller {
7374
this.paint()
7475
}
7576

77+
// After typing, replacing, or deleting, the browser leaves a collapsed
78+
// caret. If it landed on a slot that already has a character (not the
79+
// true insert-mode end of the value), re-select that character as a
80+
// 1-char range so the next keystroke replaces it instead of being
81+
// silently dropped by the native maxlength/no-selection behavior.
82+
normalizeSelection() {
83+
const input = this.inputTarget
84+
const value = input.value
85+
const s = input.selectionStart
86+
const e = input.selectionEnd
87+
if (s === null || e === null || s !== e) return
88+
89+
const isInsertMode = s === value.length && value.length < this.lengthValue
90+
if (isInsertMode) return
91+
92+
const index = Math.min(s, this.lengthValue - 1)
93+
input.setSelectionRange(index, index < value.length ? index + 1 : index)
94+
}
95+
7696
filter(raw) {
7797
const re = new RegExp(this.charClassValue)
7898
return raw.split("").filter((char) => re.test(char)).join("")

0 commit comments

Comments
 (0)