Skip to content

Commit 365f05b

Browse files
authored
feat(Tooltip): Portal tooltip to body by default to fix overflow clipping. Resolves #446 (#828)
* feat(Tooltip): Portal tooltip to body by default to fix overflow clipping. Resolves #446 * feat(Tooltip): Add `fadeDuration` prop to control fade in/out transition
1 parent 4d24f42 commit 365f05b

38 files changed

Lines changed: 634 additions & 63 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(Tooltip): Add `fadeDuration` prop to control fade in/out transition
6+
7+
The fade transition on `Tooltip.Root` is now configurable via the `fadeDuration` prop (default: `100`ms). Set to `0` to disable the fade transition entirely.

.changeset/tooltip-portal.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(Tooltip): Portal tooltip to body by default to fix overflow clipping. Resolves #446
6+
7+
Tooltip.Root now portals to `document.body` (or `.PortalTarget`) by default using the `portal` action from `@layerstack/svelte-actions`. This prevents tooltips from being clipped by ancestor elements with `overflow: hidden`. The tooltip uses `position: fixed` with viewport-relative coordinates when portaled. Set `portal={false}` to restore the previous inline behavior. Both `contained="container"` and `contained="window"` modes continue to work correctly with portaling.

docs/src/content/guides/tooltip.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,33 @@ The `anchor` prop controls which corner of the tooltip aligns to the position po
152152

153153
:example{ component="Tooltip" name="anchor-location" }
154154

155+
### Portal
156+
157+
By default, `Tooltip.Root` is portaled outside the chart DOM to `document.body` (or a `.PortalTarget` element if one exists). This prevents the tooltip from being clipped by ancestors with `overflow: hidden`.
158+
159+
| Value | Behavior |
160+
| ----------------------------- | ----------------------------------------------------------- |
161+
| `true` | Portal to `.PortalTarget` or `document.body` (default) |
162+
| `false` | Render inline within the chart (original behavior) |
163+
| `{ target: '.my-container' }` | Portal to a specific CSS selector |
164+
| `{ target: element }` | Portal to a specific DOM element |
165+
| `{ enabled: false }` | Same as `false` |
166+
167+
```svelte
168+
<!-- Default: portaled to body -->
169+
<Tooltip.Root>
170+
171+
<!-- Disable portal (inline positioning) -->
172+
<Tooltip.Root portal={false}>
173+
174+
<!-- Portal to a custom target -->
175+
<Tooltip.Root portal={{ target: '.my-tooltip-container' }}>
176+
```
177+
178+
Toggle `portal` off to see the tooltip clipped by the `overflow: hidden` container:
179+
180+
:example{ component="Tooltip" name="portal-overflow" }
181+
155182
### Containment
156183

157184
Tooltips are contained within their chart container by default, flipping sides when they would overflow. The `contained` prop controls this:

docs/src/examples/components/Tooltip/anchor-location.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
let anchor: ComponentProps<typeof Tooltip.Root>['anchor'] = $state('top-left');
1818
let snap: 'pointer' | 'data' = $state('pointer');
1919
let contained: ComponentProps<typeof Tooltip.Root>['contained'] = $state('container');
20+
let portal: boolean = $state(true);
2021
2122
export { data };
2223
</script>
2324

24-
<TooltipContextControls2 bind:anchor bind:snap bind:contained />
25+
<TooltipContextControls2 bind:anchor bind:snap bind:contained bind:portal />
2526

2627
<Chart
2728
{data}
@@ -46,6 +47,7 @@
4647
y={snap}
4748
yOffset={['left', 'center', 'right'].includes(anchor ?? '') ? 0 : 10}
4849
{contained}
50+
{portal}
4951
>
5052
{#snippet children({ data })}
5153
<Tooltip.Header value={data.date} format="day" />
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<script lang="ts">
2+
import { Field, Switch } from 'svelte-ux';
3+
import { Area, Axis, Chart, Layer, Highlight, Tooltip, defaultChartPadding } from 'layerchart';
4+
import { createDateSeries } from '$lib/utils/data.js';
5+
6+
const data = createDateSeries({
7+
count: 30,
8+
min: 20,
9+
max: 100,
10+
value: 'integer',
11+
keys: ['value']
12+
});
13+
14+
let portal: boolean = $state(true);
15+
16+
export { data };
17+
</script>
18+
19+
<div class="flex gap-2 mb-4 screenshot-hidden">
20+
<Field label="Portal">
21+
<Switch bind:checked={portal} />
22+
</Field>
23+
</div>
24+
25+
<div class="overflow-hidden rounded border p-2" style:height="200px">
26+
<Chart
27+
{data}
28+
x="date"
29+
y="value"
30+
yDomain={[0, null]}
31+
yNice
32+
padding={defaultChartPadding({ top: 5, left: 28, bottom: 24, right: 15 })}
33+
tooltipContext={{ mode: 'quadtree-x' }}
34+
>
35+
<Layer>
36+
<Axis placement="left" grid rule />
37+
<Axis placement="bottom" rule />
38+
<Area class="fill-primary/30" line={{ class: 'stroke-primary stroke-2' }} />
39+
<Highlight points lines />
40+
</Layer>
41+
<Tooltip.Root {portal} contained={false}>
42+
{#snippet children({ data })}
43+
<Tooltip.Header value={data.date} format="day" />
44+
<Tooltip.List>
45+
<Tooltip.Item label="value" value={data.value} />
46+
</Tooltip.List>
47+
{/snippet}
48+
</Tooltip.Root>
49+
</Chart>
50+
</div>

docs/src/lib/components/controls/TooltipContextControls2.svelte

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
<script lang="ts">
22
import type { ComponentProps } from 'svelte';
3-
import { Button, Field, Menu, MenuField, Toggle } from 'svelte-ux';
3+
import { Button, Field, Menu, MenuField, Switch, Toggle } from 'svelte-ux';
44
import { Tooltip } from 'layerchart';
55
66
interface Props {
77
anchor?: ComponentProps<typeof Tooltip.Root>['anchor'];
88
snap?: 'pointer' | 'data';
99
contained?: ComponentProps<typeof Tooltip.Root>['contained'];
10+
portal?: boolean;
1011
}
1112
1213
const anchorOptions = [
@@ -24,7 +25,8 @@
2425
let {
2526
anchor = $bindable('top-left' as ComponentProps<typeof Tooltip.Root>['anchor']),
2627
snap = $bindable('pointer' as 'pointer' | 'data'),
27-
contained = $bindable(false as ComponentProps<typeof Tooltip.Root>['contained'])
28+
contained = $bindable(false as ComponentProps<typeof Tooltip.Root>['contained']),
29+
portal = $bindable(true)
2830
}: Props = $props();
2931
</script>
3032

@@ -59,13 +61,19 @@
5961
]}
6062
/>
6163

62-
<MenuField
63-
label="Contained"
64-
bind:value={contained}
65-
options={[
66-
{ label: 'none', value: false },
67-
{ label: 'container', value: 'container' },
68-
{ label: 'window', value: 'window' }
69-
]}
70-
/>
64+
<div class="flex gap-2">
65+
<MenuField
66+
label="Contained"
67+
bind:value={contained}
68+
options={[
69+
{ label: 'none', value: false },
70+
{ label: 'container', value: 'container' },
71+
{ label: 'window', value: 'window' }
72+
]}
73+
class="flex-1"
74+
/>
75+
<Field label="Portal">
76+
<Switch bind:checked={portal} />
77+
</Field>
78+
</div>
7179
</div>

packages/layerchart/src/lib/components/charts/BarChart.svelte.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ describe('BarChart', () => {
294294
tooltipRect!.dispatchEvent(new PointerEvent('pointermove', eventInit));
295295

296296
await vi.waitFor(() => {
297-
const colorDots = container.querySelectorAll('.lc-tooltip-item-color');
297+
const colorDots = document.querySelectorAll('.lc-tooltip-item-color');
298298
expect(colorDots.length).toBe(4);
299299

300300
const colors = Array.from(colorDots).map((dot) =>

packages/layerchart/src/lib/components/charts/DefaultTooltip.svelte.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,16 @@ describe('DefaultTooltip', () => {
5959
triggerTooltip(tooltipCtx);
6060

6161
await vi.waitFor(() => {
62-
// Should have a header
63-
const header = container.querySelector('.lc-tooltip-header');
62+
// Should have a header (portaled to body)
63+
const header = document.querySelector('.lc-tooltip-header');
6464
expect(header).not.toBeNull();
6565

6666
// Should have 4 tooltip items (3 series + 1 total)
67-
const items = container.querySelectorAll('.lc-tooltip-item-root');
67+
const items = document.querySelectorAll('.lc-tooltip-item-root');
6868
expect(items.length).toBe(4);
6969

7070
// Labels should match series keys + total
71-
const labels = container.querySelectorAll('.lc-tooltip-item-label');
71+
const labels = document.querySelectorAll('.lc-tooltip-item-label');
7272
expect(labels.length).toBe(4);
7373
const labelTexts = Array.from(labels).map((l) => l.textContent?.trim());
7474
expect(labelTexts).toEqual(['apples', 'bananas', 'oranges', 'total']);
@@ -89,7 +89,7 @@ describe('DefaultTooltip', () => {
8989
triggerTooltip(tooltipCtx);
9090

9191
await vi.waitFor(() => {
92-
const colorDots = container.querySelectorAll('.lc-tooltip-item-color');
92+
const colorDots = document.querySelectorAll('.lc-tooltip-item-color');
9393
expect(colorDots.length).toBe(3);
9494
const colors = Array.from(colorDots).map((dot) =>
9595
(dot as HTMLElement).style.getPropertyValue('--color')
@@ -112,15 +112,15 @@ describe('DefaultTooltip', () => {
112112
triggerTooltip(tooltipCtx);
113113

114114
await vi.waitFor(() => {
115-
const items = container.querySelectorAll('.lc-tooltip-item-root');
115+
const items = document.querySelectorAll('.lc-tooltip-item-root');
116116
expect(items.length).toBe(4);
117117
});
118118

119119
const items = Array.from(
120-
container.querySelectorAll('.lc-tooltip-item-root')
120+
document.querySelectorAll('.lc-tooltip-item-root')
121121
) as HTMLElement[];
122122
const labels = Array.from(
123-
container.querySelectorAll('.lc-tooltip-item-label')
123+
document.querySelectorAll('.lc-tooltip-item-label')
124124
) as HTMLElement[];
125125

126126
triggerPointerEvent(items[0], 'pointerenter');
@@ -164,7 +164,7 @@ describe('DefaultTooltip', () => {
164164
triggerTooltip(tooltipCtx);
165165

166166
await vi.waitFor(() => {
167-
const items = container.querySelectorAll('.lc-tooltip-item-root');
167+
const items = document.querySelectorAll('.lc-tooltip-item-root');
168168
// 1 series item, no total
169169
expect(items.length).toBe(1);
170170
});
@@ -186,14 +186,14 @@ describe('DefaultTooltip', () => {
186186
triggerTooltip(tooltipCtx);
187187

188188
await vi.waitFor(() => {
189-
const header = container.querySelector('.lc-tooltip-header');
189+
const header = document.querySelector('.lc-tooltip-header');
190190
expect(header).not.toBeNull();
191191

192192
// 3 series + 1 total = 4 items
193-
const items = container.querySelectorAll('.lc-tooltip-item-root');
193+
const items = document.querySelectorAll('.lc-tooltip-item-root');
194194
expect(items.length).toBe(4);
195195

196-
const labels = container.querySelectorAll('.lc-tooltip-item-label');
196+
const labels = document.querySelectorAll('.lc-tooltip-item-label');
197197
const labelTexts = Array.from(labels).map((l) => l.textContent?.trim());
198198
expect(labelTexts).toEqual(['apples', 'bananas', 'oranges', 'total']);
199199
});
@@ -215,11 +215,11 @@ describe('DefaultTooltip', () => {
215215
triggerTooltip(tooltipCtx);
216216

217217
await vi.waitFor(() => {
218-
const items = container.querySelectorAll('.lc-tooltip-item-root');
218+
const items = document.querySelectorAll('.lc-tooltip-item-root');
219219
// Should show x and y items
220220
expect(items.length).toBe(2);
221221

222-
const labels = Array.from(container.querySelectorAll('.lc-tooltip-item-label')).map((l) =>
222+
const labels = Array.from(document.querySelectorAll('.lc-tooltip-item-label')).map((l) =>
223223
l.textContent?.trim()
224224
);
225225
expect(labels).toEqual(['x', 'y']);
@@ -241,11 +241,11 @@ describe('DefaultTooltip', () => {
241241
triggerTooltip(tooltipCtx);
242242

243243
await vi.waitFor(() => {
244-
const items = container.querySelectorAll('.lc-tooltip-item-root');
244+
const items = document.querySelectorAll('.lc-tooltip-item-root');
245245
// Should show x, y, and r items
246246
expect(items.length).toBe(3);
247247

248-
const labels = Array.from(container.querySelectorAll('.lc-tooltip-item-label')).map((l) =>
248+
const labels = Array.from(document.querySelectorAll('.lc-tooltip-item-label')).map((l) =>
249249
l.textContent?.trim()
250250
);
251251
expect(labels).toEqual(['x', 'y', 'size']);
@@ -270,12 +270,12 @@ describe('DefaultTooltip', () => {
270270

271271
await vi.waitFor(() => {
272272
// Should show a header with the series name
273-
const header = container.querySelector('.lc-tooltip-header');
273+
const header = document.querySelector('.lc-tooltip-header');
274274
expect(header).not.toBeNull();
275275
expect(header!.textContent).not.toBe('');
276276

277277
// Should show x and y items
278-
const items = container.querySelectorAll('.lc-tooltip-item-root');
278+
const items = document.querySelectorAll('.lc-tooltip-item-root');
279279
expect(items.length).toBe(2);
280280
});
281281
});

packages/layerchart/src/lib/components/charts/LineChart.svelte.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('LineChart', () => {
4040
tooltipRect!.dispatchEvent(new PointerEvent('pointermove', eventInit));
4141

4242
await vi.waitFor(() => {
43-
const colorDot = container.querySelector('.lc-tooltip-item-color') as HTMLElement | null;
43+
const colorDot = document.querySelector('.lc-tooltip-item-color') as HTMLElement | null;
4444
expect(colorDot).not.toBeNull();
4545

4646
const color = colorDot!.style.getPropertyValue('--color');

packages/layerchart/src/lib/components/charts/PieChart.svelte.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ describe('PieChart', () => {
3434
arc!.dispatchEvent(new PointerEvent('pointermove', eventInit));
3535

3636
await vi.waitFor(() => {
37-
const tooltipLabel = container.querySelector('.lc-tooltip-item-label');
38-
const tooltipValue = container.querySelector('.lc-tooltip-item-value');
37+
const tooltipLabel = document.querySelector('.lc-tooltip-item-label');
38+
const tooltipValue = document.querySelector('.lc-tooltip-item-value');
3939

4040
expect(tooltipLabel?.textContent).toContain('chrome');
4141
expect(tooltipValue?.textContent).toContain('275');

0 commit comments

Comments
 (0)