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..8be2408 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,7 @@ 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,