收口前端平台组件能力
新增 PlatformAsyncStatePanel 统一 profile 异步状态骨架 扩展 PlatformSegmentedTabs 支持滚动 tab 并接入创作入口与发现页 统一 PixelCloseButton 复用 PlatformModalCloseButton 像素关闭能力 抽取平台入口泥点前置提示弹层并收紧阻断语义 补充组件收口文档与共享决策记录
This commit is contained in:
61
src/components/common/PlatformAsyncStatePanel.test.tsx
Normal file
61
src/components/common/PlatformAsyncStatePanel.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
|
||||
|
||||
describe('PlatformAsyncStatePanel', () => {
|
||||
test('prefers error state over loading and content', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel
|
||||
errorState={<div>出错了</div>}
|
||||
isLoading
|
||||
loadingState={<div>读取中</div>}
|
||||
>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('出错了')).toBeTruthy();
|
||||
expect(screen.queryByText('读取中')).toBeNull();
|
||||
expect(screen.queryByText('内容')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders loading state before empty state', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading
|
||||
isEmpty
|
||||
loadingState={<div>读取中</div>}
|
||||
emptyState={<div>暂无内容</div>}
|
||||
>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('读取中')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无内容')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders empty state when requested', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel isEmpty emptyState={<div>暂无内容</div>}>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('暂无内容')).toBeTruthy();
|
||||
expect(screen.queryByText('内容')).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to content when no async state is active', () => {
|
||||
render(
|
||||
<PlatformAsyncStatePanel>
|
||||
<div>内容</div>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('内容')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
37
src/components/common/PlatformAsyncStatePanel.tsx
Normal file
37
src/components/common/PlatformAsyncStatePanel.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PlatformAsyncStatePanelProps = {
|
||||
errorState?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingState?: ReactNode;
|
||||
isEmpty?: boolean;
|
||||
emptyState?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台异步状态面板骨架。
|
||||
* 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。
|
||||
*/
|
||||
export function PlatformAsyncStatePanel({
|
||||
errorState,
|
||||
isLoading = false,
|
||||
loadingState = null,
|
||||
isEmpty = false,
|
||||
emptyState = null,
|
||||
children,
|
||||
}: PlatformAsyncStatePanelProps) {
|
||||
if (errorState !== undefined && errorState !== null) {
|
||||
return <>{errorState}</>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <>{loadingState}</>;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <>{emptyState}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
||||
|
||||
@@ -80,9 +81,37 @@ test('supports pixel close button', () => {
|
||||
|
||||
const button = screen.getByRole('button', { name: '关闭像素弹窗' });
|
||||
|
||||
expect(button.className).toContain('bg-black/20');
|
||||
expect(button.className).toContain('bg-black/30');
|
||||
expect(button.className).toContain('text-zinc-400');
|
||||
expect(button.className).toContain('absolute');
|
||||
expect(button.className).toContain('disabled:opacity-45');
|
||||
expect(button.getAttribute('title')).toBe('关闭像素弹窗');
|
||||
});
|
||||
|
||||
test('supports inline pixel placement and intercepted click behavior', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
const onHeaderClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onHeaderClick}>
|
||||
<PlatformModalCloseButton
|
||||
label="关闭像素标题栏"
|
||||
variant="pixel"
|
||||
placement="inline"
|
||||
stopPropagation
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '关闭像素标题栏' });
|
||||
await user.click(button);
|
||||
|
||||
expect(button.className).toContain('relative');
|
||||
expect(button.className).toContain('shrink-0');
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onHeaderClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('supports editor dark close button', () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type PlatformModalCloseButtonVariant =
|
||||
| 'profile'
|
||||
@@ -10,6 +14,8 @@ type PlatformModalCloseButtonVariant =
|
||||
| 'pixel'
|
||||
| 'editorDark';
|
||||
|
||||
type PlatformModalCloseButtonPlacement = 'absolute' | 'inline';
|
||||
|
||||
type PlatformModalCloseButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
@@ -17,6 +23,8 @@ type PlatformModalCloseButtonProps = Omit<
|
||||
label: string;
|
||||
variant?: PlatformModalCloseButtonVariant;
|
||||
icon?: ReactNode;
|
||||
placement?: PlatformModalCloseButtonPlacement;
|
||||
stopPropagation?: boolean;
|
||||
};
|
||||
|
||||
const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
|
||||
@@ -33,11 +41,19 @@ const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
|
||||
'absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]',
|
||||
platformIcon: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
|
||||
pixel:
|
||||
'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45',
|
||||
'flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 disabled:cursor-not-allowed disabled:opacity-45',
|
||||
editorDark:
|
||||
'platform-modal-close-button--editor-dark rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white',
|
||||
};
|
||||
|
||||
const PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT: Record<
|
||||
PlatformModalCloseButtonPlacement,
|
||||
string
|
||||
> = {
|
||||
absolute: 'absolute right-4 top-3 sm:right-5 sm:top-4',
|
||||
inline: 'relative shrink-0',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台弹窗关闭按钮。
|
||||
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
|
||||
@@ -48,15 +64,35 @@ export function PlatformModalCloseButton({
|
||||
icon = <X className="h-4 w-4" />,
|
||||
className,
|
||||
type = 'button',
|
||||
placement = 'absolute',
|
||||
stopPropagation = false,
|
||||
onClick,
|
||||
title,
|
||||
...buttonProps
|
||||
}: PlatformModalCloseButtonProps) {
|
||||
const handleClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
if (stopPropagation) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={title ?? label}
|
||||
onClick={handleClick}
|
||||
className={[
|
||||
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
|
||||
variant === 'pixel'
|
||||
? PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT[
|
||||
placement
|
||||
]
|
||||
: null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -162,3 +162,31 @@ test('supports auth-style tab semantics with underline tone', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('password');
|
||||
});
|
||||
|
||||
test('supports scroll layout for horizontal tabs', () => {
|
||||
render(
|
||||
<PlatformSegmentedTabs
|
||||
items={[
|
||||
{ id: 'recent', label: '最近创作' },
|
||||
{ id: 'rpg', label: '文字冒险' },
|
||||
{ id: 'jump-hop', label: '跳一跳' },
|
||||
]}
|
||||
activeId="recent"
|
||||
onChange={vi.fn()}
|
||||
layout="scroll"
|
||||
semantics="tabs"
|
||||
ariaLabel="创作入口页签"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
className="pb-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
const tablist = screen.getByRole('tablist', { name: '创作入口页签' });
|
||||
|
||||
expect(tablist.className).toContain('flex');
|
||||
expect(tablist.className).toContain('overflow-x-auto');
|
||||
expect(tablist.className).toContain('scrollbar-hide');
|
||||
expect(tablist.className).not.toContain('grid-cols-2');
|
||||
expect(tablist.className).toContain('pb-1');
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
|
||||
type PlatformSegmentedTabsTone = 'neutral' | 'warm' | 'rose' | 'underline';
|
||||
type PlatformSegmentedTabsFrame = 'panel' | 'bare';
|
||||
type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
|
||||
type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
|
||||
|
||||
export type PlatformSegmentedTabItem<TId extends string> = {
|
||||
id: TId;
|
||||
@@ -33,6 +34,7 @@ type PlatformSegmentedTabsProps<TId extends string> = {
|
||||
tone?: PlatformSegmentedTabsTone;
|
||||
frame?: PlatformSegmentedTabsFrame;
|
||||
semantics?: PlatformSegmentedTabsSemantics;
|
||||
layout?: PlatformSegmentedTabsLayout;
|
||||
ariaLabel?: string;
|
||||
truncateLabels?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -171,6 +173,7 @@ export function PlatformSegmentedTabs<TId extends string>({
|
||||
tone = 'neutral',
|
||||
frame = 'panel',
|
||||
semantics = 'segment',
|
||||
layout = 'grid',
|
||||
ariaLabel,
|
||||
truncateLabels = false,
|
||||
disabled = false,
|
||||
@@ -182,10 +185,12 @@ export function PlatformSegmentedTabs<TId extends string>({
|
||||
role={semantics === 'tabs' ? 'tablist' : undefined}
|
||||
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
|
||||
className={[
|
||||
'grid',
|
||||
layout === 'scroll'
|
||||
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
|
||||
: 'grid',
|
||||
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
|
||||
PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns],
|
||||
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
||||
layout === 'grid' ? PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns] : null,
|
||||
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
|
||||
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
|
||||
className,
|
||||
|
||||
Reference in New Issue
Block a user