From 7ce723b1132d359df01d5de4086b29fe0d0d945e Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:39:23 +0800 Subject: [PATCH 1/2] feat(chart): add `responsive` prop and rename RechartsWrapper to ChartsWrapper Follows Recharts 3.3+ by adding a `responsive` prop to chart components: the chart root fills its parent via CSS and self-measures with a ResizeObserver, so no ResponsiveContainer wrapper is needed. Non-responsive behavior is unchanged. Also renames the misnamed RechartsWrapper (React library name) to ChartsWrapper, matching the ChartsSurface convention. - generateCategoricalChart: new `responsive` prop, measured size drives layout - ChartsWrapper: `responsive`/`onResize` props, 100% CSS sizing + ResizeObserver - update Sankey/Sunburst/Treemap consumers to ChartsWrapper - add responsive-prop tests and Examples/ResponsiveProp story - document the `responsive` prop in the chart-size guide --- docs/content/2.guides/05.chart-size.md | 35 +++++- ...{RechartsWrapper.tsx => ChartsWrapper.tsx} | 36 +++++- packages/vue/src/chart/Sankey.tsx | 6 +- packages/vue/src/chart/SunburstChart.tsx | 6 +- packages/vue/src/chart/Treemap.tsx | 6 +- .../__stories__/ResponsiveProp.stories.tsx | 41 +++++++ .../vue/src/chart/__tests__/Treemap.spec.tsx | 2 +- .../chart/__tests__/responsive-prop.spec.tsx | 113 ++++++++++++++++++ .../src/chart/generateCategoricalChart.tsx | 84 ++++++++----- 9 files changed, 283 insertions(+), 46 deletions(-) rename packages/vue/src/chart/{RechartsWrapper.tsx => ChartsWrapper.tsx} (79%) create mode 100644 packages/vue/src/chart/__stories__/ResponsiveProp.stories.tsx create mode 100644 packages/vue/src/chart/__tests__/responsive-prop.spec.tsx diff --git a/docs/content/2.guides/05.chart-size.md b/docs/content/2.guides/05.chart-size.md index baadb04..d7d5945 100644 --- a/docs/content/2.guides/05.chart-size.md +++ b/docs/content/2.guides/05.chart-size.md @@ -5,7 +5,7 @@ navigation: icon: i-lucide-maximize --- -Every chart in vccs requires a width and height. You can set these explicitly or use `` for dynamic sizing. +Every chart in vccs needs a width and height. You can set them explicitly, let the chart size itself with the `responsive` prop, or wrap it in ``. ## Explicit dimensions @@ -17,9 +17,34 @@ Pass `width` and `height` directly to the chart component: ``` -## Responsive sizing +## Responsive sizing with the `responsive` prop -Wrap your chart in `` to fill its parent container. All the chart demos on this site use `ResponsiveContainer` — here's an example: +The simplest way to make a chart follow its parent is the `responsive` prop (recommended). No extra wrapper component is needed — the chart's own root element fills the parent via standard CSS sizing and measures itself with a `ResizeObserver`: + +```vue + + + + + + +
+ + + +
+ + + + + +``` + +When `responsive` is set, any `width`/`height` props are ignored — sizing is driven entirely by CSS. Give the parent (or the chart's `style`) a definite height, otherwise the chart collapses to zero height and nothing renders. + +## Responsive sizing with `ResponsiveContainer` + +Alternatively, wrap your chart in `` to fill its parent container. All the chart demos on this site use `ResponsiveContainer` — here's an example: ::chart-demo{name="Responsive Chart" description="A chart using ResponsiveContainer to fill its parent width." src="bar-charts/simple-bar-chart"} :: @@ -61,11 +86,11 @@ Increase margins when you need space for: ## Common sizing issues -**Chart not visible**: Ensure the parent element has a defined width and height. `ResponsiveContainer` with `width="100%"` requires its parent to have a non-zero width. +**Chart not visible**: Ensure the parent element has a defined width and height. Both the `responsive` prop and `ResponsiveContainer` require the parent to have a non-zero size (a definite height in particular). **Chart too small**: Check that `margin` values aren't consuming most of the available space. Large margins on a small chart can leave very little room for data. -**SSR rendering**: `ResponsiveContainer` relies on `ResizeObserver` and renders nothing on the server. Wrap it in `` in Nuxt/SSR environments: +**SSR rendering**: Both the `responsive` prop and `ResponsiveContainer` rely on `ResizeObserver`, so the chart only sizes itself on the client. In Nuxt/SSR environments, wrap the chart in ``: ```vue diff --git a/packages/vue/src/chart/RechartsWrapper.tsx b/packages/vue/src/chart/ChartsWrapper.tsx similarity index 79% rename from packages/vue/src/chart/RechartsWrapper.tsx rename to packages/vue/src/chart/ChartsWrapper.tsx index cb916b7..3233a4f 100644 --- a/packages/vue/src/chart/RechartsWrapper.tsx +++ b/packages/vue/src/chart/ChartsWrapper.tsx @@ -1,4 +1,4 @@ -import { type PropType, type Ref, type StyleValue, type VNode, defineComponent, ref } from 'vue' +import { type PropType, type StyleValue, defineComponent, onMounted, onUnmounted, ref } from 'vue' import { mouseLeaveChart } from '../state/tooltipSlice' import { useAppDispatch } from '../state/hooks' import { mouseClickAction, mouseMoveAction } from '../state/mouseEventsMiddleware' @@ -13,8 +13,8 @@ import { providePortalRaw } from '@/chart/TooltipPortalContext' import { provideLegendPortalRaw } from '@/chart/LegendPortalContext' import { getChartPointer } from '@/utils/chart' -export const RechartsWrapper = defineComponent({ - name: 'RechartsWrapper', +export const ChartsWrapper = defineComponent({ + name: 'ChartsWrapper', props: { class: classProp, height: { type: Number, required: true }, @@ -26,9 +26,11 @@ export const RechartsWrapper = defineComponent({ onMouseLeave: { type: Function as PropType }, onMouseMove: { type: Function as PropType }, onMouseUp: { type: Function as PropType }, + onResize: { type: Function as PropType<(width: number, height: number) => void> }, onTouchEnd: { type: Function as PropType }, onTouchMove: { type: Function as PropType }, onTouchStart: { type: Function as PropType }, + responsive: { type: Boolean, default: false }, style: { type: [String, Object, Array] as PropType }, width: { type: Number, required: true }, }, @@ -42,12 +44,33 @@ export const RechartsWrapper = defineComponent({ const legendPortal = ref(null) providePortalRaw(tooltipPortal) provideLegendPortalRaw(legendPortal) + const wrapperEl = ref(null) const innerRef = (node: HTMLDivElement | null) => { scaleRef.value = node tooltipPortal.value = node legendPortal.value = node + wrapperEl.value = node } + // When `responsive` is set, the wrapper div fills its parent via CSS and + // measures itself, feeding the pixel size back to the chart (Recharts 3.3+ pattern). + let resizeObserver: ResizeObserver | null = null + onMounted(() => { + if (!props.responsive || !wrapperEl.value) { + return + } + const { width, height } = wrapperEl.value.getBoundingClientRect() + props.onResize?.(width, height) + resizeObserver = new ResizeObserver((entries) => { + const { width: w, height: h } = entries[0].contentRect + props.onResize?.(w, h) + }) + resizeObserver.observe(wrapperEl.value) + }) + onUnmounted(() => { + resizeObserver?.disconnect() + }) + const myOnClick = (e: MouseEvent) => { // Capture chart pointer synchronously before dispatching to middleware, // because createListenerMiddleware defers to microtask and e.currentTarget will be null by then @@ -119,7 +142,12 @@ export const RechartsWrapper = defineComponent({ return () => (
+ {{ node: slots.node, link: slots.link }} {slots.default?.()} - + ) } }, diff --git a/packages/vue/src/chart/SunburstChart.tsx b/packages/vue/src/chart/SunburstChart.tsx index 7b09cfe..cf909a6 100644 --- a/packages/vue/src/chart/SunburstChart.tsx +++ b/packages/vue/src/chart/SunburstChart.tsx @@ -5,7 +5,7 @@ import { Layer } from '@/container/Layer' import Surface from '@/container/Surface' import { Sector } from '@/shape/Sector' import { polarToCartesian } from '@/utils/polar' -import { RechartsWrapper } from './RechartsWrapper' +import { ChartsWrapper } from './ChartsWrapper' import { createRechartsStore } from '@/state/store' import { useAppDispatch } from '@/state/hooks' import { @@ -225,7 +225,7 @@ export const SunburstChart = defineComponent({ if (!props.data?.children || props.data.children.length === 0) return null return ( - @@ -233,7 +233,7 @@ export const SunburstChart = defineComponent({ {{ content: slots.content }} {slots.default?.()} - + ) } }, diff --git a/packages/vue/src/chart/Treemap.tsx b/packages/vue/src/chart/Treemap.tsx index ea52cce..c2aba86 100644 --- a/packages/vue/src/chart/Treemap.tsx +++ b/packages/vue/src/chart/Treemap.tsx @@ -6,7 +6,7 @@ import { Animate } from '@/animation/Animate' import { Layer } from '@/container/Layer' import Surface from '@/container/Surface' import { getStringSize } from '@/utils/attrs' -import { RechartsWrapper } from './RechartsWrapper' +import { ChartsWrapper } from './ChartsWrapper' import { createRechartsStore } from '@/state/store' import { useAppDispatch } from '@/state/hooks' import { setActiveMouseOverItemIndex, setActiveClickItemIndex, mouseLeaveItem, addTooltipEntrySettings, removeTooltipEntrySettings } from '@/state/tooltipSlice' @@ -442,7 +442,7 @@ export const Treemap = defineComponent({ if (!props.data || props.data.length === 0) return null return ( - @@ -450,7 +450,7 @@ export const Treemap = defineComponent({ {{ content: slots.content }} {slots.default?.()} - + ) } }, diff --git a/packages/vue/src/chart/__stories__/ResponsiveProp.stories.tsx b/packages/vue/src/chart/__stories__/ResponsiveProp.stories.tsx new file mode 100644 index 0000000..b9ba563 --- /dev/null +++ b/packages/vue/src/chart/__stories__/ResponsiveProp.stories.tsx @@ -0,0 +1,41 @@ +import type { StoryObj } from '@storybook/vue3-vite' +import { LineChart } from '@/chart/LineChart' +import { Line } from '@/cartesian/line' +import { XAxis, YAxis } from '@/cartesian/axis' +import { CartesianGrid } from '@/cartesian/cartesian-grid' +import { Tooltip } from '@/components/Tooltip' + +export default { + title: 'Examples/ResponsiveProp', + component: LineChart, +} + +const data = [ + { name: 'Page A', uv: 4000 }, + { name: 'Page B', uv: 3000 }, + { name: 'Page C', uv: 2000 }, + { name: 'Page D', uv: 2780 }, + { name: 'Page E', uv: 1890 }, + { name: 'Page F', uv: 2390 }, + { name: 'Page G', uv: 3490 }, +] + +/** + * The `responsive` prop makes the chart fill its parent via CSS and measure itself + * with a ResizeObserver — no `ResponsiveContainer` wrapper needed. + */ +export const Responsive: StoryObj = { + render: () => { + return ( +
+ + + + + + + +
+ ) + }, +} diff --git a/packages/vue/src/chart/__tests__/Treemap.spec.tsx b/packages/vue/src/chart/__tests__/Treemap.spec.tsx index 5dd65e0..bb711ab 100644 --- a/packages/vue/src/chart/__tests__/Treemap.spec.tsx +++ b/packages/vue/src/chart/__tests__/Treemap.spec.tsx @@ -199,7 +199,7 @@ describe('tooltip integration', () => { )) - // RechartsWrapper should be rendered + // ChartsWrapper should be rendered const wrapper = container.querySelector('.v-charts-wrapper') expect(wrapper).toBeTruthy() diff --git a/packages/vue/src/chart/__tests__/responsive-prop.spec.tsx b/packages/vue/src/chart/__tests__/responsive-prop.spec.tsx new file mode 100644 index 0000000..2cc9f25 --- /dev/null +++ b/packages/vue/src/chart/__tests__/responsive-prop.spec.tsx @@ -0,0 +1,113 @@ +import { render } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { Line, LineChart } from '@/index' +import { mockGetBoundingClientRect } from '@/test/mockGetBoundingClientRect' + +class MockResizeObserver { + callback: ResizeObserverCallback + static instances: MockResizeObserver[] = [] + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + MockResizeObserver.instances.push(this) + } + + observe() {} + unobserve() {} + disconnect() {} + + trigger(width: number, height: number) { + this.callback( + [{ contentRect: { width, height } } as ResizeObserverEntry], + this as unknown as ResizeObserver, + ) + } +} + +const data = [ + { name: 'A', uv: 400 }, + { name: 'B', uv: 300 }, + { name: 'C', uv: 200 }, +] + +describe('responsive prop', () => { + beforeEach(() => { + mockGetBoundingClientRect({ width: 500, height: 300 }) + MockResizeObserver.instances = [] + vi.stubGlobal('ResizeObserver', MockResizeObserver) + }) + + it('renders the wrapper div with 100% CSS sizing in responsive mode', async () => { + const { container } = render(() => ( + + + + )) + await nextTick() + + const wrapper = container.querySelector('.v-charts-wrapper') as HTMLElement + expect(wrapper).toBeTruthy() + expect(wrapper.style.width).toBe('100%') + expect(wrapper.style.height).toBe('100%') + }) + + it('does not render the chart surface until the wrapper is measured', () => { + // Initial measurement of 0x0 keeps the chart gated out. + mockGetBoundingClientRect({ width: 0, height: 0 }) + + const { container } = render(() => ( + + + + )) + + expect(container.querySelector('.v-charts-wrapper')).toBeTruthy() + expect(container.querySelector('.vcharts-surface')).toBeNull() + }) + + it('renders the chart at the measured size once mounted', async () => { + const { container } = render(() => ( + + + + )) + await nextTick() + + const svg = container.querySelector('.vcharts-surface') as SVGElement + expect(svg).toBeTruthy() + expect(svg.getAttribute('width')).toBe('500') + expect(svg.getAttribute('height')).toBe('300') + }) + + it('updates the chart size when the ResizeObserver reports a new size', async () => { + const { container } = render(() => ( + + + + )) + await nextTick() + + expect(MockResizeObserver.instances.length).toBe(1) + MockResizeObserver.instances[0].trigger(640, 480) + await nextTick() + + const svg = container.querySelector('.vcharts-surface') as SVGElement + expect(svg.getAttribute('width')).toBe('640') + expect(svg.getAttribute('height')).toBe('480') + }) + + it('renders at fixed px size and creates no ResizeObserver when responsive is not set', async () => { + const { container } = render(() => ( + + + + )) + await nextTick() + + const wrapper = container.querySelector('.v-charts-wrapper') as HTMLElement + expect(wrapper.style.width).toBe('400px') + expect(wrapper.style.height).toBe('320px') + expect(MockResizeObserver.instances.length).toBe(0) + }) +}) diff --git a/packages/vue/src/chart/generateCategoricalChart.tsx b/packages/vue/src/chart/generateCategoricalChart.tsx index f3dbade..42a3c78 100644 --- a/packages/vue/src/chart/generateCategoricalChart.tsx +++ b/packages/vue/src/chart/generateCategoricalChart.tsx @@ -4,14 +4,14 @@ import type { DataKey, LayoutType, Margin, StackOffsetType, SyncMethod, VueProps import { validateWidthHeight } from '@/utils' import { provideStore } from '@reduxjs/vue-redux' import type { PropType, StyleValue } from 'vue' -import { Fragment, defineComponent } from 'vue' +import { Fragment, defineComponent, ref } from 'vue' import type { TooltipEventType } from '@/types/tooltip' import { provideClipPathId } from './provideClipPathId' import Surface from '@/chart/Surface.vue' import { ChartDataContextProvider } from '@/context/ChartDataContextProvider' import type { ChartData } from '@/state/chartDataSlice' import ClipPath from '@/container/ClipPath' -import { RechartsWrapper } from './RechartsWrapper' +import { ChartsWrapper } from './ChartsWrapper' import { FULL_WIDTH_AND_HEIGHT } from '@/chart/const' import { ReportMainChartProps } from '@/state/ReportMainChartProps' import type { ChartOptions } from '@/state/optionsSlice' @@ -84,6 +84,10 @@ export const CategoricalProps = { outerRadius: { type: [Number, String], }, + responsive: { + type: Boolean, + default: false, + }, reverseStackOrder: { type: Boolean, default: false, @@ -162,21 +166,37 @@ export function generateCategoricalChart({ const clipPathId = provideClipPathId(props) + // Size measured from the wrapper div when `responsive` is enabled. + const responsiveSize = ref({ width: 0, height: 0 }) + function handleResize(width: number, height: number) { + const roundedWidth = Math.round(width) + const roundedHeight = Math.round(height) + if (responsiveSize.value.width === roundedWidth && responsiveSize.value.height === roundedHeight) { + return + } + responsiveSize.value = { width: roundedWidth, height: roundedHeight } + } + return () => { - const { compact, width, height, title, desc, ...rest } = props + const { compact, width, height, title, desc, responsive, ...rest } = props const attributes = { ...attrs } - if (!validateWidthHeight({ width: width!, height: height! })) { - return null - } + + // In responsive mode the size is measured from the wrapper div; otherwise it comes from props. + const effectiveWidth = responsive ? responsiveSize.value.width : width! + const effectiveHeight = responsive ? responsiveSize.value.height : height! + const hasValidSize = validateWidthHeight({ width: effectiveWidth, height: effectiveHeight }) const layout = props.layout ?? defaultProps.layout ?? defaultLayout const isPolarChart = layout === 'centric' || layout === 'radial' if (compact) { + if (!hasValidSize) { + return null + } return ( - + {isPolarChart && ( )} - + {slots.default?.()} @@ -195,6 +215,12 @@ export function generateCategoricalChart({ ) } + // Non-responsive charts bail out early when the size is invalid (unchanged behavior). + // Responsive charts must still render the wrapper so the ResizeObserver can measure it. + if (!responsive && !hasValidSize) { + return null + } + if (props.accessibilityLayer) { attributes.tabindex = props.tabIndex ?? 0 attributes.role = props.role ?? 'application' @@ -213,9 +239,9 @@ export function generateCategoricalChart({ } return ( - - - {isPolarChart && ( + {hasValidSize && } + {hasValidSize && } + {hasValidSize && isPolarChart && ( )} - - - - {slots.default?.()} - + {hasValidSize && ( + + + {slots.default?.()} + + )} {slots.tooltip?.()} - - + + {hasValidSize && } ) } From d664c01713aac29861c03b31dfed0299c62cd6f2 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:49:20 +0800 Subject: [PATCH 2/2] refactor(chart): use vueuse useResizeObserver in ChartsWrapper Replace the hand-rolled ResizeObserver + onMounted/onUnmounted lifecycle with @vueuse/core's useResizeObserver, which owns the observer lifecycle and auto- disconnects on unmount. Keep the initial getBoundingClientRect to avoid a first-frame flash. Test targets the active (latest) observer instance since useResizeObserver re-creates its observer when the target ref resolves. --- packages/vue/src/chart/ChartsWrapper.tsx | 32 +++++++++---------- .../chart/__tests__/responsive-prop.spec.tsx | 5 +-- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/vue/src/chart/ChartsWrapper.tsx b/packages/vue/src/chart/ChartsWrapper.tsx index 3233a4f..30a29ca 100644 --- a/packages/vue/src/chart/ChartsWrapper.tsx +++ b/packages/vue/src/chart/ChartsWrapper.tsx @@ -1,4 +1,5 @@ -import { type PropType, type StyleValue, defineComponent, onMounted, onUnmounted, ref } from 'vue' +import { type PropType, type StyleValue, defineComponent, onMounted, ref } from 'vue' +import { useResizeObserver } from '@vueuse/core' import { mouseLeaveChart } from '../state/tooltipSlice' import { useAppDispatch } from '../state/hooks' import { mouseClickAction, mouseMoveAction } from '../state/mouseEventsMiddleware' @@ -54,22 +55,21 @@ export const ChartsWrapper = defineComponent({ // When `responsive` is set, the wrapper div fills its parent via CSS and // measures itself, feeding the pixel size back to the chart (Recharts 3.3+ pattern). - let resizeObserver: ResizeObserver | null = null - onMounted(() => { - if (!props.responsive || !wrapperEl.value) { - return - } - const { width, height } = wrapperEl.value.getBoundingClientRect() - props.onResize?.(width, height) - resizeObserver = new ResizeObserver((entries) => { - const { width: w, height: h } = entries[0].contentRect - props.onResize?.(w, h) + // `useResizeObserver` owns the observer lifecycle (auto-disconnects on unmount); + // the initial getBoundingClientRect avoids a first-frame flash before it fires. + if (props.responsive) { + useResizeObserver(wrapperEl, (entries) => { + const { width, height } = entries[0].contentRect + props.onResize?.(width, height) + }) + onMounted(() => { + if (!wrapperEl.value) { + return + } + const { width, height } = wrapperEl.value.getBoundingClientRect() + props.onResize?.(width, height) }) - resizeObserver.observe(wrapperEl.value) - }) - onUnmounted(() => { - resizeObserver?.disconnect() - }) + } const myOnClick = (e: MouseEvent) => { // Capture chart pointer synchronously before dispatching to middleware, diff --git a/packages/vue/src/chart/__tests__/responsive-prop.spec.tsx b/packages/vue/src/chart/__tests__/responsive-prop.spec.tsx index 2cc9f25..3207c84 100644 --- a/packages/vue/src/chart/__tests__/responsive-prop.spec.tsx +++ b/packages/vue/src/chart/__tests__/responsive-prop.spec.tsx @@ -88,8 +88,9 @@ describe('responsive prop', () => { )) await nextTick() - expect(MockResizeObserver.instances.length).toBe(1) - MockResizeObserver.instances[0].trigger(640, 480) + expect(MockResizeObserver.instances.length).toBeGreaterThanOrEqual(1) + // The latest instance is the one actively observing the wrapper. + MockResizeObserver.instances.at(-1)!.trigger(640, 480) await nextTick() const svg = container.querySelector('.vcharts-surface') as SVGElement