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
14 changes: 11 additions & 3 deletions docs/components/notification.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# Notification 通知

通知提醒框。
通知提醒框,用于展示不会打断当前流程的状态反馈,或在错误场景中提示需要立即关注的问题

## 示例

<LivePlayground :code="`
() => {
return (
<Space direction='vertical' size={8}>
<div className='flex max-w-xl flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900'>
<Notification type='success' title='发布成功' description='组件库已成功发布。' />
<Notification type='error' title='发布失败' description='请检查配置。' duration={4500} />
</Space>
</div>
)
}
`" />

## 可访问性

`Notification` 会根据类型选择 live region 语义:`error` 默认使用 `role="alert"`,适合需要立即关注的错误;其他类型默认使用 `role="status"` 与 `aria-live="polite"`,避免普通通知打断读屏器当前播报。

如果业务确认某条通知需要更高或更低的播报优先级,可以显式传入 `role` 和 `aria-live` 覆盖默认值。

## API

| 属性 | 说明 | 类型 | 默认值 |
Expand All @@ -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'` |
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Notification title="Published" description="The package is live." />)

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(<Notification type="error" title="Failed" description="Check the build logs." />)

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(<Notification type="warning" title="Session expiring" role="alert" aria-live="assertive" />)

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()
Expand All @@ -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 () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -73,6 +91,6 @@ describe('Notification', () => {
})

expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('Persistent')
expect(screen.getByRole('status')).toHaveTextContent('Persistent')
})
})
14 changes: 12 additions & 2 deletions packages/ui/src/components/feedback/Notification/Notification.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
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<HTMLDivElement, NotificationProps>(function Notification(
Expand All @@ -19,12 +24,16 @@ export const Notification = forwardRef<HTMLDivElement, NotificationProps>(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) {
Expand All @@ -49,7 +58,8 @@ export const Notification = forwardRef<HTMLDivElement, NotificationProps>(functi
return (
<div
ref={ref}
role="alert"
role={notificationRole}
aria-live={notificationAriaLive}
className={cn(
'rounded-lg border p-4 shadow-sm',
{
Expand Down
Loading