Skip to content

fix(Tooltip, TooltipContext): Keep tap-pinned tooltips locked, and keep flipped tooltips inside the container - #924

Merged
techniq merged 2 commits into
techniq:mainfrom
ntainy:fix/tooltip-lock-and-clamp
Oct 1, 2026
Merged

techniq merged 2 commits into
techniq:mainfrom
ntainy:fix/tooltip-lock-and-clamp

Conversation

@ntainy

@ntainy ntainy commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Closing issues #922 and #923

fix(TooltipContext): Keep a tooltip locked from onclick on touch, and hide it once unlocked

On touch, a tap dispatches pointerleave before click.  pointerleave scheduled a hide,
click then locked the tooltip, and the scheduled hide still cleared the data under the lock.
The chart was left locked with nothing to show, and since the root onclick is only forwarded
with data, no tap could unlock it.  The scheduled hide now re-checks locked.

A hide ignored while locked is now applied once unlocked.  Otherwise unpinning by a tap (where
the pointer has already left) or unlocking from outside the chart left the tooltip visible but
unlocked, with nothing to hide it.

fix(Tooltip): Keep a flipped tooltip inside the container when flipping overflows the other edge

Containment flips a tooltip that doesn't fit on one side of the pointer to the other, but never
checked the other edge after the flip.  A start-aligned tooltip (ex. the default top-left)
wider than about half a narrow chart then opened past the left edge, and off screen on a phone.

After the flips, the tooltip is now clamped within the edges, favoring the left/top edge when it
doesn't fit either way.  This applies to x and y in all four containment paths (container and
window, portaled or not).

ntainy added 2 commits October 1, 2026 18:26
…nd hide it once unlocked

On touch, a tap dispatches `pointerleave` before `click`.  `pointerleave` scheduled a hide,
`click` then locked the tooltip, and the scheduled hide still cleared the data under the lock.
The chart was left locked with nothing to show, and since the root `onclick` is only forwarded
with data, no tap could unlock it.  The scheduled hide now re-checks `locked`.

A hide ignored while locked is now applied once unlocked.  Otherwise unpinning by a tap (where
the pointer has already left) or unlocking from outside the chart left the tooltip visible but
unlocked, with nothing to hide it.

Fixes techniq#922
…ng overflows the other edge

Containment flips a tooltip that doesn't fit on one side of the pointer to the other, but never
checked the other edge after the flip.  A start-aligned tooltip (ex. the default `top-left`)
wider than about half a narrow chart then opened past the left edge, and off screen on a phone.

After the flips, the tooltip is now clamped within the edges, favoring the left/top edge when it
doesn't fit either way.  This applies to x and y in all four containment paths (container and
window, portaled or not).

Fixes techniq#923
@changeset-bot

changeset-bot Bot commented Oct 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3ee9f28

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
layerchart Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/layerchart@924

commit: 3ee9f28

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
layerchart ✅ Ready (View Log) Visit Preview 3ee9f28

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Use-Case Scenarios

Core (7 changed)
Scenario Current New Change
🟡 core-canvas 232.06 KB
59.70 gz
232.24 KB
59.75 gz
+0.18 KB (+0.1%)
+0.05 gz (+0.1%)
🟡 core-line 230.29 KB
58.42 gz
230.46 KB
58.46 gz
+0.18 KB (+0.1%)
+0.04 gz (+0.1%)
🟡 core-scatter 221.21 KB
55.90 gz
221.39 KB
55.93 gz
+0.18 KB (+0.1%)
+0.03 gz (+0.1%)
🟡 core-geo 216.97 KB
55.52 gz
217.14 KB
55.55 gz
+0.18 KB (+0.1%)
+0.02 gz (+0.0%)
🟡 core-html 216.37 KB
54.97 gz
216.55 KB
55.03 gz
+0.18 KB (+0.1%)
+0.06 gz (+0.1%)
🟡 core 214.29 KB
54.40 gz
214.47 KB
54.43 gz
+0.18 KB (+0.1%)
+0.04 gz (+0.1%)
🟡 core-svg 214.29 KB
54.48 gz
214.46 KB
54.52 gz
+0.18 KB (+0.1%)
+0.04 gz (+0.1%)
Base (agnostic) (4 changed)
Scenario Current New Change
🟡 base-svg-agnostic 358.92 KB
83.24 gz
359.10 KB
83.27 gz
+0.19 KB (+0.1%)
+0.03 gz (+0.0%)
🟡 base-canvas-agnostic 358.92 KB
83.25 gz
359.10 KB
83.27 gz
+0.19 KB (+0.1%)
+0.02 gz (+0.0%)
🟡 base-html-agnostic 358.92 KB
83.25 gz
359.10 KB
83.27 gz
+0.19 KB (+0.1%)
+0.02 gz (+0.0%)
🟡 base 358.91 KB
83.24 gz
359.10 KB
83.26 gz
+0.19 KB (+0.1%)
+0.02 gz (+0.0%)
Base (layer-specific) (3 changed)
Scenario Current New Change
🟡 base-canvas 346.80 KB
80.99 gz
346.99 KB
81.04 gz
+0.19 KB (+0.1%)
+0.05 gz (+0.1%)
🟡 base-html 339.19 KB
78.71 gz
339.39 KB
78.75 gz
+0.19 KB (+0.1%)
+0.04 gz (+0.1%)
🟡 base-svg 337.11 KB
78.22 gz
337.30 KB
78.26 gz
+0.19 KB (+0.1%)
+0.04 gz (+0.1%)
Cartesian charts (27 changed)
Scenario Current New Change
🟡 PieChart 399.77 KB
92.05 gz
400.51 KB
92.19 gz
+0.74 KB (+0.2%)
+0.14 gz (+0.2%)
🟡 ArcChart 393.01 KB
90.70 gz
393.76 KB
90.85 gz
+0.75 KB (+0.2%)
+0.14 gz (+0.2%)
🟡 LineChart 389.31 KB
90.39 gz
389.49 KB
90.42 gz
+0.18 KB (+0.0%)
+0.03 gz (+0.0%)
🟡 PieChart-canvas 385.64 KB
89.45 gz
386.39 KB
89.59 gz
+0.75 KB (+0.2%)
+0.14 gz (+0.2%)
🟡 AreaChart 381.34 KB
88.01 gz
381.52 KB
88.03 gz
+0.18 KB (+0.0%)
+0.03 gz (+0.0%)
🟡 ArcChart-canvas 379.46 KB
88.19 gz
380.21 KB
88.33 gz
+0.75 KB (+0.2%)
+0.13 gz (+0.2%)
🟡 area-chart 376.99 KB
87.04 gz
377.17 KB
87.08 gz
+0.18 KB (+0.0%)
+0.04 gz (+0.0%)
🟡 PieChart-svg 375.97 KB
86.66 gz
376.71 KB
86.80 gz
+0.75 KB (+0.2%)
+0.14 gz (+0.2%)
🟡 line-chart-interactive 375.57 KB
87.02 gz
376.32 KB
87.10 gz
+0.75 KB (+0.2%)
+0.09 gz (+0.1%)
🟡 pie-chart 371.22 KB
85.79 gz
371.41 KB
85.82 gz
+0.19 KB (+0.1%)
+0.03 gz (+0.0%)
🟡 ArcChart-svg 369.79 KB
85.46 gz
370.54 KB
85.60 gz
+0.75 KB (+0.2%)
+0.14 gz (+0.2%)
🟡 BarChart 369.36 KB
85.31 gz
369.54 KB
85.35 gz
+0.18 KB (+0.0%)
+0.04 gz (+0.0%)
🟡 AreaChart-canvas 368.42 KB
85.64 gz
368.61 KB
85.69 gz
+0.18 KB (+0.1%)
+0.05 gz (+0.1%)
🟡 bar-chart 363.54 KB
84.09 gz
363.73 KB
84.11 gz
+0.18 KB (+0.1%)
+0.02 gz (+0.0%)
🟡 ScatterChart 363.52 KB
84.25 gz
363.71 KB
84.29 gz
+0.18 KB (+0.1%)
+0.04 gz (+0.0%)
🟡 scatter-chart 360.00 KB
83.53 gz
360.19 KB
83.55 gz
+0.19 KB (+0.1%)
+0.02 gz (+0.0%)
🟡 line-chart 359.41 KB
83.29 gz
359.60 KB
83.30 gz
+0.19 KB (+0.1%)
+0.01 gz (+0.0%)
🟡 AreaChart-svg 358.75 KB
82.90 gz
358.94 KB
82.96 gz
+0.19 KB (+0.1%)
+0.06 gz (+0.1%)
🟡 BarChart-canvas 356.13 KB
82.95 gz
356.32 KB
82.98 gz
+0.19 KB (+0.1%)
+0.04 gz (+0.0%)
🟡 LineChart-canvas 353.37 KB
82.50 gz
353.56 KB
82.57 gz
+0.19 KB (+0.1%)
+0.07 gz (+0.1%)
🟡 ScatterChart-canvas 350.52 KB
81.77 gz
350.71 KB
81.84 gz
+0.19 KB (+0.1%)
+0.07 gz (+0.1%)
🟡 line-chart-canvas 346.82 KB
81.01 gz
347.01 KB
81.05 gz
+0.19 KB (+0.1%)
+0.04 gz (+0.0%)
🟡 BarChart-svg 346.46 KB
80.18 gz
346.65 KB
80.21 gz
+0.19 KB (+0.1%)
+0.03 gz (+0.0%)
🟡 LineChart-svg 343.69 KB
79.76 gz
343.89 KB
79.81 gz
+0.20 KB (+0.1%)
+0.05 gz (+0.1%)
🟡 ScatterChart-svg 340.85 KB
79.03 gz
341.04 KB
79.08 gz
+0.19 KB (+0.1%)
+0.05 gz (+0.1%)
🟡 line-chart-html 339.22 KB
78.73 gz
339.41 KB
78.77 gz
+0.19 KB (+0.1%)
+0.04 gz (+0.0%)
🟡 line-chart-svg 337.13 KB
78.23 gz
337.33 KB
78.27 gz
+0.19 KB (+0.1%)
+0.04 gz (+0.1%)
Geo (3 changed)
Scenario Current New Change
🟡 geo-full 433.49 KB
102.62 gz
433.68 KB
102.65 gz
+0.20 KB (+0.0%)
+0.03 gz (+0.0%)
🟡 geo-tiles 381.02 KB
88.85 gz
381.20 KB
88.87 gz
+0.18 KB (+0.0%)
+0.02 gz (+0.0%)
🟡 geo 375.90 KB
87.15 gz
376.09 KB
87.19 gz
+0.18 KB (+0.0%)
+0.03 gz (+0.0%)
Hierarchy (4 changed)
Scenario Current New Change
🟡 hierarchy-tree 369.69 KB
86.18 gz
369.88 KB
86.21 gz
+0.19 KB (+0.1%)
+0.02 gz (+0.0%)
🟡 hierarchy-treemap 366.31 KB
85.32 gz
366.49 KB
85.36 gz
+0.18 KB (+0.1%)
+0.04 gz (+0.0%)
🟡 hierarchy-pack 365.98 KB
85.34 gz
366.16 KB
85.38 gz
+0.18 KB (+0.1%)
+0.04 gz (+0.0%)
🟡 dodge 363.70 KB
84.65 gz
363.88 KB
84.70 gz
+0.18 KB (+0.1%)
+0.06 gz (+0.1%)
Graph / network (4 changed)
Scenario Current New Change
🟡 dagre 428.44 KB
102.19 gz
428.63 KB
102.21 gz
+0.19 KB (+0.0%)
+0.02 gz (+0.0%)
🟡 force 372.10 KB
86.99 gz
372.28 KB
87.03 gz
+0.18 KB (+0.0%)
+0.04 gz (+0.0%)
🟡 sankey 371.91 KB
86.40 gz
372.10 KB
86.42 gz
+0.19 KB (+0.0%)
+0.02 gz (+0.0%)
🟡 chord 368.60 KB
85.51 gz
368.79 KB
85.53 gz
+0.19 KB (+0.1%)
+0.03 gz (+0.0%)
Components (1 changed)
Scenario Current New Change
🟡 Tooltip 149.66 KB
36.18 gz
150.40 KB
36.32 gz
+0.74 KB (+0.5%)
+0.13 gz (+0.4%)
Worst case (1 changed)
Scenario Current New Change
🟡 all 1042.75 KB
243.68 gz
1043.50 KB
243.81 gz
+0.75 KB (+0.1%)
+0.13 gz (+0.1%)

Understanding this report
  • Use-case scenarios measure the bundle cost of common chart configurations (e.g. a line chart with axes)
  • Individual components measure each component imported in isolation
  • Svelte runtime is excluded; sizes reflect layerchart + its dependencies (d3, etc.)
  • When multiple components share dependencies (e.g. d3-scale), the real-world cost is lower than the sum of individual sizes
  • Changes smaller than 10 bytes or 0.1% are considered insignificant

@techniq techniq left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, thanks!

Curious if you tested this locally with your setup (you can leverage the PR preview release)?

@ntainy

ntainy commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

My pleasure, thank you for creating and maintaining such a great lib in the first place :) Before you asked, I only checked that repros and few example edge case pages built by claude work as intended; pulled now that updated pkg to the actual project and it's lit! Couldn't be happier :) will be eagerly waiting for the next release

@techniq
techniq merged commit 72a86ba into techniq:main Oct 1, 2026
9 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 1, 2026
@techniq

techniq commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Publishing 2.5.1 now (just waiting on CI/CD).

btw, related to these mobile tooltip changes, I started working on improvements a few days ago to the default anchor and offset positions when using touch (issue #485). Currently the anchor defaults to top-left which isn't optimal for touch input (hidden under your finger). Considering having the default change to bottom (center) (not bottom-left or bottom-right since we wouldn't know if a left or right finger is used and thus direction it is going).

If you have any thoughts here, I'd love to here them. We have a discord server as well

@ntainy

ntainy commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

thanks for such a quick release!

100% agree. i ended up doing essentially the same with bottom center. though if that's helpful, a few things that we ran into (just thoughts and personal decisions):

  • on [Tooltip] Increase default x/y offset on mobile / touch #485: agree, the offset needs to be larger on touch; in my measurements, fingertip pad covers something like +20-25px above the reported point. i made 24px gap for touch, seems like a sweet spot (and ofc keep smaller one for the mouse)
  • i feel like it works best decided per event from pointerType rather than coarse-pointer media query, because touchscreen laptops (or tablets with trackpads) can switch between the two. note that i may be slightly wrong here though because i can't definitively state if the issue persists with reactive svelte media queries (genuinely worth double-checking).
  • when the box doesn't fit above (say finger near the top of the short chart), flipping below puts it under the finger again; so "fits above" definition can be stretched - like measuring "fits above" against the viewport rather than container, so that the box may overflow the chart upward instead of flipping.
  • since "above" depends on the box height, if the first placement happens before we measure the height, the box appears under the finger and then animates up. measuring before the first placement avoids the jump though. (because when the box is placed under the finger, its top is at (finger+gap) (doesn't care about the box height); but placing above means the top is at (finger-gap-box height). so on frame 1, box height is 0; next frame, box height is measured, its top suddenly moves, and then the box springs from the wrong point to the right one -- essentially sliding from under the finger).
  • and for bottom centered tooltip, this PR's changes matter even more - because with narrow viewport and wide box, this clamp moves from fixing a mildly inconvenient edge case to essentially constantly being triggered on almost all touches.

@techniq

techniq commented Oct 1, 2026

Copy link
Copy Markdown
Owner

thanks for such a quick release!
welcome!

here's some initial work towards #485 - initial work: https://github.com/techniq/layerchart/pull/927/changes

100% agree. i ended up doing essentially the same with bottom center. though if that's helpful, a few things that we ran into (just thoughts and personal decisions):

  • on [Tooltip] Increase default x/y offset on mobile / touch #485: agree, the offset needs to be larger on touch; in my measurements, fingertip pad covers something like +20-25px above the reported point. i made 24px gap for touch, seems like a sweet spot (and ofc keep smaller one for the mouse)
    currently using 32 but might be too much
  • i feel like it works best decided per event from pointerType rather than coarse-pointer media query, because touchscreen laptops (or tablets with trackpads) can switch between the two. note that i may be slightly wrong here though because i can't definitively state if the issue persists with reactive svelte media queries (genuinely worth double-checking).
    good callout, and already using pointerType in the PR :). also exposing pointerType in TooltipState now (which might be helpful for other situations)
  • when the box doesn't fit above (say finger near the top of the short chart), flipping below puts it under the finger again; so "fits above" definition can be stretched - like measuring "fits above" against the viewport rather than container, so that the box may overflow the chart upward instead of flipping.
  • since "above" depends on the box height, if the first placement happens before we measure the height, the box appears under the finger and then animates up. measuring before the first placement avoids the jump though. (because when the box is placed under the finger, its top is at (finger+gap) (doesn't care about the box height); but placing above means the top is at (finger-gap-box height). so on frame 1, box height is 0; next frame, box height is measured, its top suddenly moves, and then the box springs from the wrong point to the right one -- essentially sliding from under the finger).
  • and for bottom centered tooltip, this PR's changes matter even more - because with narrow viewport and wide box, this clamp moves from fixing a mildly inconvenient edge case to essentially constantly being triggered on almost all touches.

great points and will take some thought. I typically leverage contained="window" more nowadays, especially for large tooltips. might be the better default going forward in v3

@ntainy

ntainy commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

thanks, took a quick look at it, looks great! exposed per-event pointerType is nice. on default contained="window" -- definitely +1.

though a thought on it:

  • it bounds by window.innerWidth/innerHeight (layout viewport), so if you pinch-zoom and pan, the box can go off the visible screen. personal solution was bounding by visualViewport (offsetLeft/offsetTop/w/h) and re-place on its resize/scroll, which is fired by pinch and not by window. just asked claude to build a repro of this. open preview on its own tab on a phone (or pinch on a trackpad), zoom 2x into the left half and tap near the right edge. though i tested in arc/chromium and zen/firefox, and mdn says that ios safari has its own shenanigans and it treats innerWidth differently, so worth checking further

and just commenting: considering that #927 branched off before this PR, seems like a rebase would break y clamp (clampToEdges(rect.top, ctx.padding.top, ...) -> pulls the box from above the chart back to padding.top, directly under the finger). something like clampToEdges(rect.top, isTouchPlacement? -containerRect.top : ctx.padding.top, containerRect.height - tooltipHeight) will probably do.


a correction to my earlier point (i was wrong): the entire thing about first frame jumping - nvm, Tooltip correctly waits for tooltipHeight... this only happened in my case because i was working out y myself. lesson learned :)

  • maybe treat pen like touch as well? because the hand is probably below the pen tip as well, so otherwise it ends up under the hand again
  • imo 32px is fine, 24 was my personal bet, 32 seems like it just leaves a bit of extra room for larger thumbs.

also, thanks for the discord invite, hopped on :)

This branch was successfully deployed

1 active deployment
Preview — 3ee9f285 Deployed Oct 1, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants