+ "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"ruby-ui--input-otp\"\nexport default class extends Controller {\n static targets = [\"input\", \"slot\"]\n static values = { length: Number, charClass: String }\n\n connect() {\n // A server-rendered value (prefilled from a previous submission, a\n // validation error, etc.) may exceed length or contain characters that\n // fail pattern. Sanitize it up front so the hidden slots never mask\n // an invalid/oversized value that would otherwise still get submitted.\n this.sanitizeValue()\n this.paint()\n this.boundOnSelectionChange = this.onSelectionChange.bind(this)\n document.addEventListener(\"selectionchange\", this.boundOnSelectionChange)\n }\n\n disconnect() {\n document.removeEventListener(\"selectionchange\", this.boundOnSelectionChange)\n }\n\n onInput() {\n this.sanitizeValue()\n const filtered = this.inputTarget.value\n\n this.normalizeSelection()\n this.paint()\n this.dispatch(\"input\", { detail: { value: filtered } })\n if (filtered.length === this.lengthValue) {\n this.dispatch(\"complete\", { detail: { value: filtered } })\n }\n }\n\n onFocus() {\n const end = this.inputTarget.value.length\n const start = Math.min(end, this.lengthValue - 1)\n this.inputTarget.setSelectionRange(start, end)\n this.paint()\n }\n\n onBlur() {\n this.paint()\n }\n\n onKeydown(event) {\n const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }\n if (!(event.key in moves)) return\n\n event.preventDefault()\n const current = this.inputTarget.selectionStart ?? 0\n const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1)\n const hasChar = next < this.inputTarget.value.length\n this.inputTarget.setSelectionRange(next, hasChar ? next + 1 : next)\n this.paint()\n }\n\n onPaste(event) {\n event.preventDefault()\n const pasted = this.filter(event.clipboardData.getData(\"text/plain\"))\n if (!pasted) return\n\n const start = this.inputTarget.selectionStart ?? 0\n const end = this.inputTarget.selectionEnd ?? start\n const current = this.inputTarget.value\n const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue)\n\n this.inputTarget.value = merged\n const caret = Math.min(merged.length, this.lengthValue - 1)\n this.inputTarget.setSelectionRange(caret, merged.length)\n\n this.paint()\n this.dispatch(\"input\", { detail: { value: merged } })\n if (merged.length === this.lengthValue) this.dispatch(\"complete\", { detail: { value: merged } })\n }\n\n onSelectionChange() {\n if (document.activeElement !== this.inputTarget) return\n this.paint()\n }\n\n // After typing, replacing, or deleting, the browser leaves a collapsed\n // caret. If it landed on a slot that already has a character (not the\n // true insert-mode end of the value), re-select that character as a\n // 1-char range so the next keystroke replaces it instead of being\n // silently dropped by the native maxlength/no-selection behavior.\n normalizeSelection() {\n const input = this.inputTarget\n const value = input.value\n const s = input.selectionStart\n const e = input.selectionEnd\n if (s === null || e === null || s !== e) return\n\n const isInsertMode = s === value.length && value.length < this.lengthValue\n if (isInsertMode) return\n\n const index = Math.min(s, this.lengthValue - 1)\n input.setSelectionRange(index, index < value.length ? index + 1 : index)\n }\n\n filter(raw) {\n const re = new RegExp(this.charClassValue)\n return raw.split(\"\").filter((char) => re.test(char)).join(\"\")\n }\n\n sanitizeValue() {\n const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)\n if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered\n }\n\n paint() {\n const value = this.inputTarget.value\n const isFocused = document.activeElement === this.inputTarget\n const start = this.inputTarget.selectionStart ?? value.length\n const end = this.inputTarget.selectionEnd ?? value.length\n const activeIndex = Math.min(start, this.lengthValue - 1)\n\n this.slotTargets.forEach((slot) => {\n const index = Number(slot.dataset.index)\n const char = value[index] ?? \"\"\n const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end))\n\n slot.textContent = char\n slot.dataset.active = isActive ? \"true\" : \"false\"\n slot.dataset.caret = isActive && char === \"\" ? \"true\" : \"false\"\n })\n }\n}\n"
0 commit comments