Skip to content
5 changes: 5 additions & 0 deletions .changeset/sweet-pets-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

feat(SkeletonBox): add customizable delay
8 changes: 7 additions & 1 deletion packages/react/src/Skeleton/SkeletonBox.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
],
"importPath": "@primer/react",
"props": [
{
"name": "delay",
"type": "'short' | 'long' | number",
"description": "Controls whether and how long to delay rendering the SkeletonBox. Set to 'short' to delay by 300ms, 'long' to delay by 1000ms, or provide a custom number of milliseconds.",
"defaultValue": "false"
},
{
"name": "width",
"type": "string",
Expand All @@ -35,4 +41,4 @@
}
],
"subcomponents": []
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ export default {
export const CustomHeight = () => <SkeletonBox height="4rem" />

export const CustomWidth = () => <SkeletonBox width="300px" />

export const WithDelay = () => <SkeletonBox delay="long" />
25 changes: 23 additions & 2 deletions packages/react/src/Skeleton/SkeletonBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useEffect, useState} from 'react'
import {type CSSProperties, type HTMLProps} from 'react'
import {clsx} from 'clsx'
import classes from './SkeletonBox.module.css'
Expand All @@ -10,12 +10,33 @@ export type SkeletonBoxProps = {
width?: CSSProperties['width']
/** The className of the skeleton box */
className?: string
/** Controls whether and how long to delay rendering the SkeletonBox. Set to 'short' to delay by 300ms, 'long' to delay by 1000ms, or provide a custom number of milliseconds.*/
delay?: 'short' | 'long' | number
} & HTMLProps<HTMLElement>

export const SkeletonBox = React.forwardRef<HTMLElement, SkeletonBoxProps>(function SkeletonBox(
{height, width, className, style, ...props},
{height, width, className, style, delay = false, ...props},
ref,
) {
const [isVisible, setIsVisible] = useState(!delay)

useEffect(() => {
if (delay) {
const timeoutId = setTimeout(
() => {
setIsVisible(true)
},
typeof delay === 'number' ? delay : delay === 'short' ? 300 : 1000,
)

return () => clearTimeout(timeoutId)
}
}, [delay])

if (!isVisible) {
return null
}

return (
<div
ref={ref as React.RefObject<HTMLDivElement>}
Expand Down
137 changes: 136 additions & 1 deletion packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {render} from '@testing-library/react'
import {describe, expect, it} from 'vitest'
import {beforeEach, afterEach, describe, expect, it, vi} from 'vitest'
import {SkeletonBox} from '../SkeletonBox'
import classes from '../SkeletonBox.module.css'
import {implementsClassName} from '../../utils/testing'
import {act} from 'react'

describe('SkeletonBox', () => {
implementsClassName(SkeletonBox, classes.SkeletonBox)
Expand All @@ -12,4 +13,138 @@ describe('SkeletonBox', () => {
expect(container.firstChild).toHaveStyle('height: 100px')
expect(container.firstChild).toHaveStyle('width: 200px')
})

describe('delay behavior', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})

it('should render immediately when no delay is provided', () => {
const {container} = render(<SkeletonBox />)
expect(container.querySelector('div')).toBeInTheDocument()
})

it('should not render immediately when delay is "short"', () => {
const {container} = render(<SkeletonBox delay="short" />)
expect(container.querySelector('div')).not.toBeInTheDocument()
})

it('should render after 300ms when delay is "short"', () => {
const {container} = render(<SkeletonBox delay="short" />)

// Not visible initially
expect(container.querySelector('div')).not.toBeInTheDocument()

// Advance timers by less than 300ms
act(() => {
vi.advanceTimersByTime(250)
})

// Still not visible
expect(container.querySelector('div')).not.toBeInTheDocument()

// Advance timers to complete the short delay (300ms)
act(() => {
vi.advanceTimersByTime(50)
})

// Now it should be visible
expect(container.querySelector('div')).toBeInTheDocument()
})

it('should not render immediately when delay is "long"', () => {
const {container} = render(<SkeletonBox delay="long" />)
expect(container.querySelector('div')).not.toBeInTheDocument()
})

it('should render after 1000ms when delay is "long"', () => {
const {container} = render(<SkeletonBox delay="long" />)

// Not visible initially
expect(container.querySelector('div')).not.toBeInTheDocument()

// Advance timers by less than 1000ms
act(() => {
vi.advanceTimersByTime(800)
})

// Still not visible
expect(container.querySelector('div')).not.toBeInTheDocument()

// Advance timers to complete the long delay (1000ms)
act(() => {
vi.advanceTimersByTime(200)
})

// Now it should be visible
expect(container.querySelector('div')).toBeInTheDocument()
})

it('should cleanup timeout on unmount when delay is "short"', () => {
const {unmount} = render(<SkeletonBox delay="short" />)

// Unmount before the delay completes
unmount()

// Advance timers to see if there are any side effects
vi.advanceTimersByTime(300)

// No errors should occur
expect(true).toBe(true)
})

it('should cleanup timeout on unmount when delay is "long"', () => {
const {unmount} = render(<SkeletonBox delay="long" />)

// Unmount before the delay completes
unmount()

// Advance timers to see if there are any side effects
vi.advanceTimersByTime(1000)

// No errors should occur
expect(true).toBe(true)
})

it('should render after custom delay when delay is a number', () => {
const {container} = render(<SkeletonBox delay={500} />)

// Not visible initially
expect(container.querySelector('div')).not.toBeInTheDocument()

// Advance timers by less than the custom delay (500ms)
act(() => {
vi.advanceTimersByTime(400)
})

// Still not visible
expect(container.querySelector('div')).not.toBeInTheDocument()

// Advance timers to complete the custom delay
act(() => {
vi.advanceTimersByTime(100)
})

// Now it should be visible
expect(container.querySelector('div')).toBeInTheDocument()
})

it('should cleanup timeout on unmount when delay is a number', () => {
const {unmount} = render(<SkeletonBox delay={500} />)

// Unmount before the delay completes
unmount()

// Advance timers to see if there are any side effects
vi.advanceTimersByTime(500)

// No errors should occur
expect(true).toBe(true)
})
})
})
Loading