diff --git a/resources/js/components/ui/chart.tsx b/resources/js/components/ui/chart.tsx index 816a0c9c..2395445a 100644 --- a/resources/js/components/ui/chart.tsx +++ b/resources/js/components/ui/chart.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import * as RechartsPrimitive from 'recharts'; import { usePrivacyMode } from '@/contexts/privacy-mode-context'; @@ -102,6 +103,95 @@ ${colorConfig const ChartTooltip = RechartsPrimitive.Tooltip; +/** + * Portals the tooltip content to document.body, anchoring it to the + * Recharts-applied position of its parent wrapper. Escapes ancestor + * `overflow-hidden` clipping while preserving Recharts' positioning. + */ +function ChartTooltipPortal({ + children, + coordinate, + offset = 12, +}: { + children: React.ReactNode; + coordinate?: { x?: number; y?: number }; + offset?: number; +}) { + const anchorRef = React.useRef(null); + const tooltipRef = React.useRef(null); + const [pos, setPos] = React.useState<{ x: number; y: number } | null>( + null, + ); + + React.useLayoutEffect(() => { + if (!anchorRef.current || !coordinate) { + return; + } + const wrapper = anchorRef.current.closest('.recharts-wrapper'); + if (!wrapper) { + return; + } + const rect = wrapper.getBoundingClientRect(); + const cx = (coordinate.x ?? 0) + rect.left; + const cy = (coordinate.y ?? 0) + rect.top; + + const tipEl = tooltipRef.current; + const tipW = tipEl?.offsetWidth ?? 0; + const tipH = tipEl?.offsetHeight ?? 0; + + let x = cx + offset; + let y = cy + offset; + + // Flip if overflowing viewport + if (x + tipW > window.innerWidth - 8) { + x = cx - tipW - offset; + } + if (y + tipH > window.innerHeight - 8) { + y = cy - tipH - offset; + } + if (x < 8) { + x = 8; + } + if (y < 8) { + y = 8; + } + + setPos((prev) => { + if (prev && prev.x === x && prev.y === y) { + return prev; + } + return { x, y }; + }); + }); + + return ( + <> +
+ {typeof document !== 'undefined' + ? createPortal( +
+ {children} +
, + document.body, + ) + : null} + + ); +} + interface TooltipPayloadItem { dataKey?: string | number; name?: string; @@ -113,6 +203,7 @@ interface TooltipPayloadItem { interface ChartTooltipContentProps { active?: boolean; payload?: TooltipPayloadItem[]; + coordinate?: { x?: number; y?: number }; className?: string; indicator?: 'line' | 'dot' | 'dashed'; hideLabel?: boolean; @@ -170,6 +261,7 @@ const ChartTooltipContent = React.forwardRef< accountCurrencies, displayCurrency, netWorthMode, + coordinate, }, ref, ) => { @@ -261,15 +353,16 @@ const ChartTooltipContent = React.forwardRef< currencyTotals && currencyTotals.length > 1; return ( +
{!nestLabel ? tooltipLabel : null} -
+
{payload.map( (item: TooltipPayloadItem, index: number) => { const key = `${nameKey || item.name || item.dataKey || 'value'}`; @@ -313,23 +406,23 @@ const ChartTooltipContent = React.forwardRef< ) : null}
-
+
{nestLabel ? tooltipLabel :
} - + {itemConfig?.label || item.name}
{item.value !== undefined && ( - + {(() => { const originalKey = `${accountId}_original`; const original = item.payload?.[originalKey] as { amount: number; currency_code: string } | undefined; @@ -378,19 +471,19 @@ const ChartTooltipContent = React.forwardRef< const totalLabel = hasLiabilities ? 'Net Worth' : 'Total'; return ( -
+
{hasLiabilities && displayCurrency && liabilities.map((liability, index) => ( -
-
+
+
- + {netWorthMode?.liabilityTypeLabel}: {liability.name}
- + {isPrivacyModeEnabled ? formatCurrencyWithCode(-liability.amount, displayCurrency, locale).replace(/\d/g, '*') : formatCurrencyWithCode(-liability.amount, displayCurrency, locale)} @@ -450,6 +543,7 @@ const ChartTooltipContent = React.forwardRef< })()}
+ ); }, );