From 8dbee0febb63f06e192961cf85370ddd762f1e17 Mon Sep 17 00:00:00 2001 From: Yangfan Wu <39647285+wyf027@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:35:47 +0800 Subject: [PATCH 1/3] feat(ui): add delay support to Spin --- .../ui/src/components/feedback/Spin/Spin.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/feedback/Spin/Spin.tsx b/packages/ui/src/components/feedback/Spin/Spin.tsx index 163f887..821c2f2 100644 --- a/packages/ui/src/components/feedback/Spin/Spin.tsx +++ b/packages/ui/src/components/feedback/Spin/Spin.tsx @@ -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 { spinning?: boolean size?: 'sm' | 'md' | 'lg' tip?: ReactNode + delay?: number } export const Spin = forwardRef(function Spin( @@ -13,6 +14,7 @@ export const Spin = forwardRef(function Spin( spinning = true, size = 'md', tip, + delay = 0, children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, @@ -21,9 +23,31 @@ export const Spin = forwardRef(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 = (
(function Spin( ) if (!children) { - return spinning ? ( + return showSpinner ? (
(function Spin( return (
{children} - {spinning ? ( + {showSpinner ? (
{spinner}
) : null}
) -}) +}) \ No newline at end of file From d96d5ac68e7a736da28e61cf7766149567f65f20 Mon Sep 17 00:00:00 2001 From: Yangfan Wu <39647285+wyf027@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:36:11 +0800 Subject: [PATCH 2/3] test(ui): cover Spin delay behavior --- .../components/feedback/Spin/Spin.test.tsx | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/feedback/Spin/Spin.test.tsx b/packages/ui/src/components/feedback/Spin/Spin.test.tsx index 7122c0c..8b76f32 100644 --- a/packages/ui/src/components/feedback/Spin/Spin.test.tsx +++ b/packages/ui/src/components/feedback/Spin/Spin.test.tsx @@ -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() @@ -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() + + 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() + + rerender() + + act(() => { + vi.advanceTimersByTime(200) + }) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + it('marks wrapped content busy while the overlay is spinning', () => { render( @@ -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( + +
Report content
+
, + ) + + 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( @@ -45,4 +102,4 @@ describe('Spin', () => { expect(wrapper).toHaveAttribute('aria-busy', 'false') expect(screen.queryByRole('status')).not.toBeInTheDocument() }) -}) +}) \ No newline at end of file From a42a39c4450d16bb0fa0f8113be26d04bac95c69 Mon Sep 17 00:00:00 2001 From: Yangfan Wu <39647285+wyf027@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:36:28 +0800 Subject: [PATCH 3/3] docs(ui): document Spin delay --- docs/components/spin.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/components/spin.md b/docs/components/spin.md index 31166e5..706b155 100644 --- a/docs/components/spin.md +++ b/docs/components/spin.md @@ -7,12 +7,14 @@ @@ -21,6 +23,8 @@ 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 | 属性 | 说明 | 类型 | 默认值 | @@ -28,6 +32,7 @@ Spin 在加载中时会渲染为 `role="status"` 的 polite live region。纯视 | 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'` |