feat(web): unify Semi UI workspace states
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { EmptyState, InlineError, PageLoading, PanelEmpty, PanelLoading } from './AsyncState';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test('uses one semantic loading language for page and panel scopes', () => {
|
||||
const view = render(<>
|
||||
<PageLoading label="正在加载轨迹回放" />
|
||||
<PanelLoading title="正在查询里程" description="结果返回后会自动显示。" />
|
||||
</>);
|
||||
|
||||
expect(screen.getAllByRole('status')).toHaveLength(2);
|
||||
expect(screen.getByText('正在加载轨迹回放')).toBeInTheDocument();
|
||||
expect(screen.getByText('正在查询里程')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-state-surface.is-page.is-loading')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-state-surface.is-panel.is-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps empty states consistent while allowing a clear next action', () => {
|
||||
const action = vi.fn();
|
||||
const view = render(<>
|
||||
<PanelEmpty title="还没有客户账号" description="创建后可分配菜单和车辆。" action={<button onClick={action}>创建账号</button>} />
|
||||
<EmptyState title="暂无符合条件的车辆" />
|
||||
</>);
|
||||
|
||||
expect(view.container.querySelectorAll('.v2-state-empty.semi-empty')).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建账号' }));
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('announces recoverable errors and exposes a Semi retry action', () => {
|
||||
const retry = vi.fn();
|
||||
render(<InlineError message="服务暂时不可用" onRetry={retry} />);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('数据暂时无法加载');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('服务暂时不可用');
|
||||
const button = screen.getByRole('button', { name: '重试' });
|
||||
expect(button).toHaveClass('semi-button');
|
||||
fireEvent.click(button);
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1,23 +1,88 @@
|
||||
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { Button, Empty, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
|
||||
return <section className="v2-page-state" role="status" aria-live="polite">
|
||||
<header><Spin size="large" /><div><Typography.Text strong>{label}</Typography.Text><Typography.Text type="tertiary" size="small">导航与筛选状态已保留</Typography.Text></div></header>
|
||||
<div className="v2-page-skeleton"><i /><i /><i /><i /></div>
|
||||
type StateScope = 'page' | 'panel' | 'inline';
|
||||
type StateKind = 'loading' | 'empty' | 'error';
|
||||
|
||||
function stateRole(kind: StateKind) {
|
||||
return kind === 'error' ? 'alert' : 'status';
|
||||
}
|
||||
|
||||
function StateSurface({
|
||||
kind,
|
||||
scope,
|
||||
title,
|
||||
description,
|
||||
className = '',
|
||||
action,
|
||||
compact = false
|
||||
}: {
|
||||
kind: StateKind;
|
||||
scope: StateScope;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
action?: ReactNode;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const classes = ['v2-state-surface', `is-${kind}`, `is-${scope}`, compact ? 'is-compact' : '', className].filter(Boolean).join(' ');
|
||||
|
||||
return <section className={classes} role={stateRole(kind)} aria-live={kind === 'error' ? 'assertive' : 'polite'}>
|
||||
{kind === 'empty'
|
||||
? <Empty className={`v2-state-empty${className ? ` ${className}` : ''}`} title={title} description={description}>{action}</Empty>
|
||||
: <div className="v2-state-message">
|
||||
<span className="v2-state-icon">{kind === 'loading' ? <Spin size={scope === 'page' ? 'large' : 'middle'} /> : <IconAlertTriangle />}</span>
|
||||
<div className="v2-state-copy"><Typography.Text strong>{title}</Typography.Text>{description ? <Typography.Text type={kind === 'error' ? 'danger' : 'tertiary'} size="small">{description}</Typography.Text> : null}</div>
|
||||
{action ? <div className="v2-state-action">{action}</div> : null}
|
||||
</div>}
|
||||
{kind === 'loading' ? <div className="v2-state-skeleton" aria-hidden="true"><i /><i /><i /></div> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
|
||||
return <StateSurface className="v2-page-state" kind="loading" scope="page" title={label} description="导航与筛选状态已保留,页面就绪后会自动显示。" />;
|
||||
}
|
||||
|
||||
export function PanelLoading({
|
||||
title = '正在读取数据',
|
||||
description = '当前结果返回后会自动更新。',
|
||||
className = '',
|
||||
compact = false
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return <StateSurface className={className} kind="loading" scope="panel" title={title} description={description} compact={compact} />;
|
||||
}
|
||||
|
||||
export function PanelEmpty({
|
||||
title,
|
||||
description,
|
||||
className = '',
|
||||
action
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return <StateSurface className={className} kind="empty" scope="panel" title={title} description={description} action={action} />;
|
||||
}
|
||||
|
||||
export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="v2-inline-state is-error" role="alert">
|
||||
<IconAlertTriangle />
|
||||
<span>{message}</span>
|
||||
{onRetry ? <Button theme="light" icon={<IconRefresh />} onClick={onRetry}>重试</Button> : null}
|
||||
</div>
|
||||
);
|
||||
return <StateSurface
|
||||
className="v2-inline-state"
|
||||
kind="error"
|
||||
scope="inline"
|
||||
title="数据暂时无法加载"
|
||||
description={message}
|
||||
action={onRetry ? <Button theme="light" type="danger" icon={<IconRefresh />} aria-label="重试" onClick={onRetry}>重试</Button> : null}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function EmptyState({ title = '暂无符合条件的车辆' }: { title?: string }) {
|
||||
return <div className="v2-inline-state"><Empty title={title} /></div>;
|
||||
return <StateSurface className="v2-inline-state" kind="empty" scope="inline" title={title} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user