diff --git a/docs/components/notification.md b/docs/components/notification.md index e050a7a..abf5cb8 100644 --- a/docs/components/notification.md +++ b/docs/components/notification.md @@ -1,20 +1,26 @@ # Notification 通知 -通知提醒框。 +通知提醒框,用于展示不会打断当前流程的状态反馈,或在错误场景中提示需要立即关注的问题。 ## 示例 +## 可访问性 + +`Notification` 会根据类型选择 live region 语义:`error` 默认使用 `role="alert"`,适合需要立即关注的错误;其他类型默认使用 `role="status"` 与 `aria-live="polite"`,避免普通通知打断读屏器当前播报。 + +如果业务确认某条通知需要更高或更低的播报优先级,可以显式传入 `role` 和 `aria-live` 覆盖默认值。 + ## API | 属性 | 说明 | 类型 | 默认值 | @@ -25,3 +31,5 @@ | open | 是否显示;未传时组件内部维护可见状态 | `boolean` | - | | duration | 自动关闭时长(ms),0 表示不自动关闭 | `number` | `0` | | onClose | 关闭回调 | `() => void` | - | +| role | 通知的 live region 语义 | `'status' \| 'alert'` | `error` 为 `'alert'`,其余为 `'status'` | +| aria-live | live region 播报优先级 | `'polite' \| 'assertive' \| 'off'` | `role='status'` 时为 `'polite'` | diff --git a/packages/ui/src/components/feedback/Notification/Notification.test.tsx b/packages/ui/src/components/feedback/Notification/Notification.test.tsx index 2cde4d0..072318e 100644 --- a/packages/ui/src/components/feedback/Notification/Notification.test.tsx +++ b/packages/ui/src/components/feedback/Notification/Notification.test.tsx @@ -10,15 +10,33 @@ afterEach(() => { }) describe('Notification', () => { - it('renders an alert region with title and description', () => { + it('renders non-error notifications as polite status regions', () => { render() - const notification = screen.getByRole('alert') + const notification = screen.getByRole('status') + expect(notification).toHaveAttribute('aria-live', 'polite') expect(notification).toHaveTextContent('Published') expect(notification).toHaveTextContent('The package is live.') expect(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument() }) + it('renders error notifications as alert regions', () => { + render() + + const notification = screen.getByRole('alert') + expect(notification).not.toHaveAttribute('aria-live') + expect(notification).toHaveTextContent('Failed') + expect(notification).toHaveTextContent('Check the build logs.') + }) + + it('allows callers to override live region urgency', () => { + render() + + const notification = screen.getByRole('alert') + expect(notification).toHaveAttribute('aria-live', 'assertive') + expect(notification).toHaveTextContent('Session expiring') + }) + it('hides when the close button is clicked in uncontrolled mode', async () => { const user = userEvent.setup() const onClose = vi.fn() @@ -28,7 +46,7 @@ describe('Notification', () => { await user.click(screen.getByRole('button', { name: 'Close notification' })) expect(onClose).toHaveBeenCalledTimes(1) - expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('keeps controlled notifications open until open changes', async () => { @@ -40,7 +58,7 @@ describe('Notification', () => { await user.click(screen.getByRole('button', { name: 'Close notification' })) expect(onClose).toHaveBeenCalledTimes(1) - expect(screen.getByRole('alert')).toHaveTextContent('Controlled') + expect(screen.getByRole('status')).toHaveTextContent('Controlled') }) it('auto-closes uncontrolled notifications after duration', () => { @@ -52,14 +70,14 @@ describe('Notification', () => { act(() => { vi.advanceTimersByTime(4499) }) - expect(screen.getByRole('alert')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() expect(onClose).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(1) }) expect(onClose).toHaveBeenCalledTimes(1) - expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('does not auto-close when duration is 0', () => { @@ -73,6 +91,6 @@ describe('Notification', () => { }) expect(onClose).not.toHaveBeenCalled() - expect(screen.getByRole('alert')).toHaveTextContent('Persistent') + expect(screen.getByRole('status')).toHaveTextContent('Persistent') }) }) diff --git a/packages/ui/src/components/feedback/Notification/Notification.tsx b/packages/ui/src/components/feedback/Notification/Notification.tsx index 6642586..ef3a190 100644 --- a/packages/ui/src/components/feedback/Notification/Notification.tsx +++ b/packages/ui/src/components/feedback/Notification/Notification.tsx @@ -1,13 +1,18 @@ import { forwardRef, useCallback, useEffect, useState, type HTMLAttributes } from 'react' import { cn } from '../../../utils/cn' +type NotificationType = 'info' | 'success' | 'warning' | 'error' +type NotificationRole = 'status' | 'alert' + export interface NotificationProps extends HTMLAttributes { - type?: 'info' | 'success' | 'warning' | 'error' + type?: NotificationType title?: string description?: string open?: boolean duration?: number onClose?: () => void + /** 通知的 live region 语义,默认 error 为 alert,其余类型为 status */ + role?: NotificationRole } export const Notification = forwardRef(function Notification( @@ -19,12 +24,16 @@ export const Notification = forwardRef(functi open, duration = 0, onClose, + role, + 'aria-live': ariaLive, ...props }, ref, ) { const [visible, setVisible] = useState(true) const mergedOpen = open ?? visible + const notificationRole = role ?? (type === 'error' ? 'alert' : 'status') + const notificationAriaLive = ariaLive ?? (notificationRole === 'status' ? 'polite' : undefined) const handleClose = useCallback(() => { if (open === undefined) { @@ -49,7 +58,8 @@ export const Notification = forwardRef(functi return (