收口前端平台组件能力

新增 PlatformAsyncStatePanel 统一 profile 异步状态骨架
扩展 PlatformSegmentedTabs 支持滚动 tab 并接入创作入口与发现页
统一 PixelCloseButton 复用 PlatformModalCloseButton 像素关闭能力
抽取平台入口泥点前置提示弹层并收紧阻断语义
补充组件收口文档与共享决策记录
This commit is contained in:
2026-06-11 01:06:31 +08:00
parent edf37d97a7
commit 94122583ac
22 changed files with 897 additions and 445 deletions

View 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();
});
});

View 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}</>;
}

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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');
});

View File

@@ -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,