diff --git a/.changeset/sweet-pets-breathe.md b/.changeset/sweet-pets-breathe.md new file mode 100644 index 00000000000..d0d10243081 --- /dev/null +++ b/.changeset/sweet-pets-breathe.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +feat(SkeletonBox): add customizable delay diff --git a/packages/react/src/Skeleton/SkeletonBox.docs.json b/packages/react/src/Skeleton/SkeletonBox.docs.json index 95859a9f549..8e7e4b9e30e 100644 --- a/packages/react/src/Skeleton/SkeletonBox.docs.json +++ b/packages/react/src/Skeleton/SkeletonBox.docs.json @@ -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", @@ -35,4 +41,4 @@ } ], "subcomponents": [] -} \ No newline at end of file +} diff --git a/packages/react/src/Skeleton/SkeletonBox.features.stories.tsx b/packages/react/src/Skeleton/SkeletonBox.features.stories.tsx index 06a38043726..49f640a114d 100644 --- a/packages/react/src/Skeleton/SkeletonBox.features.stories.tsx +++ b/packages/react/src/Skeleton/SkeletonBox.features.stories.tsx @@ -10,3 +10,5 @@ export default { export const CustomHeight = () => export const CustomWidth = () => + +export const WithDelay = () => diff --git a/packages/react/src/Skeleton/SkeletonBox.tsx b/packages/react/src/Skeleton/SkeletonBox.tsx index eb52dc7800b..110a2489e8d 100644 --- a/packages/react/src/Skeleton/SkeletonBox.tsx +++ b/packages/react/src/Skeleton/SkeletonBox.tsx @@ -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' @@ -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 export const SkeletonBox = React.forwardRef(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 (
} diff --git a/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx b/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx index 541b26fb973..a60cba6c2fa 100644 --- a/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx +++ b/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx @@ -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) @@ -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() + expect(container.querySelector('div')).toBeInTheDocument() + }) + + it('should not render immediately when delay is "short"', () => { + const {container} = render() + expect(container.querySelector('div')).not.toBeInTheDocument() + }) + + it('should render after 300ms when delay is "short"', () => { + const {container} = render() + + // 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() + expect(container.querySelector('div')).not.toBeInTheDocument() + }) + + it('should render after 1000ms when delay is "long"', () => { + const {container} = render() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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) + }) + }) })