@automattic/charts 1.3.1 → 1.4.1

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/AGENTS.md DELETED
@@ -1,78 +0,0 @@
1
- # AGENTS.md
2
-
3
- Package-specific guidance for AI agents working in `projects/js-packages/charts`.
4
-
5
- ## CRITICAL Rules
6
-
7
- - Do not invent behavior in docs. If unsure, verify implementation and stories first.
8
- - Do not assume wildcard exports like `./*` or `./providers/*` — they don't exist. Check the explicit exports in `package.json`.
9
-
10
- ## Changelog
11
-
12
- Run from monorepo root:
13
-
14
- ```bash
15
- jp changelog add js-packages/charts -s patch -t changed -e "Charts: <user-facing change>."
16
- ```
17
-
18
- ## Architecture Decisions (Do Not "Fix" These)
19
-
20
- - Accessibility behavior (keyboard navigation, accessible tooltips) is core chart behavior, not optional polish.
21
- - Charts are responsive by default — do not add external responsive wrappers that conflict with built-in sizing semantics.
22
-
23
- ## WordPress UI + Theme Integration
24
-
25
- The package is migrating to WordPress UI and Theme as its defaults. When adding or changing code, follow these defaults unless the task explicitly says otherwise:
26
-
27
- - **Design tokens (WPDS).** In SCSS, use `var(--wpds-dimension-*, <fallback>)`, `var(--wpds-border-*, <fallback>)`, and `var(--wpds-typography-*, <fallback>)` instead of hardcoded px values for spacing, padding, margins, border radius, border width, font size, and font weight. Fallbacks must match the WPDS spec value for that token — do not invent fallback values.
28
- - **UI primitives.** Prefer `Stack` and the stable `Text` from `@wordpress/ui` over ad-hoc flexbox or raw `<span>`/`<div>` for layout and text. Do not use `__experimental*` exports from `@wordpress/components` (e.g. `__experimentalText`, `__experimentalHStack`) — use the stable `@wordpress/ui` equivalents. Exception: `__experimentalGrid` has no stable alternative yet and is acceptable to use for now.
29
- - **Theming.** Theming flows through `@wordpress/theme`'s `ThemeProvider` (unlocked via private APIs in Storybook; see `src/stories/chart-decorator.tsx`). Do not manually override DS tokens in stories or components to achieve theming — pass a color through `ThemeProvider` instead.
30
- - **Chart element styles.** Read chart element styles via `getElementStyles` from `GlobalChartsProvider`, not directly from `theme`. This is the supported path for color/style resolution across themes.
31
-
32
- ## Documentation Workflow
33
-
34
- - For docs tasks agents should use the skill at `.agents/skills/charts-docs.md`.
35
- - For public chart/component docs, maintain the standard set when applicable: `[feature-name].stories.tsx` + `.docs.mdx` + `.api.mdx`. Some docs are intentionally guide-only and skip the full triplet.
36
- - Only include animation docs when the component actually supports an `animation` prop.
37
-
38
- ## Conventions
39
-
40
- - Preserve backward compatibility for existing public APIs unless a breaking change is explicitly requested.
41
- - Prefer extending existing chart components/patterns over introducing new surface area.
42
- - Reuse existing hooks/providers/utilities before adding new abstractions.
43
- - Avoid `!important` unless there is no viable alternative and the rationale is documented.
44
- - Add focused behavioral tests for changed behavior; avoid speculative tests for unimplemented behavior.
45
- - Verify behavior/UI changes in Storybook using browser automation, not only unit tests.
46
- - Prefer charts-scoped PR titles (e.g. `Charts: ...`, `CHARTS-###: ...`).
47
- - Include test steps and visual evidence (screenshots/GIFs) in PR descriptions for UI changes.
48
-
49
- ## Common Pitfalls
50
-
51
- - Claiming Rollup is used for builds (it's tsup).
52
- - Documenting props or behavior not present in stories and implementation.
53
- - Refactoring core composition/provider patterns as if they are accidental complexity.
54
- - Defining new chart prop interfaces that diverge from established base chart contracts (for example, not aligning with `BaseChartProps` when appropriate).
55
- - Using ad-hoc flexbox layouts where established layout primitives (e.g. `Stack` from `@wordpress/ui`) should be preferred.
56
- - Accessing colors/styles directly from `theme` rather than using `getElementStyles` from `GlobalChartsProvider`.
57
- - Hardcoding px values in SCSS for spacing, borders, or typography where a WPDS token (`--wpds-dimension-*`, `--wpds-border-*`, `--wpds-typography-*`) exists.
58
- - CSS variable fallback values that diverge from the WPDS spec for that token.
59
- - Using `__experimental*` exports from `@wordpress/components` (e.g. `__experimentalText`, `__experimentalHStack`) instead of the stable `@wordpress/ui` equivalents. (`__experimentalGrid` is excepted — no stable alternative exists yet.)
60
- - Manually overriding DS tokens in stories or components to achieve theming instead of passing a color through `@wordpress/theme`'s `ThemeProvider`.
61
- - Responsive wrappers that conflict with component sizing semantics (fixed-height charts, resize behavior, aspect-ratio assumptions).
62
- - Updating `.docs.mdx` without the corresponding `.api.mdx` when API docs are affected.
63
- - Not checking CSF file references in `.docs.mdx` when changing or removing stories.
64
- - Stories that don't visibly demonstrate documented behavior/props, or render clipped due to container sizing.
65
- - Breaking MDX `<Source code={\`...\` } />` rendering by malformed/flattened indentation inside template literals.
66
- - Tooltip styles/positioning that only work on default backgrounds or fail at chart edges.
67
- - Using mock/placeholder series data in production code.
68
- - Avoidable multi-pass data transformations in render paths when a single pass suffices.
69
- - CSS layout/overflow workarounds without documenting why they're needed.
70
-
71
- ## Definition of Done
72
-
73
- - Behavior verified in Storybook and/or tests, not only by static checks.
74
- - Edits remain in package boundaries; avoid unrelated refactors.
75
-
76
- ## References
77
-
78
- - Published Storybook: `https://automattic.github.io/jetpack-storybook/?path=/docs/js-packages-charts-library`
@@ -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
- }