layerchart 2.0.0-next.53 → 2.0.0-next.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/components/Arc.svelte +8 -7
  2. package/dist/components/Arc.svelte.test.js +1 -1
  3. package/dist/components/ArcLabel.svelte +1 -1
  4. package/dist/components/ClipPath.svelte +1 -1
  5. package/dist/components/GeoClipPath.svelte +75 -0
  6. package/dist/components/GeoClipPath.svelte.d.ts +29 -0
  7. package/dist/components/Hull.svelte +20 -2
  8. package/dist/components/Hull.svelte.d.ts +2 -2
  9. package/dist/components/Pie.svelte +8 -2
  10. package/dist/components/Text.svelte +63 -16
  11. package/dist/components/Text.svelte.d.ts +10 -0
  12. package/dist/components/charts/BarChart.svelte.test.js +1 -1
  13. package/dist/components/charts/DefaultTooltip.svelte.test.js +18 -18
  14. package/dist/components/charts/LineChart.svelte.test.js +1 -1
  15. package/dist/components/charts/PieChart.svelte.test.js +2 -2
  16. package/dist/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-tooltip-should-use-explicit-series-colors--not-color-scale-1.png +0 -0
  17. package/dist/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-tooltip-should-use-explicit-series-colors--not-color-scale-2.png +0 -0
  18. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-fade-non-highlighted-tooltip-series-items-on-hover-1.png +0 -0
  19. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-fade-non-highlighted-tooltip-series-items-on-hover-2.png +0 -0
  20. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-show-header-and-all-series-items-1.png +0 -0
  21. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-show-header-and-all-series-items-2.png +0 -0
  22. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-show-series-colors-in-tooltip-items-1.png +0 -0
  23. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-show-series-colors-in-tooltip-items-2.png +0 -0
  24. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-show-single-series-without-total-1.png +0 -0
  25. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-AreaChart--multi-series--quadtree-x-mode--should-show-single-series-without-total-2.png +0 -0
  26. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-LineChart--multi-series--quadtree-x-mode--should-show-header-and-all-series-items-1.png +0 -0
  27. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-LineChart--multi-series--quadtree-x-mode--should-show-header-and-all-series-items-2.png +0 -0
  28. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-series-header-for-multi-series-1.png +0 -0
  29. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-series-header-for-multi-series-2.png +0 -0
  30. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-x--y--and-r-items-when-r-is-configured-1.png +0 -0
  31. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-x--y--and-r-items-when-r-is-configured-2.png +0 -0
  32. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-x-and-y-items-in-tooltip-1.png +0 -0
  33. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-x-and-y-items-in-tooltip-2.png +0 -0
  34. package/dist/components/charts/__screenshots__/LineChart.svelte.test.ts/LineChart-tooltip-should-prefer-cScale-color-over-default-series-color-when-cScale-is-explicitly-provided-1.png +0 -0
  35. package/dist/components/charts/__screenshots__/LineChart.svelte.test.ts/LineChart-tooltip-should-prefer-cScale-color-over-default-series-color-when-cScale-is-explicitly-provided-2.png +0 -0
  36. package/dist/components/charts/__screenshots__/PieChart.svelte.test.ts/PieChart-uses-hovered-slice-identity-for-implicit-tooltip-series-1.png +0 -0
  37. package/dist/components/charts/__screenshots__/PieChart.svelte.test.ts/PieChart-uses-hovered-slice-identity-for-implicit-tooltip-series-2.png +0 -0
  38. package/dist/components/index.d.ts +2 -0
  39. package/dist/components/index.js +2 -0
  40. package/dist/components/tooltip/Tooltip.svelte +145 -29
  41. package/dist/components/tooltip/Tooltip.svelte.d.ts +16 -0
  42. package/dist/components/tooltip/Tooltip.svelte.test.d.ts +1 -0
  43. package/dist/components/tooltip/Tooltip.svelte.test.js +294 -0
  44. package/dist/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-1.png +0 -0
  45. package/dist/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-2.png +0 -0
  46. package/dist/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-1.png +0 -0
  47. package/dist/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-2.png +0 -0
  48. package/package.json +13 -13
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" module>
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { PortalOptions } from '@layerstack/svelte-actions';
3
4
  import type { Without } from '../../utils/types.js';
4
5
  import type { Placement } from '../types.js';
5
6
 
@@ -49,6 +50,14 @@
49
50
  */
50
51
  motion?: MotionProp;
51
52
 
53
+ /**
54
+ * Duration of the fade in/out transition in milliseconds.
55
+ * Set to `0` to disable the fade transition.
56
+ *
57
+ * @default 100
58
+ */
59
+ fadeDuration?: number;
60
+
52
61
  /**
53
62
  * Allow pointer events. Disabled by default to reduce accidental selection, but useful to
54
63
  * enable to allow interactive tooltips (using `locked`)
@@ -132,6 +141,15 @@
132
141
  content?: HTMLAttributes<HTMLElement>;
133
142
  };
134
143
 
144
+ /**
145
+ * Portal the tooltip outside the chart DOM hierarchy to avoid overflow clipping.
146
+ * Pass `true` to portal to `.PortalTarget` or `document.body`, a string CSS selector,
147
+ * an HTMLElement, or `false` to disable.
148
+ *
149
+ * @default true
150
+ */
151
+ portal?: PortalOptions;
152
+
135
153
  /**
136
154
  * Optionally pass the chart's context to the tooltip to get
137
155
  * type inference for the data.
@@ -146,6 +164,7 @@
146
164
  <script lang="ts" generics="T = any">
147
165
  import { fade } from 'svelte/transition';
148
166
  import { cls } from '@layerstack/tailwind';
167
+ import { portal as portalAction } from '@layerstack/svelte-actions';
149
168
 
150
169
  import { isScaleBand } from '../../utils/scales.svelte.js';
151
170
  import { getChartContext } from '../../contexts/chart.js';
@@ -157,8 +176,10 @@
157
176
  anchor = 'top-left',
158
177
  classes = {},
159
178
  contained = 'container',
179
+ fadeDuration = 100,
160
180
  motion = 'spring',
161
181
  pointerEvents = false,
182
+ portal: portalProp = true,
162
183
  variant = 'default',
163
184
  x = 'pointer',
164
185
  xOffset = x === 'pointer' ? 10 : 0,
@@ -189,12 +210,23 @@
189
210
  return value + (align === 'end' ? -additionalOffset : additionalOffset) - alignOffset;
190
211
  }
191
212
 
213
+ const isPortaled = $derived(
214
+ typeof portalProp === 'boolean' ? portalProp : portalProp?.enabled !== false
215
+ );
216
+
192
217
  const positions = $derived.by(() => {
193
218
  // if no data or tooltip size is not known yet, return null
194
219
  if (!ctx.tooltip.data || tooltipWidth === null || tooltipHeight === null) {
195
220
  return { x: null, y: null };
196
221
  }
197
222
 
223
+ // When portaled, we need the container's viewport rect to convert coordinates
224
+ const containerRect = isPortaled ? ctx.containerRef?.getBoundingClientRect() : null;
225
+ // If portaled but container rect not available yet, bail
226
+ if (isPortaled && !containerRect) {
227
+ return { x: null, y: null };
228
+ }
229
+
198
230
  const xBandOffset = isScaleBand(ctx.xScale)
199
231
  ? ctx.xScale.step() / 2 - (ctx.xScale.padding() * ctx.xScale.step()) / 2
200
232
  : 0;
@@ -279,44 +311,75 @@
279
311
  rect.right = rect.left + tooltipWidth;
280
312
 
281
313
  if (contained === 'container') {
282
- // Only attempt repositioning if not fixed (ie. `pointer`/`data`)
283
- if (typeof x !== 'number') {
284
- // Check if outside of container and swap align side accordingly
285
- if ((xAlign === 'start' || xAlign === 'center') && rect.right > ctx.containerWidth) {
286
- rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth);
287
- }
288
- if ((xAlign === 'end' || xAlign === 'center') && rect.left < ctx.padding.left) {
289
- rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
314
+ if (isPortaled && containerRect) {
315
+ // Containment in viewport coordinates
316
+ if (typeof x !== 'number') {
317
+ if (
318
+ (xAlign === 'start' || xAlign === 'center') &&
319
+ containerRect.left + rect.right > containerRect.right
320
+ ) {
321
+ rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth);
322
+ }
323
+ if (
324
+ (xAlign === 'end' || xAlign === 'center') &&
325
+ containerRect.left + rect.left < containerRect.left + ctx.padding.left
326
+ ) {
327
+ rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
328
+ }
290
329
  }
291
- }
292
- rect.right = rect.left + tooltipWidth;
330
+ rect.right = rect.left + tooltipWidth;
293
331
 
294
- if (typeof y !== 'number') {
295
- if ((yAlign === 'start' || yAlign === 'center') && rect.bottom > ctx.containerHeight) {
296
- rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight);
332
+ if (typeof y !== 'number') {
333
+ if (
334
+ (yAlign === 'start' || yAlign === 'center') &&
335
+ containerRect.top + rect.bottom > containerRect.bottom
336
+ ) {
337
+ rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight);
338
+ }
339
+ if (
340
+ (yAlign === 'end' || yAlign === 'center') &&
341
+ containerRect.top + rect.top < containerRect.top + ctx.padding.top
342
+ ) {
343
+ rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
344
+ }
297
345
  }
298
- if ((yAlign === 'end' || yAlign === 'center') && rect.top < ctx.padding.top) {
299
- rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
346
+ rect.bottom = rect.top + tooltipHeight;
347
+ } else {
348
+ // Original non-portaled container containment
349
+ if (typeof x !== 'number') {
350
+ // Check if outside of container and swap align side accordingly
351
+ if ((xAlign === 'start' || xAlign === 'center') && rect.right > ctx.containerWidth) {
352
+ rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth);
353
+ }
354
+ if ((xAlign === 'end' || xAlign === 'center') && rect.left < ctx.padding.left) {
355
+ rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
356
+ }
357
+ }
358
+ rect.right = rect.left + tooltipWidth;
359
+
360
+ if (typeof y !== 'number') {
361
+ if ((yAlign === 'start' || yAlign === 'center') && rect.bottom > ctx.containerHeight) {
362
+ rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight);
363
+ }
364
+ if ((yAlign === 'end' || yAlign === 'center') && rect.top < ctx.padding.top) {
365
+ rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
366
+ }
300
367
  }
368
+ rect.bottom = rect.top + tooltipHeight;
301
369
  }
302
- rect.bottom = rect.top + tooltipHeight;
303
370
  } else if (contained === 'window') {
304
- // Check if outside of window / viewport and swap align side accordingly
305
- // Root <div> won't be available on initial mount
306
- if (rootRef?.parentElement) {
307
- const parentViewportRect = rootRef.parentElement.getBoundingClientRect();
308
-
309
- // Only attempt repositioning if not fixed (ie. `pointer`/`data`)
371
+ if (isPortaled && containerRect) {
372
+ // Already in viewport coordinates, just clamp to window
310
373
  if (typeof x !== 'number') {
311
374
  if (
312
375
  (xAlign === 'start' || xAlign === 'center') &&
313
- parentViewportRect.left + rect.right > window.innerWidth
376
+ containerRect.left + rect.right > window.innerWidth
314
377
  ) {
315
378
  rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth);
316
379
  }
317
380
  if (
318
381
  (xAlign === 'end' || xAlign === 'center') &&
319
- parentViewportRect.left + rect.left < 0
382
+ containerRect.left + rect.left < 0
320
383
  ) {
321
384
  rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
322
385
  }
@@ -326,20 +389,67 @@
326
389
  if (typeof y !== 'number') {
327
390
  if (
328
391
  (yAlign === 'start' || yAlign === 'center') &&
329
- parentViewportRect.top + rect.bottom > window.innerHeight
392
+ containerRect.top + rect.bottom > window.innerHeight
330
393
  ) {
331
394
  rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight);
332
395
  }
333
- if ((yAlign === 'end' || yAlign === 'center') && parentViewportRect.top + rect.top < 0) {
396
+ if (
397
+ (yAlign === 'end' || yAlign === 'center') &&
398
+ containerRect.top + rect.top < 0
399
+ ) {
334
400
  rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
335
401
  }
336
402
  }
337
403
  rect.bottom = rect.top + tooltipHeight;
404
+ } else {
405
+ // Original non-portaled window containment
406
+ // Root <div> won't be available on initial mount
407
+ if (rootRef?.parentElement) {
408
+ const parentViewportRect = rootRef.parentElement.getBoundingClientRect();
409
+
410
+ // Only attempt repositioning if not fixed (ie. `pointer`/`data`)
411
+ if (typeof x !== 'number') {
412
+ if (
413
+ (xAlign === 'start' || xAlign === 'center') &&
414
+ parentViewportRect.left + rect.right > window.innerWidth
415
+ ) {
416
+ rect.left = alignValue(xValue, 'end', xOffset, tooltipWidth);
417
+ }
418
+ if (
419
+ (xAlign === 'end' || xAlign === 'center') &&
420
+ parentViewportRect.left + rect.left < 0
421
+ ) {
422
+ rect.left = alignValue(xValue, 'start', xOffset, tooltipWidth);
423
+ }
424
+ }
425
+ rect.right = rect.left + tooltipWidth;
426
+
427
+ if (typeof y !== 'number') {
428
+ if (
429
+ (yAlign === 'start' || yAlign === 'center') &&
430
+ parentViewportRect.top + rect.bottom > window.innerHeight
431
+ ) {
432
+ rect.top = alignValue(yValue, 'end', yOffset, tooltipHeight);
433
+ }
434
+ if (
435
+ (yAlign === 'end' || yAlign === 'center') &&
436
+ parentViewportRect.top + rect.top < 0
437
+ ) {
438
+ rect.top = alignValue(yValue, 'start', yOffset, tooltipHeight);
439
+ }
440
+ }
441
+ rect.bottom = rect.top + tooltipHeight;
442
+ }
338
443
  }
339
444
  }
445
+
446
+ // When portaled, convert from container-relative to viewport-relative coordinates
447
+ const offsetX = isPortaled && containerRect ? containerRect.left : 0;
448
+ const offsetY = isPortaled && containerRect ? containerRect.top : 0;
449
+
340
450
  return {
341
- x: rect.left,
342
- y: rect.top,
451
+ x: rect.left + offsetX,
452
+ y: rect.top + offsetY,
343
453
  };
344
454
  });
345
455
 
@@ -356,11 +466,13 @@
356
466
  {#if ctx.tooltip.data}
357
467
  <div
358
468
  {...props.root}
469
+ use:portalAction={portalProp}
359
470
  class={cls('lc-tooltip-root', classes.root, props.root?.class)}
360
471
  class:disablePointerEvents={pointerEvents === false}
472
+ class:portaled={isPortaled}
361
473
  style:top="{motionY.current}px"
362
474
  style:left="{motionX.current}px"
363
- transition:fade={{ duration: 100 }}
475
+ transition:fade={{ duration: fadeDuration }}
364
476
  bind:clientWidth={tooltipWidth}
365
477
  bind:clientHeight={tooltipHeight}
366
478
  bind:this={rootRef}
@@ -392,6 +504,10 @@
392
504
  z-index: 50;
393
505
  user-select: none;
394
506
 
507
+ &.portaled {
508
+ position: fixed;
509
+ }
510
+
395
511
  &.disablePointerEvents {
396
512
  pointer-events: none;
397
513
  }
@@ -1,4 +1,5 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { PortalOptions } from '@layerstack/svelte-actions';
2
3
  import type { Without } from '../../utils/types.js';
3
4
  import type { Placement } from '../types.js';
4
5
  export type Align = 'start' | 'center' | 'end';
@@ -41,6 +42,13 @@ export type TooltipPropsWithoutHTML<T = any> = {
41
42
  * @default "spring"
42
43
  */
43
44
  motion?: MotionProp;
45
+ /**
46
+ * Duration of the fade in/out transition in milliseconds.
47
+ * Set to `0` to disable the fade transition.
48
+ *
49
+ * @default 100
50
+ */
51
+ fadeDuration?: number;
44
52
  /**
45
53
  * Allow pointer events. Disabled by default to reduce accidental selection, but useful to
46
54
  * enable to allow interactive tooltips (using `locked`)
@@ -115,6 +123,14 @@ export type TooltipPropsWithoutHTML<T = any> = {
115
123
  */
116
124
  content?: HTMLAttributes<HTMLElement>;
117
125
  };
126
+ /**
127
+ * Portal the tooltip outside the chart DOM hierarchy to avoid overflow clipping.
128
+ * Pass `true` to portal to `.PortalTarget` or `document.body`, a string CSS selector,
129
+ * an HTMLElement, or `false` to disable.
130
+ *
131
+ * @default true
132
+ */
133
+ portal?: PortalOptions;
118
134
  /**
119
135
  * Optionally pass the chart's context to the tooltip to get
120
136
  * type inference for the data.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,294 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { render } from 'vitest-browser-svelte';
3
+ import LineChart from '../charts/LineChart.svelte';
4
+ const data = [
5
+ { date: 0, value: 10 },
6
+ { date: 1, value: 30 },
7
+ { date: 2, value: 20 },
8
+ { date: 3, value: 50 },
9
+ { date: 4, value: 40 },
10
+ ];
11
+ const baseProps = {
12
+ data,
13
+ x: 'date',
14
+ y: 'value',
15
+ height: 300,
16
+ width: 400,
17
+ tooltipContext: { mode: 'bisect-x' },
18
+ };
19
+ /** Dispatch pointer events to trigger the tooltip on a given element */
20
+ function triggerTooltip(el, position) {
21
+ const rect = el.getBoundingClientRect();
22
+ const eventInit = {
23
+ bubbles: true,
24
+ clientX: position?.clientX ?? rect.x + rect.width / 2,
25
+ clientY: position?.clientY ?? rect.y + rect.height / 2,
26
+ };
27
+ el.dispatchEvent(new PointerEvent('pointerenter', eventInit));
28
+ el.dispatchEvent(new PointerEvent('pointermove', eventInit));
29
+ }
30
+ describe('Tooltip', () => {
31
+ describe('portal', () => {
32
+ it('should portal tooltip to body by default', async () => {
33
+ const { container } = render(LineChart, {
34
+ props: baseProps,
35
+ });
36
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
37
+ await expect.element(tooltipCtx).toBeInTheDocument();
38
+ triggerTooltip(tooltipCtx);
39
+ await vi.waitFor(() => {
40
+ // Tooltip root should be portaled to body (outside the chart container)
41
+ const tooltipInBody = document.body.querySelector('.lc-tooltip-root');
42
+ expect(tooltipInBody).not.toBeNull();
43
+ // Should use fixed positioning when portaled
44
+ const style = getComputedStyle(tooltipInBody);
45
+ expect(style.position).toBe('fixed');
46
+ });
47
+ });
48
+ it('should render tooltip inline when portal is false', async () => {
49
+ const { container } = render(LineChart, {
50
+ props: {
51
+ ...baseProps,
52
+ props: { tooltip: { root: { portal: false } } },
53
+ },
54
+ });
55
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
56
+ await expect.element(tooltipCtx).toBeInTheDocument();
57
+ triggerTooltip(tooltipCtx);
58
+ await vi.waitFor(() => {
59
+ // Tooltip root should be inside the chart container
60
+ const tooltipInContainer = container.querySelector('.lc-tooltip-root');
61
+ expect(tooltipInContainer).not.toBeNull();
62
+ // Should use absolute positioning when not portaled
63
+ const style = getComputedStyle(tooltipInContainer);
64
+ expect(style.position).toBe('absolute');
65
+ });
66
+ });
67
+ it('should portal tooltip to a custom selector target', async () => {
68
+ // Create a custom portal target
69
+ const portalTarget = document.createElement('div');
70
+ portalTarget.className = 'custom-portal-target';
71
+ document.body.appendChild(portalTarget);
72
+ try {
73
+ const { container } = render(LineChart, {
74
+ props: {
75
+ ...baseProps,
76
+ props: { tooltip: { root: { portal: { target: '.custom-portal-target' } } } },
77
+ },
78
+ });
79
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
80
+ await expect.element(tooltipCtx).toBeInTheDocument();
81
+ triggerTooltip(tooltipCtx);
82
+ await vi.waitFor(() => {
83
+ const tooltipInTarget = portalTarget.querySelector('.lc-tooltip-root');
84
+ expect(tooltipInTarget).not.toBeNull();
85
+ });
86
+ }
87
+ finally {
88
+ portalTarget.remove();
89
+ }
90
+ });
91
+ it('should show tooltip content when portaled', async () => {
92
+ const { container } = render(LineChart, {
93
+ props: baseProps,
94
+ });
95
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
96
+ await expect.element(tooltipCtx).toBeInTheDocument();
97
+ triggerTooltip(tooltipCtx);
98
+ await vi.waitFor(() => {
99
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
100
+ expect(tooltipRoot).not.toBeNull();
101
+ // Should contain tooltip content (items from default tooltip)
102
+ const tooltipItems = tooltipRoot.querySelectorAll('.lc-tooltip-item-root');
103
+ expect(tooltipItems.length).toBeGreaterThan(0);
104
+ });
105
+ });
106
+ it('should have numeric top and left styles when portaled', async () => {
107
+ const { container } = render(LineChart, {
108
+ props: baseProps,
109
+ });
110
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
111
+ await expect.element(tooltipCtx).toBeInTheDocument();
112
+ triggerTooltip(tooltipCtx);
113
+ await vi.waitFor(() => {
114
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
115
+ expect(tooltipRoot).not.toBeNull();
116
+ // Should have valid pixel positions (not NaN or empty)
117
+ const top = tooltipRoot.style.top;
118
+ const left = tooltipRoot.style.left;
119
+ expect(top).toMatch(/^-?\d+(\.\d+)?px$/);
120
+ expect(left).toMatch(/^-?\d+(\.\d+)?px$/);
121
+ });
122
+ });
123
+ });
124
+ describe('contained="container" (default)', () => {
125
+ it('should flip tooltip left when pointer is near the right edge', async () => {
126
+ const { container } = render(LineChart, {
127
+ props: baseProps,
128
+ });
129
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
130
+ await expect.element(tooltipCtx).toBeInTheDocument();
131
+ const ctxRect = tooltipCtx.getBoundingClientRect();
132
+ // Trigger near the right edge of the container
133
+ triggerTooltip(tooltipCtx, {
134
+ clientX: ctxRect.right - 5,
135
+ clientY: ctxRect.top + ctxRect.height / 2,
136
+ });
137
+ await vi.waitFor(() => {
138
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
139
+ expect(tooltipRoot).not.toBeNull();
140
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
141
+ // Tooltip should be positioned to the LEFT of the pointer (flipped),
142
+ // so its left edge should be less than the pointer position
143
+ expect(tooltipLeft).toBeLessThan(ctxRect.right - 5);
144
+ // And specifically, the tooltip's right edge should not exceed the container
145
+ expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(ctxRect.right + 1);
146
+ });
147
+ });
148
+ it('should flip tooltip right when pointer is near the left edge', async () => {
149
+ const { container } = render(LineChart, {
150
+ props: {
151
+ ...baseProps,
152
+ props: { tooltip: { root: { anchor: 'top-right' } } },
153
+ },
154
+ });
155
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
156
+ await expect.element(tooltipCtx).toBeInTheDocument();
157
+ const ctxRect = tooltipCtx.getBoundingClientRect();
158
+ // Trigger near the left edge of the container
159
+ triggerTooltip(tooltipCtx, {
160
+ clientX: ctxRect.left + 5,
161
+ clientY: ctxRect.top + ctxRect.height / 2,
162
+ });
163
+ await vi.waitFor(() => {
164
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
165
+ expect(tooltipRoot).not.toBeNull();
166
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
167
+ // Tooltip should be positioned to the RIGHT of the pointer (flipped),
168
+ // so its left edge should be >= the container's left edge
169
+ expect(tooltipLeft).toBeGreaterThanOrEqual(ctxRect.left - 1);
170
+ });
171
+ });
172
+ it('should flip tooltip up when pointer is near the bottom edge', async () => {
173
+ const { container } = render(LineChart, {
174
+ props: baseProps,
175
+ });
176
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
177
+ await expect.element(tooltipCtx).toBeInTheDocument();
178
+ const ctxRect = tooltipCtx.getBoundingClientRect();
179
+ // Trigger near the bottom edge of the container
180
+ triggerTooltip(tooltipCtx, {
181
+ clientX: ctxRect.left + ctxRect.width / 2,
182
+ clientY: ctxRect.bottom - 5,
183
+ });
184
+ await vi.waitFor(() => {
185
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
186
+ expect(tooltipRoot).not.toBeNull();
187
+ const tooltipTop = parseFloat(tooltipRoot.style.top);
188
+ // Tooltip should be positioned ABOVE the pointer (flipped),
189
+ // so its bottom edge should not exceed the container
190
+ expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(ctxRect.bottom + 1);
191
+ });
192
+ });
193
+ it('should flip tooltip down when pointer is near the top edge', async () => {
194
+ const { container } = render(LineChart, {
195
+ props: {
196
+ ...baseProps,
197
+ props: { tooltip: { root: { anchor: 'bottom-left' } } },
198
+ },
199
+ });
200
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
201
+ await expect.element(tooltipCtx).toBeInTheDocument();
202
+ const ctxRect = tooltipCtx.getBoundingClientRect();
203
+ // Trigger near the top edge of the container
204
+ triggerTooltip(tooltipCtx, {
205
+ clientX: ctxRect.left + ctxRect.width / 2,
206
+ clientY: ctxRect.top + 5,
207
+ });
208
+ await vi.waitFor(() => {
209
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
210
+ expect(tooltipRoot).not.toBeNull();
211
+ const tooltipTop = parseFloat(tooltipRoot.style.top);
212
+ // Tooltip should be positioned BELOW the pointer (flipped),
213
+ // so its top edge should be >= the container's top
214
+ expect(tooltipTop).toBeGreaterThanOrEqual(ctxRect.top - 1);
215
+ });
216
+ });
217
+ });
218
+ describe('contained="window"', () => {
219
+ it('should flip tooltip left when it would overflow the right side of the viewport', async () => {
220
+ const { container } = render(LineChart, {
221
+ props: {
222
+ ...baseProps,
223
+ props: { tooltip: { root: { contained: 'window' } } },
224
+ },
225
+ });
226
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
227
+ await expect.element(tooltipCtx).toBeInTheDocument();
228
+ const ctxRect = tooltipCtx.getBoundingClientRect();
229
+ // Trigger near the right edge of the container
230
+ triggerTooltip(tooltipCtx, {
231
+ clientX: ctxRect.right - 5,
232
+ clientY: ctxRect.top + ctxRect.height / 2,
233
+ });
234
+ await vi.waitFor(() => {
235
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
236
+ expect(tooltipRoot).not.toBeNull();
237
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
238
+ // Tooltip should not overflow the right side of the viewport
239
+ expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(window.innerWidth + 1);
240
+ });
241
+ });
242
+ it('should flip tooltip up when it would overflow the bottom of the viewport', async () => {
243
+ const { container } = render(LineChart, {
244
+ props: {
245
+ ...baseProps,
246
+ props: { tooltip: { root: { contained: 'window' } } },
247
+ },
248
+ });
249
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
250
+ await expect.element(tooltipCtx).toBeInTheDocument();
251
+ const ctxRect = tooltipCtx.getBoundingClientRect();
252
+ // Trigger near the bottom edge of the container
253
+ triggerTooltip(tooltipCtx, {
254
+ clientX: ctxRect.left + ctxRect.width / 2,
255
+ clientY: ctxRect.bottom - 5,
256
+ });
257
+ await vi.waitFor(() => {
258
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
259
+ expect(tooltipRoot).not.toBeNull();
260
+ const tooltipTop = parseFloat(tooltipRoot.style.top);
261
+ // Tooltip should not overflow the bottom of the viewport
262
+ expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(window.innerHeight + 1);
263
+ });
264
+ });
265
+ });
266
+ describe('contained={false}', () => {
267
+ it('should not constrain tooltip position', async () => {
268
+ const { container } = render(LineChart, {
269
+ props: {
270
+ ...baseProps,
271
+ props: { tooltip: { root: { contained: false } } },
272
+ },
273
+ });
274
+ const tooltipCtx = container.querySelector('.lc-tooltip-context');
275
+ await expect.element(tooltipCtx).toBeInTheDocument();
276
+ const ctxRect = tooltipCtx.getBoundingClientRect();
277
+ // Trigger near the right edge — tooltip should NOT flip
278
+ triggerTooltip(tooltipCtx, {
279
+ clientX: ctxRect.right - 5,
280
+ clientY: ctxRect.top + ctxRect.height / 2,
281
+ });
282
+ await vi.waitFor(() => {
283
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
284
+ expect(tooltipRoot).not.toBeNull();
285
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
286
+ // With default anchor='top-left', tooltip is placed to the right of pointer.
287
+ // Since contained=false, the tooltip left should be near/past the pointer x
288
+ // (i.e., it doesn't flip like contained="container" would)
289
+ const pointerViewportX = ctxRect.right - 5;
290
+ expect(tooltipLeft).toBeGreaterThanOrEqual(pointerViewportX - 1);
291
+ });
292
+ });
293
+ });
294
+ });
package/package.json CHANGED
@@ -5,12 +5,12 @@
5
5
  "license": "MIT",
6
6
  "repository": "techniq/layerchart",
7
7
  "homepage": "https://layerchart.com",
8
- "version": "2.0.0-next.53",
8
+ "version": "2.0.0-next.54",
9
9
  "devDependencies": {
10
10
  "@changesets/cli": "^2.30.0",
11
11
  "@napi-rs/canvas": "^0.1.97",
12
12
  "@sveltejs/adapter-auto": "^7.0.1",
13
- "@sveltejs/kit": "^2.55.0",
13
+ "@sveltejs/kit": "^2.57.1",
14
14
  "@sveltejs/package": "^2.5.7",
15
15
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
16
16
  "@svitejs/changesets-changelog-github-compact": "^1.2.0",
@@ -33,19 +33,19 @@
33
33
  "@types/d3-scale-chromatic": "^3.1.0",
34
34
  "@types/d3-shape": "^3.1.8",
35
35
  "@types/d3-time": "^3.0.4",
36
- "@vitest/browser": "^4.1.0",
37
- "@vitest/browser-playwright": "^4.1.0",
38
- "@vitest/ui": "^4.1.0",
39
- "playwright": "^1.58.2",
40
- "prettier": "^3.8.1",
36
+ "@vitest/browser": "^4.1.4",
37
+ "@vitest/browser-playwright": "^4.1.4",
38
+ "@vitest/ui": "^4.1.4",
39
+ "playwright": "^1.59.1",
40
+ "prettier": "^3.8.2",
41
41
  "prettier-plugin-svelte": "^3.5.1",
42
- "svelte": "5.54.1",
43
- "svelte-check": "^4.4.5",
44
- "svelte2tsx": "^0.7.52",
42
+ "svelte": "5.55.3",
43
+ "svelte-check": "^4.4.6",
44
+ "svelte2tsx": "^0.7.53",
45
45
  "tslib": "^2.8.1",
46
- "typescript": "^5.9.3",
47
- "vite": "^8.0.1",
48
- "vitest": "^4.1.0",
46
+ "typescript": "^6.0.2",
47
+ "vite": "^8.0.8",
48
+ "vitest": "^4.1.4",
49
49
  "vitest-browser-svelte": "^2.1.0"
50
50
  },
51
51
  "type": "module",