Repository: electricitymaps/electricitymaps-contrib
Pull request #7919: Design revamp part III: Aligns tooltips
Author: login: madsnedergaard
Labels: name: Frontend 🎨
Created: 2025-03-17T06:43:38Z | Merged: 2025-03-18T10:59:21Z

## Issue

_Part of a bigger 2025 refresh of the app! 🚀_



## Description

This aligns the various tooltips in look and feel.

Also fixes the regression of #5089 that happened at some point.


### Double check

- [x] I have run `pnpx prettier@2 --write .` and `poetry run format` in the top level directory to format my changes.



Diff:
diff --git a/web/src/components/CarbonIntensitySquare.tsx b/web/src/components/CarbonIntensitySquare.tsx
index 43c1ac82dc..5820ea19bb 100644
--- a/web/src/components/CarbonIntensitySquare.tsx
+++ b/web/src/components/CarbonIntensitySquare.tsx
@@ -5,6 +5,7 @@ import { CarbonUnits } from 'utils/units';
 
 import { useCo2ColorScale } from '../hooks/theme';
 import InfoIconWithPadding from './InfoIconWithPadding';
+import LabelTooltip from './tooltips/LabelTooltip';
 import TooltipWrapper from './tooltips/TooltipWrapper';
 
 /**
@@ -50,8 +51,9 @@ function CarbonIntensitySquare({
   return (
     <div className="flex flex-col items-center gap-2">
       <TooltipWrapper
-        tooltipContent={tooltipContent}
-        tooltipClassName="max-w-[200px]"
+        tooltipContent={
+          <LabelTooltip className="max-w-[200px]">{tooltipContent}</LabelTooltip>
+        }
         side="bottom"
         sideOffset={8}
       >
diff --git a/web/src/components/CircularGauge.tsx b/web/src/components/CircularGauge.tsx
index e548c54ca4..35b0302858 100644
--- a/web/src/components/CircularGauge.tsx
+++ b/web/src/components/CircularGauge.tsx
@@ -5,6 +5,7 @@ import { Text } from '@visx/text';
 import { memo } from 'react';
 
 import InfoIconWithPadding from './InfoIconWithPadding';
+import LabelTooltip from './tooltips/LabelTooltip';
 import TooltipWrapper from './tooltips/TooltipWrapper';
 
 const FULL_CIRCLE = 360;
@@ -80,7 +81,11 @@ function CircularGauge({ ratio, name, testId, tooltipContent }: CircularGaugePro
 
   return (
     <div className="flex flex-col items-center gap-2">
-      <TooltipWrapper tooltipContent={tooltipContent} side="bottom" sideOffset={8}>
+      <TooltipWrapper
+        tooltipContent={<LabelTooltip>{tooltipContent}</LabelTooltip>}
+        side="bottom"
+        sideOffset={8}
+      >
         <div data-testid={testId} className="relative flex flex-col items-center">
           <svg height={height} width={width}>
             <Group top={centerY} left={centerX} height={height} width={width}>
diff --git a/web/src/components/tooltips/LabelTooltip.tsx b/web/src/components/tooltips/LabelTooltip.tsx
new file mode 100644
index 0000000000..91623a6ad5
--- /dev/null
+++ b/web/src/components/tooltips/LabelTooltip.tsx
@@ -0,0 +1,21 @@
+import GlassContainer from 'components/GlassContainer';
+import { twMerge } from 'tailwind-merge';
+
+export default function LabelTooltip({
+  className,
+  children,
+}: {
+  className?: string;
+  children: React.ReactNode;
+}) {
+  return (
+    <GlassContainer
+      className={twMerge(
+        'relative h-auto max-w-[164px] rounded px-3 py-1.5 text-center text-sm sm:rounded sm:bg-white',
+        className
+      )}
+    >
+      {children}
+    </GlassContainer>
+  );
+}
diff --git a/web/src/components/tooltips/TooltipWrapper.tsx b/web/src/components/tooltips/TooltipWrapper.tsx
index 940944a764..ab56eb4b69 100644
--- a/web/src/components/tooltips/TooltipWrapper.tsx
+++ b/web/src/components/tooltips/TooltipWrapper.tsx
@@ -1,7 +1,7 @@
 import * as Tooltip from '@radix-ui/react-tooltip';
-import GlassContainer from 'components/GlassContainer';
-import { ReactElement, useState } from 'react';
-import { twMerge } from 'tailwind-merge';
+import { useAtom } from 'jotai';
+import { ReactElement, useCallback, useState } from 'react';
+import { openTooltipIdAtom } from 'utils/state/atoms';
 import { useIsMobile } from 'utils/styling';
 
 interface TooltipWrapperProperties {
@@ -9,35 +9,67 @@ interface TooltipWrapperProperties {
   children: ReactElement;
   side?: 'top' | 'bottom' | 'left' | 'right';
   sideOffset?: number;
-  tooltipClassName?: string;
+  tooltipId?: string;
 }
 
-const noop = () => undefined;
-
 export default function TooltipWrapper({
   tooltipContent,
   children,
-  side,
-  sideOffset,
-  tooltipClassName,
+  side = 'left',
+  sideOffset = 3,
+  tooltipId,
 }: TooltipWrapperProperties): ReactElement {
-  const [isOpen, setIsOpen] = useState(false);
+  const [openTooltipId, setOpenTooltipId] = useAtom(openTooltipIdAtom);
+  const [localIsOpen, setLocalIsOpen] = useState(false);
   const isMobile = useIsMobile();
+
+  const openTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(tooltipId);
+    } else {
+      setLocalIsOpen(true);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const closeTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(null);
+    } else {
+      setLocalIsOpen(false);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const toggleTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(openTooltipId === tooltipId ? null : tooltipId);
+    } else {
+      setLocalIsOpen((previous) => !previous);
+    }
+  }, [tooltipId, openTooltipId, setOpenTooltipId]);
+
+  // Simple event handlers using memoized functions
+  const handleMouseEnter = useCallback(
+    () => !isMobile && openTooltip(),
+    [isMobile, openTooltip]
+  );
+  const handleMouseLeave = useCallback(
+    () => !isMobile && closeTooltip(),
+    [isMobile, closeTooltip]
+  );
+  const handleClick = useCallback(
+    () => isMobile && toggleTooltip(),
+    [isMobile, toggleTooltip]
+  );
+  const handleContentClick = useCallback(
+    () => isMobile && closeTooltip(),
+    [isMobile, closeTooltip]
+  );
+
   if (!tooltipContent) {
     return children;
   }
 
-  // Helpers
-  const openTooltip = () => setIsOpen(true);
-  const closeTooltip = () => setIsOpen(false);
-  const toggleTooltip = () => setIsOpen(!isOpen);
-
-  // Declare the event handlers outside of the JSX to avoid re-creating them on every render.
-  const handleMouseEnter = isMobile ? noop : openTooltip;
-  const handleMouseLeave = isMobile ? noop : closeTooltip;
-  const handleClick = isMobile ? toggleTooltip : noop;
-  const handleContentClick = isMobile ? closeTooltip : noop;
-  const handleContentPointerDownOutside = isMobile ? closeTooltip : noop;
+  const isOpen = tooltipId ? openTooltipId === tooltipId : localIsOpen;
 
   return (
     <Tooltip.Provider disableHoverableContent>
@@ -53,19 +85,12 @@ export default function TooltipWrapper({
         <Tooltip.Portal>
           <Tooltip.Content
             className="z-50"
-            sideOffset={sideOffset ?? 3}
-            side={side ?? 'left'}
+            sideOffset={sideOffset}
+            side={side}
             onClick={handleContentClick}
-            onPointerDownOutside={handleContentPointerDownOutside}
+            onPointerDownOutside={handleContentClick}
           >
-            <GlassContainer
-              className={twMerge(
-                'relative h-auto max-w-[164px] rounded-2xl px-3 py-1.5 text-center text-sm',
-                tooltipClassName
-              )}
-            >
-              {tooltipContent}
-            </GlassContainer>
+            {tooltipContent}
           </Tooltip.Content>
         </Tooltip.Portal>
       </Tooltip.Root>
diff --git a/web/src/features/app-sidebar/AppSidebar.tsx b/web/src/features/app-sidebar/AppSidebar.tsx
index 650c7bcaf6..98a9c2a00a 100644
--- a/web/src/features/app-sidebar/AppSidebar.tsx
+++ b/web/src/features/app-sidebar/AppSidebar.tsx
@@ -1,14 +1,22 @@
 import { LogoIcon } from 'components/Logo';
-import { BookOpenIcon, CodeXmlIcon, FileSpreadsheetIcon, MapIcon } from 'lucide-react';
+import {
+  BookOpenIcon,
+  CodeXmlIcon,
+  FileSpreadsheetIcon,
+  HelpCircleIcon,
+  MapIcon,
+} from 'lucide-react';
 
 import { MenuItem } from './MenuItem';
 import {
   Sidebar,
   SidebarContent,
+  SidebarFooter,
   SidebarGroup,
   SidebarHeader,
   SidebarMenu,
 } from './Sidebar';
+import { SupportButton } from './SupportButton';
 
 // Sidebar width is used in various places to calculate the view widths
 export const SIDEBAR_WIDTH = '63px';
@@ -62,11 +70,9 @@ export function AppSidebar() {
           </SidebarMenu>
         </SidebarGroup>
       </SidebarContent>
-      {/* <SidebarFooter>
-          <div className="flex items-center justify-between gap-4 overflow-hidden transition-all duration-200 group-data-[collapsible=icon]:mb-6 group-data-[collapsible=icon]:items-start">
-            <SupportButton label="Support" Icon={HelpCircleIcon} />
-          </div>
-        </SidebarFooter> */}
+      <SidebarFooter>
+        <SupportButton label="FAQ & Support" Icon={HelpCircleIcon} />
+      </SidebarFooter>
     </Sidebar>
   );
 }
diff --git a/web/src/features/app-sidebar/MenuItem.tsx b/web/src/features/app-sidebar/MenuItem.tsx
index 3b444b5596..7fa3860f1e 100644
--- a/web/src/features/app-sidebar/MenuItem.tsx
+++ b/web/src/features/app-sidebar/MenuItem.tsx
@@ -1,3 +1,4 @@
+import LabelTooltip from 'components/tooltips/LabelTooltip';
 import TooltipWrapper from 'components/tooltips/TooltipWrapper';
 import type { LucideIcon } from 'lucide-react';
 import trackEvent from 'utils/analytics';
@@ -22,10 +23,9 @@ export function MenuItem({
 
   return (
     <TooltipWrapper
-      tooltipContent={<p>{label}</p>}
+      tooltipContent={<LabelTooltip>{label}</LabelTooltip>}
       side="right"
       sideOffset={4}
-      tooltipClassName="rounded"
     >
       <SidebarMenuItem className="flex flex-col">
         <SidebarMenuButton
diff --git a/web/src/features/app-sidebar/Sidebar.tsx b/web/src/features/app-sidebar/Sidebar.tsx
index d9690cf330..3ae9e35a85 100644
--- a/web/src/features/app-sidebar/Sidebar.tsx
+++ b/web/src/features/app-sidebar/Sidebar.tsx
@@ -60,7 +60,7 @@ const SidebarHeader = React.forwardRef<
   <div
     ref={reference}
     data-sidebar="header"
-    className={twMerge('flex flex-col gap-2 px-2 py-[1.15rem]', className)}
+    className={twMerge('flex flex-col gap-2 px-2 pb-[0.90rem] pt-[1.15rem]', className)}
     {...props}
   />
 ));
@@ -84,7 +84,7 @@ const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'di
       ref={reference}
       data-sidebar="content"
       className={twMerge(
-        'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
+        'flex min-h-0 flex-1 flex-col gap-2 overflow-auto pt-1 group-data-[collapsible=icon]:overflow-hidden',
 
         className
       )}
@@ -154,7 +154,7 @@ const SidebarMenuButton = React.forwardRef<
       data-size={'default'}
       data-active={isActive}
       className={twMerge(
-        'peer/menu-button hover:text-strong active:text-strong data-[active=true]:text-strong data-[state=open]:hover:bg-hover data-[state=open]:hover:text-strong hover:bg-hover hover:text-strong flex h-8 w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-blue-500 transition-[width,height,padding] hover:bg-sunken focus-visible:ring-2 active:bg-sunken disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sunken data-[active=true]:font-medium group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
+        'peer/menu-button hover:text-strong active:text-strong data-[active=true]:text-strong data-[state=open]:hover:bg-hover data-[state=open]:hover:text-strong hover:bg-hover hover:text-strong flex h-8 w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-brand-green transition-[width,height,padding] hover:bg-sunken focus-visible:ring-2 active:bg-sunken disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sunken data-[active=true]:font-medium group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
         className
       )}
       {...props}
diff --git a/web/src/features/app-sidebar/SupportButton.tsx b/web/src/features/app-sidebar/SupportButton.tsx
new file mode 100644
index 0000000000..01d2524fcc
--- /dev/null
+++ b/web/src/features/app-sidebar/SupportButton.tsx
@@ -0,0 +1,7 @@
+import type { LucideIcon } from 'lucide-react';
+
+import { MenuItem } from './MenuItem';
+
+export function SupportButton({ Icon, label }: { Icon: LucideIcon; label: string }) {
+  return <MenuItem to="https://help.electricitymaps.com/" Icon={Icon} label={label} />;
+}
diff --git a/web/src/features/charts/DataSources.tsx b/web/src/features/charts/DataSources.tsx
index ab7130c82a..3b0d9cc9aa 100644
--- a/web/src/features/charts/DataSources.tsx
+++ b/web/src/features/charts/DataSources.tsx
@@ -1,6 +1,7 @@
 import * as Portal from '@radix-ui/react-portal';
 import { Button } from 'components/Button';
 import Link from 'components/Link';
+import LabelTooltip from 'components/tooltips/LabelTooltip';
 import TooltipWrapper from 'components/tooltips/TooltipWrapper';
 import { TFunction } from 'i18next';
 import { Info, X } from 'lucide-react';
@@ -45,14 +46,11 @@ export function DataSources({
               isMobile ? (
                 <EmissionFactorTooltip t={t} />
               ) : (
-                t('country-panel.emissionFactorDataSourcesTooltip')
+                <LabelTooltip className="max-w-[400px] text-start">
+                  {t('country-panel.emissionFactorDataSourcesTooltip')}
+                </LabelTooltip>
               )
             }
-            tooltipClassName={
-              isMobile
-                ? 'absolute h-full max-w-full rounded-none border-0 bg-red-500 p-0 text-left text-lg shadow-none dark:border-white dark:bg-neutral-900'
-                : 'min-w-64 text-left'
-            }
             side="bottom"
           >
             <Info className="text-emerald-800 dark:text-emerald-500" size={16} />
diff --git a/web/src/features/exchanges/ExchangeArrow.tsx b/web/src/features/exchanges/ExchangeArrow.tsx
index 5cf7909c05..9d435e6203 100644
--- a/web/src/features/exchanges/ExchangeArrow.tsx
+++ b/web/src/features/exchanges/ExchangeArrow.tsx
@@ -114,9 +114,9 @@ function ExchangeArrow({
   const scaledZoom = 100 * baseZoom;
 
   // Setting the top position from the arrow tooltip preventing overflowing to top.
-  let tooltipClassName = 'max-h-[256px] max-w-[512px] md:flex rounded-2xl';
+  let tooltipClassName = 'max-h-[256px] max-w-[512px] md:flex';
   if (!isMobile) {
-    tooltipClassName += y - 76 < headerHeight ? ' top-[76px]' : ' top-[-76px]';
+    tooltipClassName += y - 140 < headerHeight ? ' top-[76px]' : ' top-[-76px]';
   }
 
   if (
@@ -140,10 +140,16 @@ function ExchangeArrow({
 
   return (
     <TooltipWrapper
-      tooltipClassName={tooltipClassName}
-      tooltipContent={<ExchangeTooltip exchangeData={data} isMobile={isMobile} />}
+      tooltipContent={
+        <ExchangeTooltip
+          exchangeData={data}
+          isMobile={isMobile}
+          className={tooltipClassName}
+        />
+      }
       side={isMobile ? 'top' : 'right'}
-      sideOffset={10}
+      sideOffset={isMobile ? 10 : 0}
+      tooltipId={`exchange-${key}`}
     >
       <picture
         style={{
diff --git a/web/src/features/exchanges/ExchangeTooltip.tsx b/web/src/features/exchanges/ExchangeTooltip.tsx
index f229f46774..7182014ac2 100644
--- a/web/src/features/exchanges/ExchangeTooltip.tsx
+++ b/web/src/features/exchanges/ExchangeTooltip.tsx
@@ -1,8 +1,10 @@
 import { CarbonIntensityDisplay } from 'components/CarbonIntensityDisplay';
+import GlassContainer from 'components/GlassContainer';
 import { ZoneName } from 'components/ZoneName';
 import { useAtomValue } from 'jotai';
 import type { ReactElement } from 'react';
 import { useTranslation } from 'react-i18next';
+import { twMerge } from 'tailwind-merge';
 import { ExchangeArrowData } from 'types';
 import { formatEnergy, formatPower } from 'utils/formatting';
 import { isHourlyAtom } from 'utils/state/atoms';
@@ -10,11 +12,13 @@ import { isHourlyAtom } from 'utils/state/atoms';
 interface ExchangeTooltipProperties {
   exchangeData: ExchangeArrowData;
   isMobile: boolean;
+  className?: string;
 }
 
 export default function ExchangeTooltip({
   exchangeData,
   isMobile,
+  className,
 }: ExchangeTooltipProperties): ReactElement {
   const { t } = useTranslation();
   const isHourly = useAtomValue(isHourlyAtom);
@@ -27,31 +31,35 @@ export default function ExchangeTooltip({
 
   const divClass = `${isMobile ? 'flex-col' : 'flex'} items-center pb-2`;
   return (
-    <div className="text-start text-base font-medium" data-testid="exchange-tooltip">
-      {t('tooltips.crossborderexport')}:{' '}
-      <b className="font-bold">
+    <GlassContainer
+      className={twMerge('relative h-auto  rounded-2xl px-3 py-2', className)}
+    >
+      <div className="text-start text-base font-medium" data-testid="exchange-tooltip">
+        {t('tooltips.crossborderexport')}
         {isMobile ? '' : ':'}{' '}
-        {isHourly
-          ? formatPower({ value: roundedNetFlow })
-          : formatEnergy({ value: roundedNetFlow })}
-      </b>
-      <div>
-        <div className={divClass}>
-          <ZoneName zone={zoneFrom} textStyle="max-w-[165px]" />
-          {isMobile ? <p className="ml-0.5">↓</p> : <p className="mx-2">→</p>}{' '}
-          <ZoneName zone={zoneTo} textStyle="max-w-[165px]" />
-        </div>
-      </div>
-      {t('tooltips.carbonintensityexport')}:
-      <div className="pt-1">
-        {co2intensity > 0 ? (
-          <div className="inline-flex items-center gap-x-1">
-            <CarbonIntensityDisplay withSquare co2Intensity={co2intensity} />
+        <b className="font-bold">
+          {isHourly
+            ? formatPower({ value: roundedNetFlow })
+            : formatEnergy({ value: roundedNetFlow })}
+        </b>
+        <div>
+          <div className={divClass}>
+            <ZoneName zone={zoneFrom} textStyle="max-w-[165px]" />
+            {isMobile ? <p className="ml-0.5">↓</p> : <p className="mx-2">→</p>}{' '}
+            <ZoneName zone={zoneTo} textStyle="max-w-[165px]" />
           </div>
-        ) : (
-          <p className="text-neutral-400">{t('tooltips.temporarilyUnavailable')}</p>
-        )}
+        </div>
+        {t('tooltips.carbonintensityexport')}:
+        <div className="pt-1">
+          {co2intensity > 0 ? (
+            <div className="inline-flex items-center gap-x-1">
+              <CarbonIntensityDisplay withSquare co2Intensity={co2intensity} />
+            </div>
+          ) : (
+            <p className="text-neutral-400">{t('tooltips.temporarilyUnavailable')}</p>
+          )}
+        </div>
       </div>
-    </div>
+    </GlassContainer>
   );
 }
diff --git a/web/src/features/map-controls/MapButton.tsx b/web/src/features/map-controls/MapButton.tsx
index 62066d8744..2be1fd4365 100644
--- a/web/src/features/map-controls/MapButton.tsx
+++ b/web/src/features/map-controls/MapButton.tsx
@@ -1,5 +1,6 @@
 import * as Toggle from '@radix-ui/react-toggle';
 import { GlassBackdrop } from 'components/GlassContainer';
+import LabelTooltip from 'components/tooltips/LabelTooltip';
 import { twMerge } from 'tailwind-merge';
 
 import TooltipWrapper from '../../components/tooltips/TooltipWrapper';
@@ -25,10 +26,7 @@ export default function MapButton({
 }: MapButtonProperties) {
   const Component = asToggle ? Toggle.Root : 'div';
   return (
-    <TooltipWrapper
-      tooltipContent={tooltipText}
-      tooltipClassName="relative py-1.5 px-3 rounded"
-    >
+    <TooltipWrapper tooltipContent={<LabelTooltip>{tooltipText}</LabelTooltip>}>
       <Component
         onClick={onClick}
         className={twMerge(
diff --git a/web/src/features/panels/zone/ZoneHeaderTitle.tsx b/web/src/features/panels/zone/ZoneHeaderTitle.tsx
index 3bfce802ed..74a27bb219 100644
--- a/web/src/features/panels/zone/ZoneHeaderTitle.tsx
+++ b/web/src/features/panels/zone/ZoneHeaderTitle.tsx
@@ -1,4 +1,5 @@
 import { CountryFlag } from 'components/Flag';
+import LabelTooltip from 'components/tooltips/LabelTooltip';
 import TooltipWrapper from 'components/tooltips/TooltipWrapper';
 import { Info } from 'lucide-react';
 import { memo } from 'react';
@@ -30,9 +31,12 @@ function ZoneHeaderTitle({
         className="shadow-[0_0px_3px_rgba(0,0,0,0.2)]"
       />
       <TooltipWrapper
-        tooltipContent={showTooltip ? zoneNameFull : undefined}
+        tooltipContent={
+          showTooltip ? (
+            <LabelTooltip className="max-w-[400px]">{zoneNameFull}</LabelTooltip>
+          ) : undefined
+        }
         side="bottom"
-        tooltipClassName="max-w-[400px]"
       >
         <h1 className="truncate" data-testid="zone-name">
           {zoneName}
@@ -44,7 +48,10 @@ function ZoneHeaderTitle({
         </div>
       )}
       {disclaimer && (
-        <TooltipWrapper side="bottom" tooltipContent={disclaimer}>
+        <TooltipWrapper
+          side="bottom"
+          tooltipContent={<LabelTooltip>{disclaimer}</LabelTooltip>}
+        >
           <Info className="text-neutral-500" />
         </TooltipWrapper>
       )}
diff --git a/web/src/utils/state/atoms.ts b/web/src/utils/state/atoms.ts
index 8440ecd4bf..456f1c8bff 100644
--- a/web/src/utils/state/atoms.ts
+++ b/web/src/utils/state/atoms.ts
@@ -122,3 +122,5 @@ export const rankingPanelAccordionCollapsedAtom = atomWithStorage(
 export const futurePriceCollapsedAtom = atom<boolean>(true);
 
 export const isRedirectedToLatestDatetimeAtom = atom<boolean>(false);
+
+export const openTooltipIdAtom = atom<string | null>(null);


Reviews:
user: login: madsnedergaard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T09:49:16Z
user: login: VIKTORVAV99
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T11:44:20Z
user: login: madsnedergaard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T12:27:58Z
user: login: VIKTORVAV99
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:16:56Z
user: login: madsnedergaard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:18:06Z
user: login: VIKTORVAV99
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:22:49Z
user: login: VIKTORVAV99
body: LGTM! 🎉
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T10:37:57Z
user: login: cadeban
body: +1 to Viktor's review :) 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T10:46:11Z

Review comments:
diff_hunk: @@ -1,15 +1,15 @@
 import * as Tooltip from '@radix-ui/react-tooltip';
path: web/src/components/tooltips/TooltipWrapper.tsx
user: login: madsnedergaard
body: This component now supports both:
- Hover tooltips like the ones on the sidebar
- Clickable tooltips (exchange arrows) and making sure only one of them is open at a time
created_at: 2025-03-17T09:49:16Z
author_association: MEMBER
diff_hunk: @@ -19,18 +19,41 @@ export default function TooltipWrapper({
   children,
   side,
   sideOffset,
-  tooltipClassName,
+  tooltipId,
 }: TooltipWrapperProperties): ReactElement {
-  const [isOpen, setIsOpen] = useState(false);
+  const [openTooltipId, setOpenTooltipId] = useAtom(openTooltipIdAtom);
+  const [localIsOpen, setLocalIsOpen] = useState(false);
   const isMobile = useIsMobile();
   if (!tooltipContent) {
     return children;
   }
 
   // Helpers
-  const openTooltip = () => setIsOpen(true);
-  const closeTooltip = () => setIsOpen(false);
-  const toggleTooltip = () => setIsOpen(!isOpen);
+  const openTooltip = () => {
path: web/src/components/tooltips/TooltipWrapper.tsx
user: login: VIKTORVAV99
body: All of these need to be in useCallback hooks to prevent re-renders. Since you are changing it anyway do you mind adding that?
created_at: 2025-03-17T11:44:20Z
author_association: MEMBER
diff_hunk: @@ -19,18 +19,41 @@ export default function TooltipWrapper({
   children,
   side,
   sideOffset,
-  tooltipClassName,
+  tooltipId,
 }: TooltipWrapperProperties): ReactElement {
-  const [isOpen, setIsOpen] = useState(false);
+  const [openTooltipId, setOpenTooltipId] = useAtom(openTooltipIdAtom);
+  const [localIsOpen, setLocalIsOpen] = useState(false);
   const isMobile = useIsMobile();
   if (!tooltipContent) {
     return children;
   }
 
   // Helpers
-  const openTooltip = () => setIsOpen(true);
-  const closeTooltip = () => setIsOpen(false);
-  const toggleTooltip = () => setIsOpen(!isOpen);
+  const openTooltip = () => {
path: web/src/components/tooltips/TooltipWrapper.tsx
user: login: madsnedergaard
body: Gotcha, will do
created_at: 2025-03-17T12:27:58Z
author_association: MEMBER
diff_hunk: @@ -1,43 +1,64 @@
 import * as Tooltip from '@radix-ui/react-tooltip';
-import GlassContainer from 'components/GlassContainer';
-import { ReactElement, useState } from 'react';
-import { twMerge } from 'tailwind-merge';
+import { useAtom } from 'jotai';
+import { ReactElement, useCallback, useState } from 'react';
+import { openTooltipIdAtom } from 'utils/state/atoms';
 import { useIsMobile } from 'utils/styling';
 
 interface TooltipWrapperProperties {
   tooltipContent?: string | ReactElement;
   children: ReactElement;
   side?: 'top' | 'bottom' | 'left' | 'right';
   sideOffset?: number;
-  tooltipClassName?: string;
+  tooltipId?: string;
 }
 
-const noop = () => undefined;
-
 export default function TooltipWrapper({
   tooltipContent,
   children,
-  side,
-  sideOffset,
-  tooltipClassName,
+  side = 'left',
+  sideOffset = 3,
+  tooltipId,
 }: TooltipWrapperProperties): ReactElement {
-  const [isOpen, setIsOpen] = useState(false);
+  const [openTooltipId, setOpenTooltipId] = useAtom(openTooltipIdAtom);
+  const [localIsOpen, setLocalIsOpen] = useState(false);
   const isMobile = useIsMobile();
+
+  const openTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(tooltipId);
+    } else {
+      setLocalIsOpen(true);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const closeTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(null);
+    } else {
+      setLocalIsOpen(false);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const toggleTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(openTooltipId === tooltipId ? null : tooltipId);
+    } else {
+      setLocalIsOpen((previous) => !previous);
+    }
+  }, [tooltipId, openTooltipId, setOpenTooltipId]);
+
+  // Simple event handlers using memoized functions
+  const handleMouseEnter = () => !isMobile && openTooltip();
+  const handleMouseLeave = () => !isMobile && closeTooltip();
+  const handleClick = () => isMobile && toggleTooltip();
+  const handleContentClick = () => isMobile && closeTooltip();
+  const handleContentPointerDownOutside = () => isMobile && closeTooltip();
path: web/src/components/tooltips/TooltipWrapper.tsx
user: login: VIKTORVAV99
body: The functions themselves might be memorized but these declarations are not as they are assigned at component creation and don't use useCallback.

They either need to be moved outside the component so they have stable references or use useCallback as well.
created_at: 2025-03-17T14:16:48Z
author_association: MEMBER
diff_hunk: @@ -1,43 +1,64 @@
 import * as Tooltip from '@radix-ui/react-tooltip';
-import GlassContainer from 'components/GlassContainer';
-import { ReactElement, useState } from 'react';
-import { twMerge } from 'tailwind-merge';
+import { useAtom } from 'jotai';
+import { ReactElement, useCallback, useState } from 'react';
+import { openTooltipIdAtom } from 'utils/state/atoms';
 import { useIsMobile } from 'utils/styling';
 
 interface TooltipWrapperProperties {
   tooltipContent?: string | ReactElement;
   children: ReactElement;
   side?: 'top' | 'bottom' | 'left' | 'right';
   sideOffset?: number;
-  tooltipClassName?: string;
+  tooltipId?: string;
 }
 
-const noop = () => undefined;
-
 export default function TooltipWrapper({
   tooltipContent,
   children,
-  side,
-  sideOffset,
-  tooltipClassName,
+  side = 'left',
+  sideOffset = 3,
+  tooltipId,
 }: TooltipWrapperProperties): ReactElement {
-  const [isOpen, setIsOpen] = useState(false);
+  const [openTooltipId, setOpenTooltipId] = useAtom(openTooltipIdAtom);
+  const [localIsOpen, setLocalIsOpen] = useState(false);
   const isMobile = useIsMobile();
+
+  const openTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(tooltipId);
+    } else {
+      setLocalIsOpen(true);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const closeTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(null);
+    } else {
+      setLocalIsOpen(false);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const toggleTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(openTooltipId === tooltipId ? null : tooltipId);
+    } else {
+      setLocalIsOpen((previous) => !previous);
+    }
+  }, [tooltipId, openTooltipId, setOpenTooltipId]);
+
+  // Simple event handlers using memoized functions
+  const handleMouseEnter = () => !isMobile && openTooltip();
+  const handleMouseLeave = () => !isMobile && closeTooltip();
+  const handleClick = () => isMobile && toggleTooltip();
+  const handleContentClick = () => isMobile && closeTooltip();
+  const handleContentPointerDownOutside = () => isMobile && closeTooltip();
path: web/src/components/tooltips/TooltipWrapper.tsx
user: login: madsnedergaard
body: Ugh, I had them memo'd but then Claude told it wasn't necessary given that the open/close/toggle functions and given that isMobile is not going to change 🥲 
created_at: 2025-03-17T14:18:06Z
author_association: MEMBER
diff_hunk: @@ -1,43 +1,64 @@
 import * as Tooltip from '@radix-ui/react-tooltip';
-import GlassContainer from 'components/GlassContainer';
-import { ReactElement, useState } from 'react';
-import { twMerge } from 'tailwind-merge';
+import { useAtom } from 'jotai';
+import { ReactElement, useCallback, useState } from 'react';
+import { openTooltipIdAtom } from 'utils/state/atoms';
 import { useIsMobile } from 'utils/styling';
 
 interface TooltipWrapperProperties {
   tooltipContent?: string | ReactElement;
   children: ReactElement;
   side?: 'top' | 'bottom' | 'left' | 'right';
   sideOffset?: number;
-  tooltipClassName?: string;
+  tooltipId?: string;
 }
 
-const noop = () => undefined;
-
 export default function TooltipWrapper({
   tooltipContent,
   children,
-  side,
-  sideOffset,
-  tooltipClassName,
+  side = 'left',
+  sideOffset = 3,
+  tooltipId,
 }: TooltipWrapperProperties): ReactElement {
-  const [isOpen, setIsOpen] = useState(false);
+  const [openTooltipId, setOpenTooltipId] = useAtom(openTooltipIdAtom);
+  const [localIsOpen, setLocalIsOpen] = useState(false);
   const isMobile = useIsMobile();
+
+  const openTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(tooltipId);
+    } else {
+      setLocalIsOpen(true);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const closeTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(null);
+    } else {
+      setLocalIsOpen(false);
+    }
+  }, [tooltipId, setOpenTooltipId]);
+
+  const toggleTooltip = useCallback(() => {
+    if (tooltipId) {
+      setOpenTooltipId(openTooltipId === tooltipId ? null : tooltipId);
+    } else {
+      setLocalIsOpen((previous) => !previous);
+    }
+  }, [tooltipId, openTooltipId, setOpenTooltipId]);
+
+  // Simple event handlers using memoized functions
+  const handleMouseEnter = () => !isMobile && openTooltip();
+  const handleMouseLeave = () => !isMobile && closeTooltip();
+  const handleClick = () => isMobile && toggleTooltip();
+  const handleContentClick = () => isMobile && closeTooltip();
+  const handleContentPointerDownOutside = () => isMobile && closeTooltip();
path: web/src/components/tooltips/TooltipWrapper.tsx
user: login: VIKTORVAV99
body: Haha glad to know I can't be replaced yet 😅
created_at: 2025-03-17T14:22:49Z
author_association: MEMBER

PR comments:
user: login: madsnedergaard
created_at: 2025-03-17T06:48:44Z
body: Oy, let me just get #7908 merged first before reviewing this PR!
author_association: MEMBER
user: login: VIKTORVAV99
created_at: 2025-03-17T11:42:18Z
body: @madsnedergaard are the graph tooltips supposed to be glassy as well? I don't think they are at the moment.
author_association: MEMBER
user: login: madsnedergaard
created_at: 2025-03-17T12:27:51Z
body: > @madsnedergaard are the graph tooltips supposed to be glassy as well? I don't think they are at the moment.

Nope, kept them with solid background on purpose to avoid "layering" the glass-like containers :)
author_association: MEMBER