Skip to content

Commit 79f1fc8

Browse files
authored
feat(ui): add delay support to Spin (#94)
* feat(ui): add delay support to Spin * test(ui): cover Spin delay behavior * docs(ui): document Spin delay
1 parent c69d578 commit 79f1fc8

3 files changed

Lines changed: 98 additions & 12 deletions

File tree

docs/components/spin.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
<LivePlayground :code="`
88
() => {
99
return (
10-
<Space direction='vertical' size={12}>
11-
<Spin tip='加载中...' />
12-
<Spin spinning={true}>
13-
<Card title='卡片标题'>被 Spin 包裹的内容区域</Card>
10+
<div className='flex flex-col gap-4'>
11+
<Spin tip='加载中...' delay={200} />
12+
<Spin spinning={true} delay={200}>
13+
<div className='rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'>
14+
被 Spin 包裹的内容区域
15+
</div>
1416
</Spin>
15-
</Space>
17+
</div>
1618
)
1719
}
1820
`" />
@@ -21,13 +23,16 @@
2123

2224
Spin 在加载中时会渲染为 `role="status"` 的 polite live region。纯视觉旋转图形会从辅助技术中隐藏;当包裹子元素时,外层容器会根据 `spinning` 同步 `aria-busy`。当页面中存在多个加载状态时,建议通过 `tip``aria-label``aria-labelledby` 提供可区分名称。
2325

26+
当设置 `delay` 时,Spin 会延迟显示视觉 spinner 和 `status` live region;如果 `spinning` 在延迟结束前变为 `false`,则不会显示 spinner。包裹子元素时,`aria-busy` 仍会立即反映真实加载状态。
27+
2428
## API
2529

2630
| 属性 | 说明 | 类型 | 默认值 |
2731
| --------------- | ------------------------ | ---------------------------------- | ------------------------ |
2832
| spinning | 是否加载中 | `boolean` | `true` |
2933
| size | 尺寸 | `'sm' \| 'md' \| 'lg'` | `'md'` |
3034
| tip | 提示文字 | `ReactNode` | - |
35+
| delay | 延迟显示加载状态的毫秒数 | `number` | `0` |
3136
| aria-label | 加载状态可访问名称 | `string` | `tip` 文本或 `'Loading'` |
3237
| aria-labelledby | 使用外部标签命名加载状态 | `string` | - |
3338
| aria-live | 加载状态通知优先级 | `'off' \| 'polite' \| 'assertive'` | `'polite'` |

packages/ui/src/components/feedback/Spin/Spin.test.tsx

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { render, screen } from '@testing-library/react'
2-
import { describe, expect, it } from 'vitest'
1+
import { act, cleanup, render, screen } from '@testing-library/react'
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
33

44
import { Spin } from './Spin'
55

6+
afterEach(() => {
7+
cleanup()
8+
vi.useRealTimers()
9+
})
10+
611
describe('Spin', () => {
712
it('announces standalone loading state as a polite status', () => {
813
render(<Spin />)
@@ -20,6 +25,38 @@ describe('Spin', () => {
2025
expect(screen.getByText('Loading invoices')).toBeInTheDocument()
2126
})
2227

28+
it('delays the standalone loading status when delay is set', () => {
29+
vi.useFakeTimers()
30+
31+
render(<Spin delay={200} />)
32+
33+
expect(screen.queryByRole('status')).not.toBeInTheDocument()
34+
35+
act(() => {
36+
vi.advanceTimersByTime(199)
37+
})
38+
expect(screen.queryByRole('status')).not.toBeInTheDocument()
39+
40+
act(() => {
41+
vi.advanceTimersByTime(1)
42+
})
43+
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument()
44+
})
45+
46+
it('does not show a delayed spinner when spinning stops before delay ends', () => {
47+
vi.useFakeTimers()
48+
49+
const { rerender } = render(<Spin delay={200} />)
50+
51+
rerender(<Spin spinning={false} delay={200} />)
52+
53+
act(() => {
54+
vi.advanceTimersByTime(200)
55+
})
56+
57+
expect(screen.queryByRole('status')).not.toBeInTheDocument()
58+
})
59+
2360
it('marks wrapped content busy while the overlay is spinning', () => {
2461
render(
2562
<Spin spinning tip="Refreshing data">
@@ -33,6 +70,26 @@ describe('Spin', () => {
3370
expect(screen.getByRole('status', { name: 'Refreshing data' })).toBeInTheDocument()
3471
})
3572

73+
it('marks wrapped content busy while a delayed overlay is pending', () => {
74+
vi.useFakeTimers()
75+
76+
render(
77+
<Spin spinning delay={200} tip="Refreshing data">
78+
<section aria-label="Report panel">Report content</section>
79+
</Spin>,
80+
)
81+
82+
const wrapper = screen.getByText('Report content').parentElement
83+
84+
expect(wrapper).toHaveAttribute('aria-busy', 'true')
85+
expect(screen.queryByRole('status')).not.toBeInTheDocument()
86+
87+
act(() => {
88+
vi.advanceTimersByTime(200)
89+
})
90+
expect(screen.getByRole('status', { name: 'Refreshing data' })).toBeInTheDocument()
91+
})
92+
3693
it('marks wrapped content not busy and hides status when spinning stops', () => {
3794
render(
3895
<Spin spinning={false}>
@@ -45,4 +102,4 @@ describe('Spin', () => {
45102
expect(wrapper).toHaveAttribute('aria-busy', 'false')
46103
expect(screen.queryByRole('status')).not.toBeInTheDocument()
47104
})
48-
})
105+
})

packages/ui/src/components/feedback/Spin/Spin.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
1+
import { forwardRef, useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
22
import { cn } from '../../../utils/cn'
33

44
export interface SpinProps extends HTMLAttributes<HTMLDivElement> {
55
spinning?: boolean
66
size?: 'sm' | 'md' | 'lg'
77
tip?: ReactNode
8+
delay?: number
89
}
910

1011
export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
@@ -13,6 +14,7 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
1314
spinning = true,
1415
size = 'md',
1516
tip,
17+
delay = 0,
1618
children,
1719
'aria-label': ariaLabel,
1820
'aria-labelledby': ariaLabelledBy,
@@ -21,9 +23,31 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
2123
},
2224
ref,
2325
) {
26+
const [showSpinner, setShowSpinner] = useState(() => spinning && delay <= 0)
2427
const accessibleLabel =
2528
ariaLabel ?? (ariaLabelledBy ? undefined : typeof tip === 'string' ? tip : 'Loading')
2629

30+
useEffect(() => {
31+
if (!spinning) {
32+
setShowSpinner(false)
33+
return
34+
}
35+
36+
if (delay <= 0) {
37+
setShowSpinner(true)
38+
return
39+
}
40+
41+
setShowSpinner(false)
42+
const timer = window.setTimeout(() => {
43+
setShowSpinner(true)
44+
}, delay)
45+
46+
return () => {
47+
window.clearTimeout(timer)
48+
}
49+
}, [delay, spinning])
50+
2751
const spinner = (
2852
<div
2953
className="flex flex-col items-center gap-2"
@@ -48,7 +72,7 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
4872
)
4973

5074
if (!children) {
51-
return spinning ? (
75+
return showSpinner ? (
5276
<div
5377
ref={ref}
5478
className={cn('inline-flex items-center justify-center', className)}
@@ -62,11 +86,11 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
6286
return (
6387
<div ref={ref} className={cn('relative', className)} {...props} aria-busy={spinning}>
6488
{children}
65-
{spinning ? (
89+
{showSpinner ? (
6690
<div className="absolute inset-0 flex items-center justify-center rounded bg-white/60 dark:bg-slate-900/60">
6791
{spinner}
6892
</div>
6993
) : null}
7094
</div>
7195
)
72-
})
96+
})

0 commit comments

Comments
 (0)