diff --git a/packages/visx-responsive/Readme.md b/packages/visx-responsive/Readme.md index 500188a47..4a859733a 100644 --- a/packages/visx-responsive/Readme.md +++ b/packages/visx-responsive/Readme.md @@ -109,6 +109,10 @@ let chartToRender = ( // ... Render the chartToRender somewhere ``` +##### ⚠️ `ResizeObserver` dependency + +If you don't need a polyfill for `ResizeObserver` or are already including it in your bundle, you should use `ParentSizeModern` and `withParentSizeModern` which doesn't include the polyfill. + ## Installation ``` diff --git a/packages/visx-responsive/src/components/ParentSizeModern.tsx b/packages/visx-responsive/src/components/ParentSizeModern.tsx new file mode 100644 index 000000000..6679f28bc --- /dev/null +++ b/packages/visx-responsive/src/components/ParentSizeModern.tsx @@ -0,0 +1,105 @@ +import debounce from 'lodash/debounce'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ResizeObserver } from '../types'; + +// This can be deleted once https://git.io/Jk9FD lands in TypeScript +declare global { + interface Window { + ResizeObserver: ResizeObserver; + } +} + +export type ParentSizeProps = { + /** Optional `className` to add to the parent `div` wrapper used for size measurement. */ + className?: string; + /** Child render updates upon resize are delayed until `debounceTime` milliseconds _after_ the last resize event is observed. */ + debounceTime?: number; + /** Optional flag to toggle leading debounce calls. When set to true this will ensure that the component always renders immediately. (defaults to true) */ + enableDebounceLeadingCall?: boolean; + /** Optional dimensions provided won't trigger a state change when changed. */ + ignoreDimensions?: keyof ParentSizeState | (keyof ParentSizeState)[]; + /** Optional `style` object to apply to the parent `div` wrapper used for size measurement. */ + parentSizeStyles?: React.CSSProperties; + /** Child render function `({ width, height, top, left, ref, resize }) => ReactNode`. */ + children: ( + args: { + ref: HTMLDivElement | null; + resize: (state: ParentSizeState) => void; + } & ParentSizeState, + ) => React.ReactNode; +}; + +type ParentSizeState = { + width: number; + height: number; + top: number; + left: number; +}; + +export type ParentSizeProvidedProps = ParentSizeState; + +export default function ParentSize({ + className, + children, + debounceTime = 300, + ignoreDimensions = [], + parentSizeStyles = { width: '100%', height: '100%' }, + enableDebounceLeadingCall = true, + ...restProps +}: ParentSizeProps & Omit) { + const target = useRef(null); + const animationFrameID = useRef(0); + + const [state, setState] = useState({ + width: 0, + height: 0, + top: 0, + left: 0, + }); + + const resize = useMemo(() => { + const normalized = Array.isArray(ignoreDimensions) ? ignoreDimensions : [ignoreDimensions]; + + return debounce( + (incoming: ParentSizeState) => { + setState(existing => { + const stateKeys = Object.keys(existing) as (keyof ParentSizeState)[]; + const keysWithChanges = stateKeys.filter(key => existing[key] !== incoming[key]); + const shouldBail = keysWithChanges.every(key => normalized.includes(key)); + + return shouldBail ? existing : incoming; + }); + }, + debounceTime, + { leading: enableDebounceLeadingCall }, + ); + }, [debounceTime, enableDebounceLeadingCall, ignoreDimensions]); + + useEffect(() => { + const observer = new window.ResizeObserver(entries => { + entries.forEach(entry => { + const { left, top, width, height } = entry.contentRect; + animationFrameID.current = window.requestAnimationFrame(() => { + resize({ width, height, top, left }); + }); + }); + }); + if (target.current) observer.observe(target.current); + + return () => { + window.cancelAnimationFrame(animationFrameID.current); + observer.disconnect(); + resize.cancel(); + }; + }, [resize]); + + return ( +
+ {children({ + ...state, + ref: target.current, + resize, + })} +
+ ); +} diff --git a/packages/visx-responsive/src/enhancers/withParentSizeModern.tsx b/packages/visx-responsive/src/enhancers/withParentSizeModern.tsx new file mode 100644 index 000000000..26b1c9327 --- /dev/null +++ b/packages/visx-responsive/src/enhancers/withParentSizeModern.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import debounce from 'lodash/debounce'; +import { ResizeObserver } from '../types'; + +const CONTAINER_STYLES = { width: '100%', height: '100%' }; + +// This can be deleted once https://git.io/Jk9FD lands in TypeScript +declare global { + interface Window { + ResizeObserver: ResizeObserver; + } +} + +export type WithParentSizeProps = { + debounceTime?: number; + enableDebounceLeadingCall?: boolean; +}; + +type WithParentSizeState = { + parentWidth?: number; + parentHeight?: number; + initialWidth?: number; + initialHeight?: number; +}; + +export type WithParentSizeProvidedProps = WithParentSizeState; + +export default function withParentSize( + BaseComponent: React.ComponentType, +) { + return class WrappedComponent extends React.Component< + BaseComponentProps & WithParentSizeProvidedProps, + WithParentSizeState + > { + static defaultProps = { + debounceTime: 300, + enableDebounceLeadingCall: true, + }; + state = { + parentWidth: undefined, + parentHeight: undefined, + }; + animationFrameID: number = 0; + resizeObserver: ResizeObserver | undefined; + container: HTMLDivElement | null = null; + + componentDidMount() { + this.resizeObserver = new window.ResizeObserver(entries => { + entries.forEach(entry => { + const { width, height } = entry.contentRect; + this.animationFrameID = window.requestAnimationFrame(() => { + this.resize({ + width, + height, + }); + }); + }); + }); + if (this.container) this.resizeObserver.observe(this.container); + } + + componentWillUnmount() { + window.cancelAnimationFrame(this.animationFrameID); + if (this.resizeObserver) this.resizeObserver.disconnect(); + this.resize.cancel(); + } + + setRef = (ref: HTMLDivElement) => { + this.container = ref; + }; + + resize = debounce( + ({ width, height }: { width: number; height: number }) => { + this.setState({ + parentWidth: width, + parentHeight: height, + }); + }, + this.props.debounceTime, + { leading: this.props.enableDebounceLeadingCall }, + ); + + render() { + const { initialWidth, initialHeight } = this.props; + const { parentWidth = initialWidth, parentHeight = initialHeight } = this.state; + return ( +
+ {parentWidth != null && parentHeight != null && ( + + )} +
+ ); + } + }; +} diff --git a/packages/visx-responsive/src/index.ts b/packages/visx-responsive/src/index.ts index 53cedbcee..e1e24c48f 100644 --- a/packages/visx-responsive/src/index.ts +++ b/packages/visx-responsive/src/index.ts @@ -1,4 +1,6 @@ export { default as ScaleSVG } from './components/ScaleSVG'; export { default as ParentSize } from './components/ParentSize'; +export { default as ParentSizeModern } from './components/ParentSizeModern'; export { default as withParentSize } from './enhancers/withParentSize'; +export { default as withParentSizeModern } from './enhancers/withParentSizeModern'; export { default as withScreenSize } from './enhancers/withScreenSize'; diff --git a/packages/visx-responsive/src/types/index.ts b/packages/visx-responsive/src/types/index.ts new file mode 100644 index 000000000..d4e50ca7d --- /dev/null +++ b/packages/visx-responsive/src/types/index.ts @@ -0,0 +1,16 @@ +// This file can be deleted once https://git.io/Jk9FD lands in TypeScript +interface ResizeObserverEntry { + contentRect: { + left: number; + top: number; + width: number; + height: number; + }; +} +type ResizeObserverCallback = (entries: ResizeObserverEntry[]) => void; +export interface ResizeObserver { + // eslint-disable-next-line @typescript-eslint/no-misused-new + new (callback: ResizeObserverCallback): ResizeObserver; + observe(el: Element): void; + disconnect(): void; +}