From e3fbf3fdcb514208a82aff6bb31bcb905c79c1b4 Mon Sep 17 00:00:00 2001 From: wuyangfan Date: Sun, 5 Jul 2026 10:08:38 +0800 Subject: [PATCH] fix(ui): improve spin loading accessibility --- docs/components/spin.md | 17 +++++-- .../components/feedback/Spin/Spin.test.tsx | 48 +++++++++++++++++++ .../ui/src/components/feedback/Spin/Spin.tsx | 45 +++++++++++++---- 3 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 packages/ui/src/components/feedback/Spin/Spin.test.tsx diff --git a/docs/components/spin.md b/docs/components/spin.md index ac7d7fb..31166e5 100644 --- a/docs/components/spin.md +++ b/docs/components/spin.md @@ -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'` | diff --git a/packages/ui/src/components/feedback/Spin/Spin.test.tsx b/packages/ui/src/components/feedback/Spin/Spin.test.tsx new file mode 100644 index 0000000..7122c0c --- /dev/null +++ b/packages/ui/src/components/feedback/Spin/Spin.test.tsx @@ -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() + + 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() + + 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( + +
Report content
+
, + ) + + 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( + +
Loaded content
+
, + ) + + const wrapper = screen.getByText('Loaded content').parentElement + + expect(wrapper).toHaveAttribute('aria-busy', 'false') + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/components/feedback/Spin/Spin.tsx b/packages/ui/src/components/feedback/Spin/Spin.tsx index ee66e3a..163f887 100644 --- a/packages/ui/src/components/feedback/Spin/Spin.tsx +++ b/packages/ui/src/components/feedback/Spin/Spin.tsx @@ -8,17 +8,40 @@ export interface SpinProps extends HTMLAttributes { } export const Spin = forwardRef(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 = ( -
+
@@ -26,14 +49,18 @@ export const Spin = forwardRef(function Spin( if (!children) { return spinning ? ( -
+
{spinner}
) : null } return ( -
+
{children} {spinning ? (