diff --git a/.changeset/tooltip-clamp-flipped.md b/.changeset/tooltip-clamp-flipped.md new file mode 100644 index 000000000..e854ec512 --- /dev/null +++ b/.changeset/tooltip-clamp-flipped.md @@ -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) diff --git a/.changeset/tooltip-lock-on-tap.md b/.changeset/tooltip-lock-on-tap.md new file mode 100644 index 000000000..eb32d5a86 --- /dev/null +++ b/.changeset/tooltip-lock-on-tap.md @@ -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 diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index b5c27412d..74e133f53 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte @@ -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 ); @@ -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; @@ -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 { @@ -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; @@ -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; } @@ -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; @@ -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 { @@ -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; @@ -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; } diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts index 62b71f5e0..db7ccf9cc 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts @@ -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 }, @@ -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"', () => { @@ -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}', () => { @@ -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 diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte index faafd38ea..cf3cb09a3 100644 --- a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte +++ b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte @@ -189,6 +189,23 @@ let hideTimeoutId: ReturnType; + /** + * 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' && @@ -222,6 +239,7 @@ if (hideTimeoutId) { clearTimeout(hideTimeoutId); } + hideWhenUnlocked = false; // Ignore while locked (keep current position / data) return !locked; @@ -476,7 +494,8 @@ function hideTooltip() { if (locked) { - // Ignore (keep open) + // Ignore (keep open) until unlocked + hideWhenUnlocked = true; return; } @@ -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 diff --git a/packages/layerchart/src/lib/tests/TooltipLockHarness.svelte b/packages/layerchart/src/lib/tests/TooltipLockHarness.svelte new file mode 100644 index 000000000..b4f6895bf --- /dev/null +++ b/packages/layerchart/src/lib/tests/TooltipLockHarness.svelte @@ -0,0 +1,22 @@ + + + + (locked = !locked) }} +/> +{locked}