diff --git a/README.md b/README.md index cc1728fafd..fb2e307156 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ - [**Animations**](./docs/Animations.md) - [`useRaf`](./docs/useRaf.md) — re-renders component on each `requestAnimationFrame`. - [`useInterval`](./docs/useInterval.md) and [`useHarmonicIntervalFn`](./docs/useHarmonicIntervalFn.md) — re-renders component on a set interval using `setInterval`. + - [`useInterpolations`](./docs/useInterpolations.md) — interpolates a map of numeric values over time. - [`useSpring`](./docs/useSpring.md) — interpolates number over time according to spring dynamics. - [`useTimeout`](./docs/useTimeout.md) — re-renders component after a timeout. - [`useTimeoutFn`](./docs/useTimeoutFn.md) — calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo) diff --git a/docs/useInterpolations.md b/docs/useInterpolations.md new file mode 100644 index 0000000000..943869b0a0 --- /dev/null +++ b/docs/useInterpolations.md @@ -0,0 +1,50 @@ +# `useInterpolations` + +React animation hook that interpolates a map of numeric values over time. + +## Usage + +```jsx +import { useInterpolations } from 'react-use'; + +const Demo = () => { + const values = useInterpolations({ + left: [0, 100], + top: [0, 50], + opacity: [0, 1] + }, 'inCirc', 1000); + + return ( +
+ ); +}; +``` + +## Reference + +```ts +useInterpolations>( + map: T, + easingName?: string, + ms?: number, + delay?: number +): { [K in keyof T]: number } +``` + +Returns an object with the same keys as `map`, where each value is interpolated between its `[start, end]` range. + +- `map` — required, object where each value is a `[start, end]` tuple of numbers to interpolate. +- `easingName` — one of the valid [easing names](https://github.com/streamich/ts-easing/blob/master/src/index.ts), defaults to `inCirc`. +- `ms` — milliseconds for how long to keep re-rendering component, defaults to `200`. +- `delay` — delay in milliseconds after which to start re-rendering component, defaults to `0`. + diff --git a/src/index.ts b/src/index.ts index 62b69356b7..fdf7ea555a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export { default as useHoverDirty } from './useHoverDirty'; export { default as useIdle } from './useIdle'; export { default as useIntersection } from './useIntersection'; export { default as useInterval } from './useInterval'; +export { default as useInterpolations } from './useInterpolations'; export { default as useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; export { default as useKey } from './useKey'; export { default as createBreakpoint } from './factory/createBreakpoint'; diff --git a/src/useInterpolations.ts b/src/useInterpolations.ts new file mode 100644 index 0000000000..0bb768daf5 --- /dev/null +++ b/src/useInterpolations.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import useTween from './useTween'; + +export type InterpolationMap = Record; + +const formatMapEntryValue = (value: unknown): string => { + // Best-effort stringify for dev error logs; avoids throwing on circular structures. + try { + const json = JSON.stringify(value); + return json; + } catch { + // ignore + } + + return String(value); +}; + +const useInterpolations = ( + map: T, + easingName: string = 'inCirc', + ms: number = 200, + delay: number = 0 +): { [K in keyof T]: number } => { + const t = useTween(easingName, ms, delay); + + return useMemo(() => { + if (process.env.NODE_ENV !== 'production') { + if (!map || typeof map !== 'object') { + console.error('useInterpolations() expected "map" to be an object.'); + return {} as { [K in keyof T]: number }; + } + } + + const keys = Object.keys(map) as Array; + + if (process.env.NODE_ENV !== 'production') { + for (const key of keys) { + const value = map[key]; + const keyString = String(key); + if (!Array.isArray(value) || value.length !== 2) { + const valueString = formatMapEntryValue(value); + console.error( + `useInterpolations() expected map["${keyString}"] to be a [start, end] tuple, got ${valueString}.` + ); + return {} as { [K in keyof T]: number }; + } + if (typeof value[0] !== 'number' || typeof value[1] !== 'number') { + console.error( + `useInterpolations() expected map["${keyString}"] to contain numbers, got [${typeof value[0]}, ${typeof value[1]}].` + ); + return {} as { [K in keyof T]: number }; + } + if (!Number.isFinite(value[0]) || !Number.isFinite(value[1])) { + console.error( + `useInterpolations() expected map["${keyString}"] to contain finite numbers, got [${value[0]}, ${value[1]}].` + ); + return {} as { [K in keyof T]: number }; + } + } + } + + const result = {} as { [K in keyof T]: number }; + for (const key of keys) { + const [start, end] = map[key]; + result[key] = start + (end - start) * t; + } + return result; + }, [map, t]); +}; + +export default useInterpolations; diff --git a/stories/useInterpolations.story.tsx b/stories/useInterpolations.story.tsx new file mode 100644 index 0000000000..25ed87dc3a --- /dev/null +++ b/stories/useInterpolations.story.tsx @@ -0,0 +1,37 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useInterpolations } from '../src'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const values = useInterpolations( + { + left: [0, 300], + top: [0, 200], + opacity: [0, 1], + }, + 'inOutCirc', + 2000 + ); + + return ( +
+
+
{JSON.stringify(values, null, 2)}
+
+ ); +}; + +storiesOf('Animation/useInterpolations', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/tests/useInterpolations.test.ts b/tests/useInterpolations.test.ts new file mode 100644 index 0000000000..860c3d58c1 --- /dev/null +++ b/tests/useInterpolations.test.ts @@ -0,0 +1,136 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as useTween from '../src/useTween'; +import useInterpolations from '../src/useInterpolations'; + +let spyUseTween; + +beforeEach(() => { + spyUseTween = jest.spyOn(useTween, 'default').mockReturnValue(0.5); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('should interpolate map values with default parameters', () => { + const { result } = renderHook(() => + useInterpolations({ + left: [0, 100], + top: [50, 150], + opacity: [0, 1], + }) + ); + + expect(result.current.left).toBe(50); + expect(result.current.top).toBe(100); + expect(result.current.opacity).toBe(0.5); + expect(spyUseTween).toHaveBeenCalledTimes(1); + expect(spyUseTween).toHaveBeenCalledWith('inCirc', 200, 0); +}); + +it('should interpolate map values with custom parameters', () => { + const { result } = renderHook(() => + useInterpolations( + { + x: [10, 20], + y: [-5, 5], + }, + 'outCirc', + 500, + 100 + ) + ); + + expect(result.current.x).toBe(15); + expect(result.current.y).toBe(0); + expect(spyUseTween).toHaveBeenCalledTimes(1); + expect(spyUseTween).toHaveBeenCalledWith('outCirc', 500, 100); +}); + +it('should interpolate at t=0', () => { + spyUseTween.mockReturnValue(0); + + const { result } = renderHook(() => + useInterpolations({ + left: [10, 90], + top: [20, 80], + }) + ); + + expect(result.current.left).toBe(10); + expect(result.current.top).toBe(20); +}); + +it('should interpolate at t=1', () => { + spyUseTween.mockReturnValue(1); + + const { result } = renderHook(() => + useInterpolations({ + left: [10, 90], + top: [20, 80], + }) + ); + + expect(result.current.left).toBe(90); + expect(result.current.top).toBe(80); +}); + +describe('when invalid map is provided', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should log an error when map is not an object', () => { + const { result } = renderHook(() => + useInterpolations(null as unknown as Record) + ); + + expect(result.current).toEqual({}); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + 'useInterpolations() expected "map" to be an object.' + ); + }); + + it('should log an error when map value is not a tuple', () => { + const { result } = renderHook(() => + useInterpolations({ + left: [10] as unknown as readonly [number, number], + }) + ); + + expect(result.current).toEqual({}); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('useInterpolations() expected map["left"] to be a [start, end] tuple') + ); + }); + + it('should log an error when map value contains non-numbers', () => { + const { result } = renderHook(() => + useInterpolations({ + left: ['0', 100] as unknown as readonly [number, number], + }) + ); + + expect(result.current).toEqual({}); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('useInterpolations() expected map["left"] to contain numbers') + ); + }); + + it('should log an error when map value contains non-finite numbers', () => { + const { result } = renderHook(() => + useInterpolations({ + left: [0, Infinity], + }) + ); + + expect(result.current).toEqual({}); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('useInterpolations() expected map["left"] to contain finite numbers') + ); + }); +});