@juspay/svelte-ui-components 2.85.0 → 2.87.0

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.
@@ -8,13 +8,27 @@
8
8
  import Axis from '../_chart/Axis.svelte';
9
9
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
10
10
  import Legend from '../_chart/Legend.svelte';
11
- import { createBandScale, createLinearScale, niceLinearDomain } from '../_chart/scales';
12
- import { computeChartDimensions } from '../_chart/geometry';
11
+ import {
12
+ createBandScale,
13
+ createLinearScale,
14
+ niceLinearDomain,
15
+ computeLinearTicks
16
+ } from '../_chart/scales';
17
+ import { computeChartDimensions, computeAutoLayout } from '../_chart/geometry';
13
18
  import { getColor } from '../_chart/colors';
14
19
  import { formatNumber } from '../_chart/format';
15
20
  import { roundedRectPath, linePath } from '../_chart/paths';
16
- import type { LegendItem, TooltipData, LinearScale, BandScale, Point } from '../_chart/types';
21
+ import type {
22
+ LegendItem,
23
+ TooltipData,
24
+ LinearScale,
25
+ BandScale,
26
+ Point,
27
+ TooltipAnchor
28
+ } from '../_chart/types';
17
29
  import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
30
+ import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
31
+ import { SvelteSet } from 'svelte/reactivity';
18
32
 
19
33
  // ── Per-instance uid for SVG <defs> ids ────────────────────────
20
34
  const uid = Math.random().toString(36).slice(2, 9);
@@ -36,6 +50,8 @@
36
50
  minBarHeight = 2,
37
51
  margin,
38
52
  tooltipPortal = false,
53
+ interactiveLegend = false,
54
+ hideLegendBelow = 360,
39
55
  tooltipSnippet,
40
56
  onbarclick,
41
57
  testId,
@@ -45,43 +61,35 @@
45
61
  // ── State ──────────────────────────────────────────────────────
46
62
 
47
63
  let containerEl: HTMLDivElement | null = $state(null);
64
+ let plotEl: HTMLDivElement | null = $state(null);
48
65
  let chartWidth = $state(0);
49
66
  let chartHeight = $state(0);
50
67
  let hoveredCategoryIndex = $state<number | null>(null);
51
68
  let mouseX = $state(0);
52
69
  let mouseY = $state(0);
53
- let mouseClientX = $state(0);
54
- let mouseClientY = $state(0);
70
+ const hiddenSeries = new SvelteSet<number>();
71
+
72
+ function toggleSeries(index: number): void {
73
+ if (hiddenSeries.has(index)) {
74
+ hiddenSeries.delete(index);
75
+ } else {
76
+ hiddenSeries.add(index);
77
+ }
78
+ }
55
79
 
56
80
  // ── Formatters ─────────────────────────────────────────────────
57
81
 
58
82
  const leftFormat = $derived(leftAxis.valueFormat ?? formatNumber);
59
83
  const rightFormat = $derived(rightAxis.valueFormat ?? formatNumber);
60
84
 
61
- // ── Layout — wider right margin to accommodate right-axis labels ─
62
-
63
- const dims = $derived(
64
- computeChartDimensions(chartWidth, chartHeight, {
65
- top: 24,
66
- right: 56,
67
- bottom: 40,
68
- left: 56,
69
- ...margin
70
- })
71
- );
72
-
73
- // ── Scales ─────────────────────────────────────────────────────
74
-
75
- const catScale: BandScale = $derived(
76
- createBandScale(categories, [0, dims.innerWidth], barPadding)
77
- );
78
-
79
85
  /**
80
86
  * Computes the [min, max] domain for all series mapped to the given axis index,
81
87
  * then applies nice rounding. Returns [0, 1] for empty series.
82
88
  */
83
89
  const axisDomain = (axisIndex: 0 | 1): [number, number] => {
84
- const axisSeries = series.filter((s) => s.yAxisIndex === axisIndex);
90
+ const axisSeries = series.filter(
91
+ (s, si) => s.yAxisIndex === axisIndex && !hiddenSeries.has(si)
92
+ );
85
93
  if (axisSeries.length === 0) {
86
94
  return [0, 1];
87
95
  }
@@ -95,6 +103,39 @@
95
103
  const leftDomain: [number, number] = $derived(axisDomain(0));
96
104
  const rightDomain: [number, number] = $derived(axisDomain(1));
97
105
 
106
+ // ── Layout — auto-sized margins from measured tick-label widths ─
107
+
108
+ const yTickCount = $derived(Math.max(2, Math.min(6, Math.floor(chartHeight / 70))));
109
+
110
+ const layout = $derived.by(() =>
111
+ computeAutoLayout({
112
+ width: chartWidth,
113
+ height: chartHeight,
114
+ yTickLabels: computeLinearTicks(leftDomain, yTickCount).map((t) => leftFormat(t)),
115
+ y2TickLabels: computeLinearTicks(rightDomain, yTickCount).map((t) => rightFormat(t)),
116
+ xTickLabels: categories,
117
+ hasYAxisLabel: Boolean(leftAxis.title),
118
+ hasY2AxisLabel: Boolean(rightAxis.title),
119
+ base: { top: 24, right: 28, bottom: 40, left: 28 }
120
+ })
121
+ );
122
+
123
+ // The margin prop stays an explicit per-side override on top of auto-sizing.
124
+ const dims = $derived(
125
+ computeChartDimensions(chartWidth, chartHeight, {
126
+ top: margin?.top ?? layout.margin.top,
127
+ right: margin?.right ?? layout.margin.right,
128
+ bottom: margin?.bottom ?? layout.margin.bottom,
129
+ left: margin?.left ?? layout.margin.left
130
+ })
131
+ );
132
+
133
+ // ── Scales ─────────────────────────────────────────────────────
134
+
135
+ const catScale: BandScale = $derived(
136
+ createBandScale(categories, [0, dims.innerWidth], barPadding)
137
+ );
138
+
98
139
  const leftScale: LinearScale = $derived(createLinearScale(leftDomain, [dims.innerHeight, 0]));
99
140
  const rightScale: LinearScale = $derived(createLinearScale(rightDomain, [dims.innerHeight, 0]));
100
141
 
@@ -113,13 +154,23 @@
113
154
  const leftAxisSeries: AxisSeriesEntry[] = $derived(
114
155
  series
115
156
  .map((s, si) => ({ series: s, seriesIndex: si }))
116
- .filter((entry) => entry.series.yAxisIndex === 0 && entry.series.type !== 'line')
157
+ .filter(
158
+ (entry) =>
159
+ entry.series.yAxisIndex === 0 &&
160
+ entry.series.type !== 'line' &&
161
+ !hiddenSeries.has(entry.seriesIndex)
162
+ )
117
163
  );
118
164
 
119
165
  const rightAxisSeries: AxisSeriesEntry[] = $derived(
120
166
  series
121
167
  .map((s, si) => ({ series: s, seriesIndex: si }))
122
- .filter((entry) => entry.series.yAxisIndex === 1 && entry.series.type !== 'line')
168
+ .filter(
169
+ (entry) =>
170
+ entry.series.yAxisIndex === 1 &&
171
+ entry.series.type !== 'line' &&
172
+ !hiddenSeries.has(entry.seriesIndex)
173
+ )
123
174
  );
124
175
 
125
176
  const columnSeriesEntries: AxisSeriesEntry[] = $derived([...leftAxisSeries, ...rightAxisSeries]);
@@ -168,7 +219,10 @@
168
219
  const barY = value >= 0 ? valueY : zeroY;
169
220
  const barHeight = Math.max(minBarHeight, Math.abs(valueY - zeroY));
170
221
 
171
- const path = roundedRectPath(barX, barY, barW, barHeight, barRadius, barRadius, 0, 0);
222
+ const path =
223
+ value >= 0
224
+ ? roundedRectPath(barX, barY, barW, barHeight, barRadius, barRadius, 0, 0)
225
+ : roundedRectPath(barX, barY, barW, barHeight, 0, 0, barRadius, barRadius);
172
226
 
173
227
  result.push({
174
228
  x: barX,
@@ -201,7 +255,7 @@
201
255
  }
202
256
  return series
203
257
  .map((s, si) => ({ series: s, seriesIndex: si }))
204
- .filter((entry) => entry.series.type === 'line')
258
+ .filter((entry) => entry.series.type === 'line' && !hiddenSeries.has(entry.seriesIndex))
205
259
  .map((entry) => {
206
260
  const scale = entry.series.yAxisIndex === 0 ? leftScale : rightScale;
207
261
  const color = resolvedColor(entry.series, entry.seriesIndex);
@@ -223,7 +277,11 @@
223
277
  // ── Legend items ───────────────────────────────────────────────
224
278
 
225
279
  const legendItems: LegendItem[] = $derived(
226
- series.map((s, si) => ({ label: s.name, color: resolvedColor(s, si) }))
280
+ series.map((s, si) => ({
281
+ label: s.name,
282
+ color: resolvedColor(s, si),
283
+ hidden: hiddenSeries.has(si)
284
+ }))
227
285
  );
228
286
 
229
287
  // ── Tooltip ────────────────────────────────────────────────────
@@ -246,17 +304,47 @@
246
304
  }
247
305
  const catIdx = hoveredCategoryIndex;
248
306
  const category = categories[catIdx];
249
- const items = series.map((s, si) => {
250
- const fmt = s.yAxisIndex === 0 ? leftFormat : rightFormat;
251
- return {
252
- label: s.name,
253
- value: fmt(s.data[catIdx] ?? 0),
254
- color: resolvedColor(s, si)
255
- };
256
- });
307
+ const items = series
308
+ .map((s, si) => ({ s, si }))
309
+ .filter(({ si }) => !hiddenSeries.has(si))
310
+ .map(({ s, si }) => {
311
+ const fmt = s.yAxisIndex === 0 ? leftFormat : rightFormat;
312
+ return {
313
+ label: s.name,
314
+ value: fmt(s.data[catIdx] ?? 0),
315
+ color: resolvedColor(s, si)
316
+ };
317
+ });
257
318
  return { title: category, items };
258
319
  });
259
320
 
321
+ // Category-anchored tooltip position: the topmost bar-top or line-dot y
322
+ // across all visible series at the hovered category, matching the
323
+ // Highcharts shared-tooltip anchor convention.
324
+ const anchor = $derived.by<TooltipAnchor | null>(() => {
325
+ if (hoveredCategoryIndex === null) {
326
+ return null;
327
+ }
328
+ const catIdx = hoveredCategoryIndex;
329
+ const ys: number[] = [];
330
+ for (const bar of bars) {
331
+ if (bar.categoryIndex === catIdx) {
332
+ ys.push(bar.y);
333
+ }
334
+ }
335
+ for (const ls of lineSeriesData) {
336
+ const p = ls.points[catIdx];
337
+ if (p) {
338
+ ys.push(p.y);
339
+ }
340
+ }
341
+ return {
342
+ x: dims.margin.left + catScale(categories[catIdx]) + catScale.bandwidth / 2,
343
+ y: dims.margin.top + (ys.length > 0 ? Math.min(...ys) : 0),
344
+ side: 'top'
345
+ };
346
+ });
347
+
260
348
  // ── Axis tick formatters ───────────────────────────────────────
261
349
 
262
350
  const leftTickFormat = (tick: number | string): string =>
@@ -283,36 +371,34 @@
283
371
  }))
284
372
  );
285
373
 
374
+ function categoryAriaLabel(catIdx: number): string {
375
+ const parts = series
376
+ .map((s, si) => ({ s, si }))
377
+ .filter(({ si }) => !hiddenSeries.has(si))
378
+ .map(
379
+ ({ s }) =>
380
+ `${s.name} ${(s.yAxisIndex === 0 ? leftFormat : rightFormat)(s.data[catIdx] ?? 0)}`
381
+ );
382
+ return `${categories[catIdx]}: ${parts.join(', ')}`;
383
+ }
384
+
286
385
  // ── Interactions ───────────────────────────────────────────────
287
386
 
288
- const trackMouse = (event: MouseEvent) => {
289
- if (containerEl === null) {
290
- return;
387
+ const trackMouse = (event: PointerEvent) => {
388
+ const position = pointerPositionIn(plotEl, event);
389
+ if (position !== null) {
390
+ mouseX = position.x;
391
+ mouseY = position.y;
291
392
  }
292
- const rect = containerEl.getBoundingClientRect();
293
- mouseX = event.clientX - rect.left;
294
- mouseY = event.clientY - rect.top;
295
- mouseClientX = event.clientX;
296
- mouseClientY = event.clientY;
297
393
  };
298
394
 
299
- /**
300
- * Svelte action: relocates the tooltip layer to `document.body` so a `position:fixed`
301
- * tooltip is never clipped by an `overflow`/scroll ancestor. Used only when
302
- * `tooltipPortal` is set; `use:` actions never run during SSR.
303
- */
304
- const portalToBody = (node: HTMLElement) => {
305
- document.body.appendChild(node);
306
- return {
307
- destroy: () => {
308
- node.remove();
309
- }
310
- };
395
+ const handleCategoryEnter = (event: PointerEvent, catIdx: number) => {
396
+ hoveredCategoryIndex = catIdx;
397
+ trackMouse(event);
311
398
  };
312
399
 
313
- const handleCategoryEnter = (event: MouseEvent, catIdx: number) => {
400
+ const handleFocus = (catIdx: number) => {
314
401
  hoveredCategoryIndex = catIdx;
315
- trackMouse(event);
316
402
  };
317
403
 
318
404
  const handleCategoryLeave = () => {
@@ -326,6 +412,24 @@
326
412
  onbarclick({ categoryIndex: catIdx, context: buildTooltipContext(catIdx) });
327
413
  };
328
414
 
415
+ const handleKeydown = (event: KeyboardEvent, catIdx: number) => {
416
+ if (event.key === 'Enter' || event.key === ' ') {
417
+ event.preventDefault();
418
+ handleCategoryClick(catIdx);
419
+ }
420
+ };
421
+
422
+ // Touch taps have no pointerleave: dismiss when a pointerdown lands outside.
423
+ // eslint-disable-next-line no-restricted-syntax
424
+ $effect(() => {
425
+ if (hoveredCategoryIndex === null) {
426
+ return;
427
+ }
428
+ return dismissOnOutsidePointerDown(containerEl, () => {
429
+ hoveredCategoryIndex = null;
430
+ });
431
+ });
432
+
329
433
  // ── Empty state ────────────────────────────────────────────────
330
434
 
331
435
  const isEmpty = $derived(
@@ -339,168 +443,184 @@
339
443
  data-pw={typeof testId === 'string' ? testId : null}
340
444
  >
341
445
  {#if !isEmpty}
342
- {#if showLegend}
343
- <Legend items={legendItems} position="top" />
446
+ {#if showLegend && (chartWidth === 0 || hideLegendBelow === 0 || chartWidth >= hideLegendBelow)}
447
+ {#if interactiveLegend}
448
+ <Legend items={legendItems} position="top" onToggle={toggleSeries} />
449
+ {:else}
450
+ <Legend items={legendItems} position="top" />
451
+ {/if}
344
452
  {/if}
345
453
 
346
- <ChartContainer
347
- bind:width={chartWidth}
348
- bind:height={chartHeight}
349
- {aspectRatio}
350
- {maxHeight}
351
- {minHeight}
352
- >
353
- <g transform="translate({dims.margin.left}, {dims.margin.top})">
354
- <!-- Left Y-axis (index 0) -->
355
- <Axis
356
- orientation="left"
357
- scale={leftScale}
358
- {showGridlines}
359
- gridlineLength={dims.innerWidth}
360
- tickFormat={leftTickFormat}
361
- classes={leftAxis.color ? `axis-left-colored` : ''}
362
- />
363
-
364
- <!-- Right Y-axis (index 1) — positioned at innerWidth -->
365
- <g transform="translate({dims.innerWidth}, 0)">
454
+ <div class="chart-plot" bind:this={plotEl}>
455
+ <ChartContainer
456
+ bind:width={chartWidth}
457
+ bind:height={chartHeight}
458
+ {aspectRatio}
459
+ {maxHeight}
460
+ {minHeight}
461
+ >
462
+ <g transform="translate({dims.margin.left}, {dims.margin.top})">
463
+ <!-- Left Y-axis (index 0) -->
366
464
  <Axis
367
- orientation="right"
368
- scale={rightScale}
369
- showGridlines={false}
370
- tickFormat={rightTickFormat}
371
- classes={rightAxis.color ? `axis-right-colored` : ''}
465
+ orientation="left"
466
+ scale={leftScale}
467
+ tickCount={yTickCount}
468
+ {showGridlines}
469
+ gridlineLength={dims.innerWidth}
470
+ tickFormat={leftTickFormat}
471
+ classes={leftAxis.color ? `axis-left-colored` : ''}
372
472
  />
373
- </g>
374
473
 
375
- <!-- X-axis at bottom -->
376
- <g transform="translate(0, {dims.innerHeight})">
377
- <Axis orientation="bottom" scale={catScale} showGridlines={false} />
378
- </g>
379
-
380
- <!-- Axis titles -->
381
- {#if leftAxis.title}
382
- <text
383
- class="axis-title axis-title-left"
384
- transform="translate({-dims.margin.left + 12}, {dims.innerHeight / 2}) rotate(-90)"
385
- text-anchor="middle"
386
- style={leftAxis.color ? `fill: ${leftAxis.color}` : ''}
387
- >
388
- {leftAxis.title}
389
- </text>
390
- {/if}
391
- {#if rightAxis.title}
392
- <text
393
- class="axis-title axis-title-right"
394
- transform="translate({dims.innerWidth + dims.margin.right - 12}, {dims.innerHeight /
395
- 2}) rotate(90)"
396
- text-anchor="middle"
397
- style={rightAxis.color ? `fill: ${rightAxis.color}` : ''}
398
- >
399
- {rightAxis.title}
400
- </text>
401
- {/if}
402
-
403
- <!-- Column/bar shapes -->
404
- {#each bars as bar, barIdx (barIdx)}
405
- <path
406
- class="bar-shape"
407
- class:bar-hovered={hoveredCategoryIndex === bar.categoryIndex}
408
- class:bar-dimmed={hoveredCategoryIndex !== null &&
409
- hoveredCategoryIndex !== bar.categoryIndex}
410
- d={bar.path}
411
- fill={bar.color}
412
- aria-label="{categories[bar.categoryIndex]}: {bar.value}"
413
- role="img"
414
- />
415
- {/each}
416
-
417
- <!-- Line series drawn above columns -->
418
- {#each lineSeriesData as ls, lsi (lsi)}
419
- {#if ls.points.length >= 2}
420
- <path
421
- class="line-series"
422
- d={linePath(ls.points, 'monotone')}
423
- stroke={ls.color}
424
- fill="none"
474
+ <!-- Right Y-axis (index 1) — positioned at innerWidth -->
475
+ <g transform="translate({dims.innerWidth}, 0)">
476
+ <Axis
477
+ orientation="right"
478
+ scale={rightScale}
479
+ tickCount={yTickCount}
480
+ showGridlines={false}
481
+ tickFormat={rightTickFormat}
482
+ classes={rightAxis.color ? `axis-right-colored` : ''}
483
+ />
484
+ </g>
485
+
486
+ <!-- X-axis at bottom -->
487
+ <g transform="translate(0, {dims.innerHeight})">
488
+ <Axis
489
+ orientation="bottom"
490
+ scale={catScale}
491
+ rotateTicks={layout.xRotate}
492
+ tickEvery={layout.xEvery}
493
+ showGridlines={false}
425
494
  />
495
+ </g>
496
+
497
+ <!-- Axis titles -->
498
+ {#if leftAxis.title}
499
+ <text
500
+ class="axis-title axis-title-left"
501
+ transform="translate({-dims.margin.left + 12}, {dims.innerHeight / 2}) rotate(-90)"
502
+ text-anchor="middle"
503
+ style={leftAxis.color ? `fill: ${leftAxis.color}` : ''}
504
+ >
505
+ {leftAxis.title}
506
+ </text>
507
+ {/if}
508
+ {#if rightAxis.title}
509
+ <text
510
+ class="axis-title axis-title-right"
511
+ transform="translate({dims.innerWidth + dims.margin.right - 12}, {dims.innerHeight /
512
+ 2}) rotate(90)"
513
+ text-anchor="middle"
514
+ style={rightAxis.color ? `fill: ${rightAxis.color}` : ''}
515
+ >
516
+ {rightAxis.title}
517
+ </text>
426
518
  {/if}
427
- <!-- Line dots -->
428
- {#each ls.points as pt, ptIdx (ptIdx)}
429
- <circle
430
- class="line-dot"
431
- class:dot-hovered={hoveredCategoryIndex === ptIdx}
432
- class:dot-dimmed={hoveredCategoryIndex !== null && hoveredCategoryIndex !== ptIdx}
433
- cx={pt.x}
434
- cy={pt.y}
435
- r={hoveredCategoryIndex === ptIdx ? 6 : 4}
436
- fill={ls.color}
437
- stroke="var(--dual-axis-dot-stroke, #fff)"
438
- stroke-width="var(--dual-axis-dot-stroke-width, 1.5)"
439
- aria-label="{categories[ptIdx]}: {series[ls.seriesIndex]?.data[ptIdx] ?? 0}"
519
+
520
+ <!-- Column/bar shapes -->
521
+ {#each bars as bar, barIdx (barIdx)}
522
+ <path
523
+ class="bar-shape"
524
+ class:bar-hovered={hoveredCategoryIndex === bar.categoryIndex}
525
+ class:bar-dimmed={hoveredCategoryIndex !== null &&
526
+ hoveredCategoryIndex !== bar.categoryIndex}
527
+ d={bar.path}
528
+ fill={bar.color}
529
+ aria-label="{categories[bar.categoryIndex]}: {bar.value}"
440
530
  role="img"
441
531
  />
442
532
  {/each}
443
- {/each}
444
-
445
- <!-- Invisible per-category hover targets (full inner height) -->
446
- {#each hoverRects as hr (hr.catIdx)}
447
- <!-- svelte-ignore a11y_no_static_element_interactions -->
448
- <!-- svelte-ignore a11y_click_events_have_key_events -->
449
- <rect
450
- class="hover-target"
451
- x={hr.x}
452
- y={0}
453
- width={hr.width}
454
- height={dims.innerHeight}
455
- fill="transparent"
456
- data-category-index={hr.catIdx}
457
- onmouseenter={(event) => handleCategoryEnter(event, hr.catIdx)}
458
- onmousemove={trackMouse}
459
- onmouseleave={handleCategoryLeave}
460
- onclick={() => handleCategoryClick(hr.catIdx)}
461
- />
462
- {/each}
463
-
464
- <!-- Hover vertical guideline -->
465
- {#if hoveredCategoryIndex !== null}
466
- {@const guideX = catScale(categories[hoveredCategoryIndex]) + catScale.bandwidth / 2}
467
- <line class="hover-guideline" x1={guideX} x2={guideX} y1={0} y2={dims.innerHeight} />
468
- {/if}
469
- </g>
470
-
471
- <!-- SVG defs id namespace anchor (keeps uid live in reactive graph) -->
472
- <defs>
473
- <marker id="{uid}-anchor" />
474
- </defs>
475
- </ChartContainer>
476
-
477
- <!-- Tooltip -->
478
- {#if hoveredCategoryIndex !== null}
479
- {#if tooltipPortal}
480
- <!-- Portaled to <body> with fixed viewport coords so the tooltip is never
481
- clipped by an overflow/scroll ancestor (e.g. a scrollable report sheet).
482
- The inner elements keep their own +12/-12 offset relative to this layer. -->
483
- <div
484
- class="chart-tooltip-portal"
485
- style="left: {mouseClientX}px; top: {mouseClientY}px;"
486
- use:portalToBody
533
+
534
+ <!-- Line series drawn above columns -->
535
+ {#each lineSeriesData as ls, lsi (lsi)}
536
+ {#if ls.points.length >= 2}
537
+ <path
538
+ class="line-series"
539
+ d={linePath(ls.points, 'monotone')}
540
+ stroke={ls.color}
541
+ fill="none"
542
+ />
543
+ {/if}
544
+ <!-- Line dots -->
545
+ {#each ls.points as pt, ptIdx (ptIdx)}
546
+ <circle
547
+ class="line-dot"
548
+ class:dot-hovered={hoveredCategoryIndex === ptIdx}
549
+ class:dot-dimmed={hoveredCategoryIndex !== null && hoveredCategoryIndex !== ptIdx}
550
+ cx={pt.x}
551
+ cy={pt.y}
552
+ r={hoveredCategoryIndex === ptIdx ? 6 : 4}
553
+ fill={ls.color}
554
+ style="stroke: var(--dual-axis-dot-stroke, light-dark(#fff, #111827)); stroke-width: var(--dual-axis-dot-stroke-width, 1.5);"
555
+ aria-label="{categories[ptIdx]}: {series[ls.seriesIndex]?.data[ptIdx] ?? 0}"
556
+ role="img"
557
+ />
558
+ {/each}
559
+ {/each}
560
+
561
+ <!-- Invisible per-category hover targets (full inner height) -->
562
+ {#each hoverRects as hr (hr.catIdx)}
563
+ <rect
564
+ class="hover-target"
565
+ x={hr.x}
566
+ y={0}
567
+ width={hr.width}
568
+ height={dims.innerHeight}
569
+ fill="transparent"
570
+ data-category-index={hr.catIdx}
571
+ tabindex="0"
572
+ role="button"
573
+ aria-label={categoryAriaLabel(hr.catIdx)}
574
+ onpointerenter={(event) => handleCategoryEnter(event, hr.catIdx)}
575
+ onpointermove={trackMouse}
576
+ onpointerleave={handleCategoryLeave}
577
+ onfocus={() => handleFocus(hr.catIdx)}
578
+ onblur={handleCategoryLeave}
579
+ onkeydown={(event) => handleKeydown(event, hr.catIdx)}
580
+ onclick={() => handleCategoryClick(hr.catIdx)}
581
+ />
582
+ {/each}
583
+
584
+ <!-- Hover vertical guideline -->
585
+ {#if hoveredCategoryIndex !== null}
586
+ {@const guideX = catScale(categories[hoveredCategoryIndex]) + catScale.bandwidth / 2}
587
+ <line class="hover-guideline" x1={guideX} x2={guideX} y1={0} y2={dims.innerHeight} />
588
+ {/if}
589
+ </g>
590
+
591
+ <!-- SVG defs id namespace anchor (keeps uid live in reactive graph) -->
592
+ <defs>
593
+ <marker id="{uid}-anchor" />
594
+ </defs>
595
+ </ChartContainer>
596
+
597
+ {#if typeof tooltipSnippet === 'function'}
598
+ <ChartTooltip
599
+ data={tooltipData}
600
+ {mouseX}
601
+ {mouseY}
602
+ {anchor}
603
+ portal={tooltipPortal}
604
+ originEl={plotEl}
605
+ unstyled
487
606
  >
488
- {#if typeof tooltipSnippet === 'function'}
489
- <div class="chart-tooltip-slot" style="left: 12px; top: -12px;">
607
+ {#snippet content()}
608
+ {#if hoveredCategoryIndex !== null}
490
609
  {@render tooltipSnippet(buildTooltipContext(hoveredCategoryIndex))}
491
- </div>
492
- {:else}
493
- <ChartTooltip data={tooltipData} mouseX={0} mouseY={0} />
494
- {/if}
495
- </div>
496
- {:else if typeof tooltipSnippet === 'function'}
497
- <div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
498
- {@render tooltipSnippet(buildTooltipContext(hoveredCategoryIndex))}
499
- </div>
610
+ {/if}
611
+ {/snippet}
612
+ </ChartTooltip>
500
613
  {:else}
501
- <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
614
+ <ChartTooltip
615
+ data={tooltipData}
616
+ {mouseX}
617
+ {mouseY}
618
+ {anchor}
619
+ portal={tooltipPortal}
620
+ originEl={plotEl}
621
+ />
502
622
  {/if}
503
- {/if}
623
+ </div>
504
624
  {:else}
505
625
  <div class="chart-empty">No data available.</div>
506
626
  {/if}
@@ -512,6 +632,10 @@
512
632
  position: relative;
513
633
  }
514
634
 
635
+ .chart-plot {
636
+ position: relative;
637
+ }
638
+
515
639
  .bar-shape {
516
640
  transition: opacity var(--chart-transition-duration, 0.2s) ease;
517
641
  cursor: pointer;
@@ -547,8 +671,13 @@
547
671
  cursor: pointer;
548
672
  }
549
673
 
674
+ .hover-target:focus-visible {
675
+ outline: 2px solid var(--chart-axis-label-color, light-dark(#333, #e5e7eb));
676
+ outline-offset: -2px;
677
+ }
678
+
550
679
  .hover-guideline {
551
- stroke: var(--dual-axis-guideline-color, #aaa);
680
+ stroke: var(--dual-axis-guideline-color, light-dark(#aaa, #4b5563));
552
681
  stroke-width: var(--dual-axis-guideline-width, 1);
553
682
  stroke-dasharray: var(--dual-axis-guideline-dash, 4 3);
554
683
  pointer-events: none;
@@ -556,27 +685,15 @@
556
685
  }
557
686
 
558
687
  .axis-title {
559
- fill: var(--chart-axis-label-color, #333);
688
+ fill: var(--chart-axis-label-color, light-dark(#333, #e5e7eb));
560
689
  font-size: var(--chart-axis-label-font-size, 11px);
561
690
  font-family: var(--chart-font-family, inherit);
562
691
  font-weight: 500;
563
692
  }
564
693
 
565
- .chart-tooltip-slot {
566
- position: absolute;
567
- z-index: 10;
568
- pointer-events: none;
569
- }
570
-
571
- .chart-tooltip-portal {
572
- position: fixed;
573
- z-index: var(--chart-tooltip-z-index, 10);
574
- pointer-events: none;
575
- }
576
-
577
694
  .chart-empty {
578
695
  padding: var(--chart-empty-padding, 32px 24px);
579
- color: var(--chart-empty-color, #9ca3af);
696
+ color: var(--chart-empty-color, light-dark(#9ca3af, #6b7280));
580
697
  text-align: center;
581
698
  }
582
699
  </style>