@trustquery/browser 0.2.6 → 0.2.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trustquery/browser",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
4
4
  "description": "Turn any textarea into an interactive trigger-based editor with inline styling",
5
5
  "type": "module",
6
6
  "main": "dist/trustquery.js",
@@ -17,6 +17,8 @@ export class CommandHandlerRegistry {
17
17
  this.register('user-select-oneOf', new UserSelectHandler());
18
18
  this.register('api-json-table', new ApiJsonTableHandler());
19
19
  this.register('api-md-table', new ApiMdTableHandler());
20
+ this.register('display-menu', new DisplayMenuHandler());
21
+ this.register('display-menu-with-uri', new DisplayMenuWithUriHandler());
20
22
  }
21
23
 
22
24
  /**
@@ -347,4 +349,46 @@ class ApiMdTableHandler extends CommandHandler {
347
349
  }
348
350
  }
349
351
 
352
+ /**
353
+ * Handler for display-menu category
354
+ * Shows dropdown menu with selectable options
355
+ */
356
+ class DisplayMenuHandler extends CommandHandler {
357
+ getStyles() {
358
+ return {
359
+ backgroundColor: 'rgba(16, 185, 129, 0.15)', // Green background
360
+ color: '#065f46', // Dark green text
361
+ textDecoration: 'none',
362
+ borderBottom: '2px solid #10b981', // Green underline
363
+ cursor: 'pointer'
364
+ };
365
+ }
366
+
367
+ getBubbleContent(matchData) {
368
+ // Display menu shows dropdown, not bubble
369
+ return null;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Handler for display-menu-with-uri category
375
+ * Shows dropdown menu with selectable options and clickable URI links
376
+ */
377
+ class DisplayMenuWithUriHandler extends CommandHandler {
378
+ getStyles() {
379
+ return {
380
+ backgroundColor: 'rgba(16, 185, 129, 0.15)', // Green background
381
+ color: '#065f46', // Dark green text
382
+ textDecoration: 'none',
383
+ borderBottom: '2px solid #10b981', // Green underline
384
+ cursor: 'pointer'
385
+ };
386
+ }
387
+
388
+ getBubbleContent(matchData) {
389
+ // Display menu with URI shows dropdown, not bubble
390
+ return null;
391
+ }
392
+ }
393
+
350
394
  export default CommandHandlerRegistry;
@@ -1,5 +1,7 @@
1
1
  // DropdownManager - Handles dropdown menus with filtering, keyboard navigation, and selection
2
2
 
3
+ import EdgeDetectionHelper from './dropdown-manager-helpers/EdgeDetectionHelper.js';
4
+
3
5
  export default class DropdownManager {
4
6
  /**
5
7
  * Create dropdown manager
@@ -53,9 +55,10 @@ export default class DropdownManager {
53
55
  const dropdown = document.createElement('div');
54
56
  dropdown.className = 'tq-dropdown';
55
57
 
56
- // Apply inline styles via StyleManager
58
+ // Apply inline styles via StyleManager (pass category for width adjustment)
59
+ const category = matchData.intent?.category || '';
57
60
  if (this.options.styleManager) {
58
- this.options.styleManager.applyDropdownStyles(dropdown);
61
+ this.options.styleManager.applyDropdownStyles(dropdown, category);
59
62
  }
60
63
 
61
64
  // Add header container based on message-state
@@ -239,6 +242,10 @@ export default class DropdownManager {
239
242
  * @param {Object} matchData - Match data
240
243
  */
241
244
  createDropdownItems(dropdown, options, matchData) {
245
+ // Check if this is a display-menu-with-uri category
246
+ const category = matchData.intent?.category || '';
247
+ const hasUriSupport = category === 'display-menu-with-uri';
248
+
242
249
  options.forEach((option, index) => {
243
250
  // Check if this is a user-input option
244
251
  if (typeof option === 'object' && option['user-input'] === true) {
@@ -248,7 +255,82 @@ export default class DropdownManager {
248
255
 
249
256
  const item = document.createElement('div');
250
257
  item.className = 'tq-dropdown-item';
251
- item.textContent = typeof option === 'string' ? option : option.label || option.value;
258
+
259
+ // Create label text
260
+ const labelText = typeof option === 'string' ? option : option.label || option.value;
261
+
262
+ // If display-menu-with-uri and option has uri, create label + link icon
263
+ if (hasUriSupport && typeof option === 'object' && option.uri) {
264
+ // Create label span
265
+ const labelSpan = document.createElement('span');
266
+ labelSpan.className = 'tq-dropdown-item-label';
267
+ labelSpan.textContent = labelText;
268
+ labelSpan.style.flex = '1';
269
+ labelSpan.style.cursor = 'pointer';
270
+
271
+ // Create link with truncated URI text
272
+ const linkIcon = document.createElement('a');
273
+ linkIcon.className = 'tq-dropdown-item-link';
274
+ linkIcon.href = option.uri;
275
+ linkIcon.target = '_blank';
276
+ linkIcon.rel = 'noopener noreferrer';
277
+
278
+ // Truncate URI to 20 characters
279
+ let displayUri = option.uri;
280
+ if (displayUri.length > 20) {
281
+ displayUri = displayUri.substring(0, 20) + '...';
282
+ }
283
+ linkIcon.textContent = displayUri;
284
+
285
+ linkIcon.style.marginLeft = '8px';
286
+ linkIcon.style.fontSize = '12px';
287
+ linkIcon.style.color = '#3b82f6';
288
+ linkIcon.style.textDecoration = 'underline';
289
+ linkIcon.style.opacity = '0.7';
290
+ linkIcon.style.transition = 'opacity 0.2s';
291
+ linkIcon.style.whiteSpace = 'nowrap';
292
+ linkIcon.title = option.uri;
293
+ linkIcon.setAttribute('aria-label', `Open ${labelText} documentation`);
294
+
295
+ // Hover effect for link icon
296
+ linkIcon.addEventListener('mouseenter', () => {
297
+ linkIcon.style.opacity = '1';
298
+ });
299
+ linkIcon.addEventListener('mouseleave', () => {
300
+ linkIcon.style.opacity = '0.6';
301
+ });
302
+
303
+ // Prevent link click from selecting the option
304
+ linkIcon.addEventListener('click', (e) => {
305
+ e.stopPropagation();
306
+ // Link will open naturally via href
307
+ });
308
+
309
+ // Set item to flex layout
310
+ item.style.display = 'flex';
311
+ item.style.alignItems = 'center';
312
+ item.style.justifyContent = 'space-between';
313
+
314
+ item.appendChild(labelSpan);
315
+ item.appendChild(linkIcon);
316
+
317
+ // Only label click should select the option
318
+ labelSpan.addEventListener('click', (e) => {
319
+ e.stopPropagation();
320
+ this.handleDropdownSelect(option, matchData);
321
+ this.hideDropdown();
322
+ });
323
+ } else {
324
+ // Regular option without URI
325
+ item.textContent = labelText;
326
+
327
+ // Click anywhere on item to select
328
+ item.addEventListener('click', (e) => {
329
+ e.stopPropagation();
330
+ this.handleDropdownSelect(option, matchData);
331
+ this.hideDropdown();
332
+ });
333
+ }
252
334
 
253
335
  // Highlight first item by default
254
336
  if (index === 0) {
@@ -260,11 +342,6 @@ export default class DropdownManager {
260
342
  this.options.styleManager.applyDropdownItemStyles(item);
261
343
  }
262
344
 
263
- item.addEventListener('click', (e) => {
264
- e.stopPropagation();
265
- this.handleDropdownSelect(option, matchData);
266
- this.hideDropdown();
267
- });
268
345
  dropdown.appendChild(item);
269
346
  });
270
347
  }
@@ -372,26 +449,49 @@ export default class DropdownManager {
372
449
  * @param {HTMLElement} matchEl - Match element
373
450
  */
374
451
  positionDropdown(dropdown, matchEl) {
375
- const rect = matchEl.getBoundingClientRect();
376
- const dropdownRect = dropdown.getBoundingClientRect();
377
- const offset = this.options.dropdownOffset;
452
+ const matchRect = matchEl.getBoundingClientRect();
378
453
 
379
- // Position above match by default (since input is at bottom)
380
- let top = rect.top + window.scrollY - dropdownRect.height - offset;
381
- let left = rect.left + window.scrollX;
454
+ // Force a layout calculation by accessing offsetWidth
455
+ // This ensures we get the correct dropdown dimensions after styles are applied
456
+ const _ = dropdown.offsetWidth;
382
457
 
383
- // If dropdown goes off top edge, position below instead
384
- if (top < window.scrollY) {
385
- top = rect.bottom + window.scrollY + offset;
386
- }
458
+ const dropdownRect = dropdown.getBoundingClientRect();
387
459
 
388
- // Check if dropdown goes off right edge
389
- if (left + dropdownRect.width > window.innerWidth) {
390
- left = window.innerWidth - dropdownRect.width - 10;
391
- }
460
+ console.log('[DropdownManager] Positioning Debug:');
461
+ console.log(' Trigger position (x, y):', matchRect.left, matchRect.top);
462
+ console.log(' Dropdown width:', dropdownRect.width);
463
+ console.log(' Viewport width:', window.innerWidth);
464
+
465
+ // Use EdgeDetectionHelper to calculate optimal position
466
+ // Increased padding to account for body margins/transforms and ensure visible clearance
467
+ const position = EdgeDetectionHelper.calculatePosition({
468
+ matchRect,
469
+ dropdownRect,
470
+ offset: this.options.dropdownOffset,
471
+ padding: 35
472
+ });
392
473
 
393
- dropdown.style.top = `${top}px`;
394
- dropdown.style.left = `${left}px`;
474
+ console.log(' Calculated dropdown position (x, y):', position.left, position.top);
475
+
476
+ dropdown.style.top = `${position.top}px`;
477
+ dropdown.style.left = `${position.left}px`;
478
+
479
+ // Verify the position was actually applied
480
+ const computedStyle = window.getComputedStyle(dropdown);
481
+ const actualRect = dropdown.getBoundingClientRect();
482
+ console.log(' Verified applied styles:');
483
+ console.log(' styleTop:', dropdown.style.top);
484
+ console.log(' styleLeft:', dropdown.style.left);
485
+ console.log(' computedPosition:', computedStyle.position);
486
+ console.log(' actualBoundingRect:', JSON.stringify({
487
+ left: actualRect.left,
488
+ top: actualRect.top,
489
+ right: actualRect.right,
490
+ bottom: actualRect.bottom,
491
+ width: actualRect.width,
492
+ height: actualRect.height
493
+ }));
494
+ console.log(' Dropdown right edge:', actualRect.left + actualRect.width, 'vs viewport width:', window.innerWidth);
395
495
  }
396
496
 
397
497
  /**
@@ -139,16 +139,42 @@ export default class InteractionHandler {
139
139
  const behavior = matchEl.getAttribute('data-behavior');
140
140
  const matchData = this.getMatchData(matchEl);
141
141
 
142
- console.log('[InteractionHandler] Match clicked:', matchData);
142
+ console.log('[InteractionHandler] ===== CLICK EVENT START =====');
143
+ console.log('[InteractionHandler] Click details:', {
144
+ behavior: behavior,
145
+ matchText: matchData.text,
146
+ eventType: e.type,
147
+ target: e.target.tagName,
148
+ currentTarget: e.currentTarget.tagName,
149
+ button: e.button,
150
+ buttons: e.buttons,
151
+ clientX: e.clientX,
152
+ clientY: e.clientY,
153
+ offsetX: e.offsetX,
154
+ offsetY: e.offsetY
155
+ });
156
+ console.log('[InteractionHandler] Match element:', {
157
+ textContent: matchEl.textContent,
158
+ offsetWidth: matchEl.offsetWidth,
159
+ offsetHeight: matchEl.offsetHeight,
160
+ attributes: {
161
+ 'data-line': matchEl.getAttribute('data-line'),
162
+ 'data-col': matchEl.getAttribute('data-col'),
163
+ 'data-behavior': behavior
164
+ }
165
+ });
166
+ console.log('[InteractionHandler] Active element before click:', document.activeElement.tagName, document.activeElement.id || '(no id)');
143
167
 
144
168
  // For non-interactive elements (bubbles), manually pass click to textarea
145
169
  if (behavior !== 'dropdown' && behavior !== 'action') {
170
+ console.log('[InteractionHandler] Non-interactive match - manually focusing textarea');
146
171
  e.preventDefault();
147
172
  e.stopPropagation();
148
173
 
149
174
  // Focus textarea and position cursor at click location
150
175
  if (this.options.textarea) {
151
176
  this.options.textarea.focus();
177
+ console.log('[InteractionHandler] Textarea focused. Active element now:', document.activeElement.tagName, document.activeElement.id || '(no id)');
152
178
 
153
179
  // Get the character offset by finding the match position
154
180
  const line = parseInt(matchEl.getAttribute('data-line'));
@@ -160,15 +186,33 @@ export default class InteractionHandler {
160
186
  for (let i = 0; i < line; i++) {
161
187
  offset += lines[i].length + 1; // +1 for newline
162
188
  }
163
- offset += col + (e.offsetX / matchEl.offsetWidth * matchEl.textContent.length);
189
+ const clickOffsetInMatch = (e.offsetX / matchEl.offsetWidth * matchEl.textContent.length);
190
+ offset += col + clickOffsetInMatch;
191
+
192
+ console.log('[InteractionHandler] Cursor positioning:', {
193
+ line: line,
194
+ col: col,
195
+ clickOffsetInMatch: clickOffsetInMatch,
196
+ finalOffset: offset,
197
+ textareaValue: this.options.textarea.value,
198
+ textareaValueLength: this.options.textarea.value.length
199
+ });
164
200
 
165
201
  // Set cursor position
166
202
  this.options.textarea.setSelectionRange(offset, offset);
203
+
204
+ console.log('[InteractionHandler] Selection set:', {
205
+ selectionStart: this.options.textarea.selectionStart,
206
+ selectionEnd: this.options.textarea.selectionEnd,
207
+ selectedText: this.options.textarea.value.substring(this.options.textarea.selectionStart, this.options.textarea.selectionEnd)
208
+ });
167
209
  }
168
210
 
211
+ console.log('[InteractionHandler] ===== CLICK EVENT END (non-interactive) =====');
169
212
  return; // Don't process further for bubbles
170
213
  }
171
214
 
215
+ console.log('[InteractionHandler] Interactive match - handling dropdown/action');
172
216
  // Prevent default for interactive elements (dropdown/action)
173
217
  e.preventDefault();
174
218
  e.stopPropagation();
@@ -192,6 +236,8 @@ export default class InteractionHandler {
192
236
  if (this.options.onWordClick && !(behavior === 'dropdown' && this.dropdownManager.activeDropdownMatch === matchEl)) {
193
237
  this.options.onWordClick(matchData);
194
238
  }
239
+
240
+ console.log('[InteractionHandler] ===== CLICK EVENT END (interactive) =====');
195
241
  }
196
242
 
197
243
  /**
@@ -272,8 +272,12 @@ export default class StyleManager {
272
272
  /**
273
273
  * Apply dropdown (menu) styles
274
274
  * @param {HTMLElement} dropdown - Dropdown element
275
+ * @param {string} category - Optional category to adjust styles
275
276
  */
276
- applyDropdownStyles(dropdown) {
277
+ applyDropdownStyles(dropdown, category = '') {
278
+ // Use wider maxWidth for display-menu-with-uri to accommodate links
279
+ const maxWidth = category === 'display-menu-with-uri' ? '450px' : '300px';
280
+
277
281
  Object.assign(dropdown.style, {
278
282
  position: 'absolute',
279
283
  background: '#ffffff',
@@ -282,7 +286,7 @@ export default class StyleManager {
282
286
  boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
283
287
  zIndex: '10000',
284
288
  minWidth: '150px',
285
- maxWidth: '300px',
289
+ maxWidth: maxWidth,
286
290
  overflow: 'hidden',
287
291
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
288
292
  fontSize: '14px',
package/src/TrustQuery.js CHANGED
@@ -284,12 +284,121 @@ export default class TrustQuery {
284
284
  this.overlay.scrollLeft = this.textarea.scrollLeft;
285
285
  });
286
286
 
287
+ // Keyboard event logging for debugging selection issues
288
+ this.textarea.addEventListener('keydown', (e) => {
289
+ const isCmdOrCtrl = e.metaKey || e.ctrlKey;
290
+ const isSelectAll = (e.metaKey || e.ctrlKey) && e.key === 'a';
291
+
292
+ if (isCmdOrCtrl || isSelectAll) {
293
+ console.log('[TrustQuery] ===== KEYBOARD EVENT =====');
294
+ console.log('[TrustQuery] Key pressed:', {
295
+ key: e.key,
296
+ code: e.code,
297
+ metaKey: e.metaKey,
298
+ ctrlKey: e.ctrlKey,
299
+ shiftKey: e.shiftKey,
300
+ altKey: e.altKey,
301
+ isSelectAll: isSelectAll
302
+ });
303
+ console.log('[TrustQuery] Active element:', document.activeElement.tagName, document.activeElement.id || '(no id)');
304
+ console.log('[TrustQuery] Textarea state BEFORE:', {
305
+ value: this.textarea.value,
306
+ valueLength: this.textarea.value.length,
307
+ selectionStart: this.textarea.selectionStart,
308
+ selectionEnd: this.textarea.selectionEnd,
309
+ selectedText: this.textarea.value.substring(this.textarea.selectionStart, this.textarea.selectionEnd)
310
+ });
311
+
312
+ if (isSelectAll) {
313
+ // Log state after select all (use setTimeout to let browser process the event)
314
+ setTimeout(() => {
315
+ console.log('[TrustQuery] Textarea state AFTER CMD+A:', {
316
+ selectionStart: this.textarea.selectionStart,
317
+ selectionEnd: this.textarea.selectionEnd,
318
+ selectedText: this.textarea.value.substring(this.textarea.selectionStart, this.textarea.selectionEnd),
319
+ selectedLength: this.textarea.selectionEnd - this.textarea.selectionStart
320
+ });
321
+ console.log('[TrustQuery] ===== KEYBOARD EVENT END =====');
322
+ }, 0);
323
+ } else {
324
+ console.log('[TrustQuery] ===== KEYBOARD EVENT END =====');
325
+ }
326
+ }
327
+ });
328
+
329
+ // Selection change event
330
+ this.textarea.addEventListener('select', (e) => {
331
+ console.log('[TrustQuery] ===== SELECTION CHANGE EVENT =====');
332
+ console.log('[TrustQuery] Selection:', {
333
+ selectionStart: this.textarea.selectionStart,
334
+ selectionEnd: this.textarea.selectionEnd,
335
+ selectedText: this.textarea.value.substring(this.textarea.selectionStart, this.textarea.selectionEnd),
336
+ selectedLength: this.textarea.selectionEnd - this.textarea.selectionStart
337
+ });
338
+ });
339
+
340
+ // Context menu event - prevent keyboard-triggered context menu
341
+ this.textarea.addEventListener('contextmenu', (e) => {
342
+ console.log('[TrustQuery] ===== CONTEXTMENU EVENT =====');
343
+ console.log('[TrustQuery] Context menu triggered:', {
344
+ type: e.type,
345
+ isTrusted: e.isTrusted,
346
+ button: e.button,
347
+ buttons: e.buttons,
348
+ clientX: e.clientX,
349
+ clientY: e.clientY,
350
+ ctrlKey: e.ctrlKey,
351
+ metaKey: e.metaKey,
352
+ target: e.target.tagName
353
+ });
354
+
355
+ // Prevent context menu if triggered by keyboard (button === -1)
356
+ // This prevents the macOS context menu from opening after CMD+A
357
+ if (e.button === -1 && e.buttons === 0) {
358
+ console.log('[TrustQuery] Preventing keyboard-triggered context menu');
359
+ e.preventDefault();
360
+ e.stopPropagation();
361
+ return;
362
+ }
363
+
364
+ console.log('[TrustQuery] Allowing mouse-triggered context menu');
365
+ });
366
+
367
+ // Also prevent context menu on overlay (it interferes with text selection)
368
+ this.overlay.addEventListener('contextmenu', (e) => {
369
+ console.log('[TrustQuery] ===== CONTEXTMENU EVENT ON OVERLAY =====');
370
+ console.log('[TrustQuery] Context menu on overlay - preventing');
371
+
372
+ // Always prevent context menu on overlay
373
+ // The overlay should be transparent to user interactions
374
+ e.preventDefault();
375
+ e.stopPropagation();
376
+ });
377
+
287
378
  // Focus/blur events - add/remove focus class
288
- this.textarea.addEventListener('focus', () => {
379
+ this.textarea.addEventListener('focus', (e) => {
380
+ console.log('[TrustQuery] ===== FOCUS EVENT =====');
381
+ console.log('[TrustQuery] Textarea focused. Active element:', document.activeElement.tagName, document.activeElement.id || '(no id)');
382
+ console.log('[TrustQuery] Current selection:', {
383
+ selectionStart: this.textarea.selectionStart,
384
+ selectionEnd: this.textarea.selectionEnd
385
+ });
289
386
  this.wrapper.classList.add('tq-focused');
290
387
  });
291
388
 
292
389
  this.textarea.addEventListener('blur', (e) => {
390
+ console.log('[TrustQuery] ===== BLUR EVENT =====');
391
+ console.log('[TrustQuery] Textarea blurred. Related target:', e.relatedTarget?.tagName || '(none)');
392
+ console.log('[TrustQuery] Blur event details:', {
393
+ type: e.type,
394
+ isTrusted: e.isTrusted,
395
+ eventPhase: e.eventPhase,
396
+ target: e.target.tagName,
397
+ currentTarget: e.currentTarget.tagName
398
+ });
399
+ console.log('[TrustQuery] Stack trace at blur:');
400
+ console.trace();
401
+
293
402
  // Close dropdown when textarea loses focus (unless interacting with dropdown)
294
403
  if (this.interactionHandler) {
295
404
  // Use setTimeout to let the new focus target be set and check if clicking on dropdown
@@ -300,6 +409,8 @@ export default class TrustQuery {
300
409
  activeElement.closest('.tq-dropdown') // Check if clicking anywhere in dropdown
301
410
  );
302
411
 
412
+ console.log('[TrustQuery] After blur - active element:', activeElement?.tagName || '(none)', 'isDropdownRelated:', isDropdownRelated);
413
+
303
414
  // Only close if not interacting with dropdown
304
415
  if (!isDropdownRelated) {
305
416
  this.interactionHandler.hideDropdown();
@@ -0,0 +1,113 @@
1
+ // EdgeDetectionHelper - Calculates dropdown positioning to prevent overflow off viewport edges
2
+
3
+ export default class EdgeDetectionHelper {
4
+ /**
5
+ * Calculate optimal position for dropdown to prevent viewport overflow
6
+ * @param {Object} options - Positioning options
7
+ * @param {DOMRect} options.matchRect - Bounding rect of the match element
8
+ * @param {DOMRect} options.dropdownRect - Bounding rect of the dropdown
9
+ * @param {number} options.offset - Offset from match element (default: 28)
10
+ * @param {number} options.padding - Padding from viewport edges (default: 10)
11
+ * @returns {Object} - { top, left } position in pixels
12
+ */
13
+ static calculatePosition({ matchRect, dropdownRect, offset = 28, padding = 10 }) {
14
+ const viewportWidth = window.innerWidth;
15
+ const viewportHeight = window.innerHeight;
16
+
17
+ console.log('[EdgeDetectionHelper] Calculation inputs:');
18
+ console.log(' Match rect:', { left: matchRect.left, top: matchRect.top, width: matchRect.width });
19
+ console.log(' Dropdown rect:', { width: dropdownRect.width, height: dropdownRect.height });
20
+ console.log(' Viewport:', { width: viewportWidth, height: viewportHeight });
21
+ console.log(' Scroll:', { x: window.scrollX, y: window.scrollY });
22
+
23
+ // Calculate initial position (above match by default, since input is at bottom)
24
+ let top = matchRect.top + window.scrollY - dropdownRect.height - offset;
25
+ let left = matchRect.left + window.scrollX;
26
+
27
+ console.log(' Initial position:', { left, top });
28
+
29
+ // Vertical positioning: Check if dropdown goes off top edge
30
+ if (top < window.scrollY) {
31
+ // Position below match instead
32
+ top = matchRect.bottom + window.scrollY + offset;
33
+ console.log(' Adjusted for top overflow, new top:', top);
34
+ }
35
+
36
+ // Horizontal positioning: Check if dropdown goes off right edge
37
+ const rightEdge = left + dropdownRect.width;
38
+ const viewportRightEdge = viewportWidth + window.scrollX;
39
+
40
+ console.log(' Right edge check:', { rightEdge, viewportRightEdge: viewportRightEdge - padding });
41
+
42
+ if (rightEdge > viewportRightEdge - padding) {
43
+ // Calculate how much we overflow past the right edge
44
+ const overflow = rightEdge - (viewportRightEdge - padding);
45
+ console.log(' Right overflow detected:', overflow, 'px');
46
+
47
+ // Shift left by the overflow amount
48
+ left = left - overflow;
49
+ console.log(' Adjusted left position:', left);
50
+
51
+ // Ensure we don't go off the left edge either
52
+ const minLeft = window.scrollX + padding;
53
+ if (left < minLeft) {
54
+ console.log(' Hit left edge, clamping to:', minLeft);
55
+ left = minLeft;
56
+ }
57
+ }
58
+
59
+ // Also check left edge (in case match is near left edge)
60
+ const minLeft = window.scrollX + padding;
61
+ if (left < minLeft) {
62
+ console.log(' Left edge adjustment:', minLeft);
63
+ left = minLeft;
64
+ }
65
+
66
+ console.log('[EdgeDetectionHelper] Final position:', { left, top });
67
+
68
+ return { top, left };
69
+ }
70
+
71
+ /**
72
+ * Check if dropdown would overflow the right edge
73
+ * @param {DOMRect} matchRect - Match element rect
74
+ * @param {DOMRect} dropdownRect - Dropdown rect
75
+ * @param {number} padding - Padding from edge
76
+ * @returns {boolean} - True if would overflow
77
+ */
78
+ static wouldOverflowRight(matchRect, dropdownRect, padding = 10) {
79
+ const left = matchRect.left + window.scrollX;
80
+ const rightEdge = left + dropdownRect.width;
81
+ const viewportRightEdge = window.innerWidth + window.scrollX;
82
+
83
+ return rightEdge > viewportRightEdge - padding;
84
+ }
85
+
86
+ /**
87
+ * Check if dropdown would overflow the top edge
88
+ * @param {DOMRect} matchRect - Match element rect
89
+ * @param {DOMRect} dropdownRect - Dropdown rect
90
+ * @param {number} offset - Offset from match
91
+ * @returns {boolean} - True if would overflow
92
+ */
93
+ static wouldOverflowTop(matchRect, dropdownRect, offset = 28) {
94
+ const top = matchRect.top + window.scrollY - dropdownRect.height - offset;
95
+ return top < window.scrollY;
96
+ }
97
+
98
+ /**
99
+ * Calculate overflow amount on the right edge
100
+ * @param {DOMRect} matchRect - Match element rect
101
+ * @param {DOMRect} dropdownRect - Dropdown rect
102
+ * @param {number} padding - Padding from edge
103
+ * @returns {number} - Overflow amount in pixels (0 if no overflow)
104
+ */
105
+ static calculateRightOverflow(matchRect, dropdownRect, padding = 10) {
106
+ const left = matchRect.left + window.scrollX;
107
+ const rightEdge = left + dropdownRect.width;
108
+ const viewportRightEdge = window.innerWidth + window.scrollX;
109
+
110
+ const overflow = rightEdge - (viewportRightEdge - padding);
111
+ return Math.max(0, overflow);
112
+ }
113
+ }