diff --git a/.changeset/smooth-tooltips-inject.md b/.changeset/smooth-tooltips-inject.md new file mode 100644 index 00000000..b1d7e21f --- /dev/null +++ b/.changeset/smooth-tooltips-inject.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Inject tooltip motion through a structural renderer capability so motion works across separately bundled package entrypoints. diff --git a/API-FRICTION.md b/API-FRICTION.md index e51a837c..02117b14 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-14 +Last updated: 2026-08-15 ## Triage rule @@ -327,6 +327,7 @@ Each entry records: | F-288 | Generated examples exposed shared implementation scaffolding | Tooling/API | resolved | | F-289 | Catalog workbenches exposed runtime bootstrap files | Tooling | resolved | | F-290 | Public examples imported a private workspace package | Tooling | resolved | +| F-291 | Renderer capability injection depended on module identity | API/Tooling | resolved | ## Findings @@ -8373,3 +8374,24 @@ Each entry records: every public source closure. - Verification: the catalog contract resolves every fixture subpath as a browser module and reports no `@charts-poc/` import in any public example. + +### F-291 — Renderer capability injection depended on module identity + +- Status: resolved +- Severity: high +- Owner: API/Tooling +- Observed in: hovering the published ShadCN multiple-bar catalog example +- Friction: tooltip motion was attached to the motion renderer through a + module-local symbol. The catalog loaded `/motion` and `/react/tooltip` from + separate esm.sh build namespaces, so each copy created a different symbol. + The chart geometry animated, but the host could not discover or inject the + renderer's tooltip motion controller. +- Decision: make renderer capabilities an explicit, versioned structural + contract. The chart host creates the controller from + `renderer.capabilities.tooltipMotion` and injects it into the tooltip + extension context. Neither discovery nor consumption depends on shared + module identity. +- Verification: the renderer regression supplies a structurally compatible + tooltip-motion capability, then asserts controller creation, paint, hide, + and destruction through the normal tooltip lifecycle. The existing spring + inheritance and tooltip override tests continue to pass. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index aa9bc680..27be02dc 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 26228 }, "TanStack DOM host": { - "bytes": 70878, - "gzip": 24859 + "bytes": 70791, + "gzip": 24832 }, "React adapter": { - "bytes": 73037, - "gzip": 25621 + "bytes": 72950, + "gzip": 25604 }, "React line consumer": { - "bytes": 96267, - "gzip": 34866 + "bytes": 96180, + "gzip": 34832 }, "Compact-scale line scene": { "bytes": 29608, "gzip": 10773 }, "React compact-scale line consumer": { - "bytes": 78768, - "gzip": 27799 + "bytes": 78681, + "gzip": 27762 }, "Custom-scale line scene": { "bytes": 27792, diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index bdbb4946..312ce1b7 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-15T06:11:41.238Z", + "generatedAt": "2026-08-15T07:14:39.579Z", "packageVersions": { "tanstack": "0.13.0", "chartjs": "4.5.1", @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "91e2eef1772dc06118ce5aa1e0b388d12bf91918", - "inputDigest": "sha256:d3480809cd111f9acf9bbaf58be2b4f42bc56c4a748fcc151d49025a2c444e88" + "inputDigest": "sha256:07856952e86ead8b51b7517ecd2bcfd8af9fb47ff152125855b752cd51f2f94c" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 105980, - "gzipBytes": 38537, - "brotliBytes": 34114, - "incrementalGzipBytes": 38537, - "incrementalBrotliBytes": 34114 + "minifiedBytes": 105868, + "gzipBytes": 38506, + "brotliBytes": 34131, + "incrementalGzipBytes": 38506, + "incrementalBrotliBytes": 34131 }, "tanstack-line-interactive": { - "minifiedBytes": 111421, - "gzipBytes": 40306, - "brotliBytes": 35539, - "incrementalGzipBytes": 40306, - "incrementalBrotliBytes": 35539 + "minifiedBytes": 111309, + "gzipBytes": 40288, + "brotliBytes": 35520, + "incrementalGzipBytes": 40288, + "incrementalBrotliBytes": 35520 }, "tanstack-line-advanced": { - "minifiedBytes": 118611, - "gzipBytes": 42632, - "brotliBytes": 37568, - "incrementalGzipBytes": 42632, - "incrementalBrotliBytes": 37568 + "minifiedBytes": 118499, + "gzipBytes": 42603, + "brotliBytes": 37538, + "incrementalGzipBytes": 42603, + "incrementalBrotliBytes": 37538 }, "tanstack-bar-basic": { - "minifiedBytes": 114648, - "gzipBytes": 41796, - "brotliBytes": 36865, - "incrementalGzipBytes": 41796, - "incrementalBrotliBytes": 36865 + "minifiedBytes": 114536, + "gzipBytes": 41765, + "brotliBytes": 36861, + "incrementalGzipBytes": 41765, + "incrementalBrotliBytes": 36861 }, "tanstack-bar-interactive": { - "minifiedBytes": 118940, - "gzipBytes": 43130, - "brotliBytes": 37969, - "incrementalGzipBytes": 43130, - "incrementalBrotliBytes": 37969 + "minifiedBytes": 118828, + "gzipBytes": 43107, + "brotliBytes": 37909, + "incrementalGzipBytes": 43107, + "incrementalBrotliBytes": 37909 }, "tanstack-bar-advanced": { - "minifiedBytes": 119279, - "gzipBytes": 43273, - "brotliBytes": 38033, - "incrementalGzipBytes": 43273, - "incrementalBrotliBytes": 38033 + "minifiedBytes": 119167, + "gzipBytes": 43235, + "brotliBytes": 38037, + "incrementalGzipBytes": 43235, + "incrementalBrotliBytes": 38037 }, "tanstack-area-basic": { - "minifiedBytes": 111064, - "gzipBytes": 40462, - "brotliBytes": 35868, - "incrementalGzipBytes": 40462, - "incrementalBrotliBytes": 35868 + "minifiedBytes": 110952, + "gzipBytes": 40437, + "brotliBytes": 35806, + "incrementalGzipBytes": 40437, + "incrementalBrotliBytes": 35806 }, "tanstack-area-interactive": { - "minifiedBytes": 116505, - "gzipBytes": 42194, - "brotliBytes": 37277, - "incrementalGzipBytes": 42194, - "incrementalBrotliBytes": 37277 + "minifiedBytes": 116393, + "gzipBytes": 42169, + "brotliBytes": 37237, + "incrementalGzipBytes": 42169, + "incrementalBrotliBytes": 37237 }, "tanstack-area-advanced": { - "minifiedBytes": 123877, - "gzipBytes": 44638, - "brotliBytes": 39304, - "incrementalGzipBytes": 44638, - "incrementalBrotliBytes": 39304 + "minifiedBytes": 123765, + "gzipBytes": 44603, + "brotliBytes": 39264, + "incrementalGzipBytes": 44603, + "incrementalBrotliBytes": 39264 }, "tanstack-scatter-basic": { - "minifiedBytes": 107019, - "gzipBytes": 38925, - "brotliBytes": 34417, - "incrementalGzipBytes": 38925, - "incrementalBrotliBytes": 34417 + "minifiedBytes": 106907, + "gzipBytes": 38897, + "brotliBytes": 34396, + "incrementalGzipBytes": 38897, + "incrementalBrotliBytes": 34396 }, "tanstack-scatter-interactive": { - "minifiedBytes": 112460, - "gzipBytes": 40681, - "brotliBytes": 35819, - "incrementalGzipBytes": 40681, - "incrementalBrotliBytes": 35819 + "minifiedBytes": 112348, + "gzipBytes": 40640, + "brotliBytes": 35797, + "incrementalGzipBytes": 40640, + "incrementalBrotliBytes": 35797 }, "tanstack-scatter-advanced": { - "minifiedBytes": 112476, - "gzipBytes": 40686, - "brotliBytes": 35826, - "incrementalGzipBytes": 40686, - "incrementalBrotliBytes": 35826 + "minifiedBytes": 112364, + "gzipBytes": 40646, + "brotliBytes": 35846, + "incrementalGzipBytes": 40646, + "incrementalBrotliBytes": 35846 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 74efb069..6b745258 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "ae6e686c3f133511b6215cb1106fd70e45b90392781d5009d17ed24d2b37de79", + "sourceHash": "af9da85e290ae7b3934c3b4d88cbf6207bda412c3c824e9fb4acef35f383203d", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 8535e734..81e2a65f 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 37.63–43.59 KiB | — | Controlled suite | +| TanStack Charts | 37.60–43.56 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/docs/reference/rendering-and-export.md b/docs/reference/rendering-and-export.md index 5ccca360..01bd612d 100644 --- a/docs/reference/rendering-and-export.md +++ b/docs/reference/rendering-and-export.md @@ -508,6 +508,7 @@ interface ChartRenderer< TYValue extends ChartValue = ChartValue, > { readonly id: string + readonly capabilities?: ChartRendererCapabilities prerender: ( scene: ChartScene, options: RenderChartOptions, @@ -533,6 +534,7 @@ interface ChartRendererRenderContext< | Member | Responsibility | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `ChartRenderer.id` | Stable renderer identifier | +| `ChartRenderer.capabilities` | Expose optional versioned services that the shared host injects into extensions | | `prerender()` | Return deterministic accessible markup for the supplied scene and render options | | `mount()` | Adopt or create a surface in the container and connect renderer-owned environment observers | | `ChartSurface.renderer` | Refer to the renderer that created the surface; a different renderer object on update replaces the surface | @@ -550,6 +552,12 @@ animation frame; ordinary requests proceed only when responsive width changed. width or chart-option change, such as device-pixel ratio or resolved theme colors. Requests made before the same frame are coalesced. +Renderer capabilities are structural so independently bundled package +entrypoints do not need shared object or symbol identity. A renderer can expose +`capabilities.tooltipMotion` with protocol `1`; the host creates its controller +and injects it as `ChartTooltipExtensionContext.motion`. Renderers that omit the +capability do not load or run tooltip motion code. + Animated renderers can expose their current point geometry through `getPresentationPoints()` and notify the host through `subscribePresentationPoints()`. The host then resolves stationary pointers, @@ -588,6 +596,8 @@ stable interaction controller instead of assuming an SVG element. Use `mountChartRenderer` from `@tanstack/charts/renderer`, or the React and Octane `/core` entries, to mount a custom renderer. `RenderChartOptions`, `ChartSurfaceRenderOptions`, `ChartSurface`, `ChartRenderer`, +`ChartRendererCapabilities`, `ChartRendererTooltipMotionCapability`, +`ChartTooltipMotionController`, `ChartTooltipMotionSnapshot`, `ChartRendererRenderContext`, `ChartRendererHostCommonOptions`, `ChartRendererHostOptions`, and `ChartRendererHost` describe the complete boundary. diff --git a/docs/reference/types.md b/docs/reference/types.md index 62399f0d..e579ba1e 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -368,15 +368,19 @@ See [Focus and interaction](./focus-and-interaction.md). ## Rendering types -| Type | Purpose | -| --------------------------- | ------------------------------------------------------------------------------ | -| `RenderChartOptions` | Renderer-neutral accessible name, description, class, tab index, and ID prefix | -| `RenderChartSvgOptions` | SVG specialization of `RenderChartOptions` | -| `ChartSurfaceRenderOptions` | Render options plus optional animation | -| `ChartSurface` | Mounted element, painting, coordinates, focus, and cleanup | -| `ChartRenderer` | Server shell and browser-surface renderer contract | -| `ChartSvgRenderer` | Scene-to-SVG string function | -| `ChartAnimationOptions` | Duration, easing, and reduced-motion policy | +| Type | Purpose | +| -------------------------------------- | ------------------------------------------------------------------------------ | +| `RenderChartOptions` | Renderer-neutral accessible name, description, class, tab index, and ID prefix | +| `RenderChartSvgOptions` | SVG specialization of `RenderChartOptions` | +| `ChartSurfaceRenderOptions` | Render options plus optional animation | +| `ChartSurface` | Mounted element, painting, coordinates, focus, and cleanup | +| `ChartRenderer` | Server shell and browser-surface renderer contract | +| `ChartRendererCapabilities` | Optional structural services supplied by a renderer | +| `ChartRendererTooltipMotionCapability` | Versioned factory for an injected tooltip motion controller | +| `ChartTooltipMotionController` | Tooltip paint, hide, and cleanup motion lifecycle | +| `ChartTooltipMotionSnapshot` | Tooltip position, velocity, and presence state before repaint | +| `ChartSvgRenderer` | Scene-to-SVG string function | +| `ChartAnimationOptions` | Duration, easing, and reduced-motion policy | See [Rendering and export](./rendering-and-export.md). diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 8535e734..81e2a65f 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 37.63–43.59 KiB | — | Controlled suite | +| TanStack Charts | 37.60–43.56 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/reference/rendering-and-export.md b/packages/charts-core/docs/reference/rendering-and-export.md index 5ccca360..01bd612d 100644 --- a/packages/charts-core/docs/reference/rendering-and-export.md +++ b/packages/charts-core/docs/reference/rendering-and-export.md @@ -508,6 +508,7 @@ interface ChartRenderer< TYValue extends ChartValue = ChartValue, > { readonly id: string + readonly capabilities?: ChartRendererCapabilities prerender: ( scene: ChartScene, options: RenderChartOptions, @@ -533,6 +534,7 @@ interface ChartRendererRenderContext< | Member | Responsibility | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `ChartRenderer.id` | Stable renderer identifier | +| `ChartRenderer.capabilities` | Expose optional versioned services that the shared host injects into extensions | | `prerender()` | Return deterministic accessible markup for the supplied scene and render options | | `mount()` | Adopt or create a surface in the container and connect renderer-owned environment observers | | `ChartSurface.renderer` | Refer to the renderer that created the surface; a different renderer object on update replaces the surface | @@ -550,6 +552,12 @@ animation frame; ordinary requests proceed only when responsive width changed. width or chart-option change, such as device-pixel ratio or resolved theme colors. Requests made before the same frame are coalesced. +Renderer capabilities are structural so independently bundled package +entrypoints do not need shared object or symbol identity. A renderer can expose +`capabilities.tooltipMotion` with protocol `1`; the host creates its controller +and injects it as `ChartTooltipExtensionContext.motion`. Renderers that omit the +capability do not load or run tooltip motion code. + Animated renderers can expose their current point geometry through `getPresentationPoints()` and notify the host through `subscribePresentationPoints()`. The host then resolves stationary pointers, @@ -588,6 +596,8 @@ stable interaction controller instead of assuming an SVG element. Use `mountChartRenderer` from `@tanstack/charts/renderer`, or the React and Octane `/core` entries, to mount a custom renderer. `RenderChartOptions`, `ChartSurfaceRenderOptions`, `ChartSurface`, `ChartRenderer`, +`ChartRendererCapabilities`, `ChartRendererTooltipMotionCapability`, +`ChartTooltipMotionController`, `ChartTooltipMotionSnapshot`, `ChartRendererRenderContext`, `ChartRendererHostCommonOptions`, `ChartRendererHostOptions`, and `ChartRendererHost` describe the complete boundary. diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index 62399f0d..e579ba1e 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -368,15 +368,19 @@ See [Focus and interaction](./focus-and-interaction.md). ## Rendering types -| Type | Purpose | -| --------------------------- | ------------------------------------------------------------------------------ | -| `RenderChartOptions` | Renderer-neutral accessible name, description, class, tab index, and ID prefix | -| `RenderChartSvgOptions` | SVG specialization of `RenderChartOptions` | -| `ChartSurfaceRenderOptions` | Render options plus optional animation | -| `ChartSurface` | Mounted element, painting, coordinates, focus, and cleanup | -| `ChartRenderer` | Server shell and browser-surface renderer contract | -| `ChartSvgRenderer` | Scene-to-SVG string function | -| `ChartAnimationOptions` | Duration, easing, and reduced-motion policy | +| Type | Purpose | +| -------------------------------------- | ------------------------------------------------------------------------------ | +| `RenderChartOptions` | Renderer-neutral accessible name, description, class, tab index, and ID prefix | +| `RenderChartSvgOptions` | SVG specialization of `RenderChartOptions` | +| `ChartSurfaceRenderOptions` | Render options plus optional animation | +| `ChartSurface` | Mounted element, painting, coordinates, focus, and cleanup | +| `ChartRenderer` | Server shell and browser-surface renderer contract | +| `ChartRendererCapabilities` | Optional structural services supplied by a renderer | +| `ChartRendererTooltipMotionCapability` | Versioned factory for an injected tooltip motion controller | +| `ChartTooltipMotionController` | Tooltip paint, hide, and cleanup motion lifecycle | +| `ChartTooltipMotionSnapshot` | Tooltip position, velocity, and presence state before repaint | +| `ChartSvgRenderer` | Scene-to-SVG string function | +| `ChartAnimationOptions` | Duration, easing, and reduced-motion policy | See [Rendering and export](./rendering-and-export.md). diff --git a/packages/charts-core/src/dom-types.ts b/packages/charts-core/src/dom-types.ts index 12d8b1f7..b18a0462 100644 --- a/packages/charts-core/src/dom-types.ts +++ b/packages/charts-core/src/dom-types.ts @@ -5,6 +5,7 @@ import type { ChartFocusState, ChartHostControl, ChartHostControlExtensionToken, + ChartMotionTransition, ChartPoint, ChartScene, ChartSvgRenderer, @@ -123,12 +124,52 @@ export interface ChartSurface< destroy: () => void } +export interface ChartTooltipMotionSnapshot { + wasHidden: boolean + showPresence: boolean + previousLeft?: number + previousTop?: number + movementX: number + movementY: number + velocityX: number + velocityY: number + presence?: { opacity: number; scale: number } +} + +export interface ChartTooltipMotionController { + beforePaint: (element: HTMLElement) => ChartTooltipMotionSnapshot + afterPaint: ( + element: HTMLElement, + snapshot: ChartTooltipMotionSnapshot, + transition: false | ChartMotionTransition | undefined, + ) => void + hide: ( + element: HTMLElement, + transition: false | ChartMotionTransition | undefined, + complete: () => void, + ) => boolean + destroy: (element: HTMLElement | undefined) => void +} + +export interface ChartRendererTooltipMotionCapability { + readonly protocol: 1 + createController: (context: { + container: HTMLElement + transition: () => false | ChartMotionTransition | undefined + }) => ChartTooltipMotionController +} + +export interface ChartRendererCapabilities { + readonly tooltipMotion?: ChartRendererTooltipMotionCapability +} + export interface ChartRenderer< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, > { readonly id: string + readonly capabilities?: ChartRendererCapabilities prerender: ( scene: ChartScene, options: RenderChartOptions, @@ -142,6 +183,7 @@ export interface ChartRenderer< /** A definition-agnostic renderer that acquires chart types from its host. */ export interface UniversalChartRenderer { readonly id: string + readonly capabilities?: ChartRendererCapabilities prerender: < TDatum, TXValue extends ChartValue = ChartValue, @@ -191,6 +233,7 @@ export interface ChartTooltipExtensionContext< TYValue extends ChartValue = ChartValue, > { container: HTMLElement + motion?: ChartTooltipMotionController dismiss: () => void bodyChange: () => | (( diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index b1dddf90..bf0d7178 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -245,10 +245,12 @@ export type { ChartPointerResolution, ChartRenderContext, ChartRenderer, + ChartRendererCapabilities, ChartRendererHost, ChartRendererHostCommonOptions, ChartRendererHostOptions, ChartRendererRenderContext, + ChartRendererTooltipMotionCapability, UniversalChartRenderer, ChartSurface, ChartSurfaceRenderOptions, @@ -256,6 +258,8 @@ export type { ChartTooltipExtension, ChartTooltipExtensionContext, ChartTooltipExtensionInstance, + ChartTooltipMotionController, + ChartTooltipMotionSnapshot, ChartTooltipPaintContext, ChartTooltipPortalExtension, ChartTooltipPortalExtensionContext, diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index 7b9ecfdb..b3b60c8f 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -15,12 +15,6 @@ import { renderChartSvgWithResources } from './svg-resources' import { svgClientToScene } from './svg-coordinates' import { valueKey } from './scales' import { resolveRollingPathPlan } from './motion-path' -import { - chartRendererMotion, - type ChartRendererMotionCapability, - type ChartTooltipMotionController, - type ChartTooltipMotionSnapshot, -} from './renderer-motion-internal' import type { RollingPathPlan, RollingPathSnapshot, @@ -35,8 +29,12 @@ import { } from './svg-focus-guide-layer' import type { ChartRenderer, + ChartRendererCapabilities, + ChartRendererTooltipMotionCapability, ChartSurface, ChartSurfaceRenderOptions, + ChartTooltipMotionController, + ChartTooltipMotionSnapshot, UniversalChartRenderer, } from './dom-types' import type { @@ -121,7 +119,7 @@ interface ChartSvgMotionDriver { animateSvgFragment: ( context: ChartSvgMotionFragmentContext, ) => () => void - createTooltip: ChartRendererMotionCapability['createTooltip'] + createTooltip: ChartRendererTooltipMotionCapability['createController'] } interface MotionValueState { @@ -763,13 +761,15 @@ export function motion( options: ChartMotionOptions = {}, ): UniversalChartRenderer { const capabilityDriver = createSvgMotionDriver(options) - const renderer: UniversalChartRenderer & { - [chartRendererMotion]: ChartRendererMotionCapability - } = { - id: `svg:${capabilityDriver.id}`, - [chartRendererMotion]: { - createTooltip: capabilityDriver.createTooltip, + const capabilities: ChartRendererCapabilities = { + tooltipMotion: { + protocol: 1, + createController: capabilityDriver.createTooltip, }, + } + const renderer: UniversalChartRenderer = { + id: `svg:${capabilityDriver.id}`, + capabilities, prerender: renderChartSvgWithResources, mount< TDatum, @@ -799,11 +799,14 @@ function createMotionSvgChartRenderer< > = renderChartSvgWithResources, ownerRenderer?: ChartRenderer, ): ChartRenderer { - const renderer: ChartRenderer & { - [chartRendererMotion]: ChartRendererMotionCapability - } = { + const renderer: ChartRenderer = { id: `svg:${motion.id}`, - [chartRendererMotion]: { createTooltip: motion.createTooltip }, + capabilities: { + tooltipMotion: { + protocol: 1, + createController: motion.createTooltip, + }, + }, prerender: renderSvg, mount(container) { const adoptedRoot = diff --git a/packages/charts-core/src/renderer-motion-internal.ts b/packages/charts-core/src/renderer-motion-internal.ts deleted file mode 100644 index d16d6526..00000000 --- a/packages/charts-core/src/renderer-motion-internal.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ChartRenderer, ChartTooltipExtensionContext } from './dom-types' -import type { ChartMotionTransition, ChartValue } from './types' - -export const chartRendererMotion = Symbol('tanstack-charts-renderer-motion') - -export interface ChartTooltipMotionSnapshot { - wasHidden: boolean - showPresence: boolean - previousLeft?: number - previousTop?: number - movementX: number - movementY: number - velocityX: number - velocityY: number - presence?: { opacity: number; scale: number } -} - -export interface ChartTooltipMotionController { - beforePaint: (element: HTMLElement) => ChartTooltipMotionSnapshot - afterPaint: ( - element: HTMLElement, - snapshot: ChartTooltipMotionSnapshot, - transition: false | ChartMotionTransition | undefined, - ) => void - hide: ( - element: HTMLElement, - transition: false | ChartMotionTransition | undefined, - complete: () => void, - ) => boolean - destroy: (element: HTMLElement | undefined) => void -} - -export interface ChartRendererMotionCapability { - createTooltip: (context: { - container: HTMLElement - transition: () => false | ChartMotionTransition | undefined - }) => ChartTooltipMotionController -} - -type MotionRenderer = ChartRenderer & { - [chartRendererMotion]?: ChartRendererMotionCapability -} - -type MotionTooltipContext = ChartTooltipExtensionContext & { - [chartRendererMotion]?: ChartTooltipMotionController -} - -export function rendererMotionCapability( - renderer: ChartRenderer, -): ChartRendererMotionCapability | undefined { - return (renderer as MotionRenderer)[chartRendererMotion] -} - -export function tooltipMotionController< - TDatum, - TXValue extends ChartValue, - TYValue extends ChartValue, ->( - context: ChartTooltipExtensionContext, -): ChartTooltipMotionController | undefined { - return (context as MotionTooltipContext)[chartRendererMotion] -} - -export function connectRendererTooltipMotion< - TDatum, - TXValue extends ChartValue, - TYValue extends ChartValue, ->( - renderer: ChartRenderer, - context: ChartTooltipExtensionContext, - transition: () => false | ChartMotionTransition | undefined, -) { - const capability = rendererMotionCapability(renderer) - if (capability) { - const motionContext = context as MotionTooltipContext - motionContext[chartRendererMotion] = capability.createTooltip({ - container: context.container, - transition, - }) - } - return context -} diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts index 5b8fa267..551a6992 100644 --- a/packages/charts-core/src/renderer.test.ts +++ b/packages/charts-core/src/renderer.test.ts @@ -12,6 +12,7 @@ import { portal as portalExtension } from './tooltip-portal' import type { ChartHostControlExtension, ChartRenderer, + ChartRendererCapabilities, ChartSurface, ChartSurfaceRenderOptions, } from './dom-types' @@ -233,6 +234,62 @@ describe('renderer-neutral chart host', () => { } }) + it('injects structurally compatible renderer tooltip motion', () => { + const fake = createFakeRenderer() + const beforePaint = vi.fn(() => ({ + wasHidden: true, + showPresence: true, + movementX: 0, + movementY: 0, + velocityX: 0, + velocityY: 0, + })) + const afterPaint = vi.fn() + const hide = vi.fn(() => false) + const destroy = vi.fn() + const createController = vi.fn(() => ({ + beforePaint, + afterPaint, + hide, + destroy, + })) + const capabilities = { + tooltipMotion: { + protocol: 1, + createController, + }, + } satisfies ChartRendererCapabilities + Object.assign(fake.renderer, { capabilities }) + const container = document.createElement('div') + const host = mountChartRenderer(container, { + definition: defineChart(definition, { + maxFocusDistance: 1_000, + tooltip: tooltipExtension, + }), + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Injected tooltip motion', + }) + + try { + host.interaction.setControlledFocus(host.getScene().points[0]!) + expect(createController).toHaveBeenCalledOnce() + expect(createController).toHaveBeenCalledWith({ + container, + transition: expect.any(Function), + }) + expect(beforePaint).toHaveBeenCalledOnce() + expect(afterPaint).toHaveBeenCalledOnce() + + host.interaction.setControlledFocus(null) + expect(hide).toHaveBeenCalledOnce() + } finally { + host.destroy() + expect(destroy).toHaveBeenCalledOnce() + } + }) + it('lets chart motion disable tooltip motion while a tooltip override re-enables it', () => { const descriptor = Object.getOwnPropertyDescriptor( window.HTMLElement.prototype, diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index da3fb94f..68322c91 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -4,7 +4,6 @@ import { createDomTextMeasurer } from './dom-text' import { findNearestPoint, viewportInteractionPoints } from './scene' import { focusDisabled } from './focus-disabled' import { nearestPoint } from './nearest' -import { connectRendererTooltipMotion } from './renderer-motion-internal' import { chartPointFromNavigationOrder, chartPointFromSceneOrder, @@ -1022,17 +1021,20 @@ export function mountChartRenderer< if (tooltipExtension !== input.extension || !tooltipInstance) { destroyTooltip() tooltipExtension = input.extension - tooltipInstance = input.extension.create( - connectRendererTooltipMotion( - surface.renderer, - { - container, - dismiss: dismissTooltip, - bodyChange: () => options.onTooltipBodyChange, - }, - resolveTooltipMotion, - ), - ) + const tooltipMotionCapability = + surface.renderer.capabilities?.tooltipMotion + tooltipInstance = input.extension.create({ + container, + motion: + tooltipMotionCapability?.protocol === 1 + ? tooltipMotionCapability.createController({ + container, + transition: resolveTooltipMotion, + }) + : undefined, + dismiss: dismissTooltip, + bodyChange: () => options.onTooltipBodyChange, + }) } const instance = tooltipInstance instance.update(input.options) diff --git a/packages/charts-core/src/tooltip.ts b/packages/charts-core/src/tooltip.ts index 8dbc4c82..eed7681f 100644 --- a/packages/charts-core/src/tooltip.ts +++ b/packages/charts-core/src/tooltip.ts @@ -1,5 +1,4 @@ import { placeTooltip } from './tooltip-position' -import { tooltipMotionController } from './renderer-motion-internal' import { createChartTooltipContent, orderChartTooltipPoints, @@ -84,7 +83,7 @@ function createTooltipExtension< let portalInstance: ChartTooltipPortalExtensionInstance | undefined const { container } = extensionContext const view = container.ownerDocument.defaultView - const tooltipMotion = tooltipMotionController(extensionContext) + const tooltipMotion = extensionContext.motion function update(nextOptions: ChartTooltipOptions) { if (options !== nextOptions) bodyDirty = true diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 5855a6a3..8ef428d2 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -611,6 +611,14 @@ const callbackInventory = { ], ['@tanstack/charts:src/dom-types.ts:ChartHost', 'destroy getScene update'], ['@tanstack/charts:src/dom-types.ts:ChartRenderer', 'mount prerender'], + [ + '@tanstack/charts:src/dom-types.ts:ChartRendererTooltipMotionCapability', + 'createController transition', + ], + [ + '@tanstack/charts:src/dom-types.ts:ChartTooltipMotionController', + 'afterPaint beforePaint destroy hide hide.complete', + ], [ '@tanstack/charts:src/dom-types.ts:UniversalChartRenderer', 'mount prerender',