Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tooltip-clamp-flipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Tooltip): Keep a flipped tooltip inside the container (or window) when flipping overflows the other edge (ex. a start-aligned tooltip wider than half a narrow chart opened past its left edge)
5 changes: 5 additions & 0 deletions .changeset/tooltip-lock-on-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(TooltipContext): Keep a tooltip locked from `onclick` on touch (a tap's `pointerleave` precedes `click`, and its pending hide cleared the data under the lock), and hide it once unlocked if the pointer left while locked
33 changes: 33 additions & 0 deletions packages/layerchart/src/lib/components/tooltip/Tooltip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@
return value + (align === 'end' ? -additionalOffset : additionalOffset) - alignOffset;
}

/**
* Keep a flipped tooltip within `[min, max]`, as flipping to the other side can overflow that
* side instead (ex. a wide tooltip on a narrow chart). `min` (the left/top edge) wins when the
* tooltip doesn't fit either way.
*/
function clampToEdges(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max));
}

const isPortaled = $derived(
typeof portalProp === 'boolean' ? portalProp : portalProp?.enabled !== false
);
Expand Down Expand Up @@ -361,6 +370,7 @@
) {
rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
}
rect.left = clampToEdges(rect.left, ctx.padding.left, containerRect.width - tooltipWidth);
}
rect.right = rect.left + tooltipWidth;

Expand All @@ -377,6 +387,7 @@
) {
rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
}
rect.top = clampToEdges(rect.top, ctx.padding.top, containerRect.height - tooltipHeight);
}
rect.bottom = rect.top + tooltipHeight;
} else {
Expand All @@ -389,6 +400,7 @@
if ((xAlign === 'end' || xAlign === 'center') && rect.left < ctx.padding.left) {
rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
}
rect.left = clampToEdges(rect.left, ctx.padding.left, ctx.containerWidth - tooltipWidth);
}
rect.right = rect.left + tooltipWidth;

Expand All @@ -399,6 +411,7 @@
if ((yAlign === 'end' || yAlign === 'center') && rect.top < ctx.padding.top) {
rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
}
rect.top = clampToEdges(rect.top, ctx.padding.top, ctx.containerHeight - tooltipHeight);
}
rect.bottom = rect.top + tooltipHeight;
}
Expand All @@ -415,6 +428,11 @@
if ((xAlign === 'end' || xAlign === 'center') && containerRect.left + rect.left < 0) {
rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
}
rect.left = clampToEdges(
rect.left,
-containerRect.left,
window.innerWidth - containerRect.left - tooltipWidth
);
}
rect.right = rect.left + tooltipWidth;

Expand All @@ -428,6 +446,11 @@
if ((yAlign === 'end' || yAlign === 'center') && containerRect.top + rect.top < 0) {
rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
}
rect.top = clampToEdges(
rect.top,
-containerRect.top,
window.innerHeight - containerRect.top - tooltipHeight
);
}
rect.bottom = rect.top + tooltipHeight;
} else {
Expand All @@ -450,6 +473,11 @@
) {
rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
}
rect.left = clampToEdges(
rect.left,
-parentViewportRect.left,
window.innerWidth - parentViewportRect.left - tooltipWidth
);
}
rect.right = rect.left + tooltipWidth;

Expand All @@ -466,6 +494,11 @@
) {
rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
}
rect.top = clampToEdges(
rect.top,
-parentViewportRect.top,
window.innerHeight - parentViewportRect.top - tooltipHeight
);
}
rect.bottom = rect.top + tooltipHeight;
}
Expand Down
152 changes: 152 additions & 0 deletions packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';

import LineChart from '../charts/LineChart/LineChart.svelte';
import TooltipLockHarness from '$lib/tests/TooltipLockHarness.svelte';

const data = [
{ date: 0, value: 10 },
Expand Down Expand Up @@ -277,6 +278,60 @@ describe('Tooltip', () => {
}
);
});

/**
* A start-aligned tooltip that doesn't fit on the right flips to the left of the pointer, but
* nothing checked the left edge after the flip, so a tooltip wider than about half the chart
* opened past it.
*/
it.each([
{ portal: true, label: 'portaled' },
{ portal: false, label: 'inline' },
])('should keep a flipped tooltip inside the left edge ($label)', async ({ portal }) => {
const { container } = render(LineChart, {
props: {
...baseProps,
props: { tooltip: { root: { portal, props: { root: { style: 'width: 300px' } } } } },
},
});

const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
await expect.element(tooltipCtx).toBeInTheDocument();

const ctxRect = tooltipCtx.getBoundingClientRect();
// Too close to the right edge to fit on the right, and to the left edge once flipped
await waitForTooltip(
tooltipCtx,
{ clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.top + ctxRect.height / 2 },
() => {
const tooltipRect = getTooltipRoot(portal ? document.body : container)!.getBoundingClientRect(); // prettier-ignore
expect(tooltipRect.left).toBeGreaterThanOrEqual(ctxRect.left - 1);
expect(tooltipRect.right).toBeLessThanOrEqual(ctxRect.left + 400);
}
);
});

it('should keep a flipped tooltip inside the top edge', async () => {
const { container } = render(LineChart, {
props: {
...baseProps,
props: { tooltip: { root: { props: { root: { style: 'height: 220px' } } } } },
},
});

const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
await expect.element(tooltipCtx).toBeInTheDocument();

const ctxRect = tooltipCtx.getBoundingClientRect();
await waitForTooltip(
tooltipCtx,
{ clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.top + ctxRect.height / 2 },
() => {
const tooltipRect = getTooltipRoot()!.getBoundingClientRect();
expect(tooltipRect.top).toBeGreaterThanOrEqual(ctxRect.top - 1);
}
);
});
});

describe('contained="window"', () => {
Expand Down Expand Up @@ -333,6 +388,40 @@ describe('Tooltip', () => {
}
);
});

it('should keep a flipped tooltip inside the left side of the viewport', async () => {
// Pointer near the chart's left edge, with a tooltip too wide to fit on its right in the
// viewport, and on its left once flipped
const pointerOffset = 30;
const width = window.innerWidth - pointerOffset;

const { container } = render(LineChart, {
props: {
...baseProps,
props: {
tooltip: {
root: {
contained: 'window' as const,
props: { root: { style: `width: ${width}px` } },
},
},
},
},
});

const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
await expect.element(tooltipCtx).toBeInTheDocument();

const ctxRect = tooltipCtx.getBoundingClientRect();
await waitForTooltip(
tooltipCtx,
{ clientX: ctxRect.left + pointerOffset, clientY: ctxRect.top + ctxRect.height / 2 },
() => {
const tooltipRect = getTooltipRoot()!.getBoundingClientRect();
expect(tooltipRect.left).toBeGreaterThanOrEqual(-1);
}
);
});
});

describe('contained={false}', () => {
Expand Down Expand Up @@ -367,6 +456,69 @@ describe('Tooltip', () => {
});
});

describe('locked', () => {
/** Dispatch the boundary events of a tap. On touch the pointer leaves before `click` fires. */
function tap(el: Element) {
const rect = el.getBoundingClientRect();
const eventInit = {
bubbles: true,
clientX: rect.x + rect.width / 2,
clientY: rect.y + rect.height / 2,
};
el.dispatchEvent(new PointerEvent('pointerenter', eventInit));
el.dispatchEvent(new PointerEvent('pointerleave', eventInit));
el.dispatchEvent(new MouseEvent('click', eventInit));
}

/**
* `pointerleave` schedules a hide, then `click` locks the tooltip. The scheduled hide used to
* clear the data regardless, leaving the tooltip locked and empty, and the root `onclick`
* (which needs data) could no longer unlock it.
*/
it('should keep a tooltip pinned by a tap, and hide it once unpinned', async () => {
const { container } = render(TooltipLockHarness, { props: { data } });

const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
const lockedEl = container.querySelector('.locked') as HTMLElement;
await expect.element(tooltipCtx).toBeInTheDocument();
await waitForTooltip(tooltipCtx, undefined, () => {
expect(getTooltipRoot()).not.toBeNull();
});

tap(tooltipCtx);
await expect.element(lockedEl).toHaveTextContent('true');
await new Promise((resolve) => setTimeout(resolve, 50));
expect(getTooltipRoot(), 'should stay visible while pinned').not.toBeNull();

// The pointer has already left when `click` unpins it, so nothing else would hide it
tap(tooltipCtx);
await expect.element(lockedEl).toHaveTextContent('false');
await vi.waitFor(() => expect(getTooltipRoot()).toBeNull());
});

it('should hide once unlocked if the pointer left while locked', async () => {
const { container, rerender } = render(TooltipLockHarness, { props: { data } });

const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
const lockedEl = container.querySelector('.locked') as HTMLElement;
await expect.element(tooltipCtx).toBeInTheDocument();
await waitForTooltip(tooltipCtx, undefined, () => {
expect(getTooltipRoot()).not.toBeNull();
});

// Click while hovering (mouse), then leave
tooltipCtx.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect.element(lockedEl).toHaveTextContent('true');
tooltipCtx.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(getTooltipRoot(), 'should stay visible while locked').not.toBeNull();

// Unlocked from outside the chart
rerender({ locked: false });
await vi.waitFor(() => expect(getTooltipRoot()).toBeNull());
});
});

describe('pointerEvents', () => {
/**
* With `pointerEvents` enabled the tooltip can be hovered, which sets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,23 @@

let hideTimeoutId: ReturnType<typeof setTimeout>;

/**
* Whether a hide was ignored because the tooltip was locked (ex. the pointer left while pinned),
* and should be applied once it is unlocked. Not reactive on purpose: only `locked` should
* re-run the effect below.
*/
let hideWhenUnlocked = false;

$effect(() => {
// Apply a hide ignored while locked. On touch, a tap dispatches `pointerleave` before
// `click`, so unpinning from `onclick` would otherwise leave the tooltip visible (but
// unlocked) with nothing left to hide it.
if (!locked && hideWhenUnlocked) {
hideWhenUnlocked = false;
hideTooltip();
}
});

function resolveTooltipSeriesKey(series: any, seriesTooltipData: any) {
if (
mode === 'manual' &&
Expand Down Expand Up @@ -222,6 +239,7 @@
if (hideTimeoutId) {
clearTimeout(hideTimeoutId);
}
hideWhenUnlocked = false;

// Ignore while locked (keep current position / data)
return !locked;
Expand Down Expand Up @@ -476,7 +494,8 @@

function hideTooltip() {
if (locked) {
// Ignore (keep open)
// Ignore (keep open) until unlocked
hideWhenUnlocked = true;
return;
}

Expand All @@ -486,6 +505,13 @@
// to allow tweening (ex. moving between bands/bars)
// Additional hideDelay can be configured to extend this delay further
hideTimeoutId = setTimeout(() => {
if (locked) {
// Locked since this hide was scheduled (ex. tap to pin on touch, where `pointerleave`
// precedes `click`). Clearing the data now would leave it locked with nothing to show,
// and the root `onclick` (which needs data) could no longer unlock it.
hideWhenUnlocked = true;
return;
}
if (!tooltipState.isHoveringTooltipArea && !tooltipState.isHoveringTooltipContent) {
tooltipState.data = null;
// This chart cleared its own tooltip — attributing the clear to it is what lets a group
Expand Down
22 changes: 22 additions & 0 deletions packages/layerchart/src/lib/tests/TooltipLockHarness.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script lang="ts">
import LineChart from '$lib/components/charts/LineChart/LineChart.svelte';

let {
data,
locked = $bindable(false),
}: {
data: any[];
locked?: boolean;
} = $props();
</script>

<!-- Tap (click) on the chart pins its tooltip, and tapping again unpins it -->
<LineChart
{data}
x="date"
y="value"
width={400}
height={300}
tooltipContext={{ mode: 'bisect-x', locked, onclick: () => (locked = !locked) }}
/>
<span class="locked">{locked}</span>
Loading