-
Notifications
You must be signed in to change notification settings - Fork 52
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react-dom): add
<FadeIn/>
(#1362)
related with #1109 # `<FadeIn/>` Influenced by blog posts such as https://tech.kakaopay.com/post/skeleton-ui-idea/, we provide a component called `<Delay/>` in @suspensive/react. However, since `<Delay/>` blocks the rendering of children for a certain period of time, it affects the time when the `<PostListSkeleton/>` component is reflected on the html. Therefore, the skeleton itself can appear suddenly, and I thought this part was not good in terms of UX. Therefore, I thought that if we use a component like `<FadeIn/>` (name undecided), it will be reflected on the html immediately and the animation will be used to gradually display it, making it easier to implement a more natural UI. ## Example ```tsx export default function Page() { return ( <div> <ErrorBoundary fallback={({ error }) => <>{error.message}</>}> {Array.from({ length: 20 }).map((_, i) => { const userId = i + 1 return ( <Suspense key={userId} clientOnly fallback={ <FadeIn delay={200} duration={1000}> {skeleton} </FadeIn> } > <SuspenseQuery {...query.user(userId)}> {({ data: user }) => ( <FadeIn duration={200} inViewOptions={{ triggerOnce: true }} className="max-w-[344px]"> <h1 className="text-lg font-bold">{user.username}</h1> <p className="text-xs">{user.userAgent}</p> <p>{user.age}</p> <p>{user.maidenName}</p> <div className="mb-6" /> </FadeIn> )} </SuspenseQuery> </Suspense> ) })} </ErrorBoundary> </div> ) } const skeleton = ( <div role="status" className="mb-6 animate-pulse space-y-2"> <div className="h-4 w-[42px] rounded-sm bg-gray-300 dark:bg-gray-600" /> <div className="h-2 w-[34px] rounded-sm bg-gray-300 dark:bg-gray-600" /> <div className="h-2 w-[344px] rounded-sm bg-gray-300 dark:bg-gray-600" /> <div className="h-2 w-[344px] rounded-sm bg-gray-300 dark:bg-gray-600" /> <div className="h-4 w-[42px] rounded-sm bg-gray-300 dark:bg-gray-600" /> <div className="h-4 w-[34px] rounded-sm bg-gray-300 dark:bg-gray-600" /> </div> ) ``` ![chrome-capture-2024-11-18 (2)](https://github.com/user-attachments/assets/d21be685-1c64-43bd-937f-4ca5385a00b5) ## PR Checklist - [x] I did below actions if need 1. I read the [Contributing Guide](https://github.com/toss/suspensive/blob/main/CONTRIBUTING.md) 2. I added documents and tests. --------- Co-authored-by: Juhyeok Kang <[email protected]>
- Loading branch information
1 parent
e4af4a2
commit 244b305
Showing
12 changed files
with
381 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@suspensive/react-dom": minor | ||
--- | ||
|
||
feat(react-dom): add `<FadeIn/>` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div> | ||
<ErrorBoundary fallback={({ error }) => <>{error.message}</>}> | ||
{Array.from({ length: 20 }).map((_, i) => { | ||
const userId = i + 1 | ||
return ( | ||
<Suspense | ||
key={userId} | ||
clientOnly | ||
fallback={ | ||
<FadeIn delay={200} duration={1000}> | ||
{(fadeIn) => ( | ||
<div {...fadeIn}> | ||
<div role="status" className="mb-6 animate-pulse space-y-2"> | ||
<div className="h-4 w-[42px] rounded-sm bg-gray-300 dark:bg-gray-600" /> | ||
<div className="h-2 w-[34px] rounded-sm bg-gray-300 dark:bg-gray-600" /> | ||
<div className="h-2 w-[344px] rounded-sm bg-gray-300 dark:bg-gray-600" /> | ||
<div className="h-2 w-[344px] rounded-sm bg-gray-300 dark:bg-gray-600" /> | ||
<div className="h-4 w-[42px] rounded-sm bg-gray-300 dark:bg-gray-600" /> | ||
<div className="h-4 w-[34px] rounded-sm bg-gray-300 dark:bg-gray-600" /> | ||
</div> | ||
</div> | ||
)} | ||
</FadeIn> | ||
} | ||
> | ||
<SuspenseQuery {...query.user(userId)}> | ||
{({ data: user }) => ( | ||
<FadeIn duration={200} triggerOnce> | ||
{(fadeIn) => ( | ||
<div {...fadeIn} className="max-w-[344px]"> | ||
<h1 className="text-lg font-bold">{user.username}</h1> | ||
<p className="text-xs">{user.userAgent}</p> | ||
<p>{user.age}</p> | ||
<p>{user.maidenName}</p> | ||
<div className="mb-6" /> | ||
</div> | ||
)} | ||
</FadeIn> | ||
)} | ||
</SuspenseQuery> | ||
</Suspense> | ||
) | ||
})} | ||
</ErrorBoundary> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import { render, screen } from '@testing-library/react' | ||
import { FadeIn } from './FadeIn' | ||
import { mockAllIsIntersecting } from './test-utils' | ||
|
||
describe('<FadeIn/>', () => { | ||
it('renders children with correct styles when in view', () => { | ||
mockAllIsIntersecting(false) | ||
const mockChild = vi.fn() | ||
render( | ||
<FadeIn duration={300} timingFunction="ease-in"> | ||
{(fadeIn) => ( | ||
<div {...fadeIn} data-testid="fade-in-child"> | ||
{mockChild(fadeIn.style)} | ||
</div> | ||
)} | ||
</FadeIn> | ||
) | ||
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' }) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import type { ComponentProps } from 'react' | ||
import type { FadeIn } from './FadeIn' | ||
|
||
describe('<FadeIn/>', () => { | ||
it('type check', () => { | ||
expectTypeOf<keyof ComponentProps<typeof FadeIn>>().toEqualTypeOf< | ||
'root' | 'rootMargin' | 'threshold' | 'triggerOnce' | 'delay' | 'children' | 'duration' | 'timingFunction' | ||
>() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { type ReactNode } from 'react' | ||
import { type FadeInOptions, useFadeIn } from './useFadeIn' | ||
|
||
type FadeInProps = FadeInOptions & { | ||
children: (fadeInResult: ReturnType<typeof useFadeIn>) => 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)}</> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,4 @@ | ||
export { InView } from './InView' | ||
export { useInView } from './useInView' | ||
export { FadeIn } from './FadeIn' | ||
export { useFadeIn } from './useFadeIn' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ReturnType<typeof useInView>, 'ref'> & { | ||
style: { | ||
opacity: 0 | 1 | ||
willChange: 'opacity' | ||
transition: `opacity ${number}ms ${Required<CSSProperties>['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<FadeInResult>( | ||
() => ({ | ||
ref, | ||
style: { | ||
opacity: inView ? 1 : 0, | ||
willChange: 'opacity', | ||
transition: `opacity ${duration}ms ${timingFunction}` as const, | ||
}, | ||
}), | ||
[inView, duration, timingFunction] | ||
) | ||
} |
176 changes: 176 additions & 0 deletions
176
packages/react-dom/src/utility-types/OmitKeyof.test-d.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Omit<A, 'z' | 'y'>>().toEqualTypeOf<ExpectedType>() | ||
|
||
// 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<ExpectedType>() | ||
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<ExpectedType>() | ||
|
||
// 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<ExpectedType>() | ||
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<ExpectedType>() | ||
}) | ||
|
||
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<Omit<A, 3 | 2>>().toEqualTypeOf<ExpectedType>() | ||
|
||
// 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<ExpectedType>() | ||
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<ExpectedType>() | ||
|
||
// 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<ExpectedType>() | ||
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<ExpectedType>() | ||
}) | ||
|
||
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<Omit<A, typeof symbol3 | typeof symbol2>>().toEqualTypeOf<ExpectedType>() | ||
|
||
// 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<ExpectedType>() | ||
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<ExpectedType>() | ||
|
||
// 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<ExpectedType>() | ||
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<ExpectedType>() | ||
}) | ||
}) |
Oops, something went wrong.