From 0c82519609c55437837491caf8c7ac101fcf805d Mon Sep 17 00:00:00 2001 From: Andreas Weyer Date: Fri, 16 Jan 2026 18:02:38 +0000 Subject: [PATCH] Fix info tooltip partially hidden and gone too quickly on mobile --- src/components/settings.tsx | 214 ++++++++++++++++++++--------- src/components/ui/info-tooltip.tsx | 95 +++++++++++++ src/components/ui/tooltip.tsx | 1 + 3 files changed, 247 insertions(+), 63 deletions(-) create mode 100644 src/components/ui/info-tooltip.tsx diff --git a/src/components/settings.tsx b/src/components/settings.tsx index 466e349..f8f9aec 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -126,6 +126,27 @@ const Settings = ({ const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true); const [isAdvancedExpanded, setIsAdvancedExpanded] = React.useState(false); + // Track which tooltip is currently open (for mobile touch interaction) + const [openTooltipId, setOpenTooltipId] = React.useState(null); + + // Track window width for responsive tooltip positioning + const [isNarrowScreen, setIsNarrowScreen] = React.useState( + typeof window !== 'undefined' ? window.innerWidth < 640 : false + ); + + // Update narrow screen state on window resize + React.useEffect(() => { + const handleResize = () => { + setIsNarrowScreen(window.innerWidth < 640); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Determine tooltip side based on screen width + const tooltipSide = isNarrowScreen ? 'top' : 'right'; + // Load and persist settings card expansion states React.useEffect(() => { const savedStates = localStorage.getItem('settingsCardStates_v1'); @@ -142,6 +163,37 @@ const Settings = ({ } }, []); + // Close tooltip when clicking outside + React.useEffect(() => { + if (!openTooltipId) return; + + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + const target = e.target as HTMLElement; + // Check if click is outside tooltip content and button + if (!target.closest('[role="tooltip"]') && !target.closest('button[aria-label*="Tooltip"]')) { + setOpenTooltipId(null); + } + }; + + // Small delay to prevent immediate closure + setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + }, 10); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + }; + }, [openTooltipId]); + + // Helper to toggle tooltip (for mobile click interaction) + const handleTooltipToggle = (tooltipId: string) => (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + setOpenTooltipId(current => current === tooltipId ? null : tooltipId); + }; + const updateDiagramExpanded = (value: boolean) => { setIsDiagramExpanded(value); saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded }); @@ -218,17 +270,19 @@ const Settings = ({ {t('showTemplateDayInChart')} - + setOpenTooltipId(open ? 'showTemplateDay' : null)}> - +

{tWithDefaults(t, 'showTemplateDayTooltip', defaultsForT)}

@@ -247,17 +301,19 @@ const Settings = ({ {t('showDayReferenceLines')} - + setOpenTooltipId(open ? 'showDayReferenceLines' : null)}> - +

{tWithDefaults(t, 'showDayReferenceLinesTooltip', defaultsForT)}

@@ -276,17 +332,19 @@ const Settings = ({ {t('showTherapeuticRangeLines')} - + setOpenTooltipId(open ? 'showTherapeuticRangeLines' : null)}> - +

{tWithDefaults(t, 'showTherapeuticRangeLinesTooltip', defaultsForT)}

@@ -299,17 +357,19 @@ const Settings = ({ {t('therapeuticRange')} - - - - +

{tWithDefaults(t, 'therapeuticRangeTooltip', defaultsForT)}

@@ -347,17 +407,19 @@ const Settings = ({
- + setOpenTooltipId(open ? 'displayedDays' : null)}> - +

{tWithDefaults(t, 'displayedDaysTooltip', defaultsForT)}

@@ -379,17 +441,19 @@ const Settings = ({
- + setOpenTooltipId(open ? 'yAxisRange' : null)}> - +

{tWithDefaults(t, 'yAxisRangeTooltip', defaultsForT)}

@@ -438,7 +502,7 @@ const Settings = ({ {t('xAxisFormatContinuous')} - +

{t('xAxisFormatContinuousDesc')}

@@ -448,7 +512,7 @@ const Settings = ({ {t('xAxisFormat24h')} - +

{t('xAxisFormat24hDesc')}

@@ -458,7 +522,7 @@ const Settings = ({ {t('xAxisFormat12h')} - +

{t('xAxisFormat12hDesc')}

@@ -483,17 +547,19 @@ const Settings = ({
- + setOpenTooltipId(open ? 'simulationDuration' : null)}> - +

{tWithDefaults(t, 'simulationDurationTooltip', defaultsForT)}

@@ -533,17 +599,19 @@ const Settings = ({ {t('steadyStateDays')} - + setOpenTooltipId(open ? 'steadyStateDays' : null)}> - +

{tWithDefaults(t, 'steadyStateDaysTooltip', defaultsForT)}

@@ -581,17 +649,19 @@ const Settings = ({
- + setOpenTooltipId(open ? 'halfLife' : null)}> - +

{renderTooltipWithLinks(tWithDefaults(t, 'halfLifeTooltip', defaultsForT))}

@@ -619,17 +689,19 @@ const Settings = ({
- + setOpenTooltipId(open ? 'conversionHalfLife' : null)}> - +

{tWithDefaults(t, 'conversionHalfLifeTooltip', defaultsForT)}

@@ -653,17 +725,19 @@ const Settings = ({
- + setOpenTooltipId(open ? 'absorptionHalfLife' : null)}> - +

{tWithDefaults(t, 'absorptionHalfLifeTooltip', defaultsForT)}

@@ -711,17 +785,19 @@ const Settings = ({ {t('weightBasedVdScaling')} - + setOpenTooltipId(open ? 'weightBasedVd' : null)}> - +

{tWithDefaults(t, 'weightBasedVdTooltip', defaultsForT)}

@@ -732,17 +808,19 @@ const Settings = ({
- - - - +

{renderTooltipWithLinks(tWithDefaults(t, 'bodyWeightTooltip', defaultsForT))}

@@ -775,17 +853,19 @@ const Settings = ({ {t('foodEffectEnabled')} - + setOpenTooltipId(open ? 'foodEffect' : null)}> - +

{tWithDefaults(t, 'foodEffectTooltip', defaultsForT)}

@@ -796,17 +876,19 @@ const Settings = ({
- - - - +

{renderTooltipWithLinks(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}

@@ -839,17 +921,19 @@ const Settings = ({ {t('urinePHTendency')} - + setOpenTooltipId(open ? 'urinePH' : null)}> - +

{tWithDefaults(t, 'urinePHTooltip', defaultsForT)}

@@ -860,17 +944,19 @@ const Settings = ({
- - - - +

{tWithDefaults(t, 'urinePHValueTooltip', defaultsForT)}

@@ -896,17 +982,19 @@ const Settings = ({
- + setOpenTooltipId(open ? 'oralBioavailability' : null)}> - +

{renderTooltipWithLinks(tWithDefaults(t, 'oralBioavailabilityTooltip', defaultsForT))}

diff --git a/src/components/ui/info-tooltip.tsx b/src/components/ui/info-tooltip.tsx new file mode 100644 index 0000000..8d43a8d --- /dev/null +++ b/src/components/ui/info-tooltip.tsx @@ -0,0 +1,95 @@ +/** + * useInfoTooltip Hook & InfoTooltipButton Component + * + * Provides mobile-friendly tooltip handling for info icons. + * On touch devices, the tooltip persists until user clicks outside. + * On desktop, it shows on hover as normal. + * + * Usage in settings: + * ```tsx + * const [isOpen, handlers] = useInfoTooltip(); + * + * + * + * + * ... + * + * ``` + * + * @author Andreas Weyer + * @license MIT + */ + +import React from 'react'; + +interface TooltipHandlers { + onTouchStart: (e: React.TouchEvent) => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseLeave?: (e: React.MouseEvent) => void; +} + +/** + * Hook to manage tooltip state with touch persistence. + * Returns [isOpen, handlers, setIsOpen] for use with Radix Tooltip. + */ +export const useInfoTooltip = (): [boolean, TooltipHandlers, (open: boolean) => void] => { + const [isOpen, setIsOpen] = React.useState(false); + const [isTouchDevice, setIsTouchDevice] = React.useState(false); + const triggerRef = React.useRef(null); + + // Detect if device supports touch + React.useEffect(() => { + const isTouchScreen = () => { + return ( + (typeof window !== 'undefined' && + window.matchMedia('(hover: none) and (pointer: coarse)').matches) || + ('ontouchstart' in window) || + (navigator.maxTouchPoints > 0) + ); + }; + setIsTouchDevice(isTouchScreen()); + }, []); + + // Handle click outside to close tooltip (for touch devices) + React.useEffect(() => { + if (!isOpen || !isTouchDevice) return; + + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + if (triggerRef.current && !triggerRef.current.contains(e.target as Node)) { + const tooltip = document.querySelector('[role="tooltip"]'); + if (tooltip && !tooltip.contains(e.target as Node)) { + setIsOpen(false); + } + } + }; + + // Use a small delay to avoid immediate closing on the same touch + const timeoutId = setTimeout(() => { + document.addEventListener('touchstart', handleClickOutside); + document.addEventListener('mousedown', handleClickOutside); + }, 100); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('touchstart', handleClickOutside); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, isTouchDevice]); + + const handlers: TooltipHandlers = { + onTouchStart: (e: React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsOpen(true); + }, + // For desktop hover, let Radix UI handle it (will work via open prop) + // But we can optionally close on mouse leave for consistency + onMouseLeave: isTouchDevice ? undefined : (e: React.MouseEvent) => { + // Let Radix UI handle this naturally + }, + }; + + return [isOpen, handlers, setIsOpen]; +}; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index cbbe089..e722be1 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -5,6 +5,7 @@ import { cn } from "../../lib/utils" const TooltipProvider = TooltipPrimitive.Provider +// Tooltip with slightly longer delay to support touch interactions better const Tooltip = TooltipPrimitive.Root const TooltipTrigger = TooltipPrimitive.Trigger