diff --git a/internal/test/types.ts b/internal/test/types.ts index a986fd3ec..3608a9296 100644 --- a/internal/test/types.ts +++ b/internal/test/types.ts @@ -3,8 +3,16 @@ import type { RenderOptions as TLRenderOptions, RenderResult as TLRenderResult, } from "@testing-library/react"; +import type { + RenderHookOptions as TLLegacyRenderHookOptions, + RenderHookResult as TLLegacyRenderHookResult, +} from "@testing-library/react-hooks"; +import type { + RenderHookOptions as TLActualRenderHookOptions, + RenderHookResult as TLActualRenderHookResult, +} from "@testing-library/react-13"; -export type RenderOptions = Omit & { +export type RenderOptions = Omit & { strict?: boolean; }; @@ -15,3 +23,17 @@ export type RenderResult< setProps(props: P): RenderResult; forceUpdate(): RenderResult; }; + +export type RenderHookOptions = Omit< + TLLegacyRenderHookOptions & + TLActualRenderHookOptions & { + strict?: boolean; + }, + "wrapper" +>; + +export type RenderHookResult = TLLegacyRenderHookResult< + TProps, + TResult +> & + TLActualRenderHookResult; diff --git a/internal/test/utils.tsx b/internal/test/utils.tsx index 9b5d34252..4e9a975de 100644 --- a/internal/test/utils.tsx +++ b/internal/test/utils.tsx @@ -1,10 +1,13 @@ import * as React from "react"; -import { act } from "react-dom/test-utils"; import type { MatcherFunction } from "@testing-library/react"; import { render as tlRender, fireEvent } from "@testing-library/react"; -import { fireEvent as fireDomEvent } from "@testing-library/dom"; -import userEvent from "@testing-library/user-event"; -import type { RenderOptions, RenderResult } from "./types"; +import { renderHook as tlRenderHook } from "@testing-library/react-hooks"; +import type { + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from "./types"; /** * This function is useful if you want to query a DOM element by its text @@ -79,6 +82,17 @@ export function render< return result; } +export function renderHook( + callback: (props: TProps) => TResult, + options: RenderHookOptions = {} +): RenderHookResult { + const { strict = false, ...restOptions } = options; + return tlRenderHook(callback, { + ...restOptions, + wrapper: strict ? React.StrictMode : React.Fragment, + }); +} + export async function wait(time: number) { return await new Promise((res) => setTimeout(res, time)); } @@ -125,6 +139,10 @@ export function simulateEnterKeyClick( type Query = (f: MatcherFunction) => HTMLElement | null; -export * from "@testing-library/react"; -export { act, userEvent, fireDomEvent }; +export { + cleanup as cleanupHooks, + act as actHooks, +} from "@testing-library/react-hooks"; +export { cleanup, fireEvent, screen, act } from "@testing-library/react"; +export { default as userEvent } from "@testing-library/user-event"; export type { RenderOptions, RenderResult }; diff --git a/package.json b/package.json index 6f4ae9d1d..ab02f27f9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@testing-library/dom": "^8.16.0", "@testing-library/react": "^12.1.5", "@testing-library/react-13": "npm:@testing-library/react@^13.3.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.2.1", "@types/aria-query": "^5.0.0", "@types/css": "^0.0.33", diff --git a/packages/utils/__tests__/use-constant.test.ts b/packages/utils/__tests__/use-constant.test.ts new file mode 100644 index 000000000..727ea96e2 --- /dev/null +++ b/packages/utils/__tests__/use-constant.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { renderHook, cleanupHooks } from "@reach-internal/test/utils"; +import { useConstant } from "@reach/utils"; + +afterEach(cleanupHooks); + +describe("useConstant", () => { + const renderUseConstant = () => + renderHook(() => useConstant(() => ({ foo: "bar" }))); + + it("should return value from callback", () => { + const render = renderUseConstant(); + + const firstRenderedObject = render.result.current; + expect(firstRenderedObject).toEqual({ foo: "bar" }); + }); + + it("should return the same value after rerender", () => { + const render = renderUseConstant(); + const resultFirst = render.result.current; + render.rerender(); + const resultSecond = render.result.current; + + expect(resultFirst).toBe(resultSecond); + }); +}); diff --git a/packages/utils/__tests__/use-controlled-state.test.ts b/packages/utils/__tests__/use-controlled-state.test.ts new file mode 100644 index 000000000..9cc2c11d8 --- /dev/null +++ b/packages/utils/__tests__/use-controlled-state.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { renderHook, cleanupHooks, actHooks } from "@reach-internal/test/utils"; +import { useControlledState } from "@reach/utils"; + +afterEach(cleanupHooks); + +describe("useControlledState", () => { + const DEFAULT_VALUE = 10; + const CONTROLLED_VALUE = 42; + + it("should return value and setter", () => { + const { result } = renderHook(() => + useControlledState({ + defaultValue: DEFAULT_VALUE, + controlledValue: undefined, + }) + ); + + expect(result.current[0]).toBe(DEFAULT_VALUE); + expect(typeof result.current[1]).toBe("function"); + }); + + it("should work as uncontrolled", () => { + const { result } = renderHook(() => + useControlledState({ + defaultValue: DEFAULT_VALUE, + controlledValue: undefined, + }) + ); + expect(result.current[0]).toBe(DEFAULT_VALUE); + actHooks(() => { + result.current[1](17); + }); + expect(result.current[0]).toBe(17); + }); + + it("should work as controlled", () => { + const { result } = renderHook(() => + useControlledState({ + defaultValue: DEFAULT_VALUE, + controlledValue: CONTROLLED_VALUE, + }) + ); + expect(result.current[0]).toBe(CONTROLLED_VALUE); + actHooks(() => { + result.current[1](17); + }); + expect(result.current[0]).toBe(CONTROLLED_VALUE); + }); +}); diff --git a/packages/utils/__tests__/use-event-listener.test.tsx b/packages/utils/__tests__/use-event-listener.test.tsx new file mode 100644 index 000000000..c81fcf398 --- /dev/null +++ b/packages/utils/__tests__/use-event-listener.test.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { render, fireEvent, cleanup } from "@reach-internal/test/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useEventListener } from "@reach/utils"; + +afterEach(cleanup); + +describe("useEventListener", () => { + const Test = ({ onBodyClick }: { onBodyClick: () => void }) => { + useEventListener("click", onBodyClick, document.body); + return null; + }; + + it("should call event listener when it's need", () => { + const handleBodyClick = vi.fn(); + render(); + fireEvent.click(document.body); + expect(handleBodyClick).toHaveBeenCalledTimes(1); + fireEvent.click(document.body); + expect(handleBodyClick).toHaveBeenCalledTimes(2); + }); + + it("should can change event listener from args", () => { + const handleBodyClick1 = vi.fn(); + const handleBodyClick2 = vi.fn(); + const { rerender } = render(); + fireEvent.click(document.body); + rerender(); + fireEvent.click(document.body); + expect(handleBodyClick1).toHaveBeenCalledOnce(); + expect(handleBodyClick2).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/utils/__tests__/use-focus-change.test.tsx b/packages/utils/__tests__/use-focus-change.test.tsx new file mode 100644 index 000000000..5b75ab2b3 --- /dev/null +++ b/packages/utils/__tests__/use-focus-change.test.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import { render, cleanup, userEvent } from "@reach-internal/test/utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useFocusChange } from "@reach/utils"; + +afterEach(cleanup); + +describe("useFocusChange", () => { + const Test = ({ + onChange, + when, + }: { + onChange: () => void; + when?: "focus" | "blur"; + }) => { + useFocusChange(onChange, when); + return ( + <> + + +
just div
+ + ); + }; + + const renderTest = (when?: "focus" | "blur") => { + const handleChange = vi.fn(); + const { getByPlaceholderText, getByText } = render( + + ); + const firstInput = getByPlaceholderText("first"); + const secondInput = getByPlaceholderText("second"); + const div = getByText("just div"); + return { + firstInput, + secondInput, + div, + handleChange, + }; + }; + + /** + * WARNING: The order of the tests is important: + * the blur test should come first. + * If this is not the case, the activeElement will be dirty + * and the blur event will fire when the input is clicked. + */ + + it("should call handler on blur", async () => { + const { + firstInput, + secondInput, + div, + handleChange: handleBlur, + } = renderTest("blur"); + + await userEvent.click(firstInput); + expect(handleBlur).not.toHaveBeenCalled(); + + await userEvent.click(secondInput); + expect(handleBlur).toHaveBeenCalledTimes(1); + expect(handleBlur).toHaveBeenCalledWith( + document.body, + document.body, + expect.any(FocusEvent) + ); + + await userEvent.click(div); + expect(handleBlur).toHaveBeenCalledTimes(2); + expect(handleBlur).toHaveBeenCalledWith( + document.body, + document.body, + expect.any(FocusEvent) + ); + }); + + it("should call handler on focus", async () => { + const { firstInput, secondInput, handleChange: handleFocus } = renderTest(); + + await userEvent.click(firstInput); + expect(handleFocus).toHaveBeenCalledTimes(1); + expect(handleFocus).toHaveBeenCalledWith( + firstInput, + document.body, + expect.any(FocusEvent) + ); + + await userEvent.click(secondInput); + expect(handleFocus).toHaveBeenCalledTimes(2); + expect(handleFocus).toHaveBeenCalledWith( + secondInput, + firstInput, + expect.any(FocusEvent) + ); + }); + + it("should do not call handler on focus at the same node", async () => { + const { firstInput, handleChange: handleFocus } = renderTest(); + + await userEvent.click(firstInput); + expect(handleFocus).toHaveBeenCalledOnce(); + + await userEvent.click(firstInput); + expect(handleFocus).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/utils/__tests__/use-force-update.test.tsx b/packages/utils/__tests__/use-force-update.test.tsx new file mode 100644 index 000000000..7aa7c1db7 --- /dev/null +++ b/packages/utils/__tests__/use-force-update.test.tsx @@ -0,0 +1,34 @@ +/// + +import * as React from "react"; +import { render, cleanup, userEvent } from "@reach-internal/test/utils"; +import { afterEach, describe, expect, it } from "vitest"; +import { useForceUpdate } from "@reach/utils"; + +afterEach(cleanup); + +describe("useForceUpdate", () => { + it("should force rerender when called", async () => { + let nonObservableVariable = "foo"; + + const Test = () => { + const forceUpdate = useForceUpdate(); + return ( + <> +
{nonObservableVariable}
+