From 4a264b6e573041381c4da22b924a5127071f1d74 Mon Sep 17 00:00:00 2001 From: bsevern Date: Thu, 28 May 2026 12:35:50 -0400 Subject: [PATCH 1/2] feat: ResponsiveContainer for auto-sizing charts to parent (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoldenChart components require explicit width/height. This wrapper observes its `
` with ResizeObserver and hands the measured size to a render-prop child, so charts can fill their parent in flex/grid dashboards. - new src/components/ResponsiveContainer.tsx — ResizeObserver-backed, SSR-safe (renders nothing without a defaultSize until first measure), with aspectRatio derivation, min/max clamps, debounced updates - pure `computeResponsiveSize` helper extracted + unit-tested (project convention: no jsdom — see InteractiveChart.test.ts) - exported from goldenchart and goldenchart/components - README quick-start gains a responsive example Bundle 48 KB gzipped (budget 75 KB). --- README.md | 13 +++ src/components/ResponsiveContainer.test.ts | 53 ++++++++++ src/components/ResponsiveContainer.tsx | 117 +++++++++++++++++++++ src/components/index.ts | 5 + src/index.ts | 3 + 5 files changed, 191 insertions(+) create mode 100644 src/components/ResponsiveContainer.test.ts create mode 100644 src/components/ResponsiveContainer.tsx diff --git a/README.md b/README.md index d547484..365a85c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ export function Sales() { } ``` +Need the chart to fill its parent? Wrap it in `` — +a `ResizeObserver`-backed render-prop that hands the chart `{ width, height }`: + +```tsx +import { BarChart, ResponsiveContainer } from 'goldenchart'; + + + {({ width, height }) => ( + + )} + +``` + ## Compose your own Every chart is built from reusable primitives, so you can draw arbitrary diagrams. diff --git a/src/components/ResponsiveContainer.test.ts b/src/components/ResponsiveContainer.test.ts new file mode 100644 index 0000000..0fcb353 --- /dev/null +++ b/src/components/ResponsiveContainer.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { computeResponsiveSize } from './ResponsiveContainer'; + +// The React component itself is a thin ResizeObserver wrapper. Project +// convention (see InteractiveChart.test.ts / readMark.test.ts) is to skip +// jsdom and unit-test the pure helper that drives behaviour. +describe('computeResponsiveSize', () => { + it('derives height from width / aspectRatio when the rect has no height', () => { + expect(computeResponsiveSize({ width: 400, height: 0 }, { aspectRatio: 2 })).toEqual({ + width: 400, + height: 200, + }); + }); + + it('uses the observed height when the parent constrains it', () => { + expect(computeResponsiveSize({ width: 400, height: 250 }, { aspectRatio: 2 })).toEqual({ + width: 400, + height: 250, + }); + }); + + it('defaults to 16/9 when aspectRatio is omitted', () => { + const out = computeResponsiveSize({ width: 1600, height: 0 }); + expect(out.width).toBe(1600); + expect(Math.round(out.height)).toBe(900); + }); + + it('clamps height to maxHeight', () => { + expect( + computeResponsiveSize({ width: 1000, height: 0 }, { aspectRatio: 1, maxHeight: 200 }), + ).toEqual({ width: 1000, height: 200 }); + }); + + it('clamps height to minHeight', () => { + expect( + computeResponsiveSize({ width: 20, height: 0 }, { aspectRatio: 1, minHeight: 80 }), + ).toEqual({ width: 20, height: 80 }); + }); + + it('clamps width to minWidth', () => { + expect(computeResponsiveSize({ width: 50, height: 0 }, { minWidth: 200 })).toEqual({ + width: 200, + height: 200 / (16 / 9), + }); + }); + + it('treats heights of 1 or less as unconstrained (avoids 0-height ResizeObserver noise)', () => { + expect(computeResponsiveSize({ width: 400, height: 1 }, { aspectRatio: 2 })).toEqual({ + width: 400, + height: 200, + }); + }); +}); diff --git a/src/components/ResponsiveContainer.tsx b/src/components/ResponsiveContainer.tsx new file mode 100644 index 0000000..170677d --- /dev/null +++ b/src/components/ResponsiveContainer.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +export interface ResponsiveSize { + width: number; + height: number; +} + +export interface ResponsiveContainerProps { + /** Render-prop receiving the measured size in pixels. */ + children: (size: ResponsiveSize) => ReactNode; + /** Width-to-height ratio used to derive `height` from observed width. Default 16/9. */ + aspectRatio?: number; + /** Lower bound on the emitted width. */ + minWidth?: number; + /** Lower bound on the emitted height. */ + minHeight?: number; + /** Upper bound on the emitted height. */ + maxHeight?: number; + /** Resize debounce in ms. Default 80. */ + debounceMs?: number; + /** + * Initial size used during SSR / before the first measurement. When omitted, + * the container renders nothing until it has measured its parent — which is + * the safe default but means a one-frame layout shift in the browser. + */ + defaultSize?: ResponsiveSize; + className?: string; + style?: CSSProperties; +} + +function clamp(value: number, min?: number, max?: number): number { + let out = value; + if (typeof min === 'number') out = Math.max(out, min); + if (typeof max === 'number') out = Math.min(out, max); + return out; +} + +/** + * Pure size derivation: takes a parent's observed rect plus the container's + * `aspectRatio` / clamp options and returns the size that will be passed to + * the render-prop. Extracted so we can unit-test it without mounting React. + */ +export function computeResponsiveSize( + rect: { width: number; height: number }, + opts: { + aspectRatio?: number; + minWidth?: number; + minHeight?: number; + maxHeight?: number; + } = {}, +): ResponsiveSize { + const { aspectRatio = 16 / 9, minWidth, minHeight, maxHeight } = opts; + const width = clamp(rect.width, minWidth); + const derived = width / Math.max(aspectRatio, 0.01); + const observed = rect.height > 1 ? rect.height : derived; + const height = clamp(observed, minHeight, maxHeight); + return { width, height }; +} + +/** + * Width-driven render-prop wrapper. Measures its own `
` with + * `ResizeObserver` and hands `{ width, height }` to its child render fn so + * GoldenChart components — which require explicit pixel dimensions — fill the + * available width. + * + * SSR-safe: when no `defaultSize` is provided the container renders nothing + * until the first measurement. Pass `defaultSize` to render markup during SSR + * (it will be replaced by the measured size on hydration). + */ +export function ResponsiveContainer({ + children, + aspectRatio = 16 / 9, + minWidth, + minHeight, + maxHeight, + debounceMs = 80, + defaultSize, + className, + style, +}: ResponsiveContainerProps) { + const ref = useRef(null); + const [size, setSize] = useState(defaultSize ?? null); + + useEffect(() => { + const node = ref.current; + if (!node || typeof ResizeObserver === 'undefined') return; + + let timer: ReturnType | null = null; + const apply = (rect: { width: number; height: number }) => { + setSize(computeResponsiveSize(rect, { aspectRatio, minWidth, minHeight, maxHeight })); + }; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const rect = entry.contentRect; + if (debounceMs > 0) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => apply(rect), debounceMs); + } else { + apply(rect); + } + }); + observer.observe(node); + return () => { + observer.disconnect(); + if (timer) clearTimeout(timer); + }; + }, [aspectRatio, minWidth, minHeight, maxHeight, debounceMs]); + + return ( +
+ {size ? children(size) : null} +
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 043f200..8e1b6c7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,10 @@ export { Surface } from './Surface'; export type { SurfaceProps } from './Surface'; +export { ResponsiveContainer } from './ResponsiveContainer'; +export type { + ResponsiveContainerProps, + ResponsiveSize, +} from './ResponsiveContainer'; export { BarChart } from './BarChart'; export type { BarChartProps, BarMode } from './BarChart'; export { LineChart } from './LineChart'; diff --git a/src/index.ts b/src/index.ts index 27dacde..6a87742 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export type { RoughPathInfo } from './render/roughGenerator'; // High-level components export { Surface, + ResponsiveContainer, BarChart, LineChart, AreaChart, @@ -60,6 +61,8 @@ export { } from './components'; export type { SurfaceProps, + ResponsiveContainerProps, + ResponsiveSize, BarChartProps, BarMode, LineChartProps, From d880be4fef8da1219818302dd15b10ccf447de41 Mon Sep 17 00:00:00 2001 From: bsevern Date: Thu, 28 May 2026 12:36:54 -0400 Subject: [PATCH 2/2] fix: prettier format barrel files --- src/components/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/index.ts b/src/components/index.ts index 8e1b6c7..8be2408 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,10 +1,7 @@ export { Surface } from './Surface'; export type { SurfaceProps } from './Surface'; export { ResponsiveContainer } from './ResponsiveContainer'; -export type { - ResponsiveContainerProps, - ResponsiveSize, -} from './ResponsiveContainer'; +export type { ResponsiveContainerProps, ResponsiveSize } from './ResponsiveContainer'; export { BarChart } from './BarChart'; export type { BarChartProps, BarMode } from './BarChart'; export { LineChart } from './LineChart';