,
-) {
+type WithScreenSizeComponentProps = Simplify<
+ Omit
& WithScreenSizeConfig
+>;
+
+export default function withScreenSize
(
+ BaseComponent: React.ComponentType
,
+): React.ComponentType> {
return class WrappedComponent extends React.Component<
- BaseComponentProps & WithScreenSizeProvidedProps,
+ WithScreenSizeComponentProps,
WithScreenSizeState
> {
- static defaultProps = {
- windowResizeDebounceTime: 300,
- enableDebounceLeadingCall: true,
- };
-
+ displayName = `withScreenSize(${
+ BaseComponent.displayName ?? BaseComponent.name ?? 'Component'
+ })`;
state = {
screenWidth: undefined,
screenHeight: undefined,
@@ -48,14 +57,18 @@ export default function withScreenSize
+
);
}
};
diff --git a/packages/visx-responsive/src/hooks/useParentSize.ts b/packages/visx-responsive/src/hooks/useParentSize.ts
new file mode 100644
index 000000000..770488d09
--- /dev/null
+++ b/packages/visx-responsive/src/hooks/useParentSize.ts
@@ -0,0 +1,86 @@
+import debounce from 'lodash/debounce';
+import { RefObject, useEffect, useMemo, useRef, useState } from 'react';
+import { DebounceSettings, PrivateWindow, ResizeObserverPolyfill } from '../types';
+
+export type ParentSizeState = {
+ width: number;
+ height: number;
+ top: number;
+ left: number;
+};
+
+export type UseParentSizeConfig = {
+ /** Initial size before measuring the parent. */
+ initialSize?: Partial;
+ /** Optionally inject a ResizeObserver polyfill, else this *must* be globally available. */
+ resizeObserverPolyfill?: ResizeObserverPolyfill;
+ /** Optional dimensions provided won't trigger a state change when changed. */
+ ignoreDimensions?: keyof ParentSizeState | (keyof ParentSizeState)[];
+} & DebounceSettings;
+
+type UseParentSizeResult = ParentSizeState & {
+ parentRef: RefObject;
+ resize: (state: ParentSizeState) => void;
+};
+
+const defaultIgnoreDimensions: UseParentSizeConfig['ignoreDimensions'] = [];
+const defaultInitialSize: ParentSizeState = {
+ width: 0,
+ height: 0,
+ top: 0,
+ left: 0,
+};
+
+export default function useParentSize({
+ initialSize = defaultInitialSize,
+ debounceTime = 300,
+ ignoreDimensions = defaultIgnoreDimensions,
+ enableDebounceLeadingCall = true,
+ resizeObserverPolyfill,
+}: UseParentSizeConfig = {}): UseParentSizeResult {
+ const parentRef = useRef(null);
+ const animationFrameID = useRef(0);
+
+ const [state, setState] = useState({ ...defaultInitialSize, ...initialSize });
+
+ 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 LocalResizeObserver =
+ resizeObserverPolyfill || (window as unknown as PrivateWindow).ResizeObserver;
+
+ const observer = new LocalResizeObserver((entries) => {
+ entries.forEach((entry) => {
+ const { left, top, width, height } = entry?.contentRect ?? {};
+ animationFrameID.current = window.requestAnimationFrame(() => {
+ resize({ width, height, top, left });
+ });
+ });
+ });
+ if (parentRef.current) observer.observe(parentRef.current);
+
+ return () => {
+ window.cancelAnimationFrame(animationFrameID.current);
+ observer.disconnect();
+ resize.cancel();
+ };
+ }, [resize, resizeObserverPolyfill]);
+
+ return { parentRef, resize, ...state };
+}
diff --git a/packages/visx-responsive/src/hooks/useScreenSize.ts b/packages/visx-responsive/src/hooks/useScreenSize.ts
new file mode 100644
index 000000000..7e73a44a3
--- /dev/null
+++ b/packages/visx-responsive/src/hooks/useScreenSize.ts
@@ -0,0 +1,54 @@
+import debounce from 'lodash/debounce';
+import { useEffect, useMemo, useState } from 'react';
+import { DebounceSettings } from '../types/index';
+
+interface ScreenSize {
+ width: number;
+ height: number;
+}
+
+const defaultInitialSize: ScreenSize = {
+ width: 0,
+ height: 0,
+};
+
+export type UseScreenSizeConfig = {
+ /** Initial size before measuring the screen. */
+ initialSize?: ScreenSize;
+} & DebounceSettings;
+
+const useScreenSize = ({
+ initialSize = defaultInitialSize,
+ debounceTime = 300,
+ enableDebounceLeadingCall = true,
+}: UseScreenSizeConfig = {}) => {
+ const [screenSize, setScreenSize] = useState(initialSize);
+
+ const handleResize = useMemo(
+ () =>
+ debounce(
+ () => {
+ setScreenSize(() => ({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }));
+ },
+ debounceTime,
+ { leading: enableDebounceLeadingCall },
+ ),
+ [debounceTime, enableDebounceLeadingCall],
+ );
+
+ useEffect(() => {
+ handleResize();
+ window.addEventListener('resize', handleResize, false);
+ return () => {
+ window.removeEventListener('resize', handleResize, false);
+ handleResize.cancel();
+ };
+ }, [handleResize]);
+
+ return screenSize;
+};
+
+export default useScreenSize;
diff --git a/packages/visx-responsive/src/index.ts b/packages/visx-responsive/src/index.ts
index 53cedbcee..736efdf8b 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 withParentSize } from './enhancers/withParentSize';
-export { default as withScreenSize } from './enhancers/withScreenSize';
+export { default as ScaleSVG } from './components/ScaleSVG';
+export { default as withParentSize, WithParentSizeProvidedProps } from './enhancers/withParentSize';
+export { default as withScreenSize, WithScreenSizeProvidedProps } from './enhancers/withScreenSize';
+export { default as useParentSize, UseParentSizeConfig } from './hooks/useParentSize';
+export { default as useScreenSize, UseScreenSizeConfig } from './hooks/useScreenSize';
diff --git a/packages/visx-responsive/src/types/index.ts b/packages/visx-responsive/src/types/index.ts
index 105ed1644..729cf538c 100644
--- a/packages/visx-responsive/src/types/index.ts
+++ b/packages/visx-responsive/src/types/index.ts
@@ -1,4 +1,4 @@
-// @TODO remove when upgraded to TS 4 which has its own declaration
+// @TODO remove all these types when upgraded to TS 4 which has its own declaration
interface ResizeObserverEntry {
contentRect: {
left: number;
@@ -10,7 +10,7 @@ interface ResizeObserverEntry {
type ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
-declare class ResizeObserver {
+export declare class ResizeObserver {
constructor(callback: ResizeObserverCallback);
observe(target: Element, options?: any): void;
unobserve(target: Element): void;
@@ -18,8 +18,19 @@ declare class ResizeObserver {
static toString(): string;
}
-interface ResizeObserverPolyfill {
+export interface ResizeObserverPolyfill {
new (callback: ResizeObserverCallback): ResizeObserver;
}
-export { ResizeObserver, ResizeObserverCallback, ResizeObserverPolyfill };
+export interface PrivateWindow {
+ ResizeObserver: ResizeObserverPolyfill;
+}
+
+export type Simplify = { [Key in keyof T]: T[Key] } & {};
+
+export interface DebounceSettings {
+ /** Child render updates upon resize are delayed until `debounceTime` milliseconds _after_ the last resize event is observed. Defaults to `300`. */
+ 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;
+}
diff --git a/packages/visx-responsive/test/useScreenSize.test.ts b/packages/visx-responsive/test/useScreenSize.test.ts
new file mode 100644
index 000000000..862e91949
--- /dev/null
+++ b/packages/visx-responsive/test/useScreenSize.test.ts
@@ -0,0 +1,51 @@
+import { fireEvent, waitFor } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import useScreenSize from '../src/hooks/useScreenSize';
+
+const setWindowSize = (width: number, height: number) => {
+ Object.defineProperty(window, 'innerWidth', {
+ writable: true,
+ configurable: true,
+ value: width,
+ });
+ Object.defineProperty(window, 'innerHeight', {
+ writable: true,
+ configurable: true,
+ value: height,
+ });
+};
+
+describe('useScreenSize', () => {
+ beforeEach(() => {
+ setWindowSize(1280, 1024);
+ });
+
+ afterEach(() => {
+ // @ts-ignore is just a test why you heff to be mad
+ delete window.innerWidth;
+ // @ts-ignore
+ delete window.innerHeight;
+ });
+
+ test('it should return the initial screen size', () => {
+ const { result } = renderHook(() => useScreenSize());
+ expect(result.current).toEqual({ width: 1280, height: 1024 });
+ });
+
+ test('it should update the screen size on window resize', async () => {
+ // fake timers in Jest 25 are completely unusable so I'm using real timers here
+ // when it's upgraded should be updated to use advanceTimersByTime
+ jest.useRealTimers();
+
+ const { result } = renderHook(() => useScreenSize());
+
+ expect(result.current).toEqual({ width: 1280, height: 1024 });
+
+ setWindowSize(800, 600);
+ fireEvent(window, new Event('resize'));
+
+ await waitFor(() => expect(result.current).toEqual({ width: 800, height: 600 }));
+
+ jest.useFakeTimers();
+ });
+});
diff --git a/packages/visx-responsive/test/withParentSize.test.tsx b/packages/visx-responsive/test/withParentSize.test.tsx
index 6cac60406..ec3aded2e 100644
--- a/packages/visx-responsive/test/withParentSize.test.tsx
+++ b/packages/visx-responsive/test/withParentSize.test.tsx
@@ -1,16 +1,18 @@
-import React from 'react';
-import { render } from '@testing-library/react';
import { ResizeObserver } from '@juggle/resize-observer';
import '@testing-library/jest-dom';
-import { withParentSize } from '../src';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { withParentSize, WithParentSizeProvidedProps } from '../src';
-type ComponentProps = {
- parentWidth?: number;
- parentHeight?: number;
-};
+interface ComponentProps extends WithParentSizeProvidedProps {
+ // only there to ensure that TS allows enhanced component to have own props, different than the ones passed by the HOC
+ role: string;
+}
-function Component({ parentWidth, parentHeight }: ComponentProps) {
- return ;
+function Component({ parentWidth, parentHeight, role }: ComponentProps) {
+ return (
+
+ );
}
describe('withParentSize', () => {
@@ -19,8 +21,14 @@ describe('withParentSize', () => {
});
test('it should pass parentWidth and parentHeight props to its child', () => {
- const HOC = withParentSize(Component, ResizeObserver);
- const { getByTestId } = render();
+ const WrappedComponent = withParentSize(Component, ResizeObserver);
+
+ // @ts-expect-error ensure unknown types still error
+ render();
+
+ const { getByTestId } = render(
+ ,
+ );
const RenderedComponent = getByTestId('Component');
expect(RenderedComponent).toHaveStyle('width: 200px; height: 200px');