diff --git a/.changeset/fresh-flies-wash.md b/.changeset/fresh-flies-wash.md new file mode 100644 index 000000000..1fa3339d3 --- /dev/null +++ b/.changeset/fresh-flies-wash.md @@ -0,0 +1,5 @@ +--- +"@suspensive/react-dom": minor +--- + +feat(react-dom): add `` diff --git a/examples/visualization/src/app/layout.tsx b/examples/visualization/src/app/layout.tsx index 649e13e50..4d891f734 100644 --- a/examples/visualization/src/app/layout.tsx +++ b/examples/visualization/src/app/layout.tsx @@ -51,6 +51,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
  • {``}
  • +
  • + {``} +
  • diff --git a/examples/visualization/src/app/react-dom/FadeIn/page.tsx b/examples/visualization/src/app/react-dom/FadeIn/page.tsx new file mode 100644 index 000000000..833169b75 --- /dev/null +++ b/examples/visualization/src/app/react-dom/FadeIn/page.tsx @@ -0,0 +1,79 @@ +'use client' + +import { ErrorBoundary, Suspense } from '@suspensive/react' +import { FadeIn } from '@suspensive/react-dom' +import { SuspenseQuery, queryOptions } from '@suspensive/react-query' +import axios from 'axios' +import { delay } from '~/utils' + +const query = { + user: (userId: number) => + queryOptions({ + queryKey: ['users', userId], + queryFn: () => + delay(3000).then(() => + axios + .get<{ + id: number + username: string + maidenName: string + age: number + gender: string + email: string + image: 'https://dummyjson.com/icon/emilys/128' + userAgent: string + }>(`https://dummyjson.com/users/${userId}`) + .then(({ data }) => data) + ), + }), +} + +export default function Page() { + return ( +
    + <>{error.message}}> + {Array.from({ length: 20 }).map((_, i) => { + const userId = i + 1 + return ( + + {(fadeIn) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + )} + + } + > + + {({ data: user }) => ( + + {(fadeIn) => ( +
    +

    {user.username}

    +

    {user.userAgent}

    +

    {user.age}

    +

    {user.maidenName}

    +
    +
    + )} + + )} + + + ) + })} + +
    + ) +} diff --git a/examples/visualization/src/app/react-dom/InView/page.tsx b/examples/visualization/src/app/react-dom/InView/page.tsx index 85ed5a92d..2667aecfc 100644 --- a/examples/visualization/src/app/react-dom/InView/page.tsx +++ b/examples/visualization/src/app/react-dom/InView/page.tsx @@ -6,7 +6,6 @@ export default function Page() { return (
    {Array.from({ length: 200 }).map((_, i) => ( - // eslint-disable-next-line @eslint-react/no-duplicate-key {({ inView, ref }) => (
    diff --git a/packages/react-dom/src/FadeIn.spec.tsx b/packages/react-dom/src/FadeIn.spec.tsx new file mode 100644 index 000000000..0ed9ce558 --- /dev/null +++ b/packages/react-dom/src/FadeIn.spec.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react' +import { FadeIn } from './FadeIn' +import { mockAllIsIntersecting } from './test-utils' + +describe('', () => { + it('renders children with correct styles when in view', () => { + mockAllIsIntersecting(false) + const mockChild = vi.fn() + render( + + {(fadeIn) => ( +
    + {mockChild(fadeIn.style)} +
    + )} +
    + ) + expect(mockChild).toHaveBeenCalledWith({ opacity: 0, willChange: 'opacity', transition: 'opacity 300ms ease-in' }) + mockAllIsIntersecting(true) + const child = screen.getByTestId('fade-in-child') + expect(child).toHaveStyle({ opacity: '1', willChange: 'opacity', transition: 'opacity 300ms ease-in' }) + expect(mockChild).toHaveBeenCalledWith({ opacity: 1, willChange: 'opacity', transition: 'opacity 300ms ease-in' }) + }) +}) diff --git a/packages/react-dom/src/FadeIn.test-d.tsx b/packages/react-dom/src/FadeIn.test-d.tsx new file mode 100644 index 000000000..fc30a2927 --- /dev/null +++ b/packages/react-dom/src/FadeIn.test-d.tsx @@ -0,0 +1,10 @@ +import type { ComponentProps } from 'react' +import type { FadeIn } from './FadeIn' + +describe('', () => { + it('type check', () => { + expectTypeOf>().toEqualTypeOf< + 'root' | 'rootMargin' | 'threshold' | 'triggerOnce' | 'delay' | 'children' | 'duration' | 'timingFunction' + >() + }) +}) diff --git a/packages/react-dom/src/FadeIn.tsx b/packages/react-dom/src/FadeIn.tsx new file mode 100644 index 000000000..8a2ddb6dd --- /dev/null +++ b/packages/react-dom/src/FadeIn.tsx @@ -0,0 +1,22 @@ +import { type ReactNode } from 'react' +import { type FadeInOptions, useFadeIn } from './useFadeIn' + +type FadeInProps = FadeInOptions & { + children: (fadeInResult: ReturnType) => ReactNode +} +/** + * A component that fades in when it comes into view. + */ +export function FadeIn({ + duration = 200, + timingFunction = 'linear', + delay, + root, + rootMargin, + threshold, + triggerOnce, + children, +}: FadeInProps) { + const result = useFadeIn({ delay, duration, root, rootMargin, threshold, timingFunction, triggerOnce }) + return <>{children(result)} +} diff --git a/packages/react-dom/src/index.ts b/packages/react-dom/src/index.ts index 4ceebec90..e6b6f0a6f 100644 --- a/packages/react-dom/src/index.ts +++ b/packages/react-dom/src/index.ts @@ -1,2 +1,4 @@ export { InView } from './InView' export { useInView } from './useInView' +export { FadeIn } from './FadeIn' +export { useFadeIn } from './useFadeIn' diff --git a/packages/react-dom/src/useFadeIn.ts b/packages/react-dom/src/useFadeIn.ts new file mode 100644 index 000000000..f817e4f7c --- /dev/null +++ b/packages/react-dom/src/useFadeIn.ts @@ -0,0 +1,48 @@ +import { type CSSProperties, useMemo } from 'react' +import { type InViewOptions, useInView } from './useInView' +import type { OmitKeyof } from './utility-types' + +export type FadeInOptions = OmitKeyof< + InViewOptions, + 'fallbackInView' | 'initialInView' | 'skip' | 'onChange' | 'trackVisibility' +> & { + /** + * The duration in milliseconds of the animation. + * @default 200 + */ + duration?: number + /** + * The timing function of the animation. + * @default 'linear' + */ + timingFunction?: CSSProperties['transitionTimingFunction'] +} +type FadeInResult = Pick, 'ref'> & { + style: { + opacity: 0 | 1 + willChange: 'opacity' + transition: `opacity ${number}ms ${Required['transitionTimingFunction']}` + } +} +export function useFadeIn({ + duration = 200, + timingFunction = 'linear', + delay, + root, + rootMargin, + threshold, + triggerOnce, +}: FadeInOptions): FadeInResult { + const { inView, ref } = useInView({ delay, root, rootMargin, threshold, triggerOnce }) + return useMemo( + () => ({ + ref, + style: { + opacity: inView ? 1 : 0, + willChange: 'opacity', + transition: `opacity ${duration}ms ${timingFunction}` as const, + }, + }), + [inView, duration, timingFunction] + ) +} diff --git a/packages/react-dom/src/utility-types/OmitKeyof.test-d.ts b/packages/react-dom/src/utility-types/OmitKeyof.test-d.ts new file mode 100644 index 000000000..1391673bb --- /dev/null +++ b/packages/react-dom/src/utility-types/OmitKeyof.test-d.ts @@ -0,0 +1,176 @@ +import type { OmitKeyof } from './OmitKeyof' + +describe('OmitKeyof', () => { + it("'s string key type check", () => { + type A = { + x: string + y: number + } + + type ExpectedType = { + x: string + } + + // Bad point + // 1. original Omit can use 'z' as type parameter with no type error + // 2. original Omit have no auto complete for 2nd type parameter + expectTypeOf>().toEqualTypeOf() + + // Solution + + // 1. strictly + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use 'z' as type parameter with type error because A don't have key 'z' + // @ts-expect-error Type does not satisfy the constraint keyof A + 'z' | 'y' + > + >().toEqualTypeOf() + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use 'z' as type parameter with type error because A don't have key 'z' + // @ts-expect-error Type does not satisfy the constraint keyof A + 'z' | 'y', + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments + 'strictly' + > + >().toEqualTypeOf() + + // 2. safely + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use 'z' as type parameter type error with strictly parameter or default parameter + // @ts-expect-error Type does not satisfy the constraint keyof A + 'z' | 'y' + > + >().toEqualTypeOf() + expectTypeOf< + OmitKeyof< + A, + // With 'safely', OmitKeyof can use 'z' as type parameter like original Omit but This support autocomplete too yet for DX. + 'z' | 'y', + 'safely' + > + >().toEqualTypeOf() + }) + + it("'s number key type check", () => { + type A = { + [1]: string + [2]: number + } + + type ExpectedType = { + [1]: string + } + + // Bad point + // 1. original Omit can use 3 as type parameter with no type error + // 2. original Omit have no auto complete for 2nd type parameter + expectTypeOf>().toEqualTypeOf() + + // Solution + + // 1. strictly + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use 3 as type parameter with type error because A don't have key 3 + // @ts-expect-error Type does not satisfy the constraint keyof A + 3 | 2 + > + >().toEqualTypeOf() + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use 3 as type parameter with type error because A don't have key 3 + // @ts-expect-error Type does not satisfy the constraint keyof A + 3 | 2, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments + 'strictly' + > + >().toEqualTypeOf() + + // 2. safely + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use 3 as type parameter type error with strictly parameter or default parameter + // @ts-expect-error Type does not satisfy the constraint keyof A + 3 | 2 + > + >().toEqualTypeOf() + expectTypeOf< + OmitKeyof< + A, + // With 'safely', OmitKeyof can use 3 as type parameter like original Omit but This support autocomplete too yet for DX. + 3 | 2, + 'safely' + > + >().toEqualTypeOf() + }) + + it("'s symbol key type check", () => { + const symbol1 = Symbol() + const symbol2 = Symbol() + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const symbol3 = Symbol() + + type A = { + [symbol1]: string + [symbol2]: number + } + + type ExpectedType = { + [symbol1]: string + } + + // Bad point + // 1. original Omit can use symbol3 as type parameter with no type error + // 2. original Omit have no auto complete for 2nd type parameter + expectTypeOf>().toEqualTypeOf() + + // Solution + + // 1. strictly + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use symbol3 as type parameter with type error because A don't have key symbol3 + // @ts-expect-error Type does not satisfy the constraint keyof A + typeof symbol3 | typeof symbol2 + > + >().toEqualTypeOf() + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use symbol3 as type parameter with type error because A don't have key symbol3 + // @ts-expect-error Type does not satisfy the constraint keyof A + typeof symbol3 | typeof symbol2, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments + 'strictly' + > + >().toEqualTypeOf() + + // 2. safely + expectTypeOf< + OmitKeyof< + A, + // OmitKeyof can't use symbol3 as type parameter type error with strictly parameter or default parameter + // @ts-expect-error Type does not satisfy the constraint keyof A + typeof symbol3 | typeof symbol2 + > + >().toEqualTypeOf() + expectTypeOf< + OmitKeyof< + A, + // With 'safely', OmitKeyof can use symbol3 as type parameter like original Omit but This support autocomplete too yet for DX. + typeof symbol3 | typeof symbol2, + 'safely' + > + >().toEqualTypeOf() + }) +}) diff --git a/packages/react-dom/src/utility-types/OmitKeyof.ts b/packages/react-dom/src/utility-types/OmitKeyof.ts new file mode 100644 index 000000000..69f6be78c --- /dev/null +++ b/packages/react-dom/src/utility-types/OmitKeyof.ts @@ -0,0 +1,11 @@ +export type OmitKeyof< + TObject, + TKey extends TStrictly extends 'safely' + ? + | keyof TObject + | (string & Record) + | (number & Record) + | (symbol & Record) + : keyof TObject, + TStrictly extends 'strictly' | 'safely' = 'strictly', +> = Omit diff --git a/packages/react-dom/src/utility-types/index.ts b/packages/react-dom/src/utility-types/index.ts new file mode 100644 index 000000000..e439d3d4c --- /dev/null +++ b/packages/react-dom/src/utility-types/index.ts @@ -0,0 +1 @@ +export type { OmitKeyof } from './OmitKeyof'