Skip to content

Commit 086356b

Browse files
authored
Merge pull request #13 from etecture/feature/hooks
Feature/hooks
2 parents 690fef4 + ad89ed7 commit 086356b

16 files changed

Lines changed: 328 additions & 99 deletions

File tree

core/src/hooks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ export * from "./useBreakpoint/useBreakpoint";
33
export * from "./useSwiper/useSwiper";
44
export * from "./useHotkeys/useHotkeys";
55
export * from "./useToggle/useToggle";
6+
export * from "./useIsIdle/useIsIdle";
7+
export * from "./useOnChange/useOnChange";
8+
export * from "./usePrevious/usePrevious";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { type UseIsIdleOptions, useIsIdle } from "../useIsIdle";
3+
4+
export default {
5+
title: "Hooks/useIsIdle",
6+
parameters: {
7+
layout: "centered",
8+
},
9+
args: {
10+
idleAfterMs: 1000,
11+
} satisfies UseIsIdleOptions,
12+
} satisfies Meta;
13+
14+
export const Default: StoryObj = {
15+
render: (args: UseIsIdleOptions) => {
16+
const isIdle = useIsIdle(args);
17+
return <div>isIdle: {isIdle.toString()}</div>;
18+
},
19+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { renderHook } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { act } from "react";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { useIsIdle } from "./useIsIdle";
6+
7+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
8+
9+
describe("useIsIdle", () => {
10+
beforeEach(() => {
11+
vi.useFakeTimers({ shouldAdvanceTime: true });
12+
});
13+
14+
afterEach(() => {
15+
vi.runOnlyPendingTimers();
16+
vi.useRealTimers();
17+
});
18+
19+
it("should return idle by default", async () => {
20+
const { result } = renderHook(() => useIsIdle({ idleAfterMs: 1000 }));
21+
expect(result.current).toBe(true);
22+
});
23+
24+
it("should return idle after the set amount of ms", async () => {
25+
const { result } = renderHook(() => useIsIdle({ idleAfterMs: 1000 }));
26+
expect(result.current).toBe(true);
27+
await user.click(document.body);
28+
expect(result.current).toBe(false);
29+
await act(async () => await vi.advanceTimersByTimeAsync(1000));
30+
expect(result.current).toBe(true);
31+
});
32+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
export interface UseIsIdleOptions {
4+
/**
5+
* The amount of time after the last interaction that marks the user as idle.
6+
* @default 10000
7+
*/
8+
idleAfterMs?: number;
9+
}
10+
11+
/**
12+
* Checks for user interaction and returns idle after a set time.
13+
* The default user interactions are mousemove, mousedown, touchstart and keydown.
14+
*/
15+
export const useIsIdle = (options?: UseIsIdleOptions) => {
16+
const { idleAfterMs = 10000 } = options ?? {};
17+
18+
const [isIdle, setIsIdle] = useState(true);
19+
20+
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
21+
22+
useEffect(() => {
23+
const handleAction = (event: Event) => {
24+
event.preventDefault();
25+
26+
setIsIdle(false);
27+
clearTimeout(timeoutRef.current);
28+
timeoutRef.current = setTimeout(() => {
29+
setIsIdle(true);
30+
}, idleAfterMs);
31+
};
32+
33+
document.addEventListener("mousemove", handleAction);
34+
document.addEventListener("touchstart mousedown", handleAction);
35+
document.addEventListener("keydown", handleAction);
36+
37+
return () => {
38+
document.removeEventListener("mousemove", handleAction);
39+
document.removeEventListener("touchstart mousedown", handleAction);
40+
document.removeEventListener("keydown", handleAction);
41+
};
42+
}, [idleAfterMs]);
43+
44+
return isIdle;
45+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { useOnChange } from "./useOnChange";
4+
5+
describe("useOnChange", () => {
6+
it("should not trigger when nothing changed", async () => {
7+
const onChange = vi.fn();
8+
9+
const { rerender } = renderHook((value: unknown) =>
10+
useOnChange({ value: value ?? "first", onChange }),
11+
);
12+
13+
expect(onChange).toHaveBeenCalledTimes(0);
14+
rerender("first");
15+
expect(onChange).toHaveBeenCalledTimes(0);
16+
});
17+
18+
it("should trigger when value changed", async () => {
19+
const onChange = vi.fn();
20+
21+
const { rerender } = renderHook((value: unknown) =>
22+
useOnChange({ value: value ?? "first", onChange }),
23+
);
24+
25+
rerender("first");
26+
expect(onChange).toHaveBeenCalledTimes(0);
27+
rerender("second");
28+
expect(onChange).toHaveBeenCalledTimes(1);
29+
expect(onChange).toHaveBeenCalledWith("second");
30+
rerender("third");
31+
expect(onChange).toHaveBeenCalledTimes(2);
32+
expect(onChange).toHaveBeenCalledWith("third");
33+
rerender("fourth");
34+
expect(onChange).toHaveBeenCalledTimes(3);
35+
expect(onChange).toHaveBeenCalledWith("fourth");
36+
});
37+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useEffect, useRef } from "react";
2+
import { usePrevious } from "../usePrevious/usePrevious";
3+
4+
export interface UseOnChangeProps<ValueType> {
5+
value: ValueType;
6+
onChange: (value: ValueType) => void;
7+
}
8+
9+
/**
10+
* Triggers the onChange callback whenever the passed value changed since the last render.
11+
*/
12+
export const useOnChange = <ValueType>(props: UseOnChangeProps<ValueType>) => {
13+
const { value, onChange } = props;
14+
15+
const lastChangeValue = useRef<ValueType | null>(null);
16+
const previousValue = usePrevious(value, { useInitialValueAsPrevious: true });
17+
18+
useEffect(() => {
19+
if (value === lastChangeValue.current) return;
20+
if (value !== previousValue) {
21+
onChange(value);
22+
}
23+
lastChangeValue.current = value;
24+
}, [value, previousValue, onChange]);
25+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { usePrevious } from "./usePrevious";
4+
5+
describe("usePrevious", () => {
6+
it("should return null for the first render", async () => {
7+
const { result } = renderHook(() => usePrevious("first"));
8+
expect(result.current).toBeNull();
9+
});
10+
11+
it("should return the previous value after rerender", async () => {
12+
const { result, rerender } = renderHook((value: string) => usePrevious(value ?? "first"));
13+
expect(result.current).toBeNull();
14+
rerender("second");
15+
expect(result.current).toBe("first");
16+
rerender("third");
17+
expect(result.current).toBe("second");
18+
rerender("fourth");
19+
rerender("fifth");
20+
rerender("sixth");
21+
expect(result.current).toBe("fifth");
22+
});
23+
24+
it("should return the first value after the first render when useInitialValueAsPrevious is true", async () => {
25+
const { result, rerender } = renderHook((value: string) =>
26+
usePrevious(value ?? "first", { useInitialValueAsPrevious: true }),
27+
);
28+
expect(result.current).toBe("first");
29+
rerender("second");
30+
expect(result.current).toBe("first");
31+
});
32+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
export interface UsePreviousOptions {
4+
/**
5+
* If true, the first previous value will be the first passed value so you can avoid null checks.
6+
*/
7+
useInitialValueAsPrevious?: boolean;
8+
}
9+
10+
/**
11+
* Keeps track of a states value from the last render. Is initially null.
12+
* Set options: { useInitialValueAsPrevious = true } to use the first passed value as the first previous value.
13+
*/
14+
export const usePrevious = <ValueType, Options extends UsePreviousOptions>(
15+
value: ValueType,
16+
options?: Options,
17+
) => {
18+
const { useInitialValueAsPrevious = false } = options ?? {};
19+
20+
const currentValue = useRef<ValueType | null>(useInitialValueAsPrevious ? value : null);
21+
const [previousValue, setPreviousValue] = useState<ValueType | null>(
22+
useInitialValueAsPrevious ? value : null,
23+
);
24+
25+
useEffect(() => {
26+
setPreviousValue(currentValue.current);
27+
currentValue.current = value;
28+
}, [value]);
29+
30+
type ReturnType = Options["useInitialValueAsPrevious"] extends true
31+
? ValueType
32+
: ValueType | null;
33+
34+
return previousValue as ReturnType;
35+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ComponentLayout } from "../../../../layout/ComponentLayout/ComponentLayout"
2+
import { Demo } from "@/components/Demo";
3+
import { Default } from "@/examples/hooks/useIsIdle"
4+
5+
```tsx
6+
const isIdle = useIsIdle({ idleAfterMs: 10000 })
7+
```
8+
9+
This hook keeps track of user interactions and returns idle if the user didnt interact with the page for a set amount of time.
10+
11+
## Example
12+
13+
<Demo data={Default} />
14+
15+
export default function MDXPage({ children }) {
16+
return <ComponentLayout componentName={"useIsIdle"}>{children}</ComponentLayout>;
17+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ComponentLayout } from "../../../../layout/ComponentLayout/ComponentLayout"
2+
import { Demo } from "@/components/Demo";
3+
4+
```tsx
5+
const [value, setValue] = useState("hello world");
6+
7+
const onChange = useCallback((changedValue: string) => {
8+
console.log(changedValue)
9+
}, []);
10+
11+
useOnChange({ value, onChange })
12+
```
13+
14+
This hook triggers the passed onChange callback whenever the passed value is different to the one from the last render. It uses usePrevious under the hood.
15+
16+
The check uses a shallow compare, if you pass an object you have to make sure that it is immutable.
17+
18+
export default function MDXPage({ children }) {
19+
return <ComponentLayout componentName={"useOnChange"}>{children}</ComponentLayout>;
20+
}

0 commit comments

Comments
 (0)