@bundle-stats/ui 4.23.0-beta.1 → 4.23.0-beta.3

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 (91) hide show
  1. package/lib/app/app.module.css +1 -1
  2. package/lib/chart-colors.json +58 -44
  3. package/lib/components/entry-info/entry-info.js +1 -1
  4. package/lib/components/entry-info/entry-info.js.map +1 -1
  5. package/lib/components/entry-info/entry-info.module.css +1 -1
  6. package/lib/components/metric-change-legend/metric-change-legend.js +2 -2
  7. package/lib/components/metric-change-legend/metric-change-legend.js.map +1 -1
  8. package/lib/components/metric-change-legend/metric-change-legend.module.css +11 -0
  9. package/lib/components/metric-run-info/metric-run-info.js +1 -9
  10. package/lib/components/metric-run-info/metric-run-info.js.map +1 -1
  11. package/lib/components/metrics-table-options/metrics-table-options.js +3 -4
  12. package/lib/components/metrics-table-options/metrics-table-options.js.map +1 -1
  13. package/lib/components/metrics-treemap/metrics-treemap.constants.js +3 -1
  14. package/lib/components/metrics-treemap/metrics-treemap.constants.js.map +1 -1
  15. package/lib/components/metrics-treemap/metrics-treemap.js +68 -57
  16. package/lib/components/metrics-treemap/metrics-treemap.js.map +1 -1
  17. package/lib/components/metrics-treemap/metrics-treemap.module.css +9 -16
  18. package/lib/components/metrics-treemap/metrics-treemap.utils.js +72 -16
  19. package/lib/components/metrics-treemap/metrics-treemap.utils.js.map +1 -1
  20. package/lib/ui/button/button.js +2 -2
  21. package/lib/ui/button/button.js.map +1 -1
  22. package/lib/ui/dropdown/dropdown.js +11 -3
  23. package/lib/ui/dropdown/dropdown.js.map +1 -1
  24. package/lib/ui/dropdown/dropdown.module.css +40 -6
  25. package/lib/ui/filters/filters.js +22 -11
  26. package/lib/ui/filters/filters.js.map +1 -1
  27. package/lib/ui/filters/filters.module.css +0 -14
  28. package/lib/ui/hover-card/hover-card.js +7 -1
  29. package/lib/ui/hover-card/hover-card.js.map +1 -1
  30. package/lib/ui/tooltip/tooltip.js +6 -1
  31. package/lib/ui/tooltip/tooltip.js.map +1 -1
  32. package/lib-esm/app/app.module.css +1 -1
  33. package/lib-esm/chart-colors.json +58 -44
  34. package/lib-esm/components/entry-info/entry-info.js +1 -1
  35. package/lib-esm/components/entry-info/entry-info.js.map +1 -1
  36. package/lib-esm/components/entry-info/entry-info.module.css +1 -1
  37. package/lib-esm/components/metric-change-legend/metric-change-legend.js +2 -2
  38. package/lib-esm/components/metric-change-legend/metric-change-legend.js.map +1 -1
  39. package/lib-esm/components/metric-change-legend/metric-change-legend.module.css +11 -0
  40. package/lib-esm/components/metric-run-info/metric-run-info.js +2 -10
  41. package/lib-esm/components/metric-run-info/metric-run-info.js.map +1 -1
  42. package/lib-esm/components/metrics-table-options/metrics-table-options.js +4 -5
  43. package/lib-esm/components/metrics-table-options/metrics-table-options.js.map +1 -1
  44. package/lib-esm/components/metrics-treemap/metrics-treemap.constants.js +2 -0
  45. package/lib-esm/components/metrics-treemap/metrics-treemap.constants.js.map +1 -1
  46. package/lib-esm/components/metrics-treemap/metrics-treemap.js +74 -63
  47. package/lib-esm/components/metrics-treemap/metrics-treemap.js.map +1 -1
  48. package/lib-esm/components/metrics-treemap/metrics-treemap.module.css +9 -16
  49. package/lib-esm/components/metrics-treemap/metrics-treemap.utils.js +71 -17
  50. package/lib-esm/components/metrics-treemap/metrics-treemap.utils.js.map +1 -1
  51. package/lib-esm/ui/button/button.js +2 -2
  52. package/lib-esm/ui/button/button.js.map +1 -1
  53. package/lib-esm/ui/dropdown/dropdown.js +9 -2
  54. package/lib-esm/ui/dropdown/dropdown.js.map +1 -1
  55. package/lib-esm/ui/dropdown/dropdown.module.css +40 -6
  56. package/lib-esm/ui/filters/filters.js +23 -12
  57. package/lib-esm/ui/filters/filters.js.map +1 -1
  58. package/lib-esm/ui/filters/filters.module.css +0 -14
  59. package/lib-esm/ui/hover-card/hover-card.js +7 -1
  60. package/lib-esm/ui/hover-card/hover-card.js.map +1 -1
  61. package/lib-esm/ui/tooltip/tooltip.js +6 -1
  62. package/lib-esm/ui/tooltip/tooltip.js.map +1 -1
  63. package/package.json +12 -12
  64. package/src/app/app.module.css +1 -1
  65. package/src/chart-colors.json +58 -44
  66. package/src/components/entry-info/entry-info.module.css +1 -1
  67. package/src/components/entry-info/entry-info.tsx +2 -2
  68. package/src/components/metric-change-legend/metric-change-legend.module.css +11 -0
  69. package/src/components/metric-change-legend/metric-change-legend.tsx +2 -2
  70. package/src/components/metric-run-info/metric-run-info.tsx +2 -11
  71. package/src/components/metrics-table-options/metrics-table-options.tsx +6 -7
  72. package/src/components/metrics-treemap/metrics-treemap.constants.ts +8 -0
  73. package/src/components/metrics-treemap/metrics-treemap.module.css +9 -16
  74. package/src/components/metrics-treemap/metrics-treemap.tsx +160 -135
  75. package/src/components/metrics-treemap/metrics-treemap.utils.ts +141 -27
  76. package/src/ui/button/button.tsx +2 -1
  77. package/src/ui/dropdown/dropdown.module.css +40 -6
  78. package/src/ui/dropdown/dropdown.tsx +13 -2
  79. package/src/ui/filters/filters.module.css +0 -14
  80. package/src/ui/filters/filters.tsx +111 -47
  81. package/src/ui/hover-card/hover-card.tsx +7 -1
  82. package/src/ui/tooltip/tooltip.tsx +6 -1
  83. package/types/components/metric-run-info/metric-run-info.d.ts +1 -0
  84. package/types/components/metrics-treemap/metrics-treemap.constants.d.ts +2 -0
  85. package/types/components/metrics-treemap/metrics-treemap.utils.d.ts +11 -5
  86. package/types/ui/dropdown/dropdown.d.ts +3 -1
  87. package/types/ui/filters/filters.d.ts +2 -1
  88. package/vitest.config.ts +4 -0
  89. package/lib/components/metrics-table-options/metrics-table-options.module.css +0 -15
  90. package/lib-esm/components/metrics-table-options/metrics-table-options.module.css +0 -15
  91. package/src/components/metrics-table-options/metrics-table-options.module.css +0 -15
@@ -2,17 +2,15 @@ import React, {
2
2
  type ComponentProps,
3
3
  type MouseEventHandler,
4
4
  type ReactNode,
5
- type Ref,
6
- type RefObject,
7
- forwardRef,
8
5
  useCallback,
6
+ useEffect,
9
7
  useMemo,
10
8
  useRef,
11
9
  } from 'react';
12
- import { useHoverDirty, useMeasure } from 'react-use';
10
+ import { useMeasure } from 'react-use';
13
11
  import cx from 'classnames';
14
12
  import { HierarchyRectangularNode, hierarchy, treemap, treemapSquarify } from 'd3';
15
- import { Tooltip, TooltipArrow, TooltipAnchor } from 'ariakit/tooltip';
13
+ import { Tooltip, TooltipArrow } from 'ariakit/tooltip';
16
14
  import {
17
15
  type ReportMetricRow,
18
16
  type MetricRunInfo,
@@ -30,23 +28,43 @@ import {
30
28
  type TreeLeaf,
31
29
  type TreeNode,
32
30
  type Tree,
31
+ type TreeTotal,
33
32
  SQUARIFY_RATIO,
34
33
  PADDING_OUTER,
35
34
  PADDING_INNER,
36
35
  NESTED_PADDING,
37
36
  NESTED_PADDING_TOP,
38
37
  NESTED_PADDING_LEFT,
38
+ NODE_ID_SELECTOR,
39
39
  TileSizeDisplay,
40
40
  } from './metrics-treemap.constants';
41
41
  import * as I18N from './metrics-treemap.i18n';
42
42
  import css from './metrics-treemap.module.css';
43
43
  import {
44
+ getTreemapNodesIndex,
44
45
  resolveGroupDeltaType,
45
46
  resolveTileSizeDisplay,
46
47
  resolveTileGroupSizeDisplay,
47
- useTooltipStateWithMouseFollow,
48
+ useTreemapTooltipState,
48
49
  } from './metrics-treemap.utils';
49
50
 
51
+ /**
52
+ * Resolve the run info of a tile group from the children total
53
+ */
54
+ function getGroupRunInfo(total: TreeTotal) {
55
+ const runInfo = getMetricRunInfo(
56
+ METRIC_TYPE_CONFIGS.METRIC_TYPE_FILE_SIZE,
57
+ total.current,
58
+ total.baseline,
59
+ ) as MetricRunInfo;
60
+
61
+ return {
62
+ runInfo,
63
+ baselineDisplayValue: formatFileSize(total.baseline),
64
+ deltaType: resolveGroupDeltaType(runInfo),
65
+ };
66
+ }
67
+
50
68
  interface TileTooltipContentProps {
51
69
  item: ReportMetricRow;
52
70
  }
@@ -64,7 +82,8 @@ const TileTooltipContent = (props: TileTooltipContentProps) => {
64
82
  </h3>
65
83
  <RunInfo
66
84
  current={currentRun.displayValue}
67
- delta={currentRun.displayDeltaPercentage}
85
+ delta={currentRun.displayDelta}
86
+ deltaPercentage={currentRun.displayDeltaPercentage}
68
87
  deltaType={currentRun.deltaType}
69
88
  baseline={baselineRun?.displayValue || '0B'}
70
89
  />
@@ -91,12 +110,12 @@ interface TileContentProps {
91
110
  runInfo: MetricRunInfo;
92
111
  }
93
112
 
94
- const TileContent = forwardRef((props: TileContentProps, ref: Ref<HTMLDivElement>) => {
113
+ const TileContent = (props: TileContentProps) => {
95
114
  const { label, sizeDisplay, item, runInfo } = props;
96
115
 
97
116
  // Render only the container
98
117
  if (sizeDisplay === 'minimal') {
99
- return <div className={css.tileContent} ref={ref} />;
118
+ return <div className={css.tileContent} />;
100
119
  }
101
120
 
102
121
  const resolvedLabel = label || item.label;
@@ -104,14 +123,14 @@ const TileContent = forwardRef((props: TileContentProps, ref: Ref<HTMLDivElement
104
123
  // Render only the label
105
124
  if (sizeDisplay === 'small') {
106
125
  return (
107
- <div className={css.tileContent} ref={ref}>
126
+ <div className={css.tileContent}>
108
127
  <p className={css.tileContentLabel}>{resolvedLabel}</p>
109
128
  </div>
110
129
  );
111
130
  }
112
131
 
113
132
  return (
114
- <div className={css.tileContent} ref={ref}>
133
+ <div className={css.tileContent}>
115
134
  <p className={css.tileContentLabel}>{label || item.label}</p>
116
135
  <p className={css.tileContentValue}>
117
136
  <span className={css.tileContentMetric}>{runInfo.displayValue}</span>
@@ -123,24 +142,6 @@ const TileContent = forwardRef((props: TileContentProps, ref: Ref<HTMLDivElement
123
142
  </p>
124
143
  </div>
125
144
  );
126
- });
127
-
128
- const TileContentWithTooltip = (props: TileContentProps & { parentRef: RefObject<Element> }) => {
129
- const { label, sizeDisplay, item, runInfo, parentRef } = props;
130
-
131
- const tooltipState = useTooltipStateWithMouseFollow({ parentRef });
132
-
133
- return (
134
- <>
135
- <TooltipAnchor state={tooltipState} className={css.tileContentTooltipAnchor}>
136
- <TileContent label={label} sizeDisplay={sizeDisplay} item={item} runInfo={runInfo} />
137
- </TooltipAnchor>
138
- <Tooltip state={tooltipState} className={css.tooltip}>
139
- <TooltipArrow state={tooltipState} size={16} className={css.tileTooltipArrow} />
140
- <TileTooltipContent item={item} />
141
- </Tooltip>
142
- </>
143
- );
144
145
  };
145
146
 
146
147
  interface TileProps {
@@ -167,9 +168,6 @@ const Tile = (props: TileProps) => {
167
168
  [item.key, onClick],
168
169
  );
169
170
 
170
- const contentRef = useRef<HTMLDivElement>(null);
171
- const hover = useHoverDirty(contentRef);
172
-
173
171
  const className = cx(
174
172
  css.tile,
175
173
  css[`tile-${runInfo.deltaType}`],
@@ -184,51 +182,57 @@ const Tile = (props: TileProps) => {
184
182
  onClick={handleOnClick}
185
183
  role="button"
186
184
  aria-label={I18N.TILE_LABEL}
187
- ref={contentRef}
185
+ data-treemap-id={data.id}
188
186
  style={{ left, top, width, height }}
189
187
  className={className}
190
188
  >
191
- {hover ? (
192
- <TileContentWithTooltip
193
- label={label}
194
- sizeDisplay={sizeDisplay}
195
- item={item}
196
- runInfo={runInfo}
197
- parentRef={contentRef}
198
- />
199
- ) : (
200
- <TileContent label={label} sizeDisplay={sizeDisplay} item={item} runInfo={runInfo} />
201
- )}
189
+ <TileContent label={label} sizeDisplay={sizeDisplay} item={item} runInfo={runInfo} />
202
190
  </div>
203
191
  );
204
192
  };
205
193
 
206
- interface TileGroupTitleTooltipContentProps {
207
- title: string;
208
- runInfo?: MetricRunInfo;
209
- baselineDisplayValue?: string;
194
+ interface TileGroupTooltipContentProps {
195
+ node: Tree;
210
196
  }
211
197
 
212
- const TileGroupTitleTooltipContent = (props: TileGroupTitleTooltipContentProps) => {
213
- const { title, runInfo, baselineDisplayValue } = props;
198
+ const TileGroupTooltipContent = (props: TileGroupTooltipContentProps) => {
199
+ const { node } = props;
200
+
201
+ // by default show the node id (full path) as title
202
+ const title = node.id || node.label;
203
+ const groupRunInfo = node.total && getGroupRunInfo(node.total);
214
204
 
215
205
  return (
216
- <Stack space="small" className={css.tileTooltip}>
206
+ <Stack space="small" className={css.tileTooltipContent}>
217
207
  <h3 className={css.tileTooltipContentTitle}>
218
208
  <FileName as="code" name={title} />
219
209
  </h3>
220
- {runInfo && (
210
+ {groupRunInfo && (
221
211
  <RunInfo
222
- current={runInfo.displayValue}
223
- baseline={baselineDisplayValue}
224
- delta={runInfo.displayDeltaPercentage}
225
- deltaType={runInfo.deltaType}
212
+ current={groupRunInfo.runInfo.displayValue}
213
+ baseline={groupRunInfo.baselineDisplayValue}
214
+ delta={groupRunInfo.runInfo.displayDeltaPercentage}
215
+ deltaType={groupRunInfo.runInfo.deltaType}
226
216
  />
227
217
  )}
228
218
  </Stack>
229
219
  );
230
220
  };
231
221
 
222
+ interface TreemapTooltipContentProps {
223
+ node: TreeNode;
224
+ }
225
+
226
+ const TreemapTooltipContent = (props: TreemapTooltipContentProps) => {
227
+ const { node } = props;
228
+
229
+ if ('item' in node) {
230
+ return <TileTooltipContent item={node.item} />;
231
+ }
232
+
233
+ return <TileGroupTooltipContent node={node} />;
234
+ };
235
+
232
236
  interface TileGroupTitleContentProps {
233
237
  title?: string;
234
238
  runInfo?: MetricRunInfo;
@@ -250,47 +254,19 @@ const TileGroupTitleContent = (props: TileGroupTitleContentProps) => {
250
254
  );
251
255
  };
252
256
 
253
- type TileGroupTitleContentWithTooltipProps = {
254
- parentRef: RefObject<Element>;
255
- tooltipContent: TileGroupTitleTooltipContentProps;
256
- } & TileGroupTitleContentProps;
257
-
258
- const TileGroupTitleContentWithTooltip = (props: TileGroupTitleContentWithTooltipProps) => {
259
- const { parentRef, tooltipContent, ...restProps } = props;
260
-
261
- const tooltipState = useTooltipStateWithMouseFollow({ parentRef });
262
-
263
- return (
264
- <>
265
- <TooltipAnchor state={tooltipState} className={css.tileContentTooltipAnchor}>
266
- <TileGroupTitleContent {...restProps} />
267
- </TooltipAnchor>
268
- <Tooltip state={tooltipState} className={css.tooltip}>
269
- <TooltipArrow state={tooltipState} size={16} className={css.tileTooltipArrow} />
270
- <TileGroupTitleTooltipContent {...tooltipContent} />
271
- </Tooltip>
272
- </>
273
- );
257
+ type TileGroupTitleProps = TileGroupTitleContentProps & {
258
+ /**
259
+ * Node id - used to resolve the tooltip content on hover
260
+ */
261
+ id: string;
274
262
  };
275
263
 
276
- type TileGroupTitleProps = Omit<TileGroupTitleContentWithTooltipProps, 'parentRef'>;
277
-
278
264
  const TileGroupTitle = (props: TileGroupTitleProps) => {
279
- const { tooltipContent, ...restProps } = props;
280
- const contentRef = useRef<HTMLDivElement>(null);
281
- const hover = useHoverDirty(contentRef);
265
+ const { id, ...restProps } = props;
282
266
 
283
267
  return (
284
- <div className={css.tileGroupTitle} ref={contentRef}>
285
- {hover ? (
286
- <TileGroupTitleContentWithTooltip
287
- parentRef={contentRef}
288
- tooltipContent={tooltipContent}
289
- {...restProps}
290
- />
291
- ) : (
292
- <TileGroupTitleContent {...restProps} />
293
- )}
268
+ <div className={css.tileGroupTitle} data-treemap-id={id}>
269
+ <TileGroupTitleContent {...restProps} />
294
270
  </div>
295
271
  );
296
272
  };
@@ -370,36 +346,14 @@ const TileGroup = (props: TileGroupProps) => {
370
346
  );
371
347
 
372
348
  // Prepare data
373
- const [runInfo, baselineDisplayValue, deltaType] = useMemo(() => {
374
- if (!total) {
375
- return [];
376
- }
377
-
378
- const resolvedRunInfo = getMetricRunInfo(
379
- METRIC_TYPE_CONFIGS.METRIC_TYPE_FILE_SIZE,
380
- total.current,
381
- total.baseline,
382
- ) as MetricRunInfo;
383
- const resolvedBaselineDisplayValue = formatFileSize(total.baseline);
384
- const resolvedDeltaType = resolveGroupDeltaType(resolvedRunInfo);
385
-
386
- return [resolvedRunInfo, resolvedBaselineDisplayValue, resolvedDeltaType];
387
- }, [total]);
349
+ const groupRunInfo = useMemo(() => (total ? getGroupRunInfo(total) : undefined), [total]);
388
350
 
389
351
  const rootClassName = cx(
390
352
  css.tileGroup,
391
- css[`tileGroup--${deltaType}`],
353
+ css[`tileGroup--${groupRunInfo?.deltaType}`],
392
354
  css[`tileGroup--${displaySize}`],
393
355
  );
394
356
 
395
- // Tooltip data
396
- // - by default show the node id (full path) as title
397
- const tooltipContent = {
398
- title: id || title,
399
- runInfo,
400
- baselineDisplayValue,
401
- } as TileGroupTitleTooltipContentProps;
402
-
403
357
  if (title && displaySize === 'minimal') {
404
358
  return (
405
359
  <div
@@ -409,7 +363,7 @@ const TileGroup = (props: TileGroupProps) => {
409
363
  className={rootClassName}
410
364
  style={{ left, top, width, height }}
411
365
  >
412
- <TileGroupTitle tooltipContent={tooltipContent} />
366
+ <TileGroupTitle id={id} />
413
367
  </div>
414
368
  );
415
369
  }
@@ -423,7 +377,7 @@ const TileGroup = (props: TileGroupProps) => {
423
377
  style={{ left, top, width, height }}
424
378
  className={rootClassName}
425
379
  >
426
- <TileGroupTitle title={title} tooltipContent={tooltipContent} />
380
+ <TileGroupTitle id={id} title={title} />
427
381
  </div>
428
382
  );
429
383
  }
@@ -436,7 +390,7 @@ const TileGroup = (props: TileGroupProps) => {
436
390
  className={cx(rootClassName, css.tileGroupSizeDefault)}
437
391
  style={{ left, top, width, height }}
438
392
  >
439
- <TileGroupTitle title={title} runInfo={runInfo} tooltipContent={tooltipContent} />
393
+ <TileGroupTitle id={id} title={title} runInfo={groupRunInfo?.runInfo} />
440
394
  {childNodes?.map((childNode) => {
441
395
  if ('children' in childNode) {
442
396
  const groupData = childNode.data as Tree;
@@ -542,34 +496,105 @@ export const MetricsTreemap = (props: MetricsTreemapProps & ComponentProps<'div'
542
496
  const [containerRef, { width, height }] = useMeasure<HTMLDivElement>();
543
497
  const rootNode = useMetricsTreemapHierarchy({ treeNodes, width, height, nested });
544
498
 
499
+ /**
500
+ * A single tooltip is shared by every tile and tile group title:
501
+ * - tiles are tagged with `data-treemap-id`
502
+ * - the pointer is tracked by one delegated handler on the canvas
503
+ * - hiding is deferred so that the tooltip stays up while the pointer
504
+ * crosses the gutter between two tiles
505
+ */
506
+ const nodesIndex = useMemo(() => getTreemapNodesIndex(treeNodes), [treeNodes]);
507
+ const { tooltipState, setPointer, hoveredNodeId, setHoveredNode, clearHoveredNode } =
508
+ useTreemapTooltipState();
509
+ const canvasRef = useRef<HTMLDivElement>(null);
510
+
511
+ // Resolved on render so that the content cannot go stale when the data changes
512
+ const tooltipNode = hoveredNodeId === null ? null : nodesIndex.get(hoveredNodeId);
513
+
514
+ const handlePointerEvent = useCallback(
515
+ (event: MouseEvent) => {
516
+ // Anchor to the canvas to let ariakit track scrolling/resizing
517
+ tooltipState.anchorRef.current = canvasRef.current;
518
+
519
+ setPointer(event.clientX, event.clientY);
520
+
521
+ const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(NODE_ID_SELECTOR);
522
+ setHoveredNode(target?.dataset.treemapId ?? null);
523
+ },
524
+ [tooltipState, setPointer, setHoveredNode],
525
+ );
526
+
527
+ const hasNodes = Boolean(rootNode.children && rootNode.children.length > 0);
528
+
529
+ useEffect(() => {
530
+ const canvas = canvasRef.current;
531
+
532
+ if (!canvas) {
533
+ return undefined;
534
+ }
535
+
536
+ /**
537
+ * `mouseover` is needed on top of `mousemove`: the hovered element can
538
+ * change without the pointer moving (scrolling, resizing, layout shifts),
539
+ * and the browser only emits boundary events in that case.
540
+ *
541
+ * Native listeners are used instead of the React props because React skips
542
+ * `mouseover` dispatch when `relatedTarget` is inside the same tree.
543
+ */
544
+ canvas.addEventListener('mousemove', handlePointerEvent);
545
+ canvas.addEventListener('mouseover', handlePointerEvent);
546
+ canvas.addEventListener('mouseleave', clearHoveredNode);
547
+
548
+ return () => {
549
+ canvas.removeEventListener('mousemove', handlePointerEvent);
550
+ canvas.removeEventListener('mouseover', handlePointerEvent);
551
+ canvas.removeEventListener('mouseleave', clearHoveredNode);
552
+ };
553
+ }, [hasNodes, handlePointerEvent, clearHoveredNode]);
554
+
555
+ // Keep the cells out of the tooltip render path
556
+ // (`rootNode` is mutated in place by d3, so the layout size drives the memo)
557
+ const canvasContent = useMemo(
558
+ () => (
559
+ <TileGroup
560
+ title={nested ? rootNode.data.label : undefined}
561
+ total={(rootNode.data as Tree).total}
562
+ id={rootNode.data.id}
563
+ childNodes={rootNode.children}
564
+ onItemClick={onItemClick}
565
+ onGroupClick={onGroupClick}
566
+ left={rootNode.x0}
567
+ top={rootNode.y0}
568
+ absoluteLeft={rootNode.x0}
569
+ absoluteTop={rootNode.y0}
570
+ width={rootNode.x1 - rootNode.x0}
571
+ height={rootNode.y1 - rootNode.y0}
572
+ />
573
+ ),
574
+ [rootNode, width, height, nested, onItemClick, onGroupClick],
575
+ );
576
+
545
577
  return (
546
578
  <div
547
579
  className={cx(css.root, nested && css.nested, className)}
548
580
  {...restProps}
549
581
  ref={containerRef}
550
582
  >
551
- {rootNode.children && rootNode.children?.length > 0 ? (
552
- <div className={css.canvas}>
553
- <TileGroup
554
- title={nested ? rootNode.data.label : undefined}
555
- total={(rootNode.data as Tree).total}
556
- id={rootNode.data.id}
557
- childNodes={rootNode.children}
558
- onItemClick={onItemClick}
559
- onGroupClick={onGroupClick}
560
- left={rootNode.x0}
561
- top={rootNode.y0}
562
- absoluteLeft={rootNode.x0}
563
- absoluteTop={rootNode.y0}
564
- width={rootNode.x1 - rootNode.x0}
565
- height={rootNode.y1 - rootNode.y0}
566
- />
583
+ {hasNodes ? (
584
+ <div className={css.canvas} ref={canvasRef}>
585
+ {canvasContent}
567
586
  </div>
568
587
  ) : (
569
588
  <div className={css.emptyMessage}>
570
589
  <div className={css.emptyMessageWrapper}>{emptyMessage}</div>
571
590
  </div>
572
591
  )}
592
+ {tooltipNode && (
593
+ <Tooltip state={tooltipState} className={css.tooltip}>
594
+ <TooltipArrow state={tooltipState} size={16} className={css.tileTooltipArrow} />
595
+ <TreemapTooltipContent node={tooltipNode} />
596
+ </Tooltip>
597
+ )}
573
598
  </div>
574
599
  );
575
600
  };
@@ -1,5 +1,4 @@
1
- import { type RefObject, useCallback } from 'react';
2
- import { useDebounce, useMouseHovered } from 'react-use';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
2
  import { useTooltipState } from 'ariakit/tooltip';
4
3
  import {
5
4
  DeltaType,
@@ -10,6 +9,7 @@ import {
10
9
 
11
10
  import {
12
11
  type TreeLeaf,
12
+ type TreeNode,
13
13
  type TreeNodeChildren,
14
14
  type Tree,
15
15
  type TreeTotal,
@@ -158,6 +158,28 @@ export function getTreemapNodesGroupedByPath(items: Array<ReportMetricRow>): Tre
158
158
  };
159
159
  }
160
160
 
161
+ /**
162
+ * Index every tree node by id, to allow resolving the hovered node from the
163
+ * `data-treemap-id` DOM marker without walking the tree on every pointer move
164
+ */
165
+ export function getTreemapNodesIndex(
166
+ node: Tree,
167
+ index: Map<string, TreeNode> = new Map(),
168
+ ): Map<string, TreeNode> {
169
+ index.set(node.id, node);
170
+
171
+ node.children.forEach((childNode) => {
172
+ if ('children' in childNode) {
173
+ getTreemapNodesIndex(childNode, index);
174
+ return;
175
+ }
176
+
177
+ index.set(childNode.id, childNode);
178
+ });
179
+
180
+ return index;
181
+ }
182
+
161
183
  export function resolveGroupDeltaType(
162
184
  metricRunInfo?: MetricRunInfo | MetricRunInfoBaseline,
163
185
  ): DeltaType.NO_CHANGE | DeltaType.NEGATIVE | DeltaType.POSITIVE {
@@ -225,50 +247,142 @@ export function resolveTileSizeDisplay(width: number, height: number): TileSizeD
225
247
  return 'default';
226
248
  }
227
249
 
228
- interface UseTooltipStateWithMouseFollowOptions {
229
- /**
230
- * React ref to parent div
231
- */
232
- parentRef: RefObject<Element>;
250
+ interface UseTreemapTooltipStateOptions {
233
251
  /**
234
252
  * Tooltip gutter
235
253
  */
236
254
  gutter?: number;
237
255
  /**
238
- * Tooltip timeout
256
+ * Delay before showing the tooltip
239
257
  */
240
258
  timeout?: number;
259
+ /**
260
+ * Delay before hiding the tooltip once the pointer is no longer over a cell.
261
+ *
262
+ * Tiles are separated by a gutter that belongs to no node, so without this
263
+ * grace period moving from one cell to another would close and reopen the
264
+ * tooltip every time the pointer crosses the gap.
265
+ */
266
+ hideTimeout?: number;
241
267
  }
242
268
 
243
269
  /**
244
- * Ariakit tooltip state hook with mouse follow functionality
270
+ * Ariakit tooltip state shared by every tile/tile group of a treemap.
271
+ *
272
+ * The tooltip is anchored to a virtual 1x1 rect that follows the pointer, so
273
+ * no `TooltipAnchor` is rendered per cell: the anchor position is stored on a
274
+ * ref and ariakit is asked to reposition at most once per animation frame,
275
+ * which keeps the treemap itself out of the pointer move render path.
276
+ *
277
+ * The hovered node id is owned here as well, so that all the tooltip timing
278
+ * lives in a single place.
245
279
  */
246
- export function useTooltipStateWithMouseFollow(options: UseTooltipStateWithMouseFollowOptions) {
247
- const { parentRef, gutter = 16, timeout = 240 } = options;
280
+ export function useTreemapTooltipState(options: UseTreemapTooltipStateOptions = {}) {
281
+ const { gutter = 16, timeout = 240, hideTimeout = timeout } = options;
248
282
 
249
- const pointer = useMouseHovered(parentRef, { whenHovered: true });
283
+ const pointerRef = useRef<{ x: number; y: number } | null>(null);
284
+ const frameRef = useRef(0);
285
+ const hideTimeoutRef = useRef(0);
250
286
 
251
- // Return custom rect based on the mouse position
287
+ // Return custom rect based on the pointer position
252
288
  const getAnchorRect = useCallback(() => {
253
- // Skip custom component rect area if the pointer position is empty
254
- if (pointer.docX === 0 && pointer.docY === 0 && pointer.elW === 0 && pointer.elH === 0) {
289
+ const pointer = pointerRef.current;
290
+
291
+ // Skip the custom rect until we have a pointer position
292
+ if (!pointer) {
255
293
  return null;
256
294
  }
257
295
 
258
- const newRect = {
259
- x: pointer.docX - (document?.defaultView?.scrollX ?? 0),
260
- y: pointer.docY - (document?.defaultView?.scrollY ?? 0),
261
- w: 1,
262
- h: 1,
263
- };
264
-
265
- return newRect;
266
- }, [pointer.docX, pointer.docY]);
296
+ return { x: pointer.x, y: pointer.y, w: 1, h: 1 };
297
+ }, []);
267
298
 
268
299
  const tooltipState = useTooltipState({ gutter, getAnchorRect, timeout });
300
+ const { mounted, render, show, hide } = tooltipState;
301
+
302
+ const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
303
+ const hoveredNodeIdRef = useRef<string | null>(null);
304
+
305
+ useEffect(
306
+ () => () => {
307
+ cancelAnimationFrame(frameRef.current);
308
+ window.clearTimeout(hideTimeoutRef.current);
309
+ },
310
+ [],
311
+ );
312
+
313
+ const setPointer = useCallback(
314
+ (x: number, y: number) => {
315
+ pointerRef.current = { x, y };
316
+
317
+ // Reposition only while the tooltip is visible, once per frame
318
+ if (!mounted || frameRef.current) {
319
+ return;
320
+ }
321
+
322
+ frameRef.current = requestAnimationFrame(() => {
323
+ frameRef.current = 0;
324
+ render();
325
+ });
326
+ },
327
+ [mounted, render],
328
+ );
329
+
330
+ const commitHoveredNode = useCallback(
331
+ (nodeId: string | null) => {
332
+ hoveredNodeIdRef.current = nodeId;
333
+ setHoveredNodeId(nodeId);
334
+
335
+ if (nodeId === null) {
336
+ hide();
337
+ return;
338
+ }
339
+
340
+ show();
341
+ },
342
+ [hide, show],
343
+ );
344
+
345
+ /**
346
+ * Set the hovered node. Showing another node is applied right away, while
347
+ * hiding is deferred so that the tooltip survives the gap between two cells.
348
+ */
349
+ const setHoveredNode = useCallback(
350
+ (nodeId: string | null) => {
351
+ window.clearTimeout(hideTimeoutRef.current);
352
+ hideTimeoutRef.current = 0;
353
+
354
+ // Guard against re-triggering `show`, which would restart the show timeout
355
+ if (nodeId === hoveredNodeIdRef.current) {
356
+ return;
357
+ }
358
+
359
+ if (nodeId !== null || !hideTimeout) {
360
+ commitHoveredNode(nodeId);
361
+ return;
362
+ }
363
+
364
+ hideTimeoutRef.current = window.setTimeout(() => {
365
+ hideTimeoutRef.current = 0;
366
+ commitHoveredNode(null);
367
+ }, hideTimeout);
368
+ },
369
+ [commitHoveredNode, hideTimeout],
370
+ );
371
+
372
+ /**
373
+ * Hide the tooltip right away, without the grace period - the pointer left
374
+ * the treemap, there is no next cell to move to.
375
+ */
376
+ const clearHoveredNode = useCallback(() => {
377
+ window.clearTimeout(hideTimeoutRef.current);
378
+ hideTimeoutRef.current = 0;
379
+
380
+ if (hoveredNodeIdRef.current === null) {
381
+ return;
382
+ }
269
383
 
270
- // Update tooltip position when pointer values change
271
- useDebounce(tooltipState.render, 10, [pointer.docX, pointer.docY]);
384
+ commitHoveredNode(null);
385
+ }, [commitHoveredNode]);
272
386
 
273
- return tooltipState;
387
+ return { tooltipState, setPointer, hoveredNodeId, setHoveredNode, clearHoveredNode };
274
388
  }
@@ -41,6 +41,7 @@ interface ButtonProps<T extends React.ElementType> {
41
41
 
42
42
  const ButtonComponent = <T extends React.ElementType = 'button'>(
43
43
  props: ButtonProps<T> & Omit<React.ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>,
44
+ ref: React.Ref<HTMLButtonElement>,
44
45
  ) => {
45
46
  const {
46
47
  className = '',
@@ -86,7 +87,7 @@ const ButtonComponent = <T extends React.ElementType = 'button'>(
86
87
  );
87
88
 
88
89
  return (
89
- <Component {...restProps} className={rootClassName}>
90
+ <Component {...restProps} ref={ref} className={rootClassName}>
90
91
  {glyph && <Icon glyph={glyph} className={css.glyph} />}
91
92
  {children && <span className={css.content}>{children}</span>}
92
93
  {rightGlyph && <Icon glyph={rightGlyph} className={css.glyph} />}