@automattic/charts 1.3.1 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -99,8 +99,8 @@
99
99
  "@babel/core": "7.29.0",
100
100
  "@babel/preset-react": "7.28.5",
101
101
  "@babel/preset-typescript": "7.28.5",
102
- "@storybook/addon-docs": "10.3.5",
103
- "@storybook/react": "10.3.5",
102
+ "@storybook/addon-docs": "10.3.6",
103
+ "@storybook/react": "10.3.6",
104
104
  "@testing-library/dom": "^10.0.0",
105
105
  "@testing-library/jest-dom": "^6.0.0",
106
106
  "@testing-library/react": "^16.0.0",
@@ -121,12 +121,12 @@
121
121
  "identity-obj-proxy": "^3.0.0",
122
122
  "jest": "30.3.0",
123
123
  "jest-extended": "7.0.0",
124
- "postcss": "8.5.10",
124
+ "postcss": "8.5.14",
125
125
  "postcss-modules": "6.0.1",
126
126
  "react": "18.3.1",
127
127
  "react-dom": "18.3.1",
128
128
  "sass-embedded": "1.97.3",
129
- "storybook": "10.3.5",
129
+ "storybook": "10.3.6",
130
130
  "tsup": "8.5.1",
131
131
  "typescript": "5.9.3"
132
132
  },
@@ -9,4 +9,3 @@ export { useZeroValueDisplay } from './use-zero-value-display';
9
9
  export { useDataWithPercentages } from './use-data-with-percentages';
10
10
  export { useInteractiveLegendData } from './use-interactive-legend-data';
11
11
  export { usePrefersReducedMotion } from './use-prefers-reduced-motion';
12
- export { useTooltipPortalRelocator } from './use-tooltip-portal-relocator';
@@ -89,6 +89,27 @@ describe( 'useChartMargin', () => {
89
89
  expect( result.current.right ).toBe( 48 ); // 40 + 8
90
90
  } );
91
91
 
92
+ it( 'uses explicit y tickValues when provided', () => {
93
+ const options = {
94
+ ...optionsBase,
95
+ axis: {
96
+ ...optionsBase.axis,
97
+ y: {
98
+ ...optionsBase.axis.y,
99
+ tickValues: [ 0, 1000 ],
100
+ },
101
+ },
102
+ };
103
+ const height = 300;
104
+ const theme = baseTheme;
105
+ renderHook( () => useChartMargin( height, options, data, theme ) );
106
+ expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
107
+ [ 0, 1000 ],
108
+ options.axis.y.tickFormat,
109
+ theme.axisStyles.y.left.axisLabel
110
+ );
111
+ } );
112
+
92
113
  it( 'sets top and bottom margin for top x axis', () => {
93
114
  const options = {
94
115
  ...optionsBase,
@@ -81,6 +81,10 @@ export const useChartMargin = (
81
81
  );
82
82
  }
83
83
 
84
+ if ( options.axis?.y?.tickValues?.length ) {
85
+ return options.axis.y.tickValues;
86
+ }
87
+
84
88
  const minY = Math.min( ...allDataPoints.map( d => d.value ) );
85
89
  const maxY = Math.max( ...allDataPoints.map( d => d.value ) );
86
90
  const yScale = createScale( {
@@ -8,7 +8,6 @@ import {
8
8
  useLayoutEffect,
9
9
  useRef,
10
10
  } from 'react';
11
- import { useTooltipPortalRelocator } from '../../hooks/use-tooltip-portal-relocator';
12
11
  import {
13
12
  getItemShapeStyles,
14
13
  getSeriesLineStyles,
@@ -27,22 +26,9 @@ export const GlobalChartsContext = createContext< GlobalChartsContextValue | nul
27
26
  export interface GlobalChartsProviderProps {
28
27
  children: ReactNode;
29
28
  theme?: Partial< ChartTheme >;
30
- /**
31
- * Optional ref to an element that chart tooltip portals should be relocated into.
32
- * When provided, visx tooltip portals (normally appended to document.body) will be
33
- * moved into this container so they participate in the same effective CSS stacking context.
34
- * The element referenced here, or one of its ancestors, should establish the desired
35
- * stacking context (for example by using `position` and `z-index`) so that tooltips
36
- * appear above the relevant chart content.
37
- */
38
- portalContainer?: React.RefObject< HTMLElement | null >;
39
29
  }
40
30
 
41
- export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
42
- children,
43
- theme,
44
- portalContainer,
45
- } ) => {
31
+ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { children, theme } ) => {
46
32
  const [ charts, setCharts ] = useState< Map< string, ChartRegistration > >( () => new Map() );
47
33
  // Track hidden series per chart: chartId -> Set<seriesLabel>
48
34
  const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >(
@@ -52,9 +38,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
52
38
  // Ref to the wrapper element for resolving scoped CSS variables
53
39
  const wrapperRef = useRef< HTMLDivElement >( null );
54
40
 
55
- // Relocate tooltip portals into the wrapper (or a consumer-provided container) for z-index control.
56
- useTooltipPortalRelocator( portalContainer ?? wrapperRef );
57
-
58
41
  const providerTheme: CompleteChartTheme = useMemo( () => {
59
42
  return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme;
60
43
  }, [ theme ] );
package/src/style.css ADDED
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Placeholder for the `jetpack:src` arm of the `./style.css` package export.
3
+ *
4
+ * In-monorepo consumers resolving via source already pull each chart's
5
+ * `.module.scss` styles through the JS module graph, so no aggregated
6
+ * stylesheet is needed at this path.
7
+ *
8
+ * Published consumers resolve via the `default` condition, which points at
9
+ * the aggregated `./dist/index.css` produced by tsup.
10
+ */
package/src/types.ts CHANGED
@@ -322,6 +322,11 @@ export type CompleteChartTheme = Required< ChartTheme > & {
322
322
  export type AxisOptions = {
323
323
  orientation?: OrientationType;
324
324
  numTicks?: number;
325
+ /**
326
+ * Explicit tick values for the axis. When set, takes precedence over `numTicks`
327
+ * so callers can force a specific axis (e.g. integer-only steps on a sparse chart).
328
+ */
329
+ tickValues?: ScaleInput< AxisScale >[];
325
330
  axisClassName?: string;
326
331
  axisLineClassName?: string;
327
332
  labelClassName?: string;
@@ -352,6 +357,11 @@ export type AxisOptions = {
352
357
  export type ScaleOptions = {
353
358
  type?: ScaleType;
354
359
  zero?: boolean;
360
+ /**
361
+ * Extends the scale's domain to nice round values. Pass `false` together with
362
+ * an explicit `domain` to keep the tick values you set exactly.
363
+ */
364
+ nice?: boolean;
355
365
  domain?: [ number, number ];
356
366
  range?: [ number, number ];
357
367
  /**
package/tsup.config.ts CHANGED
@@ -3,10 +3,11 @@ import { sassPlugin, postcssModules } from 'esbuild-sass-plugin';
3
3
  import { defineConfig } from 'tsup';
4
4
  import pkg from './package.json';
5
5
 
6
- // Extract entries from package exports
6
+ // Extract JS/TS entries from package exports. Non-JS source paths (e.g. the
7
+ // `./style.css` placeholder) are skipped so tsup doesn't try to bundle them.
7
8
  const entry = Object.values( pkg.exports )
8
9
  .map( $export => ( typeof $export === 'object' ? $export[ 'jetpack:src' ] : '' ) )
9
- .filter( ( path ): path is string => Boolean( path ) );
10
+ .filter( ( path ): path is string => Boolean( path ) && /\.[cm]?[jt]sx?$/.test( path ) );
10
11
 
11
12
  export default defineConfig( {
12
13
  entry,
@@ -1,216 +0,0 @@
1
- /* eslint-disable testing-library/no-node-access */
2
- import { renderHook } from '@testing-library/react';
3
- import { useTooltipPortalRelocator } from '../use-tooltip-portal-relocator';
4
-
5
- // In the production build, CSS module class names are hashed (e.g. "a8ccharts-abc123").
6
- // In jest, the SCSS module import is stubbed to a filename string, so
7
- // styles.relocatedPortal resolves to undefined and classList.add() is a no-op.
8
- // We mock the module to return a proper class map so we can assert on class names.
9
- jest.mock( '../use-tooltip-portal-relocator.module.scss', () => ( {
10
- __esModule: true,
11
- default: { relocatedPortal: 'relocatedPortal' },
12
- } ) );
13
-
14
- /**
15
- * Create a mock visx tooltip portal node for testing.
16
- * @return {HTMLDivElement} A div mimicking a visx tooltip portal.
17
- */
18
- function createVisxPortalNode(): HTMLDivElement {
19
- const portal = document.createElement( 'div' );
20
- const child = document.createElement( 'div' );
21
- child.className = 'visx-tooltip';
22
- portal.appendChild( child );
23
- return portal;
24
- }
25
-
26
- /**
27
- * Sets up a container, ref, and renders the hook.
28
- * Optionally appends a visx portal node to document.body before rendering.
29
- * @param options - Setup options.
30
- * @param options.withPortal - If true, creates and appends a visx portal before rendering.
31
- * @return Setup result with container, ref, unmount, and optionally the portal node.
32
- */
33
- function setupHook( { withPortal = false } = {} ) {
34
- const container = document.createElement( 'div' );
35
- document.body.appendChild( container );
36
-
37
- let portal: HTMLDivElement | undefined;
38
- if ( withPortal ) {
39
- portal = createVisxPortalNode();
40
- document.body.appendChild( portal );
41
- }
42
-
43
- const ref = { current: container };
44
- const { unmount } = renderHook( () => useTooltipPortalRelocator( ref ) );
45
-
46
- return { container, ref, unmount, portal };
47
- }
48
-
49
- describe( 'useTooltipPortalRelocator', () => {
50
- let nativeRemoveChild: typeof document.body.removeChild;
51
-
52
- beforeAll( () => {
53
- nativeRemoveChild = document.body.removeChild;
54
- } );
55
-
56
- afterEach( () => {
57
- // Restore native removeChild and clear all body children to prevent
58
- // leaked portals from interfering with subsequent tests.
59
- document.body.removeChild = nativeRemoveChild;
60
- while ( document.body.firstChild ) {
61
- nativeRemoveChild.call( document.body, document.body.firstChild );
62
- }
63
- } );
64
-
65
- test( 'does nothing when containerRef is undefined', () => {
66
- const { unmount } = renderHook( () => useTooltipPortalRelocator( undefined ) );
67
- const portal = createVisxPortalNode();
68
- document.body.appendChild( portal );
69
- expect( portal.parentNode ).toBe( document.body );
70
- unmount();
71
- } );
72
-
73
- test( 'does nothing when containerRef.current is null', () => {
74
- const nullRef = { current: null };
75
- const { unmount } = renderHook( () => useTooltipPortalRelocator( nullRef ) );
76
- const portal = createVisxPortalNode();
77
- document.body.appendChild( portal );
78
- expect( portal.parentNode ).toBe( document.body );
79
- unmount();
80
- } );
81
-
82
- test( 'relocates existing visx portal nodes into the container', () => {
83
- const { container, unmount, portal } = setupHook( { withPortal: true } );
84
- expect( portal!.parentNode ).toBe( container );
85
- unmount();
86
- } );
87
-
88
- test( 'applies relocated-portal class to relocated portals', () => {
89
- const { unmount, portal } = setupHook( { withPortal: true } );
90
- expect( portal! ).toHaveClass( 'relocatedPortal' );
91
- unmount();
92
- } );
93
-
94
- test( 'does not relocate newly added non-visx nodes', async () => {
95
- const { unmount } = setupHook();
96
-
97
- const regularDiv = document.createElement( 'div' );
98
- regularDiv.id = 'some-id';
99
- document.body.appendChild( regularDiv );
100
-
101
- // MutationObserver is async — wait for microtask
102
- await new Promise( resolve => setTimeout( resolve, 0 ) );
103
-
104
- expect( regularDiv.parentNode ).toBe( document.body );
105
- unmount();
106
- } );
107
-
108
- test( 'observes and relocates newly added portal nodes', async () => {
109
- const { container, unmount } = setupHook();
110
-
111
- const portal = createVisxPortalNode();
112
- document.body.appendChild( portal );
113
-
114
- // MutationObserver is async — wait for microtask
115
- await new Promise( resolve => setTimeout( resolve, 0 ) );
116
-
117
- expect( portal.parentNode ).toBe( container );
118
- unmount();
119
- } );
120
-
121
- test( 'patched removeChild handles relocated nodes without throwing', () => {
122
- const { unmount, portal } = setupHook( { withPortal: true } );
123
-
124
- // Portal is now in container, but visx will call document.body.removeChild(portal)
125
- expect( () => document.body.removeChild( portal! ) ).not.toThrow();
126
- expect( portal!.parentNode ).toBeNull();
127
- unmount();
128
- } );
129
-
130
- test( 'patched removeChild delegates to original for non-relocated nodes', () => {
131
- const { unmount } = setupHook();
132
-
133
- const regularDiv = document.createElement( 'div' );
134
- document.body.appendChild( regularDiv );
135
- expect( () => document.body.removeChild( regularDiv ) ).not.toThrow();
136
- expect( regularDiv.parentNode ).toBeNull();
137
- unmount();
138
- } );
139
-
140
- test( 'cleanup restores removeChild when it has not been wrapped by others', () => {
141
- const originalRemoveChild = document.body.removeChild;
142
- const { unmount } = setupHook();
143
-
144
- // removeChild should be patched
145
- expect( document.body.removeChild ).not.toBe( originalRemoveChild );
146
-
147
- unmount();
148
-
149
- // Should be restored
150
- expect( document.body.removeChild ).toBe( originalRemoveChild );
151
- } );
152
-
153
- test( 'cleanup leaves removeChild when another wrapper was installed after ours', () => {
154
- const { unmount } = setupHook();
155
-
156
- // Simulate another library wrapping removeChild after our patch
157
- const ourPatch = document.body.removeChild;
158
- const thirdPartyWrapper = function < T extends Node >( child: T ): T {
159
- return ourPatch.call( document.body, child );
160
- };
161
- document.body.removeChild = thirdPartyWrapper;
162
-
163
- unmount();
164
-
165
- // Should NOT restore — third party wrapper is still in place
166
- expect( document.body.removeChild ).toBe( thirdPartyWrapper );
167
- } );
168
-
169
- test( 'cleanup moves relocated nodes back to document.body', () => {
170
- const { container, unmount, portal } = setupHook( { withPortal: true } );
171
-
172
- expect( portal!.parentNode ).toBe( container );
173
-
174
- unmount();
175
-
176
- // Node should be moved back to body on cleanup
177
- expect( portal!.parentNode ).toBe( document.body );
178
- } );
179
-
180
- test( 'cleanup removes relocated-portal class from nodes', () => {
181
- const { unmount, portal } = setupHook( { withPortal: true } );
182
-
183
- expect( portal! ).toHaveClass( 'relocatedPortal' );
184
-
185
- unmount();
186
-
187
- expect( portal! ).not.toHaveClass( 'relocatedPortal' );
188
- } );
189
-
190
- test( 'ref-counting allows multiple instances to share the patch', () => {
191
- const container1 = document.createElement( 'div' );
192
- const container2 = document.createElement( 'div' );
193
- document.body.appendChild( container1 );
194
- document.body.appendChild( container2 );
195
-
196
- const ref1 = { current: container1 };
197
- const ref2 = { current: container2 };
198
-
199
- const originalRemoveChild = document.body.removeChild;
200
-
201
- const { unmount: unmountFirst } = renderHook( () => useTooltipPortalRelocator( ref1 ) );
202
- const patchedFn = document.body.removeChild;
203
- const { unmount: unmountSecond } = renderHook( () => useTooltipPortalRelocator( ref2 ) );
204
-
205
- // Both should share the same patched removeChild
206
- expect( document.body.removeChild ).toBe( patchedFn );
207
-
208
- // Unmounting the first should keep the patch (ref count > 0)
209
- unmountFirst();
210
- expect( document.body.removeChild ).toBe( patchedFn );
211
-
212
- // Unmounting the second should restore the original
213
- unmountSecond();
214
- expect( document.body.removeChild ).toBe( originalRemoveChild );
215
- } );
216
- } );
@@ -1,7 +0,0 @@
1
- .relocatedPortal {
2
- position: fixed;
3
- inset: 0;
4
- overflow: visible;
5
- z-index: 1;
6
- pointer-events: none;
7
- }
@@ -1,188 +0,0 @@
1
- import { useEffect } from 'react';
2
- import styles from './use-tooltip-portal-relocator.module.scss';
3
- import type { RefObject } from 'react';
4
-
5
- /**
6
- * Detects whether a DOM node is a visx chart tooltip portal.
7
- *
8
- * visx renders tooltips via `ReactDOM.createPortal` into plain `<div>` elements
9
- * appended to `document.body`. These portals have no id or className and contain
10
- * a child element with the class `visx-tooltip`.
11
- * @param node - The DOM node to check.
12
- * @return Whether the node is a visx tooltip portal div.
13
- */
14
- function isVisxPortalNode( node: Node ): node is HTMLDivElement {
15
- return (
16
- node instanceof HTMLDivElement &&
17
- node.parentElement === document.body &&
18
- ! node.id &&
19
- ! node.className &&
20
- node.querySelector( '.visx-tooltip' ) !== null
21
- );
22
- }
23
-
24
- // Shared state for the document.body.removeChild patch.
25
- // Reference-counted so multiple hook instances can coexist safely.
26
- let patchRefCount = 0;
27
- let origRemoveChild: typeof document.body.removeChild | null = null;
28
- let patchedRemoveChild: typeof document.body.removeChild | null = null;
29
- const relocatedNodes = new WeakSet< Node >();
30
-
31
- /**
32
- * Installs (or increments the ref count of) the shared removeChild patch.
33
- */
34
- function installRemoveChildPatch() {
35
- if ( patchRefCount++ > 0 ) {
36
- return;
37
- }
38
- origRemoveChild = document.body.removeChild;
39
- patchedRemoveChild = function < T extends Node >( this: HTMLElement, child: T ): T {
40
- if ( relocatedNodes.has( child ) && child.parentNode !== this ) {
41
- relocatedNodes.delete( child );
42
- child.parentNode?.removeChild( child );
43
- return child;
44
- }
45
- return origRemoveChild!.call( this, child );
46
- };
47
- document.body.removeChild = patchedRemoveChild;
48
- }
49
-
50
- /**
51
- * Decrements the ref count and removes the patch when no instances remain.
52
- * If another library has since wrapped our patch, we leave it in place to
53
- * avoid breaking their chain — our function becomes a transparent pass-through
54
- * once all relocated nodes have been cleaned up.
55
- */
56
- function uninstallRemoveChildPatch() {
57
- if ( --patchRefCount > 0 ) {
58
- return;
59
- }
60
- // Only revert if removeChild is still our function. If something else
61
- // has wrapped it, reverting would break their patch.
62
- if ( document.body.removeChild === patchedRemoveChild ) {
63
- document.body.removeChild = origRemoveChild!;
64
- }
65
- origRemoveChild = null;
66
- patchedRemoveChild = null;
67
- }
68
-
69
- /**
70
- * Relocates visx chart tooltip portals from `document.body` into a target
71
- * container element. This allows the tooltips to participate in the same CSS
72
- * stacking context as other elements in the container (e.g. a sticky header),
73
- * so z-index ordering works correctly between them.
74
- *
75
- * The relocated portal divs use `position: fixed` at the viewport origin to
76
- * preserve the tooltip coordinate system (visx calculates positions relative
77
- * to the viewport).
78
- *
79
- * Because the visx Portal class calls `document.body.removeChild(node)` during
80
- * unmount, we patch `document.body.removeChild` to gracefully handle nodes that
81
- * were moved out of body. Without this, React throws a "not a child of this
82
- * node" error when tooltips unmount.
83
- *
84
- * **Important:** The container and its ancestors must not have CSS `transform`,
85
- * `perspective`, or `filter` properties set, as these create a new containing
86
- * block for `position: fixed` children, breaking viewport-relative positioning.
87
- *
88
- * @param containerRef - Ref to the element that portals should be relocated into.
89
- * The element referenced here, or one of its ancestors,
90
- * should establish the desired stacking context (for example
91
- * by using position and z-index).
92
- */
93
- export function useTooltipPortalRelocator(
94
- containerRef: RefObject< HTMLElement | null > | undefined
95
- ) {
96
- useEffect( () => {
97
- const container = containerRef?.current;
98
- if ( ! container ) {
99
- return;
100
- }
101
-
102
- // Track nodes relocated by this instance so we can move them back on cleanup.
103
- const instanceNodes = new Set< Node >();
104
-
105
- const relocateNode = ( node: Node ) => {
106
- if ( ! isVisxPortalNode( node ) ) {
107
- return;
108
- }
109
-
110
- // Hide the portal immediately to prevent the tooltip from
111
- // flashing at (0,0) before visx calculates the correct position.
112
- node.style.opacity = '0';
113
-
114
- // Position the portal at the viewport origin so visx's
115
- // absolute-positioned tooltip coordinates remain correct.
116
- // Zero-size with overflow: visible so it doesn't affect layout
117
- // but tooltip content still renders. pointerEvents: none on the
118
- // wrapper is intentional — tooltip inner elements manage their own.
119
- node.classList.add( styles.relocatedPortal );
120
-
121
- // Remember the focused element before moving the node — relocating
122
- // a DOM subtree causes the browser to blur any focused descendants.
123
- const { activeElement } = node.ownerDocument;
124
- const focusedElement =
125
- activeElement instanceof HTMLElement && node.contains( activeElement )
126
- ? activeElement
127
- : null;
128
-
129
- // Insert at the start of the container (before header and content).
130
- container.insertBefore( node, container.firstChild );
131
- relocatedNodes.add( node );
132
- instanceNodes.add( node );
133
-
134
- // Restore focus that was lost due to the DOM move.
135
- if ( focusedElement ) {
136
- focusedElement.focus();
137
- }
138
-
139
- // Reveal after two animation frames so visx has positioned the tooltip.
140
- requestAnimationFrame( () => {
141
- requestAnimationFrame( () => {
142
- node.style.opacity = '';
143
- } );
144
- } );
145
- };
146
-
147
- // Patch document.body.removeChild so visx Portal unmount doesn't throw
148
- // when it tries to remove a node we already moved out of body.
149
- installRemoveChildPatch();
150
-
151
- // Relocate any portals that already exist.
152
- for ( const child of Array.from( document.body.children ) ) {
153
- relocateNode( child );
154
- }
155
-
156
- // Watch for new portals being appended to body.
157
- const observer = new MutationObserver( mutations => {
158
- for ( const mutation of mutations ) {
159
- for ( const node of mutation.addedNodes ) {
160
- relocateNode( node );
161
- }
162
- }
163
- } );
164
-
165
- observer.observe( document.body, { childList: true } );
166
-
167
- return () => {
168
- // Disconnect first to avoid the observer re-relocating nodes
169
- // as we move them back to body.
170
- observer.disconnect();
171
-
172
- // Move relocated nodes back to body so visx can clean them up
173
- // normally with the original removeChild.
174
- for ( const node of instanceNodes ) {
175
- if ( node instanceof HTMLElement ) {
176
- node.classList.remove( styles.relocatedPortal );
177
- }
178
- if ( node.parentNode === container ) {
179
- document.body.appendChild( node );
180
- }
181
- relocatedNodes.delete( node );
182
- }
183
- instanceNodes.clear();
184
-
185
- uninstallRemoveChildPatch();
186
- };
187
- }, [ containerRef ] );
188
- }