]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
OTP: fix click-to-focus and overwrite-on-retype (#42524)
authorMark Otto <markd.otto@gmail.com>
Sat, 4 Jul 2026 03:49:42 +0000 (20:49 -0700)
committerGitHub <noreply@github.com>
Sat, 4 Jul 2026 03:49:42 +0000 (20:49 -0700)
* OtpInput: fix click-to-focus and overwrite-on-retype

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

* bump bundlewatch

* OTP: focus the input natively on tap to fix iPadOS keyboard dismiss

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.

* Bump bundlewatch

* OtpInput: cover tap-to-focus, beforeinput, and caret behavior

Adds unit tests for the native tap-to-focus (iPadOS keyboard) fix,
beforeinput overwrite/backspace/append semantics, tap clamping, paste
caret placement, selectionchange sync, the public focus() method, and
beforeinput listener cleanup on dispose. Brings branch coverage back
above the 89% threshold.

.bundlewatch.config.json
js/src/otp-input.js
js/tests/unit/otp-input.spec.js
scss/forms/_otp-input.scss
site/src/content/docs/forms/otp-input.mdx

index e24e775e822d8a679915a8f27c4d0a9988efbd4e..8d9a0724cb0086c63a0ce5e404c52c8e8be24a61 100644 (file)
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "86.75 kB"
+      "maxSize": "88.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-      "maxSize": "54.5 kB"
+      "maxSize": "54.75 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "58.0 kB"
+      "maxSize": "59.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "32.5 kB"
+      "maxSize": "32.75 kB"
     }
   ],
   "ci": {
index 937382d44352b72929ab2707ea0ed114cfdb5d5a..978946eee9beffd2db5ea992ca37d3551ebf6894 100644 (file)
@@ -26,7 +26,7 @@ const SELECTOR_DATA_OTP = '[data-bs-otp]'
 const SELECTOR_INPUT = 'input'
 
 // Events that should refresh the active-slot highlight as the caret moves
-const SYNC_EVENTS = ['blur', 'keyup', 'click', 'select']
+const SYNC_EVENTS = ['blur', 'keyup', 'select']
 
 const CLASS_NAME_INPUT = 'otp-input'
 const CLASS_NAME_RENDERED = 'otp-rendered'
@@ -77,6 +77,11 @@ class OtpInput extends BaseComponent {
     this._type = TYPES[this._config.type] || TYPES.numeric
     this._length = this._resolveLength()
     this._slots = []
+    // Tracks whether focus was triggered by a click so we can respect the
+    // clicked slot instead of jumping to the first empty one
+    this._pointerActive = false
+    // Slot index from the most recent tap, applied once focus settles
+    this._pointerIndex = 0
 
     this._setupInput()
     this._renderSlots()
@@ -116,15 +121,17 @@ class OtpInput extends BaseComponent {
 
   focus() {
     this._input.focus()
-    // Place the caret after the last entered character
-    const end = this._input.value.length
-    this._input.setSelectionRange(end, end)
+    // Select the first empty slot (or the last one when the value is full)
+    this._selectSlot(this._firstEmptyIndex())
     this._render()
   }
 
   dispose() {
     EventHandler.off(this._input, 'input', this._onInput)
+    EventHandler.off(this._input, 'beforeinput', this._onBeforeInput)
     EventHandler.off(this._input, 'focus', this._onFocus)
+    EventHandler.off(this._input, 'pointerdown', this._onPointerDown)
+    EventHandler.off(document, 'selectionchange', this._onSelectionChange)
     for (const type of SYNC_EVENTS) {
       EventHandler.off(this._input, type, this._onSync)
     }
@@ -204,14 +211,39 @@ class OtpInput extends BaseComponent {
 
   _addEventListeners() {
     // Listeners are attached with bare event names (not namespaced) because
-    // `input` is not in EventHandler's native-events list; we keep references
-    // so they can be removed on dispose.
+    // `input`, `beforeinput`, and `selectionchange` are not in EventHandler's
+    // native-events list; we keep references so they can be removed on dispose.
     this._onInput = () => this._handleInput()
-    this._onFocus = () => this.focus()
+    this._onBeforeInput = event => this._handleBeforeInput(event)
+    this._onPointerDown = event => this._handlePointerDown(event)
+    this._onFocus = () => {
+      if (this._pointerActive) {
+        // A tap focused the input natively; position the caret on the clicked
+        // slot now that focus has settled (doing this before native focus would
+        // make iOS/iPadOS raise then immediately dismiss the keyboard)
+        this._pointerActive = false
+        this._selectSlot(this._pointerIndex)
+        this._render()
+        return
+      }
+
+      // Keyboard (Tab) focus lands on the first empty slot
+      this._selectSlot(this._firstEmptyIndex())
+      this._render()
+    }
+
     this._onSync = () => this._render()
+    this._onSelectionChange = () => {
+      if (document.activeElement === this._input) {
+        this._render()
+      }
+    }
 
     EventHandler.on(this._input, 'input', this._onInput)
+    EventHandler.on(this._input, 'beforeinput', this._onBeforeInput)
     EventHandler.on(this._input, 'focus', this._onFocus)
+    EventHandler.on(this._input, 'pointerdown', this._onPointerDown)
+    EventHandler.on(document, 'selectionchange', this._onSelectionChange)
 
     // Keep the active-slot highlight in sync with the caret
     for (const type of SYNC_EVENTS) {
@@ -219,19 +251,124 @@ class OtpInput extends BaseComponent {
     }
   }
 
+  // Bulk path: paste, SMS autofill, or a programmatic value change land here as
+  // a single multi-character `input` event. Single keystrokes are handled by
+  // `_handleBeforeInput` (overwrite semantics) and never reach this method.
   _handleInput() {
     const sanitized = this._sanitize(this._input.value)
     if (sanitized !== this._input.value) {
       this._input.value = sanitized
     }
 
-    this._render()
+    // Place the caret on the first empty slot after a paste/autofill
+    if (document.activeElement === this._input) {
+      this._selectSlot(this._firstEmptyIndex())
+    }
 
-    EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })
+    this._afterValueChange()
+  }
+
+  // Intercept single-character typing and backspace so each slot is overwritten
+  // in place rather than inserting and shifting the rest of the value. Anything
+  // else (paste, autofill, IME composition) falls through to `_handleInput`.
+  _handleBeforeInput(event) {
+    const { inputType, data } = event
+
+    if (inputType === 'insertText' && data && data.length === 1) {
+      event.preventDefault()
+
+      const char = this._sanitize(data)
+      if (!char) {
+        return
+      }
+
+      const index = Math.min(this._input.selectionStart ?? 0, this._length - 1)
+      const chars = [...this._input.value]
+      chars[index] = char
+      this._input.value = chars.join('').slice(0, this._length)
+
+      this._selectSlot(index + 1)
+      this._afterValueChange()
+      return
+    }
+
+    if (inputType === 'deleteContentBackward') {
+      event.preventDefault()
+
+      const start = this._input.selectionStart ?? 0
+      const end = this._input.selectionEnd ?? start
+      const chars = [...this._input.value]
+
+      if (end > start) {
+        // A filled slot is selected: clear it and keep the caret in place
+        chars.splice(start, end - start)
+        this._input.value = chars.join('')
+        this._selectSlot(start)
+      } else if (start > 0) {
+        // Collapsed caret: remove the previous character and step back
+        chars.splice(start - 1, 1)
+        this._input.value = chars.join('')
+        this._selectSlot(start - 1)
+      }
+
+      this._afterValueChange()
+    }
+  }
+
+  _handlePointerDown(event) {
+    const index = this._slotIndexFromPoint(event.clientX)
+    if (index === null) {
+      return
+    }
+
+    // Don't let the caret land past the first empty slot
+    const target = Math.min(index, this._firstEmptyIndex())
+
+    if (document.activeElement === this._input) {
+      // Already focused (keyboard is up): take over caret placement from the
+      // browser. Safe to preventDefault here — it won't dismiss the keyboard.
+      event.preventDefault()
+      this._selectSlot(target)
+      this._render()
+      return
+    }
+
+    // Not yet focused: let the browser focus the input natively so the
+    // on-screen keyboard is raised by the user's tap. Position the caret in the
+    // focus handler once focus settles.
+    this._pointerActive = true
+    this._pointerIndex = target
+  }
+
+  // Map a viewport x-coordinate to the slot under it, clamped to the last slot
+  _slotIndexFromPoint(x) {
+    for (const [index, slot] of this._slots.entries()) {
+      if (x <= slot.getBoundingClientRect().right || index === this._slots.length - 1) {
+        return index
+      }
+    }
 
+    return null
+  }
+
+  _afterValueChange() {
+    this._render()
+    EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })
     this._checkComplete()
   }
 
+  _firstEmptyIndex() {
+    return Math.min(this._input.value.length, this._length - 1)
+  }
+
+  // Represent the active slot as a selection: a filled slot is selected so the
+  // next keystroke overwrites it; an empty slot gets a collapsed caret.
+  _selectSlot(index) {
+    const clamped = Math.max(0, Math.min(index, this._length - 1))
+    const end = clamped < this._input.value.length ? clamped + 1 : clamped
+    this._input.setSelectionRange(clamped, end)
+  }
+
   _sanitize(value) {
     return value.replace(this._type.filter, '').slice(0, this._length)
   }
index f94aedbf3d337f25451d57ce178dd8a3f2080cab..7989a326f492640300583cbea5b10a0a3e71cc73 100644 (file)
@@ -20,6 +20,29 @@ describe('OtpInput', () => {
     input.dispatchEvent(createEvent('input'))
   }
 
+  // The compiled CSS lays the slots out horizontally, but the unit test DOM has
+  // no stylesheet so the generated `<div>`s stack vertically (identical x-range).
+  // Force an inline horizontal layout so coordinate-based hit testing is
+  // deterministic when exercising pointer/tap behavior.
+  const layoutSlotsHorizontally = (otpEl, slotWidth = 30) => {
+    for (const slot of otpEl.querySelectorAll('.otp-slot, .otp-separator')) {
+      slot.style.display = 'inline-block'
+      slot.style.width = `${slotWidth}px`
+      slot.style.margin = '0'
+      slot.style.padding = '0'
+    }
+  }
+
+  const pointerDownOnSlot = (input, slot) => {
+    const { left, width } = slot.getBoundingClientRect()
+    input.dispatchEvent(new MouseEvent('pointerdown', {
+      bubbles: true, cancelable: true, clientX: left + (width / 2)
+    }))
+  }
+
+  const beforeInput = (input, options) =>
+    input.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, ...options }))
+
   describe('VERSION', () => {
     it('should return plugin version', () => {
       expect(OtpInput.VERSION).toEqual(jasmine.any(String))
@@ -219,6 +242,258 @@ describe('OtpInput', () => {
     })
   })
 
+  describe('interaction', () => {
+    it('should position the active slot from the tapped coordinate', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123456')
+
+      const slots = otpEl.querySelectorAll('.otp-slot')
+      // The input is already focused (keyboard up), so a tap repositions the
+      // caret immediately based on its x-coordinate
+      input.focus()
+      const { left, width } = slots[0].getBoundingClientRect()
+      input.dispatchEvent(new MouseEvent('pointerdown', {
+        bubbles: true, cancelable: true, clientX: left + (width / 2)
+      }))
+
+      expect(input.selectionStart).toEqual(0)
+      // A filled slot is selected so the next keystroke overwrites it
+      expect(input.selectionEnd).toEqual(1)
+      expect(slots[0]).toHaveClass('otp-slot-active')
+    })
+
+    it('should overwrite the active slot instead of inserting', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123456')
+
+      input.focus()
+      input.setSelectionRange(2, 3)
+      input.dispatchEvent(new InputEvent('beforeinput', {
+        inputType: 'insertText', data: '9', bubbles: true, cancelable: true
+      }))
+
+      expect(input.value).toEqual('129456')
+      // Caret advances to the next slot
+      expect(input.selectionStart).toEqual(3)
+    })
+
+    it('should delete the previous character on backspace', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123')
+
+      input.focus()
+      input.setSelectionRange(3, 3)
+      input.dispatchEvent(new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true }))
+
+      expect(input.value).toEqual('12')
+      expect(input.selectionStart).toEqual(2)
+    })
+
+    it('should focus the first empty slot on keyboard focus', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('12')
+
+      input.focus()
+
+      expect(input.selectionStart).toEqual(2)
+      expect(otpEl.querySelectorAll('.otp-slot')[2]).toHaveClass('otp-slot-active')
+    })
+
+    it('should swallow a disallowed character on beforeinput', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      new OtpInput(otpEl) // eslint-disable-line no-new
+      const input = otpEl.querySelector('input')
+
+      input.focus()
+      const event = new InputEvent('beforeinput', {
+        inputType: 'insertText', data: 'a', bubbles: true, cancelable: true
+      })
+      input.dispatchEvent(event)
+
+      expect(input.value).toEqual('')
+      expect(event.defaultPrevented).toBeTrue()
+    })
+
+    it('should position the caret from the tap only after the input focuses natively (iPadOS keyboard fix)', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123456')
+
+      const slots = otpEl.querySelectorAll('.otp-slot')
+      // Input is not yet focused: a tap must NOT reposition the caret itself
+      // (that would let the browser raise then immediately dismiss the keyboard).
+      // The tapped slot is only remembered until focus settles.
+      pointerDownOnSlot(input, slots[0])
+      expect(document.activeElement).not.toEqual(input)
+
+      // Focus settles (the browser focuses the input from the same tap)
+      input.focus()
+
+      // Caret lands on the tapped slot (0), not the first-empty/last slot (5)
+      // that a plain keyboard focus would choose
+      expect(input.selectionStart).toEqual(0)
+      expect(input.selectionEnd).toEqual(1)
+      expect(slots[0]).toHaveClass('otp-slot-active')
+    })
+
+    it('should clamp a tap beyond the filled slots to the first empty slot', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('12')
+      layoutSlotsHorizontally(otpEl)
+
+      const slots = otpEl.querySelectorAll('.otp-slot')
+      input.focus()
+      // Move the caret away from the default first-empty position first
+      input.setSelectionRange(0, 1)
+
+      // Tap the far right (last) slot while only two of six are filled
+      pointerDownOnSlot(input, slots[5])
+
+      // The caret is clamped to the first empty slot (index 2), not slot 5
+      expect(input.selectionStart).toEqual(2)
+      expect(slots[2]).toHaveClass('otp-slot-active')
+    })
+
+    it('should clear the selected slot and keep the caret on backspace', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123456')
+
+      input.focus()
+      // A filled slot is selected (as the active slot always is)
+      input.setSelectionRange(2, 3)
+      beforeInput(input, { inputType: 'deleteContentBackward' })
+
+      expect(input.value).toEqual('12456')
+      expect(input.selectionStart).toEqual(2)
+    })
+
+    it('should do nothing on backspace at the start of an empty field', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      new OtpInput(otpEl) // eslint-disable-line no-new
+      const input = otpEl.querySelector('input')
+
+      input.focus()
+      input.setSelectionRange(0, 0)
+      const event = new InputEvent('beforeinput', {
+        inputType: 'deleteContentBackward', bubbles: true, cancelable: true
+      })
+      input.dispatchEvent(event)
+
+      expect(input.value).toEqual('')
+      expect(event.defaultPrevented).toBeTrue()
+    })
+
+    it('should append into an empty slot when typing at the caret', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('12')
+
+      input.focus()
+      input.setSelectionRange(2, 2)
+      beforeInput(input, { inputType: 'insertText', data: '3' })
+
+      expect(input.value).toEqual('123')
+      expect(input.selectionStart).toEqual(3)
+    })
+
+    it('should place the caret on the first empty slot after a paste/autofill', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      new OtpInput(otpEl) // eslint-disable-line no-new
+      const input = otpEl.querySelector('input')
+
+      input.focus()
+      // A paste lands as a single multi-character input event
+      typeInto(input, '123')
+
+      expect(input.value).toEqual('123')
+      expect(input.selectionStart).toEqual(3)
+      expect(input.selectionEnd).toEqual(3)
+    })
+
+    it('should keep the active slot in sync on selectionchange', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123456')
+
+      input.focus()
+      input.setSelectionRange(3, 4)
+      document.dispatchEvent(new Event('selectionchange'))
+
+      expect(otpEl.querySelectorAll('.otp-slot')[3]).toHaveClass('otp-slot-active')
+    })
+  })
+
+  describe('focus', () => {
+    it('should focus the input and select the first empty slot', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123')
+
+      otp.focus()
+
+      expect(document.activeElement).toEqual(input)
+      expect(input.selectionStart).toEqual(3)
+      expect(otpEl.querySelectorAll('.otp-slot')[3]).toHaveClass('otp-slot-active')
+    })
+
+    it('should select the last slot when the value is already full', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+      otp.setValue('123456')
+
+      otp.focus()
+
+      // Clamp to the last slot and select it so the next keystroke overwrites it
+      expect(input.selectionStart).toEqual(5)
+      expect(input.selectionEnd).toEqual(6)
+    })
+  })
+
   describe('mask', () => {
     it('should render the mask character but keep the real value', () => {
       fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"')
@@ -305,6 +580,24 @@ describe('OtpInput', () => {
       expect(fixtureEl.querySelector('.otp-slots')).toBeNull()
       expect(fixtureEl.querySelector('.otp').classList.contains('otp-rendered')).toBeFalse()
     })
+
+    it('should stop intercepting beforeinput after dispose', () => {
+      fixtureEl.innerHTML = getOtpHtml()
+
+      const otpEl = fixtureEl.querySelector('.otp')
+      const otp = new OtpInput(otpEl)
+      const input = otpEl.querySelector('input')
+
+      otp.dispose()
+
+      // The beforeinput listener is gone, so the event is no longer prevented
+      const event = new InputEvent('beforeinput', {
+        inputType: 'insertText', data: '1', bubbles: true, cancelable: true
+      })
+      input.dispatchEvent(event)
+
+      expect(event.defaultPrevented).toBeFalse()
+    })
   })
 
   describe('getInstance', () => {
index 06e34a57be6dd88d3ead9b771529b1f6ec707e03..4cb0a6ce8fdb846ba9f19a96d020500358b70fa8 100644 (file)
@@ -53,7 +53,10 @@ $otp-sizes: defaults(
     height: 100%;
     padding: 0;
     color: transparent;
-    text-align: center;
+    // The input sits on top and receives taps itself, so a touch focuses it
+    // natively (raising the on-screen keyboard via a genuine user gesture).
+    // The JS maps the tap's position to a slot and sets the caret.
+    pointer-events: auto;
     cursor: default;
     caret-color: transparent;
     background-color: transparent;
@@ -71,7 +74,7 @@ $otp-sizes: defaults(
   .otp-slots {
     display: inline-flex;
     gap: var(--otp-gap);
-    pointer-events: none; // let clicks fall through to the input overlay
+    pointer-events: none; // purely visual; the overlaid input handles interaction
   }
 
   .otp-slot {
@@ -84,6 +87,7 @@ $otp-sizes: defaults(
     font-weight: 500;
     line-height: 1;
     color: var(--otp-slot-fg);
+    user-select: none; // decorative cells; the real input handles selection
     background-color: var(--otp-slot-bg);
     border: var(--otp-slot-border-width) solid var(--otp-slot-border-color);
     @include border-radius(var(--otp-slot-border-radius));
index ce0c10ab74e72e33bd6d23c301c0b44ccaeded82..129a971b7abb226b9431e8a7ca7d9a893856a938 100644 (file)
@@ -13,6 +13,7 @@ OTP (One-Time Password) inputs are a common pattern for two-factor authenticatio
 - **One accessible control**: a single `<input>` backs the whole field, so assistive technology announces one field, not one per digit
 - **Browser autofill**: supports `autocomplete="one-time-code"` for SMS/email code autofill
 - **Paste support**: paste a full code—even a formatted one like `123-456`—and the extra characters are stripped automatically
+- **Click to edit**: click any slot to jump to it; typing overwrites that digit in place instead of pushing the others along
 - **Keyboard navigation**: all native text editing (typing, arrows, backspace, delete, selection) works out of the box
 - **Masking and types**: optionally mask the value, and restrict input to numeric, alphanumeric, or alphabetic characters