@trustquery/browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,592 @@
1
+ // DropdownManager - Handles dropdown menus with filtering, keyboard navigation, and selection
2
+
3
+ export default class DropdownManager {
4
+ /**
5
+ * Create dropdown manager
6
+ * @param {Object} options - Configuration
7
+ */
8
+ constructor(options = {}) {
9
+ this.options = {
10
+ styleManager: options.styleManager || null,
11
+ textarea: options.textarea || null,
12
+ onWordClick: options.onWordClick || null,
13
+ dropdownOffset: options.dropdownOffset || 10, // Configurable offset from trigger word
14
+ ...options
15
+ };
16
+
17
+ this.activeDropdown = null;
18
+ this.activeDropdownMatch = null;
19
+ this.dropdownOptions = null;
20
+ this.dropdownMatchData = null;
21
+ this.selectedDropdownIndex = 0;
22
+ this.keyboardHandler = null;
23
+
24
+ console.log('[DropdownManager] Initialized with offset:', this.options.dropdownOffset);
25
+ }
26
+
27
+ /**
28
+ * Show dropdown for a match
29
+ * @param {HTMLElement} matchEl - Match element
30
+ * @param {Object} matchData - Match data
31
+ */
32
+ showDropdown(matchEl, matchData) {
33
+ // Close any existing dropdown
34
+ this.hideDropdown();
35
+
36
+ const command = matchData.command;
37
+
38
+ // Get dropdown options - check intent.handler.options first (new format)
39
+ const options = matchData.intent?.handler?.options || command.options || command.dropdownOptions || [];
40
+
41
+ if (options.length === 0) {
42
+ console.warn('[DropdownManager] No dropdown options for:', matchData.text);
43
+ return;
44
+ }
45
+
46
+ // Store reference to match element
47
+ this.activeDropdownMatch = matchEl;
48
+ this.selectedDropdownIndex = 0;
49
+
50
+ // Create dropdown
51
+ const dropdown = document.createElement('div');
52
+ dropdown.className = 'tq-dropdown';
53
+
54
+ // Apply inline styles via StyleManager
55
+ if (this.options.styleManager) {
56
+ this.options.styleManager.applyDropdownStyles(dropdown);
57
+ }
58
+
59
+ // Add header container based on message-state
60
+ const messageState = matchData.intent?.handler?.['message-state'] || 'info';
61
+ this.createHeaderContainer(dropdown, messageState);
62
+
63
+ // Add description row if message exists
64
+ const message = matchData.intent?.handler?.message;
65
+ if (message) {
66
+ this.createDescriptionRow(dropdown, message, messageState);
67
+ }
68
+
69
+ // Check if filter is enabled
70
+ const hasFilter = matchData.intent?.handler?.filter === true;
71
+
72
+ // Add filter input if enabled
73
+ if (hasFilter) {
74
+ this.createFilterInput(dropdown, matchData);
75
+ }
76
+
77
+ // Create dropdown items
78
+ this.createDropdownItems(dropdown, options, matchData);
79
+
80
+ // Prevent textarea blur when clicking dropdown (except filter input)
81
+ dropdown.addEventListener('mousedown', (e) => {
82
+ // Allow filter input to receive focus naturally
83
+ if (!e.target.classList.contains('tq-dropdown-filter')) {
84
+ e.preventDefault(); // Prevent blur on textarea for items
85
+ }
86
+ });
87
+
88
+ // Add to document
89
+ document.body.appendChild(dropdown);
90
+ this.activeDropdown = dropdown;
91
+ this.dropdownOptions = options;
92
+ this.dropdownMatchData = matchData;
93
+
94
+ // Position dropdown
95
+ this.positionDropdown(dropdown, matchEl);
96
+
97
+ // Setup keyboard navigation
98
+ this.setupKeyboardHandlers();
99
+
100
+ // Close on click outside
101
+ setTimeout(() => {
102
+ document.addEventListener('click', this.closeDropdownHandler);
103
+ }, 0);
104
+
105
+ console.log('[DropdownManager] Dropdown shown with', options.length, 'options');
106
+ }
107
+
108
+ /**
109
+ * Create header container for dropdown
110
+ * @param {HTMLElement} dropdown - Dropdown element
111
+ * @param {string} messageState - Message state (error, warning, info)
112
+ */
113
+ createHeaderContainer(dropdown, messageState) {
114
+ const headerContainer = document.createElement('div');
115
+ headerContainer.className = 'bubble-header-container';
116
+ headerContainer.setAttribute('data-type', messageState);
117
+
118
+ // Create image
119
+ const img = document.createElement('img');
120
+ img.src = `./assets/trustquery-${messageState}.svg`;
121
+ img.style.height = '24px';
122
+ img.style.width = 'auto';
123
+
124
+ // Create text span
125
+ const span = document.createElement('span');
126
+ const textMap = {
127
+ 'error': 'TrustQuery Stop',
128
+ 'warning': 'TrustQuery Clarify',
129
+ 'info': 'TrustQuery Quick Link'
130
+ };
131
+ span.textContent = textMap[messageState] || 'TrustQuery';
132
+
133
+ // Append to header
134
+ headerContainer.appendChild(img);
135
+ headerContainer.appendChild(span);
136
+
137
+ // Apply styles to header via StyleManager
138
+ if (this.options.styleManager) {
139
+ this.options.styleManager.applyDropdownHeaderStyles(headerContainer, messageState);
140
+ }
141
+
142
+ dropdown.appendChild(headerContainer);
143
+ }
144
+
145
+ /**
146
+ * Create description row for dropdown
147
+ * @param {HTMLElement} dropdown - Dropdown element
148
+ * @param {string} message - Description message
149
+ * @param {string} messageState - Message state (error, warning, info)
150
+ */
151
+ createDescriptionRow(dropdown, message, messageState) {
152
+ const descriptionRow = document.createElement('div');
153
+ descriptionRow.className = 'tq-dropdown-description';
154
+ descriptionRow.textContent = message;
155
+
156
+ // Apply styles to description via StyleManager
157
+ if (this.options.styleManager) {
158
+ this.options.styleManager.applyDropdownDescriptionStyles(descriptionRow, messageState);
159
+ }
160
+
161
+ dropdown.appendChild(descriptionRow);
162
+ }
163
+
164
+ /**
165
+ * Create filter input for dropdown
166
+ * @param {HTMLElement} dropdown - Dropdown element
167
+ * @param {Object} matchData - Match data
168
+ */
169
+ createFilterInput(dropdown, matchData) {
170
+ const filterInput = document.createElement('input');
171
+ filterInput.type = 'text';
172
+ filterInput.className = 'tq-dropdown-filter';
173
+ filterInput.placeholder = 'Filter options...';
174
+
175
+ // Apply inline styles via StyleManager
176
+ if (this.options.styleManager) {
177
+ this.options.styleManager.applyDropdownFilterStyles(filterInput);
178
+ }
179
+
180
+ // Filter click handling is managed by dropdown and closeDropdownHandler
181
+ // No special mousedown/blur handling needed - filter focuses naturally
182
+
183
+ // Filter options as user types
184
+ filterInput.addEventListener('input', (e) => {
185
+ const query = e.target.value.toLowerCase();
186
+ const items = dropdown.querySelectorAll('.tq-dropdown-item');
187
+ let firstVisibleIndex = -1;
188
+
189
+ items.forEach((item, index) => {
190
+ const text = item.textContent.toLowerCase();
191
+ const matches = text.includes(query);
192
+ item.style.display = matches ? 'block' : 'none';
193
+
194
+ // Track first visible item for selection
195
+ if (matches && firstVisibleIndex === -1) {
196
+ firstVisibleIndex = index;
197
+ }
198
+ });
199
+
200
+ // Update selected index to first visible item
201
+ if (firstVisibleIndex !== -1) {
202
+ this.selectedDropdownIndex = firstVisibleIndex;
203
+ this.updateDropdownSelection();
204
+ }
205
+ });
206
+
207
+ // Prevent dropdown keyboard navigation from affecting filter input
208
+ filterInput.addEventListener('keydown', (e) => {
209
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
210
+ e.preventDefault();
211
+ e.stopPropagation();
212
+ // Move focus to dropdown items
213
+ filterInput.blur();
214
+ this.options.textarea.focus();
215
+ } else if (e.key === 'Enter') {
216
+ e.preventDefault();
217
+ e.stopPropagation();
218
+ this.selectCurrentDropdownItem();
219
+ } else if (e.key === 'Escape') {
220
+ e.preventDefault();
221
+ e.stopPropagation();
222
+ this.hideDropdown();
223
+ }
224
+ });
225
+
226
+ dropdown.appendChild(filterInput);
227
+
228
+ // Don't auto-focus - keep focus on textarea for natural typing/arrow key flow
229
+ }
230
+
231
+ /**
232
+ * Create dropdown items
233
+ * @param {HTMLElement} dropdown - Dropdown element
234
+ * @param {Array} options - Dropdown options
235
+ * @param {Object} matchData - Match data
236
+ */
237
+ createDropdownItems(dropdown, options, matchData) {
238
+ options.forEach((option, index) => {
239
+ // Check if this is a user-input option
240
+ if (typeof option === 'object' && option['user-input'] === true) {
241
+ this.createUserInputOption(dropdown, option, matchData);
242
+ return;
243
+ }
244
+
245
+ const item = document.createElement('div');
246
+ item.className = 'tq-dropdown-item';
247
+ item.textContent = typeof option === 'string' ? option : option.label || option.value;
248
+
249
+ // Highlight first item by default
250
+ if (index === 0) {
251
+ item.classList.add('tq-dropdown-item-selected');
252
+ }
253
+
254
+ // Apply inline styles to item via StyleManager
255
+ if (this.options.styleManager) {
256
+ this.options.styleManager.applyDropdownItemStyles(item);
257
+ }
258
+
259
+ item.addEventListener('click', (e) => {
260
+ e.stopPropagation();
261
+ this.handleDropdownSelect(option, matchData);
262
+ this.hideDropdown();
263
+ });
264
+ dropdown.appendChild(item);
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Create user input option
270
+ * @param {HTMLElement} dropdown - Dropdown element
271
+ * @param {Object} option - Option with user-input flag
272
+ * @param {Object} matchData - Match data
273
+ */
274
+ createUserInputOption(dropdown, option, matchData) {
275
+ const container = document.createElement('div');
276
+ container.className = 'tq-dropdown-user-input-container tq-dropdown-item';
277
+ container.setAttribute('data-user-input', 'true');
278
+
279
+ const input = document.createElement('input');
280
+ input.type = 'text';
281
+ input.className = 'tq-dropdown-user-input';
282
+ input.placeholder = option.placeholder || 'Enter custom value...';
283
+
284
+ container.appendChild(input);
285
+
286
+ // Apply styles via StyleManager
287
+ if (this.options.styleManager) {
288
+ this.options.styleManager.applyUserInputStyles(container, input);
289
+ }
290
+
291
+ // Click on container focuses input
292
+ container.addEventListener('click', (e) => {
293
+ e.stopPropagation();
294
+ input.focus();
295
+ });
296
+
297
+ // Handle submit on Enter key
298
+ const handleSubmit = () => {
299
+ const value = input.value.trim();
300
+ if (value) {
301
+ const customOption = {
302
+ label: value,
303
+ 'on-select': {
304
+ display: value
305
+ }
306
+ };
307
+ this.handleDropdownSelect(customOption, matchData);
308
+ this.hideDropdown();
309
+ }
310
+ };
311
+
312
+ input.addEventListener('keydown', (e) => {
313
+ if (e.key === 'Enter') {
314
+ e.preventDefault();
315
+ e.stopPropagation();
316
+ handleSubmit();
317
+ } else if (e.key === 'Escape') {
318
+ e.preventDefault();
319
+ e.stopPropagation();
320
+ this.hideDropdown();
321
+ } else if (e.key === 'ArrowDown') {
322
+ // Navigate to next item
323
+ e.preventDefault();
324
+ e.stopPropagation();
325
+ this.moveDropdownSelection(1);
326
+ this.options.textarea.focus();
327
+ } else if (e.key === 'ArrowUp') {
328
+ // Navigate to previous item
329
+ e.preventDefault();
330
+ e.stopPropagation();
331
+ this.moveDropdownSelection(-1);
332
+ this.options.textarea.focus();
333
+ }
334
+ });
335
+
336
+ dropdown.appendChild(container);
337
+ }
338
+
339
+ /**
340
+ * Hide current dropdown
341
+ */
342
+ hideDropdown() {
343
+ if (this.activeDropdown) {
344
+ this.activeDropdown.remove();
345
+ this.activeDropdown = null;
346
+ this.activeDropdownMatch = null;
347
+ this.dropdownOptions = null;
348
+ this.dropdownMatchData = null;
349
+ this.selectedDropdownIndex = 0;
350
+ document.removeEventListener('click', this.closeDropdownHandler);
351
+ document.removeEventListener('keydown', this.keyboardHandler);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Close dropdown handler (bound to document)
357
+ */
358
+ closeDropdownHandler = (e) => {
359
+ // Only close if clicking outside the dropdown
360
+ if (this.activeDropdown && !this.activeDropdown.contains(e.target)) {
361
+ this.hideDropdown();
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Position dropdown relative to match element
367
+ * @param {HTMLElement} dropdown - Dropdown element
368
+ * @param {HTMLElement} matchEl - Match element
369
+ */
370
+ positionDropdown(dropdown, matchEl) {
371
+ const rect = matchEl.getBoundingClientRect();
372
+ const dropdownRect = dropdown.getBoundingClientRect();
373
+ const offset = this.options.dropdownOffset;
374
+
375
+ // Position above match by default (since input is at bottom)
376
+ let top = rect.top + window.scrollY - dropdownRect.height - offset;
377
+ let left = rect.left + window.scrollX;
378
+
379
+ // If dropdown goes off top edge, position below instead
380
+ if (top < window.scrollY) {
381
+ top = rect.bottom + window.scrollY + offset;
382
+ }
383
+
384
+ // Check if dropdown goes off right edge
385
+ if (left + dropdownRect.width > window.innerWidth) {
386
+ left = window.innerWidth - dropdownRect.width - 10;
387
+ }
388
+
389
+ dropdown.style.top = `${top}px`;
390
+ dropdown.style.left = `${left}px`;
391
+ }
392
+
393
+ /**
394
+ * Setup keyboard handlers for dropdown navigation
395
+ */
396
+ setupKeyboardHandlers() {
397
+ // Remove old handler if exists
398
+ if (this.keyboardHandler) {
399
+ document.removeEventListener('keydown', this.keyboardHandler);
400
+ }
401
+
402
+ // Create new handler
403
+ this.keyboardHandler = (e) => {
404
+ // Only handle if dropdown is active
405
+ if (!this.activeDropdown) {
406
+ return;
407
+ }
408
+
409
+ switch (e.key) {
410
+ case 'ArrowDown':
411
+ e.preventDefault();
412
+ this.moveDropdownSelection(1);
413
+ break;
414
+ case 'ArrowUp':
415
+ e.preventDefault();
416
+ this.moveDropdownSelection(-1);
417
+ break;
418
+ case 'Enter':
419
+ e.preventDefault();
420
+ this.selectCurrentDropdownItem();
421
+ break;
422
+ case 'Escape':
423
+ e.preventDefault();
424
+ this.hideDropdown();
425
+ break;
426
+ }
427
+ };
428
+
429
+ // Attach handler
430
+ document.addEventListener('keydown', this.keyboardHandler);
431
+ }
432
+
433
+ /**
434
+ * Move dropdown selection up or down
435
+ * @param {number} direction - 1 for down, -1 for up
436
+ */
437
+ moveDropdownSelection(direction) {
438
+ if (!this.activeDropdown || !this.dropdownOptions) {
439
+ return;
440
+ }
441
+
442
+ const items = this.activeDropdown.querySelectorAll('.tq-dropdown-item');
443
+ const visibleItems = Array.from(items).filter(item => item.style.display !== 'none');
444
+
445
+ if (visibleItems.length === 0) {
446
+ return;
447
+ }
448
+
449
+ // Find current selected visible index
450
+ let currentVisibleIndex = visibleItems.findIndex((item, index) => {
451
+ return Array.from(items).indexOf(item) === this.selectedDropdownIndex;
452
+ });
453
+
454
+ // Move to next/prev visible item
455
+ currentVisibleIndex += direction;
456
+
457
+ // Wrap around visible items only
458
+ if (currentVisibleIndex < 0) {
459
+ currentVisibleIndex = visibleItems.length - 1;
460
+ } else if (currentVisibleIndex >= visibleItems.length) {
461
+ currentVisibleIndex = 0;
462
+ }
463
+
464
+ // Update selected index to the actual index
465
+ this.selectedDropdownIndex = Array.from(items).indexOf(visibleItems[currentVisibleIndex]);
466
+
467
+ // Update visual selection
468
+ this.updateDropdownSelection();
469
+ }
470
+
471
+ /**
472
+ * Update visual selection in dropdown
473
+ */
474
+ updateDropdownSelection() {
475
+ if (!this.activeDropdown) {
476
+ return;
477
+ }
478
+
479
+ const items = this.activeDropdown.querySelectorAll('.tq-dropdown-item');
480
+ items.forEach((item, index) => {
481
+ if (index === this.selectedDropdownIndex) {
482
+ item.classList.add('tq-dropdown-item-selected');
483
+ // Scroll into view if needed
484
+ item.scrollIntoView({ block: 'nearest' });
485
+
486
+ // If this is a user input item, focus the input
487
+ if (item.getAttribute('data-user-input') === 'true') {
488
+ const input = item.querySelector('.tq-dropdown-user-input');
489
+ if (input) {
490
+ setTimeout(() => input.focus(), 0);
491
+ }
492
+ }
493
+ } else {
494
+ item.classList.remove('tq-dropdown-item-selected');
495
+
496
+ // If this is a user input item, blur the input when navigating away
497
+ if (item.getAttribute('data-user-input') === 'true') {
498
+ const input = item.querySelector('.tq-dropdown-user-input');
499
+ if (input) {
500
+ input.blur();
501
+ }
502
+ }
503
+ }
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Select the currently highlighted dropdown item
509
+ */
510
+ selectCurrentDropdownItem() {
511
+ if (!this.dropdownOptions || !this.dropdownMatchData) {
512
+ return;
513
+ }
514
+
515
+ const items = this.activeDropdown.querySelectorAll('.tq-dropdown-item');
516
+ const selectedItem = items[this.selectedDropdownIndex];
517
+
518
+ // Check if this is a user input item
519
+ if (selectedItem && selectedItem.getAttribute('data-user-input') === 'true') {
520
+ // Focus the input field instead of selecting
521
+ const input = selectedItem.querySelector('.tq-dropdown-user-input');
522
+ if (input) {
523
+ input.focus();
524
+ }
525
+ return;
526
+ }
527
+
528
+ const selectedOption = this.dropdownOptions[this.selectedDropdownIndex];
529
+ this.handleDropdownSelect(selectedOption, this.dropdownMatchData);
530
+ this.hideDropdown();
531
+ }
532
+
533
+ /**
534
+ * Handle dropdown option selection
535
+ * @param {*} option - Selected option
536
+ * @param {Object} matchData - Match data
537
+ */
538
+ handleDropdownSelect(option, matchData) {
539
+ console.log('[DropdownManager] Dropdown option selected:', option, 'for:', matchData.text);
540
+
541
+ // Check if option has on-select.display
542
+ if (option['on-select'] && option['on-select'].display && this.options.textarea) {
543
+ const displayText = option['on-select'].display;
544
+ const textarea = this.options.textarea;
545
+ const text = textarea.value;
546
+
547
+ // Find the trigger text position
548
+ const lines = text.split('\n');
549
+ const line = lines[matchData.line];
550
+
551
+ if (line) {
552
+ // Append to trigger text with "/" separator
553
+ const before = line.substring(0, matchData.col);
554
+ const after = line.substring(matchData.col + matchData.text.length);
555
+ const newText = matchData.text + '/' + displayText;
556
+ lines[matchData.line] = before + newText + after;
557
+
558
+ // Update textarea
559
+ textarea.value = lines.join('\n');
560
+
561
+ // Trigger input event to re-render
562
+ const inputEvent = new Event('input', { bubbles: true });
563
+ textarea.dispatchEvent(inputEvent);
564
+
565
+ console.log('[DropdownManager] Appended to', matchData.text, '→', newText);
566
+ }
567
+ }
568
+
569
+ // Trigger callback
570
+ if (this.options.onWordClick) {
571
+ this.options.onWordClick({
572
+ ...matchData,
573
+ selectedOption: option
574
+ });
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Cleanup
580
+ */
581
+ cleanup() {
582
+ this.hideDropdown();
583
+ }
584
+
585
+ /**
586
+ * Destroy
587
+ */
588
+ destroy() {
589
+ this.cleanup();
590
+ console.log('[DropdownManager] Destroyed');
591
+ }
592
+ }