Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/components/spin.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
<LivePlayground :code="`
() => {
return (
<Space direction='vertical' size={12}>
<Spin tip='加载中...' />
<Spin spinning={true}>
<Card title='卡片标题'>被 Spin 包裹的内容区域</Card>
<div className='flex flex-col gap-4'>
<Spin tip='加载中...' delay={200} />
<Spin spinning={true} delay={200}>
<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'>
被 Spin 包裹的内容区域
</div>
</Spin>
</Space>
</div>
)
}
`" />
Expand All @@ -21,13 +23,16 @@

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

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

## API

| 属性 | 说明 | 类型 | 默认值 |
| --------------- | ------------------------ | ---------------------------------- | ------------------------ |
| spinning | 是否加载中 | `boolean` | `true` |
| size | 尺寸 | `'sm' \| 'md' \| 'lg'` | `'md'` |
| tip | 提示文字 | `ReactNode` | - |
| delay | 延迟显示加载状态的毫秒数 | `number` | `0` |
| aria-label | 加载状态可访问名称 | `string` | `tip` 文本或 `'Loading'` |
| aria-labelledby | 使用外部标签命名加载状态 | `string` | - |
| aria-live | 加载状态通知优先级 | `'off' \| 'polite' \| 'assertive'` | `'polite'` |
63 changes: 60 additions & 3 deletions packages/ui/src/components/feedback/Spin/Spin.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { Spin } from './Spin'

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

describe('Spin', () => {
it('announces standalone loading state as a polite status', () => {
render(<Spin />)
Expand All @@ -20,6 +25,38 @@ describe('Spin', () => {
expect(screen.getByText('Loading invoices')).toBeInTheDocument()
})

it('delays the standalone loading status when delay is set', () => {
vi.useFakeTimers()

render(<Spin delay={200} />)

expect(screen.queryByRole('status')).not.toBeInTheDocument()

act(() => {
vi.advanceTimersByTime(199)
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()

act(() => {
vi.advanceTimersByTime(1)
})
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument()
})

it('does not show a delayed spinner when spinning stops before delay ends', () => {
vi.useFakeTimers()

const { rerender } = render(<Spin delay={200} />)

rerender(<Spin spinning={false} delay={200} />)

act(() => {
vi.advanceTimersByTime(200)
})

expect(screen.queryByRole('status')).not.toBeInTheDocument()
})

it('marks wrapped content busy while the overlay is spinning', () => {
render(
<Spin spinning tip="Refreshing data">
Expand All @@ -33,6 +70,26 @@ describe('Spin', () => {
expect(screen.getByRole('status', { name: 'Refreshing data' })).toBeInTheDocument()
})

it('marks wrapped content busy while a delayed overlay is pending', () => {
vi.useFakeTimers()

render(
<Spin spinning delay={200} tip="Refreshing data">
<section aria-label="Report panel">Report content</section>
</Spin>,
)

const wrapper = screen.getByText('Report content').parentElement

expect(wrapper).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('status')).not.toBeInTheDocument()

act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.getByRole('status', { name: 'Refreshing data' })).toBeInTheDocument()
})

it('marks wrapped content not busy and hides status when spinning stops', () => {
render(
<Spin spinning={false}>
Expand All @@ -45,4 +102,4 @@ describe('Spin', () => {
expect(wrapper).toHaveAttribute('aria-busy', 'false')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
})
32 changes: 28 additions & 4 deletions packages/ui/src/components/feedback/Spin/Spin.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { forwardRef, useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '../../../utils/cn'

export interface SpinProps extends HTMLAttributes<HTMLDivElement> {
spinning?: boolean
size?: 'sm' | 'md' | 'lg'
tip?: ReactNode
delay?: number
}

export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
Expand All @@ -13,6 +14,7 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
spinning = true,
size = 'md',
tip,
delay = 0,
children,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
Expand All @@ -21,9 +23,31 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
},
ref,
) {
const [showSpinner, setShowSpinner] = useState(() => spinning && delay <= 0)
const accessibleLabel =
ariaLabel ?? (ariaLabelledBy ? undefined : typeof tip === 'string' ? tip : 'Loading')

useEffect(() => {
if (!spinning) {
setShowSpinner(false)
return
}

if (delay <= 0) {
setShowSpinner(true)
return
}

setShowSpinner(false)
const timer = window.setTimeout(() => {
setShowSpinner(true)
}, delay)

return () => {
window.clearTimeout(timer)
}
}, [delay, spinning])

const spinner = (
<div
className="flex flex-col items-center gap-2"
Expand All @@ -48,7 +72,7 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
)

if (!children) {
return spinning ? (
return showSpinner ? (
<div
ref={ref}
className={cn('inline-flex items-center justify-center', className)}
Expand All @@ -62,11 +86,11 @@ export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
return (
<div ref={ref} className={cn('relative', className)} {...props} aria-busy={spinning}>
{children}
{spinning ? (
{showSpinner ? (
<div className="absolute inset-0 flex items-center justify-center rounded bg-white/60 dark:bg-slate-900/60">
{spinner}
</div>
) : null}
</div>
)
})
})
Loading