1import { Controller } from "@hotwired/stimulus"; 2 3export default class extends Controller { 4 static targets = ["digit", "form", "submitButton"]; 5 static values = { 6 autoSubmit: { type: Boolean, default: false }, // Whether to automatically submit the form 7 autofocus: { type: Boolean, default: true }, // Whether to autofocus the first input 8 numericOnly: { type: Boolean, default: true }, // Whether to allow only digits (0-9) 9 }; 10 11 connect() { 12 const inputs = this._getInputTargets(); 13 14 // Set autofocus on the first input if autofocus is enabled 15 if (this.autofocusValue && inputs[0]) { 16 inputs[0].focus(); 17 } 18 19 // Keep a stable function reference for proper listener cleanup. 20 this.boundHandleFocus = this.handleFocus.bind(this); 21 22 // Add focus event listeners to all input targets. 23 inputs.forEach((input) => { 24 input.addEventListener("focus", this.boundHandleFocus); 25 }); 26 } 27 28 disconnect() { 29 // Clean up event listeners 30 this._getInputTargets().forEach((input) => { 31 input.removeEventListener("focus", this.boundHandleFocus); 32 }); 33 } 34 35 handleFocus(event) { 36 const input = event.target; 37 // Move cursor to end of input value 38 setTimeout(() => { 39 input.setSelectionRange(input.value.length, input.value.length); 40 }, 0); 41 } 42 43 isNumber(value) { 44 return /^[0-9]$/.test(value); 45 } 46 47 handleInput(event) { 48 const currentInput = event.target; 49 const nextInput = this._getNextInput(currentInput); 50 const normalizedValue = this._normalizeSingleValue(currentInput.value); 51 currentInput.value = normalizedValue; 52 53 if (this._isValidCharacter(normalizedValue)) { 54 if (nextInput) { 55 nextInput.focus(); 56 } else { 57 // Last input filled 58 this.handleSubmit(); 59 } 60 } 61 } 62 63 handleKeydown(event) { 64 const currentInput = event.target; 65 66 // Handle backspace navigation 67 if (event.key === "Backspace" && currentInput.value === "") { 68 const prevInput = this._getPreviousInput(currentInput); 69 if (prevInput) { 70 prevInput.focus(); 71 } 72 } 73 74 // Handle arrow key navigation 75 if (event.key === "ArrowLeft") { 76 event.preventDefault(); 77 const prevInput = this._getPreviousInput(currentInput); 78 if (prevInput) { 79 prevInput.focus(); 80 } 81 } 82 83 if (event.key === "ArrowRight") { 84 event.preventDefault(); 85 const nextInput = this._getNextInput(currentInput); 86 if (nextInput) { 87 nextInput.focus(); 88 } 89 } 90 } 91 92 handlePaste(event) { 93 event.preventDefault(); 94 const paste = this._normalizePasteValue((event.clipboardData || window.clipboardData).getData("text")); 95 const inputs = this._getInputTargets(); 96 97 if (!paste.length || !inputs.length) { 98 return; 99 } 100 101 const characters = paste.slice(0, inputs.length).split(""); 102 characters.forEach((character, index) => { 103 inputs[index].value = character; 104 }); 105 106 if (characters.length === inputs.length) { 107 this.handleSubmit(); 108 return; 109 } 110 111 const nextInput = inputs[characters.length]; 112 if (nextInput) { 113 nextInput.focus(); 114 } 115 } 116 117 handleSubmit(event) { 118 if (event) { 119 // If triggered by form submit event 120 event.preventDefault(); 121 } 122 if (this.hasSubmitButtonTarget && this._isComplete()) { 123 this.submitButtonTarget.focus(); 124 } 125 126 // Submit form if autoSubmit is true 127 if (this.autoSubmitValue) { 128 if (this.hasFormTarget) { 129 this.formTarget.submit(); 130 } 131 } 132 } 133 134 _getInputTargets() { 135 if (this.hasDigitTarget) { 136 return this.digitTargets; 137 } 138 139 // Backward-compatible fallback for older markup using num1..numN targets. 140 return Array.from(this.element.querySelectorAll('[data-two-factor-target^="num"]')); 141 } 142 143 _getNextInput(currentInput) { 144 const inputs = this._getInputTargets(); 145 const currentIndex = inputs.indexOf(currentInput); 146 if (currentIndex !== -1 && currentIndex < inputs.length - 1) { 147 return inputs[currentIndex + 1]; 148 } 149 return null; 150 } 151 152 _getPreviousInput(currentInput) { 153 const inputs = this._getInputTargets();
154 const currentIndex = inputs.indexOf(currentInput); 155 if (currentIndex > 0) { 156 return inputs[currentIndex - 1]; 157 } 158 return null; 159 } 160 161 _isComplete() { 162 const inputs = this._getInputTargets(); 163 return inputs.length > 0 && inputs.every((input) => this._isValidCharacter(input.value)); 164 } 165 166 _normalizeSingleValue(value) { 167 const withoutWhitespace = value.replace(/\s/g, ""); 168 if (this.numericOnlyValue) { 169 return withoutWhitespace.replace(/\D/g, "").slice(-1); 170 } 171 172 return withoutWhitespace.slice(-1); 173 } 174 175 _normalizePasteValue(value) { 176 const withoutWhitespace = value.replace(/\s/g, ""); 177 if (this.numericOnlyValue) { 178 return withoutWhitespace.replace(/\D/g, ""); 179 } 180 181 return withoutWhitespace; 182 } 183 184 _isValidCharacter(value) { 185 if (this.numericOnlyValue) { 186 return this.isNumber(value); 187 } 188 189 return value.length === 1; 190 } 191};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.