Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions docs/useInterpolations.md
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
position: 'relative',
left: values.left,
top: values.top,
opacity: values.opacity,
width: 100,
height: 100,
background: 'tomato'
}}
/>
);
};
```

## Reference

```ts
useInterpolations<T extends Record<string, readonly [number, number]>>(
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` &mdash; required, object where each value is a `[start, end]` tuple of numbers to interpolate.
- `easingName` &mdash; one of the valid [easing names](https://github.com/streamich/ts-easing/blob/master/src/index.ts), defaults to `inCirc`.
- `ms` &mdash; milliseconds for how long to keep re-rendering component, defaults to `200`.
- `delay` &mdash; delay in milliseconds after which to start re-rendering component, defaults to `0`.

1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
71 changes: 71 additions & 0 deletions src/useInterpolations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import useTween from './useTween';

export type InterpolationMap = Record<string, readonly [number, number]>;

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 = <T extends InterpolationMap>(
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<keyof T>;

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;
37 changes: 37 additions & 0 deletions stories/useInterpolations.story.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div
style={{
position: 'relative',
left: values.left,
top: values.top,
opacity: values.opacity,
width: 100,
height: 100,
background: 'tomato',
}}
/>
<pre>{JSON.stringify(values, null, 2)}</pre>
</div>
);
};

storiesOf('Animation/useInterpolations', module)
.add('Docs', () => <ShowDocs md={require('../docs/useInterpolations.md')} />)
.add('Demo', () => <Demo />);
136 changes: 136 additions & 0 deletions tests/useInterpolations.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, readonly [number, number]>)
);

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')
);
});
});