fix(chart): tooltip escapes overflow, truncates long labels (#317)

## Summary

- Portal chart tooltip to `document.body` to escape ancestor
`overflow-hidden` (CardContent, scroll container). Positioned via
Recharts' `coordinate` prop + `.recharts-wrapper` viewport rect, with
viewport-edge flipping.
- Cap tooltip width and truncate long account/liability names with
ellipsis; values stay nowrap.
- Force `grid-cols-[minmax(0,1fr)]` on tooltip grid tracks so `truncate`
actually engages (grid items default to min-width auto).

## Test plan
- Hover net-worth chart: tooltip no longer clipped by container.
- Position follows cursor; flips near viewport edges.
- Long liability/account names show ellipsis within tooltip bounds.
This commit is contained in:
Víctor Falcón 2026-04-23 08:17:38 +01:00 committed by GitHub
parent 2604f1158c
commit e4d2ade92f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 106 additions and 12 deletions

View File

@ -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<HTMLDivElement>(null);
const tooltipRef = React.useRef<HTMLDivElement>(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 (
<>
<div
ref={anchorRef}
style={{ width: 0, height: 0, pointerEvents: 'none' }}
/>
{typeof document !== 'undefined'
? createPortal(
<div
ref={tooltipRef}
style={{
position: 'fixed',
left: pos?.x ?? -9999,
top: pos?.y ?? -9999,
visibility: pos ? 'visible' : 'hidden',
pointerEvents: 'none',
zIndex: 50,
}}
>
{children}
</div>,
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 (
<ChartTooltipPortal coordinate={coordinate}>
<div
ref={ref}
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
'border-border/50 bg-background grid grid-cols-[minmax(0,1fr)] min-w-[8rem] max-w-[min(20rem,calc(100vw-2rem))] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
<div className="grid grid-cols-[minmax(0,1fr)] gap-1.5">
{payload.map(
(item: TooltipPayloadItem, index: number) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
@ -313,23 +406,23 @@ const ChartTooltipContent = React.forwardRef<
) : null}
<div
className={cn(
'flex flex-1 gap-4 justify-between leading-none',
'flex flex-1 min-w-0 gap-4 justify-between leading-none',
nestLabel ? 'items-end' : '',
)}
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{nestLabel
? tooltipLabel
: <div style={{ backgroundColor: item.color }} className={cn([
'size-2.5 rounded-xs'
'size-2.5 shrink-0 rounded-xs'
])}></div>}
<span className="text-muted-foreground ml-0">
<span className="text-muted-foreground ml-0 truncate">
{itemConfig?.label ||
item.name}
</span>
</div>
{item.value !== undefined && (
<span className="text-foreground font-mono font-medium tabular-nums">
<span className="text-foreground font-mono font-medium tabular-nums shrink-0 whitespace-nowrap">
{(() => {
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 (
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5">
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5 min-w-0">
{hasLiabilities && displayCurrency && liabilities.map((liability, index) => (
<div key={index} className="flex justify-between gap-2">
<div className="flex items-center gap-2">
<div key={index} className="flex min-w-0 justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div
className="size-2.5 shrink-0 rounded-xs"
style={{ backgroundColor: netWorthMode?.liabilityDotColor ?? 'var(--color-destructive)' }}
/>
<span className="text-muted-foreground font-medium">
<span className="text-muted-foreground truncate font-medium">
{netWorthMode?.liabilityTypeLabel}: {liability.name}
</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums">
<span className="text-foreground font-mono font-medium tabular-nums shrink-0 whitespace-nowrap">
{isPrivacyModeEnabled
? formatCurrencyWithCode(-liability.amount, displayCurrency, locale).replace(/\d/g, '*')
: formatCurrencyWithCode(-liability.amount, displayCurrency, locale)}
@ -450,6 +543,7 @@ const ChartTooltipContent = React.forwardRef<
})()}
</div>
</div>
</ChartTooltipPortal>
);
},
);