From 62380777c92c530fb02f48b904f8754ab5c508e1 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Thu, 10 Jul 2025 15:09:06 +0100 Subject: [PATCH] File selector files move around too much (vibe-kanban) (#122) * I've simplified the dropdown positioning to be much more stable and consistent. The key changes: 1. **Removed complex cursor-based positioning** - Instead of trying to calculate exact character positions, the dropdown now appears consistently below (or above) the textarea 2. **Fixed reference point** - Always positions relative to the textarea edges rather than cursor position 3. **Simpler logic** - Just checks if there's enough space below, otherwise positions above 4. **Consistent spacing** - Uses a 4px gap between textarea and dropdown This approach eliminates the positioning inconsistencies while keeping the dropdown visually connected to the textarea where the user is typing. * Perfect! Now the dropdown positioning accounts for the actual content height when positioning above the textarea. The key changes: 1. **Calculate actual height** - Based on the number of filtered results (`filteredFiles.length * itemHeight + dropdownPadding`) 2. **Use actual height for positioning** - When positioning above, uses `actualHeight` instead of `maxDropdownHeight` to eliminate gaps 3. **Configurable constants** - Added `itemHeight` and `dropdownPadding` that can be adjusted based on your actual item styling This ensures the dropdown appears right above the textarea without blank space, regardless of how many results are shown. * Fixed! Now the dropdown positioning correctly uses `searchResults.length` instead of the undefined `filteredFiles` variable. The dropdown will now properly calculate its actual height based on the number of search results and position itself without gaps when appearing above the textarea. * I've increased the `itemHeight` to 60px to better account for the actual rendered height of each item (which has `px-3 py-2` padding plus two lines of text), and reduced the `dropdownPadding` to 8px to match the `py-1` class. This should eliminate the gap when the dropdown is positioned above the textarea. * Now I'm using a ref-based approach that measures the actual rendered height of the dropdown and repositions it accordingly. The key changes: 1. **Real height measurement** - Uses `dropdownRef.current?.getBoundingClientRect().height` to get the actual rendered height 2. **Dynamic repositioning** - Added a `useEffect` that runs when the dropdown content changes to reposition it 3. **Eliminated guesswork** - No more trying to predict the height with calculations This should eliminate the gap completely since it uses the actual rendered dimensions rather than estimates. * prettier --- .../components/ui/file-search-textarea.tsx | 103 +++++++----------- 1 file changed, 40 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/ui/file-search-textarea.tsx b/frontend/src/components/ui/file-search-textarea.tsx index 92427e56..64512c95 100644 --- a/frontend/src/components/ui/file-search-textarea.tsx +++ b/frontend/src/components/ui/file-search-textarea.tsx @@ -169,97 +169,74 @@ export function FileSearchTextarea({ }, 0); }; - // Calculate dropdown position relative to viewport + // Calculate dropdown position relative to textarea (simpler, more stable approach) const getDropdownPosition = () => { - if (!textareaRef.current || atSymbolPosition === -1) - return { top: 0, left: 0, maxHeight: 240 }; + if (!textareaRef.current) return { top: 0, left: 0, maxHeight: 240 }; const textareaRect = textareaRef.current.getBoundingClientRect(); - const textBeforeAt = value.slice(0, atSymbolPosition); - const lines = textBeforeAt.split('\n'); - const currentLine = lines.length - 1; - const charInLine = lines[lines.length - 1].length; - - // More accurate calculation using computed styles - const computedStyle = window.getComputedStyle(textareaRef.current); - const lineHeight = parseInt(computedStyle.lineHeight) || 20; - const fontSize = parseInt(computedStyle.fontSize) || 14; - const charWidth = fontSize * 0.6; // Approximate character width - const paddingLeft = parseInt(computedStyle.paddingLeft) || 12; - const paddingTop = parseInt(computedStyle.paddingTop) || 8; - - // Position relative to textarea - const relativeTop = paddingTop + currentLine * lineHeight + lineHeight; - const relativeLeft = paddingLeft + charWidth * charInLine; - - // Convert to viewport coordinates - const viewportTop = textareaRect.top + relativeTop; - const viewportLeft = textareaRect.left + relativeLeft; - - // Dropdown dimensions const dropdownWidth = 256; // min-w-64 = 256px + const maxDropdownHeight = 320; const minDropdownHeight = 120; - const maxDropdownHeight = 400; // Increased to show more results without scrolling - let finalTop = viewportTop; - let finalLeft = viewportLeft; + // Position dropdown below the textarea by default + let finalTop = textareaRect.bottom + 4; // 4px gap + let finalLeft = textareaRect.left; let maxHeight = maxDropdownHeight; - // Prevent going off the right edge - if (viewportLeft + dropdownWidth > window.innerWidth - 16) { + // Ensure dropdown doesn't go off the right edge + if (finalLeft + dropdownWidth > window.innerWidth - 16) { finalLeft = window.innerWidth - dropdownWidth - 16; } - // Prevent going off the left edge + // Ensure dropdown doesn't go off the left edge if (finalLeft < 16) { finalLeft = 16; } - // Smart positioning: avoid clipping by positioning above when needed - const availableSpaceBelow = window.innerHeight - viewportTop - 32; - const availableSpaceAbove = - textareaRect.top + currentLine * lineHeight - 32; + // Calculate available space below and above textarea + const availableSpaceBelow = window.innerHeight - textareaRect.bottom - 32; + const availableSpaceAbove = textareaRect.top - 32; - // Check if dropdown would be clipped at bottom - if so, try positioning above - const wouldBeClippedBelow = availableSpaceBelow < maxDropdownHeight; - const hasEnoughSpaceAbove = availableSpaceAbove >= maxDropdownHeight; - - if (wouldBeClippedBelow && hasEnoughSpaceAbove) { - // Position above the cursor line with full height - finalTop = - textareaRect.top + - paddingTop + - currentLine * lineHeight - - maxDropdownHeight; - maxHeight = maxDropdownHeight; - } else if ( - wouldBeClippedBelow && + // If not enough space below, position above + if ( + availableSpaceBelow < minDropdownHeight && availableSpaceAbove > availableSpaceBelow ) { - // Position above but with reduced height if not enough space - finalTop = - textareaRect.top + - paddingTop + - currentLine * lineHeight - - availableSpaceAbove; - maxHeight = Math.max(availableSpaceAbove, minDropdownHeight); + // Get actual height from rendered dropdown + const actualHeight = + dropdownRef.current?.getBoundingClientRect().height || + minDropdownHeight; + finalTop = textareaRect.top - actualHeight - 4; + maxHeight = Math.min( + maxDropdownHeight, + Math.max(availableSpaceAbove, minDropdownHeight) + ); } else { - // Position below the cursor line + // Position below with available space maxHeight = Math.min( maxDropdownHeight, Math.max(availableSpaceBelow, minDropdownHeight) ); } - // Ensure minimum height - if (maxHeight < minDropdownHeight) { - maxHeight = minDropdownHeight; - finalTop = Math.max(16, window.innerHeight - minDropdownHeight - 16); - } - return { top: finalTop, left: finalLeft, maxHeight }; }; + // Use effect to reposition when dropdown content changes + useEffect(() => { + if (showDropdown && dropdownRef.current) { + // Small delay to ensure content is rendered + setTimeout(() => { + const newPosition = getDropdownPosition(); + if (dropdownRef.current) { + dropdownRef.current.style.top = `${newPosition.top}px`; + dropdownRef.current.style.left = `${newPosition.left}px`; + dropdownRef.current.style.maxHeight = `${newPosition.maxHeight}px`; + } + }, 0); + } + }, [searchResults.length, showDropdown]); + const dropdownPosition = getDropdownPosition(); return (