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:
parent
2604f1158c
commit
e4d2ade92f
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue