From 7831a04cfa1a361546235a49e7f4a4759b547685 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 1 Oct 2026 15:04:22 -0400 Subject: [PATCH 1/7] Place touch tooltips above the finger --- .changeset/tooltip-touch-placement.md | 5 + .../src/lib/components/tooltip/Tooltip.svelte | 39 ++++++-- .../components/tooltip/Tooltip.svelte.test.ts | 95 ++++++++++++++++++- .../components/tooltip/TooltipContext.svelte | 14 ++- .../src/lib/states/tooltip.svelte.ts | 8 ++ 5 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 .changeset/tooltip-touch-placement.md diff --git a/.changeset/tooltip-touch-placement.md b/.changeset/tooltip-touch-placement.md new file mode 100644 index 000000000..f65b794cf --- /dev/null +++ b/.changeset/tooltip-touch-placement.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Tooltip): Place the tooltip above the finger on touch instead of underneath it, and expose `tooltip.pointerType` diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index 74e133f53..e68a4e235 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte @@ -42,20 +42,23 @@ /** * Offset added to `x` position * - * @default x === 'pointer' ? 10 : 0 + * @default x === 'pointer' ? 10 : 0 (0 when following a finger) */ xOffset?: number; /** * Offset added to `y` position * - * @default y === 'pointer' ? 10 : 0 + * @default y === 'pointer' ? 10 : 0 (32 when following a finger) */ yOffset?: number; /** * Align based on edge of tooltip * + * When following a touch, defaults to `'bottom'` (with a larger `yOffset`) so the tooltip sits + * above the finger instead of under it. Setting `anchor` opts out of this. + * * @default 'top-left' */ anchor?: Placement; @@ -193,7 +196,7 @@ import { type Snippet } from 'svelte'; let { - anchor = 'top-left', + anchor: anchorProp, classes = {}, contained = 'container', fadeDuration = 100, @@ -204,9 +207,9 @@ data: dataProp, facetAll = false, x = 'pointer', - xOffset = x === 'pointer' || facetAll ? 10 : 0, + xOffset: xOffsetProp, y = 'pointer', - yOffset = y === 'pointer' || facetAll ? 10 : 0, + yOffset: yOffsetProp, children, rootRef: rootRefProp = $bindable(), props = { @@ -227,6 +230,27 @@ const ctx = getChartContext(); + /** + * Whether the tooltip is following a finger. The default below-right placement puts it under + * the finger (and the hand behind it), so it moves above instead. + * + * Driven by the event rather than a `(pointer: coarse)` media query so a touchscreen laptop + * gets this for a tap and the usual placement for its mouse. + */ + const isTouchPlacement = $derived( + anchorProp === undefined && + ctx.tooltip.pointerType === 'touch' && + ((x === 'pointer' && y === 'pointer') || facetAll) + ); + + const anchor = $derived(anchorProp ?? (isTouchPlacement ? 'bottom' : 'top-left')); + const xOffset = $derived( + xOffsetProp ?? (isTouchPlacement ? 0 : x === 'pointer' || facetAll ? 10 : 0) + ); + const yOffset = $derived( + yOffsetProp ?? (isTouchPlacement ? 32 : y === 'pointer' || facetAll ? 10 : 0) + ); + /** The row this tooltip shows — its own when given, else whatever the pointer resolved */ const tooltipData = $derived(dataProp ?? ctx.tooltip.data); @@ -383,7 +407,10 @@ } if ( (yAlign === 'end' || yAlign === 'center') && - containerRect.top + rect.top < containerRect.top + ctx.padding.top + // Above a finger, rising past the chart beats flipping underneath it, and the portal + // keeps it from being clipped, so only the viewport limits it + containerRect.top + rect.top < + (isTouchPlacement ? 0 : containerRect.top + ctx.padding.top) ) { rect.top = alignValue(yValue, 'start', yOffset, 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 db7ccf9cc..11507be4e 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import LineChart from '../charts/LineChart/LineChart.svelte'; @@ -22,12 +22,17 @@ const baseProps = { }; /** Dispatch pointer events to trigger the tooltip on a given element */ -function triggerTooltip(el: Element, position?: { clientX: number; clientY: number }) { +function triggerTooltip( + el: Element, + position?: { clientX: number; clientY: number }, + pointerType?: string +) { const rect = el.getBoundingClientRect(); const eventInit = { bubbles: true, clientX: position?.clientX ?? rect.x + rect.width / 2, clientY: position?.clientY ?? rect.y + rect.height / 2, + pointerType, }; el.dispatchEvent(new PointerEvent('pointerenter', eventInit)); el.dispatchEvent(new PointerEvent('pointermove', eventInit)); @@ -45,10 +50,11 @@ function triggerTooltip(el: Element, position?: { clientX: number; clientY: numb async function waitForTooltip( el: Element, position: { clientX: number; clientY: number } | undefined, - assertion: () => void + assertion: () => void, + pointerType?: string ) { await vi.waitFor(() => { - triggerTooltip(el, position); + triggerTooltip(el, position, pointerType); assertion(); }); } @@ -519,6 +525,87 @@ describe('Tooltip', () => { }); }); + describe('touch', () => { + it('should sit centered above the finger', async () => { + const { container } = render(LineChart, { props: baseProps }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + + const ctxRect = tooltipCtx.getBoundingClientRect(); + const pointer = { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.bottom - 20 }; + await waitForTooltip( + tooltipCtx, + pointer, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const top = parseFloat(tooltipRoot.style.top); + const left = parseFloat(tooltipRoot.style.left); + expect(top + tooltipRoot.offsetHeight).toBeCloseTo(pointer.clientY - 32, 0); + expect(left + tooltipRoot.offsetWidth / 2).toBeCloseTo(pointer.clientX, 0); + }, + 'touch' + ); + }); + + it('should rise above the chart rather than flip under a finger near the top', async () => { + // Room above the chart, so only the container (not the viewport) would force a flip + document.body.style.paddingTop = '200px'; + onTestFinished(() => { + document.body.style.paddingTop = ''; + }); + + const { container } = render(LineChart, { props: baseProps }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + + const ctxRect = tooltipCtx.getBoundingClientRect(); + const pointer = { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.top + 5 }; + await waitForTooltip( + tooltipCtx, + pointer, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const top = parseFloat(tooltipRoot.style.top); + expect(top + tooltipRoot.offsetHeight).toBeCloseTo(pointer.clientY - 32, 0); + }, + 'touch' + ); + }); + + it('should keep an explicit `anchor`', async () => { + const { container } = render(LineChart, { + props: { + ...baseProps, + props: { tooltip: { root: { anchor: 'top-left' as const } } }, + }, + }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + + const ctxRect = tooltipCtx.getBoundingClientRect(); + const pointer = { clientX: ctxRect.left + 20, clientY: ctxRect.top + 20 }; + await waitForTooltip( + tooltipCtx, + pointer, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + expect(parseFloat(tooltipRoot.style.top)).toBeCloseTo(pointer.clientY + 10, 0); + expect(parseFloat(tooltipRoot.style.left)).toBeCloseTo(pointer.clientX + 10, 0); + }, + 'touch' + ); + }); + }); + 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 cf3cb09a3..b34f16bd4 100644 --- a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte +++ b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte @@ -404,7 +404,11 @@ function applyTooltip( point: { x: number; y: number }, tooltipData: any, - options: { source?: string | symbol | null; suppressed?: boolean } = {} + options: { + source?: string | symbol | null; + suppressed?: boolean; + pointerType?: string | null; + } = {} ) { if (tooltipData == null) { // Hide tooltip if unable to locate @@ -420,6 +424,7 @@ // Unset `source` means this chart's own pointer drove it tooltipState.source = options.source ?? ctx.id; tooltipState.suppressed = options.suppressed ?? false; + tooltipState.pointerType = options.pointerType ?? null; tooltipState.onChange?.(); // Reverse series order for stacked charts to match visual stack order (bottom to top). // `ctx.isStacked` rather than the layout: an inferred stack that the marks declined draws @@ -454,7 +459,12 @@ raise(e.target as Element); } - applyTooltip(point, tooltipData); + applyTooltip(point, tooltipData, { pointerType: getPointerType(e) }); + } + + function getPointerType(e: PointerEvent | MouseEvent | TouchEvent) { + if ('pointerType' in e && e.pointerType) return e.pointerType; + return 'touches' in e ? 'touch' : 'mouse'; } function showFromOptions({ point, value, data, source, suppressed }: TooltipShowOptions) { diff --git a/packages/layerchart/src/lib/states/tooltip.svelte.ts b/packages/layerchart/src/lib/states/tooltip.svelte.ts index aca791aab..39f5548e1 100644 --- a/packages/layerchart/src/lib/states/tooltip.svelte.ts +++ b/packages/layerchart/src/lib/states/tooltip.svelte.ts @@ -87,6 +87,14 @@ export class TooltipState { */ suppressed = $state(false); + /** + * The kind of pointer that drove the current tooltip (`'mouse'`, `'pen'`, or `'touch'`), or + * `null` when it was shown some other way (keyboard, a chart group, `show({ ... })`). + * + * `Tooltip` reads this to sit above a finger rather than underneath it. + */ + pointerType = $state(null); + mode: TooltipMode; /** From 505997265f420946faf96e6f5bf3b36daf83e9df Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 2 Oct 2026 17:07:18 -0400 Subject: [PATCH 2/7] Keep tooltips visible when pinch-zoomed - Clamp tooltip placement to the visual viewport so zoomed pages do not place it off screen - Apply touch-style above-pointer placement to pen input as well as fingers --- .changeset/tooltip-touch-placement.md | 2 +- .changeset/tooltip-visual-viewport.md | 5 + .../src/lib/components/tooltip/Tooltip.svelte | 154 ++++++++++++------ .../components/tooltip/Tooltip.svelte.test.ts | 94 ++++++++++- .../src/lib/states/tooltip.svelte.ts | 2 +- 5 files changed, 203 insertions(+), 54 deletions(-) create mode 100644 .changeset/tooltip-visual-viewport.md diff --git a/.changeset/tooltip-touch-placement.md b/.changeset/tooltip-touch-placement.md index f65b794cf..43fa81588 100644 --- a/.changeset/tooltip-touch-placement.md +++ b/.changeset/tooltip-touch-placement.md @@ -2,4 +2,4 @@ 'layerchart': minor --- -feat(Tooltip): Place the tooltip above the finger on touch instead of underneath it, and expose `tooltip.pointerType` +feat(Tooltip): Place the tooltip above a finger or pen instead of underneath it, and expose `tooltip.pointerType` diff --git a/.changeset/tooltip-visual-viewport.md b/.changeset/tooltip-visual-viewport.md new file mode 100644 index 000000000..47fd4ee90 --- /dev/null +++ b/.changeset/tooltip-visual-viewport.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Tooltip): Keep the tooltip within the visible viewport when pinch-zoomed diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index e68a4e235..1e4b694fa 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte @@ -42,22 +42,22 @@ /** * Offset added to `x` position * - * @default x === 'pointer' ? 10 : 0 (0 when following a finger) + * @default x === 'pointer' ? 10 : 0 (0 when following a finger or pen) */ xOffset?: number; /** * Offset added to `y` position * - * @default y === 'pointer' ? 10 : 0 (32 when following a finger) + * @default y === 'pointer' ? 10 : 0 (32 when following a finger or pen) */ yOffset?: number; /** * Align based on edge of tooltip * - * When following a touch, defaults to `'bottom'` (with a larger `yOffset`) so the tooltip sits - * above the finger instead of under it. Setting `anchor` opts out of this. + * When following a finger or pen, defaults to `'bottom'` (with a larger `yOffset`) so the + * tooltip sits above it instead of under the hand. Setting `anchor` opts out of this. * * @default 'top-left' */ @@ -231,24 +231,25 @@ const ctx = getChartContext(); /** - * Whether the tooltip is following a finger. The default below-right placement puts it under - * the finger (and the hand behind it), so it moves above instead. + * Whether to keep the tooltip clear of a finger or pen it is following. The default below-right + * placement puts it under the finger (or pen tip) and the hand behind it, so it moves above + * instead. An explicit `anchor` opts out. * * Driven by the event rather than a `(pointer: coarse)` media query so a touchscreen laptop * gets this for a tap and the usual placement for its mouse. */ - const isTouchPlacement = $derived( + const avoidPointer = $derived( anchorProp === undefined && - ctx.tooltip.pointerType === 'touch' && + (ctx.tooltip.pointerType === 'touch' || ctx.tooltip.pointerType === 'pen') && ((x === 'pointer' && y === 'pointer') || facetAll) ); - const anchor = $derived(anchorProp ?? (isTouchPlacement ? 'bottom' : 'top-left')); + const anchor = $derived(anchorProp ?? (avoidPointer ? 'bottom' : 'top-left')); const xOffset = $derived( - xOffsetProp ?? (isTouchPlacement ? 0 : x === 'pointer' || facetAll ? 10 : 0) + xOffsetProp ?? (avoidPointer ? 0 : x === 'pointer' || facetAll ? 10 : 0) ); const yOffset = $derived( - yOffsetProp ?? (isTouchPlacement ? 32 : y === 'pointer' || facetAll ? 10 : 0) + yOffsetProp ?? (avoidPointer ? 32 : y === 'pointer' || facetAll ? 10 : 0) ); /** The row this tooltip shows — its own when given, else whatever the pointer resolved */ @@ -271,6 +272,40 @@ return Math.max(min, Math.min(value, max)); } + /** + * `[min, max]` narrowed to the visible `[visibleMin, visibleMax]`, unless the tooltip wouldn't + * fit in what's left (including when none of it is visible). + */ + function visibleBounds( + min: number, + max: number, + visibleMin: number, + visibleMax: number, + tooltipSize: number + ) { + const narrowedMin = Math.max(min, visibleMin); + const narrowedMax = Math.min(max, visibleMax); + return narrowedMax - narrowedMin >= tooltipSize ? [narrowedMin, narrowedMax] : [min, max]; + } + + /** + * The visible part of the page, in the layout viewport coordinates `getBoundingClientRect()` + * and `position: fixed` use. Pinch-zoom shrinks and pans this within the layout viewport, + * which `innerWidth` / `innerHeight` don't reflect, so bounding by those can place the tooltip + * off screen. + */ + function getVisibleViewport() { + const vv = window.visualViewport; + return vv + ? { + left: vv.offsetLeft, + top: vv.offsetTop, + right: vv.offsetLeft + vv.width, + bottom: vv.offsetTop + vv.height, + } + : { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }; + } + const isPortaled = $derived( typeof portalProp === 'boolean' ? portalProp : portalProp?.enabled !== false ); @@ -292,14 +327,21 @@ * `capture` is what catches scrolling of any *ancestor* (ex. a dashboard inside a scrolling * panel), not just the window — which is also why `scrollY` from `svelte/reactivity/window` * isn't enough here, and why runed's `ScrollState` (bound to one element) doesn't fit either. + * + * Pinch-zoom only fires `visualViewport` events, not the window's. */ const subscribeToViewport = createSubscriber((update) => { const offScroll = on(window, 'scroll', update, { capture: true, passive: true }); const offResize = on(window, 'resize', update, { passive: true }); + const vv = window.visualViewport; + const offVisualScroll = vv ? on(vv, 'scroll', update, { passive: true }) : undefined; + const offVisualResize = vv ? on(vv, 'resize', update, { passive: true }) : undefined; return () => { offScroll(); offResize(); + offVisualScroll?.(); + offVisualResize?.(); }; }); @@ -319,6 +361,8 @@ return { x: null, y: null }; } + const viewport = getVisibleViewport(); + // Container-relative position of the tooltip data, used by the `'data'` placement const coords = x === 'data' || y === 'data' ? dataCoords(ctx, tooltipData) : null; @@ -380,41 +424,45 @@ if (contained === 'container') { if (isPortaled && containerRect) { - // Containment in viewport coordinates + // Container-relative bounds, narrowed to what is on screen (ex. pinch-zoomed in on part of + // the chart) when the tooltip fits there. A chart scrolled out of view (ex. one driven by + // a chart group) keeps its tooltip beside it rather than pinned to the screen's edge. + const [minX, maxX] = visibleBounds( + ctx.padding.left, + containerRect.width, + viewport.left - containerRect.left, + viewport.right - containerRect.left, + tooltipWidth + ); + const [minY, maxY] = visibleBounds( + // Above a finger, rising past the chart beats flipping underneath it, and the portal + // keeps it from being clipped, so only the viewport limits it + avoidPointer ? viewport.top - containerRect.top : ctx.padding.top, + containerRect.height, + viewport.top - containerRect.top, + viewport.bottom - containerRect.top, + tooltipHeight + ); + if (typeof x !== 'number') { - if ( - (xAlign === 'start' || xAlign === 'center') && - containerRect.left + rect.right > containerRect.right - ) { + if ((xAlign === 'start' || xAlign === 'center') && rect.right > maxX) { rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth); } - if ( - (xAlign === 'end' || xAlign === 'center') && - containerRect.left + rect.left < containerRect.left + ctx.padding.left - ) { + if ((xAlign === 'end' || xAlign === 'center') && rect.left < minX) { rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth); } - rect.left = clampToEdges(rect.left, ctx.padding.left, containerRect.width - tooltipWidth); + rect.left = clampToEdges(rect.left, minX, maxX - tooltipWidth); } rect.right = rect.left + tooltipWidth; if (typeof y !== 'number') { - if ( - (yAlign === 'start' || yAlign === 'center') && - containerRect.top + rect.bottom > containerRect.bottom - ) { + if ((yAlign === 'start' || yAlign === 'center') && rect.bottom > maxY) { rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight); } - if ( - (yAlign === 'end' || yAlign === 'center') && - // Above a finger, rising past the chart beats flipping underneath it, and the portal - // keeps it from being clipped, so only the viewport limits it - containerRect.top + rect.top < - (isTouchPlacement ? 0 : containerRect.top + ctx.padding.top) - ) { + if ((yAlign === 'end' || yAlign === 'center') && rect.top < minY) { rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight); } - rect.top = clampToEdges(rect.top, ctx.padding.top, containerRect.height - tooltipHeight); + rect.top = clampToEdges(rect.top, minY, maxY - tooltipHeight); } rect.bottom = rect.top + tooltipHeight; } else { @@ -444,21 +492,24 @@ } } else if (contained === 'window') { if (isPortaled && containerRect) { - // Already in viewport coordinates, just clamp to window + // Already in viewport coordinates, just clamp to the visible viewport if (typeof x !== 'number') { if ( (xAlign === 'start' || xAlign === 'center') && - containerRect.left + rect.right > window.innerWidth + containerRect.left + rect.right > viewport.right ) { rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth); } - if ((xAlign === 'end' || xAlign === 'center') && containerRect.left + rect.left < 0) { + if ( + (xAlign === 'end' || xAlign === 'center') && + containerRect.left + rect.left < viewport.left + ) { rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth); } rect.left = clampToEdges( rect.left, - -containerRect.left, - window.innerWidth - containerRect.left - tooltipWidth + viewport.left - containerRect.left, + viewport.right - containerRect.left - tooltipWidth ); } rect.right = rect.left + tooltipWidth; @@ -466,17 +517,20 @@ if (typeof y !== 'number') { if ( (yAlign === 'start' || yAlign === 'center') && - containerRect.top + rect.bottom > window.innerHeight + containerRect.top + rect.bottom > viewport.bottom ) { rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight); } - if ((yAlign === 'end' || yAlign === 'center') && containerRect.top + rect.top < 0) { + if ( + (yAlign === 'end' || yAlign === 'center') && + containerRect.top + rect.top < viewport.top + ) { rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight); } rect.top = clampToEdges( rect.top, - -containerRect.top, - window.innerHeight - containerRect.top - tooltipHeight + viewport.top - containerRect.top, + viewport.bottom - containerRect.top - tooltipHeight ); } rect.bottom = rect.top + tooltipHeight; @@ -490,20 +544,20 @@ if (typeof x !== 'number') { if ( (xAlign === 'start' || xAlign === 'center') && - parentViewportRect.left + rect.right > window.innerWidth + parentViewportRect.left + rect.right > viewport.right ) { rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth); } if ( (xAlign === 'end' || xAlign === 'center') && - parentViewportRect.left + rect.left < 0 + parentViewportRect.left + rect.left < viewport.left ) { rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth); } rect.left = clampToEdges( rect.left, - -parentViewportRect.left, - window.innerWidth - parentViewportRect.left - tooltipWidth + viewport.left - parentViewportRect.left, + viewport.right - parentViewportRect.left - tooltipWidth ); } rect.right = rect.left + tooltipWidth; @@ -511,20 +565,20 @@ if (typeof y !== 'number') { if ( (yAlign === 'start' || yAlign === 'center') && - parentViewportRect.top + rect.bottom > window.innerHeight + parentViewportRect.top + rect.bottom > viewport.bottom ) { rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight); } if ( (yAlign === 'end' || yAlign === 'center') && - parentViewportRect.top + rect.top < 0 + parentViewportRect.top + rect.top < viewport.top ) { rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight); } rect.top = clampToEdges( rect.top, - -parentViewportRect.top, - window.innerHeight - parentViewportRect.top - tooltipHeight + viewport.top - parentViewportRect.top, + viewport.bottom - 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 11507be4e..4831effe3 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts @@ -73,6 +73,25 @@ function getTooltipRoot(target: ParentNode = document.body) { return roots.length ? roots[roots.length - 1] : null; } +/** + * Pinch-zoom shrinks and pans the visible viewport inside the layout viewport, which + * `innerWidth` / `innerHeight` don't reflect. Stubbed, since a test can't pinch. + */ +function stubVisualViewport(rect: { left: number; top: number; width: number; height: number }) { + vi.stubGlobal( + 'visualViewport', + Object.assign(new EventTarget(), { + offsetLeft: rect.left, + offsetTop: rect.top, + width: rect.width, + height: rect.height, + }) + ); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); +} + describe('Tooltip', () => { describe('portal', () => { it('should portal tooltip to body by default', async () => { @@ -338,6 +357,48 @@ describe('Tooltip', () => { } ); }); + it('should stay within the visible viewport when pinch-zoomed', async () => { + const { container } = render(LineChart, { props: baseProps }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + const ctxRect = tooltipCtx.getBoundingClientRect(); + + // Zoomed into the left part of the page, its right edge partway across the chart + const visibleRight = ctxRect.left + ctxRect.width / 2; + stubVisualViewport({ left: 0, top: 0, width: visibleRight, height: window.innerHeight }); + + await waitForTooltip( + tooltipCtx, + { clientX: visibleRight - 10, clientY: ctxRect.top + ctxRect.height / 2 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipLeft = parseFloat(tooltipRoot.style.left); + expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(visibleRight + 1); + } + ); + }); + + it('should stay beside a chart that is scrolled out of view', async () => { + const { container } = render(LineChart, { props: baseProps }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + const ctxRect = tooltipCtx.getBoundingClientRect(); + + // Scrolled well past the chart (ex. one driven by a chart group) + stubVisualViewport({ left: 0, top: ctxRect.bottom + 500, width: 400, height: 400 }); + + await waitForTooltip(tooltipCtx, undefined, () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipTop = parseFloat(tooltipRoot.style.top); + expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(ctxRect.bottom + 1); + }); + }); }); describe('contained="window"', () => { @@ -368,6 +429,35 @@ describe('Tooltip', () => { ); }); + it('should stay within the visible viewport when pinch-zoomed', async () => { + const { container } = render(LineChart, { + props: { + ...baseProps, + props: { tooltip: { root: { contained: 'window' as const } } }, + }, + }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + const ctxRect = tooltipCtx.getBoundingClientRect(); + + // Zoomed into the left part of the page, its right edge partway across the chart + const visibleRight = ctxRect.left + ctxRect.width / 2; + stubVisualViewport({ left: 0, top: 0, width: visibleRight, height: window.innerHeight }); + + await waitForTooltip( + tooltipCtx, + { clientX: visibleRight - 10, clientY: ctxRect.top + ctxRect.height / 2 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipLeft = parseFloat(tooltipRoot.style.left); + expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(visibleRight + 1); + } + ); + }); + it('should flip tooltip up when it would overflow the bottom of the viewport', async () => { const { container } = render(LineChart, { props: { @@ -526,7 +616,7 @@ describe('Tooltip', () => { }); describe('touch', () => { - it('should sit centered above the finger', async () => { + it.each(['touch', 'pen'])('should sit centered above a %s', async (pointerType) => { const { container } = render(LineChart, { props: baseProps }); const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; @@ -546,7 +636,7 @@ describe('Tooltip', () => { expect(top + tooltipRoot.offsetHeight).toBeCloseTo(pointer.clientY - 32, 0); expect(left + tooltipRoot.offsetWidth / 2).toBeCloseTo(pointer.clientX, 0); }, - 'touch' + pointerType ); }); diff --git a/packages/layerchart/src/lib/states/tooltip.svelte.ts b/packages/layerchart/src/lib/states/tooltip.svelte.ts index 39f5548e1..4deb7d805 100644 --- a/packages/layerchart/src/lib/states/tooltip.svelte.ts +++ b/packages/layerchart/src/lib/states/tooltip.svelte.ts @@ -91,7 +91,7 @@ export class TooltipState { * The kind of pointer that drove the current tooltip (`'mouse'`, `'pen'`, or `'touch'`), or * `null` when it was shown some other way (keyboard, a chart group, `show({ ... })`). * - * `Tooltip` reads this to sit above a finger rather than underneath it. + * `Tooltip` reads this to sit above a finger or pen rather than underneath it. */ pointerType = $state(null); From 43805379ee76153b006e025329ddc7bd4ef9e020 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 12 Oct 2026 10:14:01 -0400 Subject: [PATCH 3/7] fix(Tooltip): keep pointer-aligned tooltips visible and correctly positioned when pinch-zoomed --- .changeset/tooltip-visual-viewport.md | 2 +- .../src/lib/components/tooltip/Tooltip.svelte | 49 ++++++++--- .../components/tooltip/Tooltip.svelte.test.ts | 81 +++++++++++++++++++ 3 files changed, 122 insertions(+), 10 deletions(-) diff --git a/.changeset/tooltip-visual-viewport.md b/.changeset/tooltip-visual-viewport.md index 47fd4ee90..4aca44f8b 100644 --- a/.changeset/tooltip-visual-viewport.md +++ b/.changeset/tooltip-visual-viewport.md @@ -2,4 +2,4 @@ 'layerchart': patch --- -fix(Tooltip): Keep the tooltip within the visible viewport when pinch-zoomed +fix(Tooltip): Keep the tooltip beside the pointer and on screen when pinch-zoomed, or wider than the chart diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index 1e4b694fa..9b2ff04ae 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte @@ -273,8 +273,12 @@ } /** - * `[min, max]` narrowed to the visible `[visibleMin, visibleMax]`, unless the tooltip wouldn't - * fit in what's left (including when none of it is visible). + * `[min, max]` narrowed to the visible `[visibleMin, visibleMax]` when the tooltip fits there. + * + * When it doesn't (ex. a tooltip wider than a phone-width chart), staying on screen matters more + * than staying within the chart, so it gets the whole visible range — but only while some of + * the chart is visible. One scrolled out of view keeps `[min, max]`, so its tooltip stays + * beside it rather than pinned to the screen's edge. */ function visibleBounds( min: number, @@ -285,15 +289,39 @@ ) { const narrowedMin = Math.max(min, visibleMin); const narrowedMax = Math.min(max, visibleMax); - return narrowedMax - narrowedMin >= tooltipSize ? [narrowedMin, narrowedMax] : [min, max]; + if (narrowedMax - narrowedMin >= tooltipSize) return [narrowedMin, narrowedMax]; + return narrowedMax > narrowedMin ? [visibleMin, visibleMax] : [min, max]; } /** - * The visible part of the page, in the layout viewport coordinates `getBoundingClientRect()` - * and `position: fixed` use. Pinch-zoom shrinks and pans this within the layout viewport, - * which `innerWidth` / `innerHeight` don't reflect, so bounding by those can place the tooltip - * off screen. + * The visible part of the page, in the layout viewport coordinates `position: fixed` uses. + * Pinch-zoom shrinks and pans this within the layout viewport, which `innerWidth` / + * `innerHeight` don't reflect, so bounding by those can place the tooltip off screen. + */ + /** + * Converts a `getBoundingClientRect()` rect to the layout viewport coordinates + * `position: fixed` uses. + * + * They're the same until pinch-zoomed. Chromium and Firefox measure client coordinates from + * the layout viewport too, but WebKit (every iOS browser) measures them from the visual + * viewport, which put a fixed tooltip up and to the left of the pointer by however far the + * zoomed view was panned. + * + * Measured rather than detecting WebKit: `scrollX` / `scrollY` follow the same viewport client + * coordinates do, while `pageLeft - offsetLeft` is always the layout viewport's scroll, so the + * difference is zero wherever the two already agree. */ + function toLayoutViewport(rect: DOMRect) { + const vv = window.visualViewport; + if (!vv) return rect; + return new DOMRect( + rect.x + window.scrollX - (vv.pageLeft - vv.offsetLeft), + rect.y + window.scrollY - (vv.pageTop - vv.offsetTop), + rect.width, + rect.height + ); + } + function getVisibleViewport() { const vv = window.visualViewport; return vv @@ -355,7 +383,8 @@ if (isPortaled) subscribeToViewport(); // When portaled, we need the container's viewport rect to convert coordinates - const containerRect = isPortaled ? ctx.containerRef?.getBoundingClientRect() : null; + const clientRect = isPortaled ? ctx.containerRef?.getBoundingClientRect() : null; + const containerRect = clientRect ? toLayoutViewport(clientRect) : null; // If portaled but the container rect is not available yet, bail if (isPortaled && !containerRect) { return { x: null, y: null }; @@ -538,7 +567,9 @@ // Original non-portaled window containment // Root
won't be available on initial mount if (rootRef?.parentElement) { - const parentViewportRect = rootRef.parentElement.getBoundingClientRect(); + const parentViewportRect = toLayoutViewport( + rootRef.parentElement.getBoundingClientRect() + ); // Only attempt repositioning if not fixed (ie. `pointer`/`data`) if (typeof x !== 'number') { 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 4831effe3..88d28b94c 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts @@ -76,6 +76,8 @@ function getTooltipRoot(target: ParentNode = document.body) { /** * Pinch-zoom shrinks and pans the visible viewport inside the layout viewport, which * `innerWidth` / `innerHeight` don't reflect. Stubbed, since a test can't pinch. + * + * `rect` is relative to the layout viewport, which is where Chromium keeps `scrollX` / `scrollY`. */ function stubVisualViewport(rect: { left: number; top: number; width: number; height: number }) { vi.stubGlobal( @@ -83,6 +85,8 @@ function stubVisualViewport(rect: { left: number; top: number; width: number; he Object.assign(new EventTarget(), { offsetLeft: rect.left, offsetTop: rect.top, + pageLeft: window.scrollX + rect.left, + pageTop: window.scrollY + rect.top, width: rect.width, height: rect.height, }) @@ -381,6 +385,43 @@ describe('Tooltip', () => { ); }); + it('should stay on screen when wider than the chart', async () => { + // A narrow chart near the right edge of the screen (ex. a phone), with room to its left + document.body.style.paddingLeft = '200px'; + onTestFinished(() => { + document.body.style.paddingLeft = ''; + }); + + const { container } = render(LineChart, { + props: { + ...baseProps, + width: 200, + props: { tooltip: { root: { props: { root: { style: 'width: 300px' } } } } }, + }, + }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + const ctxRect = tooltipCtx.getBoundingClientRect(); + + const visibleRight = ctxRect.right + 20; + stubVisualViewport({ left: 0, top: 0, width: visibleRight, height: window.innerHeight }); + + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.right - 5, clientY: ctxRect.top + ctxRect.height / 2 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + expect(tooltipRoot.offsetWidth).toBe(300); + + const tooltipLeft = parseFloat(tooltipRoot.style.left); + expect(tooltipLeft).toBeGreaterThanOrEqual(-1); + expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(visibleRight + 1); + } + ); + }); + it('should stay beside a chart that is scrolled out of view', async () => { const { container } = render(LineChart, { props: baseProps }); @@ -616,6 +657,46 @@ describe('Tooltip', () => { }); describe('touch', () => { + /** + * WebKit (every iOS browser) measures client coordinates from the visual viewport, and moves + * `scrollX` / `scrollY` with it, while `position: fixed` stays on the layout viewport. + */ + it('should stay over the finger when pinch-zoomed in WebKit', async () => { + const { container } = render(LineChart, { props: baseProps }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + const ctxRect = tooltipCtx.getBoundingClientRect(); + + // Zoomed in and panned 30px right and 20px down within the layout viewport + const pan = { x: 30, y: 20 }; + stubVisualViewport({ + left: pan.x, + top: pan.y, + width: window.innerWidth - pan.x, + height: window.innerHeight - pan.y, + }); + vi.stubGlobal('scrollX', window.scrollX + pan.x); + vi.stubGlobal('scrollY', window.scrollY + pan.y); + + const pointer = { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.bottom - 20 }; + await waitForTooltip( + tooltipCtx, + pointer, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + // Placed in layout viewport coordinates, so offset by the pan from the client position + const top = parseFloat(tooltipRoot.style.top); + const left = parseFloat(tooltipRoot.style.left); + expect(top + tooltipRoot.offsetHeight).toBeCloseTo(pointer.clientY + pan.y - 32, 0); + expect(left + tooltipRoot.offsetWidth / 2).toBeCloseTo(pointer.clientX + pan.x, 0); + }, + 'touch' + ); + }); + it.each(['touch', 'pen'])('should sit centered above a %s', async (pointerType) => { const { container } = render(LineChart, { props: baseProps }); From 1351b1266c412b36416d539b5d83e7b289933d7a Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 12 Oct 2026 10:36:44 -0400 Subject: [PATCH 4/7] Fix window-contained tooltip positioning for touch and facets --- .../src/lib/components/tooltip/Tooltip.svelte | 21 +-- .../components/tooltip/Tooltip.svelte.test.ts | 124 ++++++++++++++++-- .../src/lib/tests/TooltipFacetHarness.svelte | 25 ++++ 3 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 packages/layerchart/src/lib/tests/TooltipFacetHarness.svelte diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index 9b2ff04ae..6ed31e0ac 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte @@ -341,9 +341,9 @@ /** * Makes reading the container's viewport rect reactive to scrolling and resizing. * - * A portaled tooltip is positioned from the chart container's *viewport* rect, and - * `getBoundingClientRect()` is not reactive — scrolling moves the chart out from under a - * tooltip that never re-measures. Pointer-driven tooltips mostly dodge this (scrolling fires + * A portaled tooltip is positioned from the chart container's *viewport* rect (and a + * `contained="window"` one is bounded by it), and `getBoundingClientRect()` is not reactive — + * scrolling moves the chart out from under a tooltip that never re-measures. Pointer-driven tooltips mostly dodge this (scrolling fires * `pointercancel`, or a pointer event as content moves under the cursor), but one shown * programmatically — keyboard navigation, a chart group, `locked` — has no pointer to cancel it * and must follow the chart. @@ -379,8 +379,8 @@ return { x: null, y: null }; } - // Only track the viewport while there is a portaled tooltip to keep positioned - if (isPortaled) subscribeToViewport(); + // Only track the viewport while there is a tooltip measured against it to keep positioned + if (isPortaled || contained === 'window') subscribeToViewport(); // When portaled, we need the container's viewport rect to convert coordinates const clientRect = isPortaled ? ctx.containerRef?.getBoundingClientRect() : null; @@ -641,7 +641,9 @@ content — used by chart groups sharing a highlight without tooltips --> {#if facetAll && ctx.facet.enabled && dataProp === undefined} {#each ctx.facet.panels as panel (panel.key)} @@ -651,9 +653,10 @@ data={row} x={x === 'pointer' ? 'data' : x} y={y === 'pointer' ? 'data' : y} - {anchor} - {xOffset} - {yOffset} + {facetAll} + anchor={anchorProp} + xOffset={xOffsetProp} + yOffset={yOffsetProp} {classes} {contained} {fadeDuration} 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 88d28b94c..71ff74d27 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts @@ -3,6 +3,7 @@ import { render } from 'vitest-browser-svelte'; import LineChart from '../charts/LineChart/LineChart.svelte'; import TooltipLockHarness from '$lib/tests/TooltipLockHarness.svelte'; +import TooltipFacetHarness from '$lib/tests/TooltipFacetHarness.svelte'; const data = [ { date: 0, value: 10 }, @@ -80,20 +81,19 @@ function getTooltipRoot(target: ParentNode = document.body) { * `rect` is relative to the layout viewport, which is where Chromium keeps `scrollX` / `scrollY`. */ function stubVisualViewport(rect: { left: number; top: number; width: number; height: number }) { - vi.stubGlobal( - 'visualViewport', - Object.assign(new EventTarget(), { - offsetLeft: rect.left, - offsetTop: rect.top, - pageLeft: window.scrollX + rect.left, - pageTop: window.scrollY + rect.top, - width: rect.width, - height: rect.height, - }) - ); + const visualViewport = Object.assign(new EventTarget(), { + offsetLeft: rect.left, + offsetTop: rect.top, + pageLeft: window.scrollX + rect.left, + pageTop: window.scrollY + rect.top, + width: rect.width, + height: rect.height, + }); + vi.stubGlobal('visualViewport', visualViewport); onTestFinished(() => { vi.unstubAllGlobals(); }); + return visualViewport; } describe('Tooltip', () => { @@ -499,6 +499,41 @@ describe('Tooltip', () => { ); }); + it('should follow a pinch without the portal', async () => { + const { container } = render(LineChart, { + props: { + ...baseProps, + props: { tooltip: { root: { contained: 'window' as const, portal: false } } }, + }, + }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + const ctxRect = tooltipCtx.getBoundingClientRect(); + + const visualViewport = stubVisualViewport({ + left: 0, + top: 0, + width: window.innerWidth, + height: window.innerHeight, + }); + + const pointer = { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.top + 20 }; + await waitForTooltip(tooltipCtx, pointer, () => { + expect(getTooltipRoot(container)).not.toBeNull(); + }); + + // Zoom in until the visible right edge is just past the pointer, with no pointer event + const visibleRight = pointer.clientX + 20; + visualViewport.width = visibleRight; + visualViewport.dispatchEvent(new Event('resize')); + + await vi.waitFor(() => { + const tooltipRect = getTooltipRoot(container)!.getBoundingClientRect(); + expect(tooltipRect.right).toBeLessThanOrEqual(visibleRight + 1); + }); + }); + it('should flip tooltip up when it would overflow the bottom of the viewport', async () => { const { container } = render(LineChart, { props: { @@ -749,6 +784,73 @@ describe('Tooltip', () => { ); }); + it('should rise above the chart with `contained="window"`', async () => { + // Room above the chart, so only the viewport would force a flip + document.body.style.paddingTop = '200px'; + onTestFinished(() => { + document.body.style.paddingTop = ''; + }); + + const { container } = render(LineChart, { + props: { + ...baseProps, + props: { tooltip: { root: { contained: 'window' as const } } }, + }, + }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + + const ctxRect = tooltipCtx.getBoundingClientRect(); + const pointer = { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.top + 5 }; + await waitForTooltip( + tooltipCtx, + pointer, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const top = parseFloat(tooltipRoot.style.top); + const left = parseFloat(tooltipRoot.style.left); + expect(top + tooltipRoot.offsetHeight).toBeCloseTo(pointer.clientY - 32, 0); + expect(left + tooltipRoot.offsetWidth / 2).toBeCloseTo(pointer.clientX, 0); + }, + 'touch' + ); + }); + + it('should rise above the chart for each `facetAll` panel', async () => { + document.body.style.paddingTop = '200px'; + onTestFinished(() => { + document.body.style.paddingTop = ''; + }); + + // Every panel peaks at the middle date, so each point there sits at the top of its panel + const facetData = ['A', 'B'].flatMap((region) => + [10, 50, 100, 50, 10].map((value, date) => ({ date, value, region })) + ); + const { container } = render(TooltipFacetHarness, { props: { data: facetData } }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + + const ctxRect = tooltipCtx.getBoundingClientRect(); + // The middle of the first panel + const pointer = { clientX: ctxRect.left + ctxRect.width / 4, clientY: ctxRect.top + 20 }; + await waitForTooltip( + tooltipCtx, + pointer, + () => { + const roots = [...document.body.querySelectorAll('.lc-tooltip-root')]; + expect(roots).toHaveLength(2); + for (const root of roots) { + expect(parseFloat(root.style.top) + root.offsetHeight).toBeLessThan(ctxRect.top); + } + }, + 'touch' + ); + }); + it('should keep an explicit `anchor`', async () => { const { container } = render(LineChart, { props: { diff --git a/packages/layerchart/src/lib/tests/TooltipFacetHarness.svelte b/packages/layerchart/src/lib/tests/TooltipFacetHarness.svelte new file mode 100644 index 000000000..8729ea383 --- /dev/null +++ b/packages/layerchart/src/lib/tests/TooltipFacetHarness.svelte @@ -0,0 +1,25 @@ + + + + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.region}: {data.value} + {/snippet} + + {/snippet} + From 7cc3eace8374bd8574e16f682ff4d6121de2a42d Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 13 Oct 2026 08:32:12 -0400 Subject: [PATCH 5/7] Update changeset --- .changeset/tooltip-touch-placement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tooltip-touch-placement.md b/.changeset/tooltip-touch-placement.md index 43fa81588..5cb9beefc 100644 --- a/.changeset/tooltip-touch-placement.md +++ b/.changeset/tooltip-touch-placement.md @@ -2,4 +2,4 @@ 'layerchart': minor --- -feat(Tooltip): Place the tooltip above a finger or pen instead of underneath it, and expose `tooltip.pointerType` +feat(Tooltip): By default place above a finger or pen instead of below, and expose `tooltip.pointerType` From 72e1c4a7a40fb2849483650d5ae06b663e443545 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 13 Oct 2026 08:33:34 -0400 Subject: [PATCH 6/7] Improve changeset --- .changeset/tooltip-visual-viewport.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tooltip-visual-viewport.md b/.changeset/tooltip-visual-viewport.md index 4aca44f8b..13260eeb9 100644 --- a/.changeset/tooltip-visual-viewport.md +++ b/.changeset/tooltip-visual-viewport.md @@ -2,4 +2,4 @@ 'layerchart': patch --- -fix(Tooltip): Keep the tooltip beside the pointer and on screen when pinch-zoomed, or wider than the chart +fix(Tooltip): Fix tooltip pointer location when pinch-zoomed, or when wider than the chart From c47782865f4b903002ecf971e7035000723f16c5 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 13 Oct 2026 08:48:16 -0400 Subject: [PATCH 7/7] Document touch and pen tooltip placement behavior --- docs/src/content/guides/tooltip.md | 31 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/src/content/guides/tooltip.md b/docs/src/content/guides/tooltip.md index 2ad35d689..ace8d9b31 100644 --- a/docs/src/content/guides/tooltip.md +++ b/docs/src/content/guides/tooltip.md @@ -103,6 +103,12 @@ The tooltip follows the mouse with a small offset to avoid overlapping the curso :example{ component="Tooltip" name="default-mouse-position-with-offset" } +### Touch and pen + +A finger or pen covers whatever is below and to the right of it, so for touch and pen input the tooltip sits centered above the point of contact instead (`anchor="bottom"`, `yOffset={32}`). Near the top of the chart, a portaled tooltip (the default) rises above the chart rather than flipping underneath the finger. + +This is decided per interaction from the pointer event, so a touchscreen laptop gets the touch placement for a tap and the usual placement for its mouse. It applies when the tooltip follows the pointer on both axes (or uses `facetAll`). Setting `anchor` opts out, and `xOffset` / `yOffset` still override the offsets. + ### Data snapping Snap the tooltip to the data point's chart position. Useful when you want the tooltip anchored to the mark: @@ -137,7 +143,7 @@ You can render multiple `Tooltip.Root` instances. This is useful for placing a t ### Offsets -`xOffset` and `yOffset` add space between the tooltip and its anchor position. They default to `10` for pointer mode and `0` for data mode: +`xOffset` and `yOffset` add space between the tooltip and its anchor position. They default to `10` for pointer mode and `0` for data mode (`0` and `32` for [touch and pen](#touch-and-pen)): ```svelte @@ -145,7 +151,7 @@ You can render multiple `Tooltip.Root` instances. This is useful for placing a t ### Anchor placement -The `anchor` prop controls which corner of the tooltip aligns to the position point. It accepts placement values like `'top-left'` (default), `'bottom-right'`, `'top'`, `'right'`, etc: +The `anchor` prop controls which corner of the tooltip aligns to the position point. It accepts placement values like `'top-left'` (default, or `'bottom'` for [touch and pen](#touch-and-pen)), `'bottom-right'`, `'top'`, `'right'`, etc: ```svelte @@ -194,6 +200,8 @@ Tooltips are contained within their chart container by default, flipping sides w ``` +Containment only counts the part of the page that is on screen. When the page is pinch-zoomed or scrolled so the chart is partly out of view, or the tooltip is wider than the chart, the tooltip stays within the visible viewport instead of being cut off. With `'container'` this needs the default `portal`, since an inline tooltip can't leave the chart. + ## Display components The tooltip content is built from composable sub-components: @@ -419,6 +427,8 @@ The `touchEvents` prop controls how touch interactions behave on mobile. It maps | `'none'` | All touch triggers tooltip (may block scrolling) | | `'auto'` | Browser default touch behavior | +See [touch and pen](#touch-and-pen) for where the tooltip is placed for a touch. + ## Externally accessing tooltip data Use `bind:context` on `Chart` to read tooltip state from outside: @@ -532,14 +542,15 @@ Add an `onclick` handler to `tooltipContext` to respond to clicks on data points ## TooltipState properties -| Property | Type | Description | -| -------------------------- | ----------------- | ---------------------------------------------- | -| `data` | `T \| null` | The hovered data point (or null) | -| `x` | `number` | Pointer x position in chart coordinates | -| `y` | `number` | Pointer y position in chart coordinates | -| `series` | `TooltipSeries[]` | Series values at the hovered point | -| `isHoveringTooltipArea` | `boolean` | Whether the pointer is in the tooltip area | -| `isHoveringTooltipContent` | `boolean` | Whether the pointer is over the tooltip itself | +| Property | Type | Description | +| -------------------------- | ----------------- | ------------------------------------------------------------------------------------------- | +| `data` | `T \| null` | The hovered data point (or null) | +| `x` | `number` | Pointer x position in chart coordinates | +| `y` | `number` | Pointer y position in chart coordinates | +| `series` | `TooltipSeries[]` | Series values at the hovered point | +| `pointerType` | `string \| null` | `'mouse'`, `'pen'`, or `'touch'`; `null` when shown another way (keyboard, `show({ ... })`) | +| `isHoveringTooltipArea` | `boolean` | Whether the pointer is in the tooltip area | +| `isHoveringTooltipContent` | `boolean` | Whether the pointer is over the tooltip itself | ### Methods