@automattic/charts 1.3.0 → 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/CHANGELOG.md +16 -0
- package/dist/index.cjs +50 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +0 -9
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +11 -10
- package/dist/index.d.ts +11 -10
- package/dist/index.js +59 -159
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/hooks/index.ts +0 -1
- package/src/hooks/test/use-chart-margin.test.tsx +21 -0
- package/src/hooks/use-chart-margin.tsx +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +1 -18
- package/src/style.css +10 -0
- package/src/types.ts +10 -0
- package/tsup.config.ts +3 -2
- package/src/hooks/test/use-tooltip-portal-relocator.test.ts +0 -216
- package/src/hooks/use-tooltip-portal-relocator.module.scss +0 -7
- package/src/hooks/use-tooltip-portal-relocator.ts +0 -188
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "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.
|
|
103
|
-
"@storybook/react": "10.3.
|
|
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.
|
|
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.
|
|
129
|
+
"storybook": "10.3.6",
|
|
130
130
|
"tsup": "8.5.1",
|
|
131
131
|
"typescript": "5.9.3"
|
|
132
132
|
},
|
package/src/hooks/index.ts
CHANGED
|
@@ -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,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
|
-
}
|