From a758f8fc2e0931bcf9f1731dee53de21c24901c2 Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Sun, 14 Dec 2025 20:26:18 +0300 Subject: [PATCH 1/2] feat: useInterpolations --- README.md | 1 + docs/useInterpolations.md | 50 ++++++++++ src/index.ts | 1 + src/useInterpolations.ts | 71 +++++++++++++++ stories/useInterpolations.story.tsx | 37 ++++++++ tests/useInterpolations.test.ts | 136 ++++++++++++++++++++++++++++ 6 files changed, 296 insertions(+) create mode 100644 docs/useInterpolations.md create mode 100644 src/useInterpolations.ts create mode 100644 stories/useInterpolations.story.tsx create mode 100644 tests/useInterpolations.test.ts 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..471cce5489 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,7 @@ export { default as useTimeout } from './useTimeout'; export { default as useTimeoutFn } from './useTimeoutFn'; export { default as useTitle } from './useTitle'; export { default as useToggle } from './useToggle'; +export { default as useInterpolations } from './useInterpolations'; export { default as useTween } from './useTween'; export { default as useUnmount } from './useUnmount'; export { default as useUnmountPromise } from './useUnmountPromise'; diff --git a/src/useInterpolations.ts b/src/useInterpolations.ts new file mode 100644 index 0000000000..bb218ad980 --- /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 => { + try { + const json = JSON.stringify(value); + if (typeof json === "string") { + 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; + + 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 }; + const keys = Object.keys(map) as Array; + 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..814bdddc32 --- /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') + ); + }); +}); From 44ce3e7d6af3e29ab129da9263517e3e5b49633f Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Sun, 14 Dec 2025 20:58:26 +0300 Subject: [PATCH 2/2] fix: review fixes --- src/index.ts | 2 +- src/useInterpolations.ts | 22 ++++++++++---------- tests/useInterpolations.test.ts | 36 ++++++++++++++++----------------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/index.ts b/src/index.ts index 471cce5489..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'; @@ -95,7 +96,6 @@ export { default as useTimeout } from './useTimeout'; export { default as useTimeoutFn } from './useTimeoutFn'; export { default as useTitle } from './useTitle'; export { default as useToggle } from './useToggle'; -export { default as useInterpolations } from './useInterpolations'; export { default as useTween } from './useTween'; export { default as useUnmount } from './useUnmount'; export { default as useUnmountPromise } from './useUnmountPromise'; diff --git a/src/useInterpolations.ts b/src/useInterpolations.ts index bb218ad980..0bb768daf5 100644 --- a/src/useInterpolations.ts +++ b/src/useInterpolations.ts @@ -1,14 +1,13 @@ -import { useMemo } from "react"; -import useTween from "./useTween"; +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); - if (typeof json === "string") { - return json; - } + return json; } catch { // ignore } @@ -18,21 +17,23 @@ const formatMapEntryValue = (value: unknown): string => { const useInterpolations = ( map: T, - easingName: string = "inCirc", + 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") { + 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; + 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); @@ -43,7 +44,7 @@ const useInterpolations = ( ); return {} as { [K in keyof T]: number }; } - if (typeof value[0] !== "number" || typeof value[1] !== "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]}].` ); @@ -59,7 +60,6 @@ const useInterpolations = ( } const result = {} as { [K in keyof T]: number }; - const keys = Object.keys(map) as Array; for (const key of keys) { const [start, end] = map[key]; result[key] = start + (end - start) * t; diff --git a/tests/useInterpolations.test.ts b/tests/useInterpolations.test.ts index 814bdddc32..860c3d58c1 100644 --- a/tests/useInterpolations.test.ts +++ b/tests/useInterpolations.test.ts @@ -1,18 +1,18 @@ -import { renderHook } from "@testing-library/react-hooks"; -import * as useTween from "../src/useTween"; -import useInterpolations from "../src/useInterpolations"; +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); + spyUseTween = jest.spyOn(useTween, 'default').mockReturnValue(0.5); }); afterEach(() => { jest.restoreAllMocks(); }); -it("should interpolate map values with default parameters", () => { +it('should interpolate map values with default parameters', () => { const { result } = renderHook(() => useInterpolations({ left: [0, 100], @@ -25,17 +25,17 @@ it("should interpolate map values with default parameters", () => { expect(result.current.top).toBe(100); expect(result.current.opacity).toBe(0.5); expect(spyUseTween).toHaveBeenCalledTimes(1); - expect(spyUseTween).toHaveBeenCalledWith("inCirc", 200, 0); + expect(spyUseTween).toHaveBeenCalledWith('inCirc', 200, 0); }); -it("should interpolate map values with custom parameters", () => { +it('should interpolate map values with custom parameters', () => { const { result } = renderHook(() => useInterpolations( { x: [10, 20], y: [-5, 5], }, - "outCirc", + 'outCirc', 500, 100 ) @@ -44,10 +44,10 @@ it("should interpolate map values with custom parameters", () => { expect(result.current.x).toBe(15); expect(result.current.y).toBe(0); expect(spyUseTween).toHaveBeenCalledTimes(1); - expect(spyUseTween).toHaveBeenCalledWith("outCirc", 500, 100); + expect(spyUseTween).toHaveBeenCalledWith('outCirc', 500, 100); }); -it("should interpolate at t=0", () => { +it('should interpolate at t=0', () => { spyUseTween.mockReturnValue(0); const { result } = renderHook(() => @@ -61,7 +61,7 @@ it("should interpolate at t=0", () => { expect(result.current.top).toBe(20); }); -it("should interpolate at t=1", () => { +it('should interpolate at t=1', () => { spyUseTween.mockReturnValue(1); const { result } = renderHook(() => @@ -75,12 +75,12 @@ it("should interpolate at t=1", () => { expect(result.current.top).toBe(80); }); -describe("when invalid map is provided", () => { +describe('when invalid map is provided', () => { beforeEach(() => { - jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); }); - it("should log an error when map is not an object", () => { + it('should log an error when map is not an object', () => { const { result } = renderHook(() => useInterpolations(null as unknown as Record) ); @@ -92,7 +92,7 @@ describe("when invalid map is provided", () => { ); }); - it("should log an error when map value is not a tuple", () => { + it('should log an error when map value is not a tuple', () => { const { result } = renderHook(() => useInterpolations({ left: [10] as unknown as readonly [number, number], @@ -106,10 +106,10 @@ describe("when invalid map is provided", () => { ); }); - it("should log an error when map value contains non-numbers", () => { + it('should log an error when map value contains non-numbers', () => { const { result } = renderHook(() => useInterpolations({ - left: ["0", 100] as unknown as readonly [number, number], + left: ['0', 100] as unknown as readonly [number, number], }) ); @@ -120,7 +120,7 @@ describe("when invalid map is provided", () => { ); }); - it("should log an error when map value contains non-finite numbers", () => { + it('should log an error when map value contains non-finite numbers', () => { const { result } = renderHook(() => useInterpolations({ left: [0, Infinity],