diff --git a/.changeset/tooltip-touch-placement.md b/.changeset/tooltip-touch-placement.md new file mode 100644 index 000000000..5cb9beefc --- /dev/null +++ b/.changeset/tooltip-touch-placement.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Tooltip): By default place above a finger or pen instead of below, and expose `tooltip.pointerType` diff --git a/.changeset/tooltip-visual-viewport.md b/.changeset/tooltip-visual-viewport.md new file mode 100644 index 000000000..13260eeb9 --- /dev/null +++ b/.changeset/tooltip-visual-viewport.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Tooltip): Fix tooltip pointer location when pinch-zoomed, or when wider than the chart 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 diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index 74e133f53..6ed31e0ac 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 or pen) */ xOffset?: number; /** * Offset added to `y` position * - * @default y === 'pointer' ? 10 : 0 + * @default y === 'pointer' ? 10 : 0 (32 when following a finger or pen) */ yOffset?: number; /** * Align based on edge of tooltip * + * 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' */ 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,28 @@ const ctx = getChartContext(); + /** + * 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 avoidPointer = $derived( + anchorProp === undefined && + (ctx.tooltip.pointerType === 'touch' || ctx.tooltip.pointerType === 'pen') && + ((x === 'pointer' && y === 'pointer') || facetAll) + ); + + const anchor = $derived(anchorProp ?? (avoidPointer ? 'bottom' : 'top-left')); + const xOffset = $derived( + xOffsetProp ?? (avoidPointer ? 0 : x === 'pointer' || facetAll ? 10 : 0) + ); + const yOffset = $derived( + yOffsetProp ?? (avoidPointer ? 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); @@ -247,6 +272,68 @@ return Math.max(min, Math.min(value, max)); } + /** + * `[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, + max: number, + visibleMin: number, + visibleMax: number, + tooltipSize: number + ) { + const narrowedMin = Math.max(min, visibleMin); + const narrowedMax = Math.min(max, visibleMax); + 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 `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 + ? { + 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 ); @@ -254,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. @@ -268,14 +355,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?.(); }; }); @@ -285,16 +379,19 @@ 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 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 }; } + 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; @@ -356,38 +453,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') && - containerRect.top + rect.top < 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 { @@ -417,21 +521,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; @@ -439,17 +546,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; @@ -457,26 +567,28 @@ // 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') { 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; @@ -484,20 +596,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; @@ -529,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)} @@ -539,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 db7ccf9cc..71ff74d27 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts @@ -1,8 +1,9 @@ -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'; import TooltipLockHarness from '$lib/tests/TooltipLockHarness.svelte'; +import TooltipFacetHarness from '$lib/tests/TooltipFacetHarness.svelte'; const data = [ { date: 0, value: 10 }, @@ -22,12 +23,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 +51,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(); }); } @@ -67,6 +74,28 @@ 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. + * + * `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 }) { + 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', () => { describe('portal', () => { it('should portal tooltip to body by default', async () => { @@ -332,6 +361,85 @@ 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 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 }); + + 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"', () => { @@ -362,6 +470,70 @@ 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 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: { @@ -519,6 +691,194 @@ 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 }); + + 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); + }, + pointerType + ); + }); + + 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 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: { + ...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..4deb7d805 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 or pen rather than underneath it. + */ + pointerType = $state(null); + mode: TooltipMode; /** 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} +