@trustquery/browser 0.2.8 → 0.2.10

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.8",
3
+ "version": "0.2.10",
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",
@@ -36,7 +36,7 @@
36
36
  "license": "MIT",
37
37
  "repository": {
38
38
  "type": "git",
39
- "url": "https://github.com/RonItelman/trustquery-browser.git"
39
+ "url": "git+https://github.com/RonItelman/trustquery-browser.git"
40
40
  },
41
41
  "bugs": {
42
42
  "url": "https://github.com/RonItelman/trustquery-browser/issues"
@@ -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
  /**
@@ -0,0 +1,240 @@
1
+ // MobileKeyboardHandler - Handles mobile virtual keyboard behavior
2
+ // Detects keyboard appearance and adjusts layout to keep textarea visible
3
+
4
+ export default class MobileKeyboardHandler {
5
+ /**
6
+ * Create mobile keyboard handler
7
+ * @param {Object} options - Configuration options
8
+ */
9
+ constructor(options = {}) {
10
+ this.options = {
11
+ textarea: options.textarea || null,
12
+ wrapper: options.wrapper || null,
13
+ debug: options.debug || false,
14
+ ...options
15
+ };
16
+
17
+ this.isKeyboardVisible = false;
18
+ this.lastViewportHeight = window.innerHeight;
19
+ this.visualViewport = window.visualViewport;
20
+
21
+ if (this.options.debug) {
22
+ console.log('[MobileKeyboardHandler] Initialized');
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Initialize keyboard detection
28
+ */
29
+ init() {
30
+ if (!this.options.textarea) {
31
+ console.warn('[MobileKeyboardHandler] No textarea provided');
32
+ return;
33
+ }
34
+
35
+ // Use Visual Viewport API if available (preferred method)
36
+ if (this.visualViewport) {
37
+ this.visualViewport.addEventListener('resize', this.handleViewportResize);
38
+ this.visualViewport.addEventListener('scroll', this.handleViewportScroll);
39
+
40
+ if (this.options.debug) {
41
+ console.log('[MobileKeyboardHandler] Using Visual Viewport API');
42
+ }
43
+ } else {
44
+ // Fallback to window resize
45
+ window.addEventListener('resize', this.handleWindowResize);
46
+
47
+ if (this.options.debug) {
48
+ console.log('[MobileKeyboardHandler] Using window resize fallback');
49
+ }
50
+ }
51
+
52
+ // Handle focus events
53
+ this.options.textarea.addEventListener('focus', this.handleFocus);
54
+ this.options.textarea.addEventListener('blur', this.handleBlur);
55
+ }
56
+
57
+ /**
58
+ * Handle Visual Viewport resize (keyboard appearance/disappearance)
59
+ */
60
+ handleViewportResize = () => {
61
+ if (!this.visualViewport) return;
62
+
63
+ const viewportHeight = this.visualViewport.height;
64
+ const windowHeight = window.innerHeight;
65
+
66
+ if (this.options.debug) {
67
+ console.log('[MobileKeyboardHandler] Viewport resize:', {
68
+ viewportHeight,
69
+ windowHeight,
70
+ scale: this.visualViewport.scale
71
+ });
72
+ }
73
+
74
+ // Keyboard is visible if viewport height is significantly smaller than window height
75
+ const wasKeyboardVisible = this.isKeyboardVisible;
76
+ this.isKeyboardVisible = viewportHeight < windowHeight * 0.75;
77
+
78
+ if (this.isKeyboardVisible !== wasKeyboardVisible) {
79
+ if (this.isKeyboardVisible) {
80
+ this.onKeyboardShow();
81
+ } else {
82
+ this.onKeyboardHide();
83
+ }
84
+ }
85
+
86
+ // Always adjust layout when viewport changes
87
+ if (this.isKeyboardVisible) {
88
+ this.adjustLayout();
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Handle Visual Viewport scroll
94
+ */
95
+ handleViewportScroll = () => {
96
+ if (this.isKeyboardVisible) {
97
+ // Ensure textarea stays in view during scroll
98
+ this.ensureTextareaVisible();
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Handle window resize (fallback)
104
+ */
105
+ handleWindowResize = () => {
106
+ const currentHeight = window.innerHeight;
107
+ const heightDifference = this.lastViewportHeight - currentHeight;
108
+
109
+ if (this.options.debug) {
110
+ console.log('[MobileKeyboardHandler] Window resize:', {
111
+ lastHeight: this.lastViewportHeight,
112
+ currentHeight,
113
+ difference: heightDifference
114
+ });
115
+ }
116
+
117
+ // Significant decrease in height suggests keyboard appeared
118
+ if (heightDifference > 150) {
119
+ if (!this.isKeyboardVisible) {
120
+ this.isKeyboardVisible = true;
121
+ this.onKeyboardShow();
122
+ }
123
+ }
124
+ // Significant increase suggests keyboard hidden
125
+ else if (heightDifference < -150) {
126
+ if (this.isKeyboardVisible) {
127
+ this.isKeyboardVisible = false;
128
+ this.onKeyboardHide();
129
+ }
130
+ }
131
+
132
+ this.lastViewportHeight = currentHeight;
133
+ };
134
+
135
+ /**
136
+ * Handle textarea focus
137
+ */
138
+ handleFocus = () => {
139
+ if (this.options.debug) {
140
+ console.log('[MobileKeyboardHandler] Textarea focused');
141
+ }
142
+
143
+ // Delay to allow keyboard to appear
144
+ setTimeout(() => {
145
+ this.ensureTextareaVisible();
146
+ }, 300);
147
+ };
148
+
149
+ /**
150
+ * Handle textarea blur
151
+ */
152
+ handleBlur = () => {
153
+ if (this.options.debug) {
154
+ console.log('[MobileKeyboardHandler] Textarea blurred');
155
+ }
156
+ };
157
+
158
+ /**
159
+ * Called when keyboard appears
160
+ */
161
+ onKeyboardShow() {
162
+ if (this.options.debug) {
163
+ console.log('[MobileKeyboardHandler] Keyboard shown');
164
+ }
165
+
166
+ this.adjustLayout();
167
+ this.ensureTextareaVisible();
168
+ }
169
+
170
+ /**
171
+ * Called when keyboard hides
172
+ */
173
+ onKeyboardHide() {
174
+ if (this.options.debug) {
175
+ console.log('[MobileKeyboardHandler] Keyboard hidden');
176
+ }
177
+
178
+ // Reset wrapper height to auto
179
+ if (this.options.wrapper) {
180
+ this.options.wrapper.style.maxHeight = '';
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Adjust layout to accommodate keyboard
186
+ */
187
+ adjustLayout() {
188
+ if (!this.visualViewport || !this.options.wrapper) return;
189
+
190
+ const viewportHeight = this.visualViewport.height;
191
+
192
+ // Set wrapper max-height to visible viewport height minus some padding
193
+ const maxHeight = viewportHeight - 20; // 20px padding
194
+ this.options.wrapper.style.maxHeight = `${maxHeight}px`;
195
+ this.options.wrapper.style.overflow = 'auto';
196
+
197
+ if (this.options.debug) {
198
+ console.log('[MobileKeyboardHandler] Adjusted wrapper height:', maxHeight);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Ensure textarea is visible above the keyboard
204
+ */
205
+ ensureTextareaVisible() {
206
+ if (!this.options.textarea) return;
207
+
208
+ // Scroll textarea into view
209
+ this.options.textarea.scrollIntoView({
210
+ behavior: 'smooth',
211
+ block: 'center',
212
+ inline: 'nearest'
213
+ });
214
+
215
+ if (this.options.debug) {
216
+ console.log('[MobileKeyboardHandler] Scrolled textarea into view');
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Cleanup event listeners
222
+ */
223
+ destroy() {
224
+ if (this.visualViewport) {
225
+ this.visualViewport.removeEventListener('resize', this.handleViewportResize);
226
+ this.visualViewport.removeEventListener('scroll', this.handleViewportScroll);
227
+ } else {
228
+ window.removeEventListener('resize', this.handleWindowResize);
229
+ }
230
+
231
+ if (this.options.textarea) {
232
+ this.options.textarea.removeEventListener('focus', this.handleFocus);
233
+ this.options.textarea.removeEventListener('blur', this.handleBlur);
234
+ }
235
+
236
+ if (this.options.debug) {
237
+ console.log('[MobileKeyboardHandler] Destroyed');
238
+ }
239
+ }
240
+ }
@@ -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
@@ -8,6 +8,7 @@ import StyleManager from './StyleManager.js';
8
8
  import CommandHandlerRegistry from './CommandHandlers.js';
9
9
  import AutoGrow from './AutoGrow.js';
10
10
  import ValidationStateManager from './ValidationStateManager.js';
11
+ import MobileKeyboardHandler from './MobileKeyboardHandler.js';
11
12
 
12
13
  export default class TrustQuery {
13
14
  // Store all instances
@@ -212,6 +213,17 @@ export default class TrustQuery {
212
213
  console.log('[TrustQuery] AutoGrow feature enabled');
213
214
  }
214
215
 
216
+ // Mobile keyboard handler (enabled by default, can be disabled via options)
217
+ if (this.options.mobileKeyboard !== false) {
218
+ this.features.mobileKeyboard = new MobileKeyboardHandler({
219
+ textarea: this.textarea,
220
+ wrapper: this.wrapper,
221
+ debug: this.options.debug
222
+ });
223
+ this.features.mobileKeyboard.init();
224
+ console.log('[TrustQuery] Mobile keyboard handler enabled');
225
+ }
226
+
215
227
  // Debug logging feature
216
228
  if (this.options.debug) {
217
229
  this.enableDebugLogging();
@@ -502,6 +514,16 @@ export default class TrustQuery {
502
514
  this.interactionHandler.destroy();
503
515
  }
504
516
 
517
+ // Cleanup mobile keyboard handler
518
+ if (this.features.mobileKeyboard) {
519
+ this.features.mobileKeyboard.destroy();
520
+ }
521
+
522
+ // Cleanup auto-grow
523
+ if (this.features.autoGrow) {
524
+ this.features.autoGrow.destroy();
525
+ }
526
+
505
527
  // Unwrap textarea
506
528
  const parent = this.wrapper.parentNode;
507
529
  parent.insertBefore(this.textarea, this.wrapper);
@@ -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
+ }