Skip to content
Open
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
17 changes: 12 additions & 5 deletions docs/components/spin.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@
}
`" />

## 可访问性

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

## API

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

import { Spin } from './Spin'

describe('Spin', () => {
it('announces standalone loading state as a polite status', () => {
render(<Spin />)

const status = screen.getByRole('status', { name: 'Loading' })

expect(status).toHaveAttribute('aria-live', 'polite')
expect(status.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
})

it('uses string tip as the accessible loading name', () => {
render(<Spin tip="Loading invoices" />)

expect(screen.getByRole('status', { name: 'Loading invoices' })).toBeInTheDocument()
expect(screen.getByText('Loading invoices')).toBeInTheDocument()
})

it('marks wrapped content busy while the overlay is spinning', () => {
render(
<Spin spinning 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.getByRole('status', { name: 'Refreshing data' })).toBeInTheDocument()
})

it('marks wrapped content not busy and hides status when spinning stops', () => {
render(
<Spin spinning={false}>
<section>Loaded content</section>
</Spin>,
)

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

expect(wrapper).toHaveAttribute('aria-busy', 'false')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
45 changes: 36 additions & 9 deletions packages/ui/src/components/feedback/Spin/Spin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,59 @@ export interface SpinProps extends HTMLAttributes<HTMLDivElement> {
}

export const Spin = forwardRef<HTMLDivElement, SpinProps>(function Spin(
{ className, spinning = true, size = 'md', tip, children, ...props },
{
className,
spinning = true,
size = 'md',
tip,
children,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-live': ariaLive,
...props
},
ref,
) {
const accessibleLabel =
ariaLabel ?? (ariaLabelledBy ? undefined : typeof tip === 'string' ? tip : 'Loading')

const spinner = (
<div className="flex flex-col items-center gap-2">
<div
className="flex flex-col items-center gap-2"
role="status"
aria-label={accessibleLabel}
aria-labelledby={ariaLabelledBy}
aria-live={ariaLive ?? 'polite'}
>
<span
className={cn('inline-block animate-spin rounded-full border-2 border-brand-500 border-r-transparent', {
'h-4 w-4': size === 'sm',
'h-6 w-6': size === 'md',
'h-8 w-8': size === 'lg',
})}
aria-hidden="true"
className={cn(
'inline-block animate-spin rounded-full border-2 border-brand-500 border-r-transparent',
{
'h-4 w-4': size === 'sm',
'h-6 w-6': size === 'md',
'h-8 w-8': size === 'lg',
},
)}
/>
{tip ? <span className="text-sm text-slate-500 dark:text-slate-400">{tip}</span> : null}
</div>
)

if (!children) {
return spinning ? (
<div ref={ref} className={cn('inline-flex items-center justify-center', className)} {...props}>
<div
ref={ref}
className={cn('inline-flex items-center justify-center', className)}
{...props}
>
{spinner}
</div>
) : null
}

return (
<div ref={ref} className={cn('relative', className)} {...props}>
<div ref={ref} className={cn('relative', className)} {...props} aria-busy={spinning}>
{children}
{spinning ? (
<div className="absolute inset-0 flex items-center justify-center rounded bg-white/60 dark:bg-slate-900/60">
Expand Down
Loading