-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat(chart): add responsive prop #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CategoricalChartFunc> }, | ||
| onMouseMove: { type: Function as PropType<CategoricalChartFunc> }, | ||
| onMouseUp: { type: Function as PropType<CategoricalChartFunc> }, | ||
| onResize: { type: Function as PropType<(width: number, height: number) => void> }, | ||
| onTouchEnd: { type: Function as PropType<CategoricalChartFunc> }, | ||
| onTouchMove: { type: Function as PropType<CategoricalChartFunc> }, | ||
| onTouchStart: { type: Function as PropType<CategoricalChartFunc> }, | ||
| responsive: { type: Boolean, default: false }, | ||
| style: { type: [String, Object, Array] as PropType<StyleValue> }, | ||
| width: { type: Number, required: true }, | ||
| }, | ||
|
|
@@ -42,12 +44,33 @@ export const RechartsWrapper = defineComponent({ | |
| const legendPortal = ref<HTMLElement | null>(null) | ||
| providePortalRaw(tooltipPortal) | ||
| provideLegendPortalRaw(legendPortal) | ||
| const wrapperEl = ref<HTMLDivElement | null>(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() | ||
| }) | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
The ♻️ Suggested fix using `watch`- 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()
- })
+ let resizeObserver: ResizeObserver | null = null
+ function startObserving() {
+ if (!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)
+ }
+ function stopObserving() {
+ resizeObserver?.disconnect()
+ resizeObserver = null
+ }
+ watch(() => props.responsive, (responsive) => {
+ stopObserving()
+ if (responsive) startObserving()
+ }, { immediate: true })
+ onUnmounted(stopObserving)As per coding guidelines, 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| 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 () => ( | ||
| <div | ||
| class={['v-charts-wrapper', props.class]} | ||
| style={[{ position: 'relative', cursor: 'default', width: `${props.width}px`, height: `${props.height}px` }, props.style]} | ||
| style={[ | ||
| props.responsive | ||
| ? { position: 'relative', cursor: 'default', width: '100%', height: '100%' } | ||
| : { position: 'relative', cursor: 'default', width: `${props.width}px`, height: `${props.height}px` }, | ||
| props.style, | ||
| ]} | ||
| role="application" | ||
| onClick={myOnClick} | ||
| onContextmenu={myOnContextMenu} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div style={{ width: '100%', height: '300px' }}> | ||
| <LineChart responsive data={[...data]}> | ||
| <CartesianGrid stroke-dasharray="3 3" /> | ||
| <XAxis dataKey="name" /> | ||
| <YAxis /> | ||
| <Tooltip /> | ||
| <Line type="monotone" dataKey="uv" stroke="#f97316" activeDot={{ r: 8 }} /> | ||
| </LineChart> | ||
| </div> | ||
| ) | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(() => ( | ||
| <LineChart responsive data={data}> | ||
| <Line dataKey="uv" isAnimationActive={false} /> | ||
| </LineChart> | ||
| )) | ||
| 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(() => ( | ||
| <LineChart responsive data={data}> | ||
| <Line dataKey="uv" isAnimationActive={false} /> | ||
| </LineChart> | ||
| )) | ||
|
|
||
| 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(() => ( | ||
| <LineChart responsive data={data}> | ||
| <Line dataKey="uv" isAnimationActive={false} /> | ||
| </LineChart> | ||
| )) | ||
| 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(() => ( | ||
| <LineChart responsive data={data}> | ||
| <Line dataKey="uv" isAnimationActive={false} /> | ||
| </LineChart> | ||
| )) | ||
| 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(() => ( | ||
| <LineChart width={400} height={320} data={data}> | ||
| <Line dataKey="uv" isAnimationActive={false} /> | ||
| </LineChart> | ||
| )) | ||
| 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) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does ResizeObserver.observe support a {box: 'border-box'} option to get border-box measurements directly?💡 Result:
Yes, the ResizeObserver.observe method supports the box option, which allows you to specify whether the observer should track changes to the element's content-box (the default) or its border-box [1][2][3]. You can use it by passing an options object as the second argument to the observe method [2][3]: resizeObserver.observe(element, { box: 'border-box' }); When this option is set, the observer monitors the size of the element's border box as defined in CSS [1][3]. This option specifically determines which box the observer monitors for layout changes [3]. Note that the ResizeObserver API also provides additional options such as 'device-pixel-content-box' for tracking sizes in device pixels [2][4][3].
Citations:
🏁 Script executed:
Repository: unovue/vue-charts
Length of output: 7935
Align the resize observer with the initial measurement.
getBoundingClientRect()returns the border box, butResizeObserverdefaults tocontentRect; ifprops.styleadds padding or a border, the firstonResizevalue will differ from later updates. UseresizeObserver.observe(wrapperEl.value, { box: 'border-box' })or make both paths measure the same box.🤖 Prompt for AI Agents