From ba7c628d3596c0dcb1f40ab52829ed0ae052b2d2 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Sat, 11 Apr 2026 11:21:57 +0300 Subject: [PATCH] fix(thinking-mode): fix dropdown positioning --- .../subcomponents/ThinkingModeSelector.tsx | 146 +++++++++++++++--- 1 file changed, 123 insertions(+), 23 deletions(-) diff --git a/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx b/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx index 9a92386d..2b8d8062 100644 --- a/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx +++ b/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx @@ -1,4 +1,5 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; import { Brain, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { thinkingModes } from '../../constants/thinkingModes'; @@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = { function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) { const { t } = useTranslation('chat'); + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState(null); // Mapping from mode ID to translation key const modeKeyMap: Record = { @@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = }; }); - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); + const closeDropdown = useCallback(() => { + setIsOpen(false); + onClose?.(); + }, [onClose]); + + const updateDropdownPosition = useCallback(() => { + const trigger = triggerRef.current; + const dropdown = dropdownRef.current; + if (!trigger || !dropdown || typeof window === 'undefined') { + return; + } + + const triggerRect = trigger.getBoundingClientRect(); + const viewportPadding = window.innerWidth < 640 ? 12 : 16; + const spacing = 8; + const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256); + let left = triggerRect.left + triggerRect.width / 2 - width / 2; + left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding)); + + const measuredHeight = dropdown.offsetHeight || 0; + const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding; + const spaceAbove = triggerRect.top - spacing - viewportPadding; + const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove; + const availableHeight = Math.min( + window.innerHeight - viewportPadding * 2, + Math.max(180, openBelow ? spaceBelow : spaceAbove), + ); + const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight); + const top = openBelow + ? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight) + : Math.max(viewportPadding, triggerRect.top - spacing - panelHeight); + + setDropdownStyle({ + position: 'fixed', + top, + left, + width, + maxHeight: availableHeight, + zIndex: 80, + }); + }, []); useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - if (onClose) onClose(); + if (!isOpen) { + setDropdownStyle(null); + return; + } + + const rafId = window.requestAnimationFrame(updateDropdownPosition); + const handleViewportChange = () => updateDropdownPosition(); + + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange, true); + + return () => { + window.cancelAnimationFrame(rafId); + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange, true); + }; + }, [isOpen, updateDropdownPosition]); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) { + return; + } + + closeDropdown(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeDropdown(); } }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose]); + document.addEventListener('pointerdown', handlePointerDown, true); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, closeDropdown]); const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0]; const IconComponent = currentMode.icon || Brain; return ( -
+
- {isOpen && ( -
+ {isOpen && typeof document !== 'undefined' && createPortal( +

{t('thinkingMode.selector.title')}

-
+
{translatedModes.map((mode) => { const ModeIcon = mode.icon; const isSelected = mode.id === selectedMode; @@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = return (
-
+
, + document.body )}
); } -export default ThinkingModeSelector; \ No newline at end of file +export default ThinkingModeSelector;