Passed
Push — master ( a578a1...c6e1c4 )
by Jesús
02:17
created

wordInput.ts ➔ validateWordInput   B

Complexity

Conditions 5

Size

Total Lines 34
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 27
dl 0
loc 34
rs 8.7653
c 0
b 0
f 0
1
import { elements, resetBoxes, setStateFromIndex, state } from '../../bip39';
2
import { setSyncWordInputCallback, updateDisplay } from '../../display';
3
import { showToast } from '../../display';
4
import { currentTranslations } from '../../language';
5
import { binaryValueToIndex, getWordByIndex, getWordIndex, isWordInWordlist } from '../domain/wordInputHelpers';
6
7
let selectedSuggestionIndex = -1;
8
let hideSuggestionsTimeout: NodeJS.Timeout | null = null;
9
let errorClearTimeout: NodeJS.Timeout | null = null;
10
11
export function setupWordInput(): void {
12
  // Register callback to avoid circular dependency
13
  setSyncWordInputCallback(syncWordInputFromState);
14
15
  elements.wordInput.addEventListener('input', handleWordInput);
16
  elements.wordInput.addEventListener('keydown', handleKeydown);
17
  elements.wordInput.addEventListener('focus', handleWordInput);
18
  elements.wordInput.addEventListener('blur', handleWordInputBlur);
19
20
  const clearBtn = document.getElementById('clear-input-btn');
21
  if (clearBtn) {
22
    clearBtn.addEventListener('click', handleClearInput);
23
  }
24
25
  document.addEventListener('click', e => {
26
    if (!elements.wordInput.contains(e.target as Node) && !elements.wordSuggestions.contains(e.target as Node)) {
27
      hideSuggestions();
28
    }
29
  });
30
}
31
32
function handleClearInput(): void {
33
  elements.wordInput.value = '';
34
  elements.wordInput.classList.remove('error');
35
  resetBoxes();
36
  updateDisplay();
37
  hideSuggestions();
38
  toggleClearButton(false);
39
  elements.wordInput.focus();
40
}
41
42
function handleWordInputBlur(): void {
43
  hideSuggestions();
44
  validateWordInput();
45
}
46
47
function validateWordInput(): void {
48
  const value = elements.wordInput.value.trim().toLowerCase();
49
50
  if (!value) {
51
    elements.wordInput.classList.remove('error');
52
    return;
53
  }
54
55
  // Check if word exists in wordlist using helper
56
  const wordExists = isWordInWordlist(value, state.wordlist);
57
58
  if (wordExists) {
59
    elements.wordInput.classList.remove('error');
60
    const wordIndex = getWordIndex(value, state.wordlist);
61
    if (wordIndex !== -1) {
62
      setStateFromIndex(wordIndex);
63
      updateDisplay();
64
    }
65
    return;
66
  }
67
68
  // Invalid word - show error and reset boxes
69
  elements.wordInput.classList.add('error');
70
  resetBoxes();
71
  updateDisplay();
72
  showToast('invalid-word-toast', currentTranslations.invalidWordMessage);
73
74
  if (errorClearTimeout) {
75
    clearTimeout(errorClearTimeout);
76
  }
77
  errorClearTimeout = setTimeout(() => {
78
    elements.wordInput.classList.remove('error');
79
  }, 3500);
80
}
81
82
function handleWordInput(): void {
83
  const value = elements.wordInput.value.trim().toLowerCase();
84
85
  toggleClearButton(elements.wordInput.value.length > 0);
86
87
  if (!value) {
88
    hideSuggestions();
89
    return;
90
  }
91
92
  // Find matching words
93
  const matches = state.wordlist.filter(word => word.toLowerCase().startsWith(value));
94
95
  if (matches.length === 0) {
96
    hideSuggestions();
97
    return;
98
  }
99
100
  // Hide suggestions if only 1 match and it's an exact match
101
  if (matches.length === 1 && matches[0].toLowerCase() === value) {
102
    hideSuggestions();
103
    return;
104
  }
105
106
  // Show suggestions
107
  showSuggestions(matches.slice(0, 10)); // Limit to 10 suggestions
108
  selectedSuggestionIndex = -1;
109
}
110
111
function showSuggestions(matches: string[]): void {
112
  elements.wordSuggestions.innerHTML = '';
113
114
  matches.forEach((word, index) => {
115
    const wordIndex = state.wordlist.indexOf(word);
116
    const item = document.createElement('div');
117
    item.className = 'suggestion-item';
118
    item.setAttribute('role', 'option');
119
    item.setAttribute('data-index', index.toString());
120
121
    item.innerHTML = `
122
      <span class="suggestion-word">${word}</span>
123
      <span class="suggestion-index">#${wordIndex + 1}</span>
124
    `;
125
126
    item.addEventListener('mousedown', e => {
127
      e.preventDefault(); // Prevent blur event
128
      selectWord(word);
129
    });
130
131
    item.addEventListener('mouseenter', () => {
132
      clearSuggestionSelection();
133
      selectedSuggestionIndex = index;
134
      item.setAttribute('aria-selected', 'true');
135
    });
136
137
    elements.wordSuggestions.appendChild(item);
138
  });
139
140
  elements.wordSuggestions.removeAttribute('hidden');
141
}
142
143
function hideSuggestions(): void {
144
  // Clear existing timeout to prevent multiple timers
145
  if (hideSuggestionsTimeout) {
146
    clearTimeout(hideSuggestionsTimeout);
147
  }
148
149
  hideSuggestionsTimeout = setTimeout(() => {
150
    elements.wordSuggestions.setAttribute('hidden', '');
151
    selectedSuggestionIndex = -1;
152
  }, 200);
153
}
154
155
function handleArrowDown(suggestions: NodeListOf<Element>): void {
156
  selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, suggestions.length - 1);
157
  updateSuggestionSelection(suggestions);
158
}
159
160
function handleArrowUp(suggestions: NodeListOf<Element>): void {
161
  selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, 0);
162
  updateSuggestionSelection(suggestions);
163
}
164
165
function handleEnterKey(suggestions: NodeListOf<Element>): void {
166
  if (selectedSuggestionIndex >= 0) {
167
    const selectedItem = suggestions[selectedSuggestionIndex] as HTMLElement;
168
    const word = selectedItem.querySelector('.suggestion-word')?.textContent || '';
169
    selectWord(word);
170
  }
171
}
172
173
function handleEscapeKey(): void {
174
  hideSuggestions();
175
  elements.wordInput.blur();
176
}
177
178
function handleKeydown(e: KeyboardEvent): void {
179
  const suggestions = elements.wordSuggestions.querySelectorAll('.suggestion-item');
180
181
  if (suggestions.length === 0) {
182
    return;
183
  }
184
185
  switch (e.key) {
186
    case 'ArrowDown':
187
      e.preventDefault();
188
      handleArrowDown(suggestions);
189
      break;
190
191
    case 'ArrowUp':
192
      e.preventDefault();
193
      handleArrowUp(suggestions);
194
      break;
195
196
    case 'Enter':
197
      e.preventDefault();
198
      handleEnterKey(suggestions);
199
      break;
200
201
    case 'Escape':
202
      handleEscapeKey();
203
      break;
204
  }
205
}
206
207
function updateSuggestionSelection(suggestions: NodeListOf<Element>): void {
208
  clearSuggestionSelection();
209
210
  if (selectedSuggestionIndex >= 0 && selectedSuggestionIndex < suggestions.length) {
211
    const selectedItem = suggestions[selectedSuggestionIndex] as HTMLElement;
212
    selectedItem.setAttribute('aria-selected', 'true');
213
    selectedItem.scrollIntoView({ block: 'nearest' });
214
  }
215
}
216
217
function clearSuggestionSelection(): void {
218
  elements.wordSuggestions.querySelectorAll('.suggestion-item').forEach(item => {
219
    item.setAttribute('aria-selected', 'false');
220
  });
221
}
222
223
function selectWord(word: string): void {
224
  const wordIndex = getWordIndex(word, state.wordlist);
225
226
  if (wordIndex === -1) return;
227
228
  elements.wordInput.value = word;
229
  elements.wordInput.classList.remove('error');
230
  setStateFromIndex(wordIndex);
231
  updateDisplay();
232
  hideSuggestions();
233
  elements.wordInput.blur();
234
}
235
236
export function clearWordInput(): void {
237
  elements.wordInput.value = '';
238
  elements.wordInput.classList.remove('error');
239
  hideSuggestions();
240
  toggleClearButton(false);
241
}
242
243
function toggleClearButton(show: boolean): void {
244
  const clearBtn = document.getElementById('clear-input-btn') as HTMLButtonElement | null;
245
  if (clearBtn) {
246
    if (show) {
247
      clearBtn.disabled = false;
248
      clearBtn.removeAttribute('aria-disabled');
249
    } else {
250
      clearBtn.disabled = true;
251
      clearBtn.setAttribute('aria-disabled', 'true');
252
    }
253
  }
254
}
255
256
export function syncWordInputFromState(): void {
257
  const index = state.boxes.reduce((acc, val, i) => acc + (val ? Math.pow(2, 11 - i) : 0), 0);
258
259
  if (index > 0 && index <= state.wordlist.length) {
260
    const wordIndex = binaryValueToIndex(index);
261
    const word = getWordByIndex(wordIndex, state.wordlist);
262
    if (word && elements.wordInput.value !== word) {
263
      elements.wordInput.value = word;
264
      toggleClearButton(true);
265
    }
266
  } else {
267
    if (elements.wordInput.value !== '') {
268
      elements.wordInput.value = '';
269
      toggleClearButton(false);
270
    }
271
  }
272
}
273