Skip to content

Commit

Permalink
feat(react-dom): add <FadeIn/> (#1362)
Browse files Browse the repository at this point in the history
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
manudeli and kangju2000 authored Nov 23, 2024
1 parent e4af4a2 commit 244b305
Show file tree
Hide file tree
Showing 12 changed files with 381 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/fresh-flies-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@suspensive/react-dom": minor
---

feat(react-dom): add `<FadeIn/>`
3 changes: 3 additions & 0 deletions examples/visualization/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<li>
<Link href="/react-dom/InView">{`<InView/>`}</Link>
</li>
<li>
<Link href="/react-dom/FadeIn">{`<FadeIn/>`}</Link>
</li>
</details>
</li>
<li>
Expand Down
79 changes: 79 additions & 0 deletions examples/visualization/src/app/react-dom/FadeIn/page.tsx
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>
)
}
1 change: 0 additions & 1 deletion examples/visualization/src/app/react-dom/InView/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export default function Page() {
return (
<div>
{Array.from({ length: 200 }).map((_, i) => (
// eslint-disable-next-line @eslint-react/no-duplicate-key
<InView key={i} threshold={0.8} delay={200} triggerOnce initialInView>
{({ inView, ref }) => (
<div ref={ref}>
Expand Down
24 changes: 24 additions & 0 deletions packages/react-dom/src/FadeIn.spec.tsx
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' })
})
})
10 changes: 10 additions & 0 deletions packages/react-dom/src/FadeIn.test-d.tsx
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'
>()
})
})
22 changes: 22 additions & 0 deletions packages/react-dom/src/FadeIn.tsx
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)}</>
}
2 changes: 2 additions & 0 deletions packages/react-dom/src/index.ts
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'
48 changes: 48 additions & 0 deletions packages/react-dom/src/useFadeIn.ts
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 packages/react-dom/src/utility-types/OmitKeyof.test-d.ts
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>()
})
})
Loading

0 comments on commit 244b305

Please sign in to comment.