继续收口工具弹窗与分段切换预设

新增 PlatformToolModalShell 承接白底工具弹窗壳层和固定可访问名称

新增 PlatformSegmentedTabPresets 沉淀频道下划线、创作 pill rail 与二列 option segment

迁移拼图、抓大鹅、历史素材弹窗和首页 / 作品架 / 充值切换的重复组件写法

同步 PlatformUiKit 文档和 Hermes 决策记录
This commit is contained in:
2026-06-11 16:32:56 +08:00
parent 7c47ad3358
commit ffcffef6d2
15 changed files with 848 additions and 621 deletions

View File

@@ -0,0 +1,135 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PlatformOptionSegment,
PlatformPillTabRail,
PlatformUnderlineTabRail,
} from './PlatformSegmentedTabPresets';
test('underline tab rail keeps channel preset classes and tab semantics', () => {
const onChange = vi.fn();
render(
<PlatformUnderlineTabRail
items={[
{ id: 'recommend', label: '推荐' },
{ id: 'ranking', label: '排行' },
]}
activeId="recommend"
onChange={onChange}
ariaLabel="发现频道"
className="min-w-0"
/>,
);
const tablist = screen.getByRole('tablist', { name: '发现频道' });
const recommendTab = screen.getByRole('tab', { name: '推荐' });
const rankingTab = screen.getByRole('tab', { name: '排行' });
expect(tablist.className).toContain('platform-mobile-home-channelbar');
expect(tablist.className).toContain('min-w-0');
expect(recommendTab.className).toContain('platform-mobile-home-channel');
expect(recommendTab.className).toContain('platform-mobile-home-channel--active');
expect(rankingTab.className).not.toContain(
'platform-mobile-home-channel--active',
);
fireEvent.click(rankingTab);
expect(onChange).toHaveBeenCalledWith('ranking');
});
test('underline tab rail supports ranking preset', () => {
render(
<PlatformUnderlineTabRail
items={[
{ id: 'hot', label: '热门' },
{ id: 'new', label: '最新' },
]}
activeId="hot"
onChange={vi.fn()}
ariaLabel="作品排行"
variant="ranking"
/>,
);
const hotTab = screen.getByRole('tab', { name: '热门' });
expect(hotTab.closest('div')?.className).toContain('platform-ranking-tabs');
expect(hotTab.className).toContain('platform-ranking-tab');
expect(hotTab.className).toContain('platform-ranking-tab--active');
});
test('option segment keeps category filter preset classes', () => {
render(
<PlatformOptionSegment
items={[
{ id: 'all', label: '全部' },
{ id: 'rpg', label: '文字冒险' },
]}
activeId="all"
onChange={vi.fn()}
variant="categoryFilter"
/>,
);
const allButton = screen.getByRole('button', { name: '全部' });
expect(allButton.closest('div')?.className).toContain(
'platform-category-filter-dialog__options',
);
expect(allButton.className).toContain('platform-category-filter-dialog__option');
expect(allButton.className).toContain(
'platform-category-filter-dialog__option--active',
);
});
test('option segment supports profile tab semantics', () => {
const onChange = vi.fn();
render(
<PlatformOptionSegment
items={[
{ id: 'points', label: '泥点充值' },
{ id: 'membership', label: '会员卡' },
]}
activeId="points"
onChange={onChange}
variant="profile"
ariaLabel="充值类型"
/>,
);
const tablist = screen.getByRole('tablist', { name: '充值类型' });
const membershipTab = screen.getByRole('tab', { name: '会员卡' });
expect(tablist.className).toContain('grid-cols-2');
expect(membershipTab.className).toContain('w-full');
fireEvent.click(membershipTab);
expect(onChange).toHaveBeenCalledWith('membership');
});
test('pill tab rail keeps creation entry preset classes', () => {
render(
<PlatformPillTabRail
items={[
{ id: 'recent', label: '最近创作' },
{ id: 'recommend', label: '热门推荐' },
]}
activeId="recent"
onChange={vi.fn()}
ariaLabel="创作入口页签"
/>,
);
const recentTab = screen.getByRole('tab', { name: '最近创作' });
expect(recentTab.closest('div')?.className).toContain('snap-x');
expect(recentTab.className).toContain('snap-start');
expect(recentTab.className).toContain('after:bg-[#d9793f]');
});

View File

@@ -0,0 +1,182 @@
import {
PlatformSegmentedTabs,
type PlatformSegmentedTabItem,
} from './PlatformSegmentedTabs';
type PlatformSegmentedTabPresetProps<TId extends string> = {
items: readonly PlatformSegmentedTabItem<TId>[];
activeId: TId;
onChange: (id: TId) => void;
ariaLabel?: string;
className?: string;
};
export type PlatformUnderlineTabRailVariant = 'channel' | 'ranking';
const PLATFORM_UNDERLINE_TAB_RAIL_CLASS: Record<
PlatformUnderlineTabRailVariant,
string
> = {
channel: 'platform-mobile-home-channelbar pb-1',
ranking: 'platform-ranking-tabs pb-1',
};
const PLATFORM_UNDERLINE_TAB_ITEM_CLASS: Record<
PlatformUnderlineTabRailVariant,
{ base: string; active: string }
> = {
channel: {
base: 'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active: 'platform-mobile-home-channel--active',
},
ranking: {
base: 'platform-ranking-tab shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-[0.15rem] !shadow-none hover:!bg-transparent',
active: 'platform-ranking-tab--active',
},
};
export type PlatformOptionSegmentVariant = 'categoryFilter' | 'profile';
const PLATFORM_OPTION_SEGMENT_CLASS: Record<
PlatformOptionSegmentVariant,
{
rail: string;
active: string;
idle: string;
}
> = {
categoryFilter: {
rail: 'platform-category-filter-dialog__options',
active:
'platform-category-filter-dialog__option--active !border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]',
idle:
'platform-category-filter-dialog__option !min-h-[2.35rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[var(--platform-subpanel-fill)] !px-3 !text-[0.88rem] !font-black !text-[var(--platform-text-base)] !shadow-none hover:!bg-[var(--platform-subpanel-fill)]',
},
profile: {
rail: '',
active:
'!border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]',
idle:
'w-full !min-h-[2.25rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[rgba(255,255,255,0.04)] !px-3 !text-sm !font-extrabold !text-[var(--platform-text-base)] !shadow-none hover:!bg-[rgba(255,255,255,0.08)]',
},
};
/**
* 统一首页、作品架这类横向文字 rail只沉淀稳定的滚动与下划线皮肤。
*/
export function PlatformUnderlineTabRail<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
variant = 'channel',
}: PlatformSegmentedTabPresetProps<TId> & {
variant?: PlatformUnderlineTabRailVariant;
}) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel={ariaLabel}
className={[PLATFORM_UNDERLINE_TAB_RAIL_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].base,
active ? PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].active : null,
]
.filter(Boolean)
.join(' ')
}
/>
);
}
/**
* 统一二列按钮式切换,只负责稳定的视觉 preset不承接业务语义。
*/
export function PlatformOptionSegment<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
variant,
}: PlatformSegmentedTabPresetProps<TId> & {
variant: PlatformOptionSegmentVariant;
}) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
columns="two"
layout="grid"
gap={variant === 'profile' ? 'sm' : 'md'}
frame="bare"
surface="transparent"
size="sm"
semantics={variant === 'profile' ? 'tabs' : 'segment'}
ariaLabel={ariaLabel}
className={[PLATFORM_OPTION_SEGMENT_CLASS[variant].rail, className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
PLATFORM_OPTION_SEGMENT_CLASS[variant].idle,
active ? PLATFORM_OPTION_SEGMENT_CLASS[variant].active : null,
]
.filter(Boolean)
.join(' ')
}
/>
);
}
/**
* 创作入口使用的轻量 pill rail保留 snap 与下划线的组合语义。
*/
export function PlatformPillTabRail<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
}: PlatformSegmentedTabPresetProps<TId>) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel={ariaLabel}
className={['-mx-0.5 snap-x px-0.5 pb-1 scroll-px-2 sm:!gap-3', className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
"relative shrink-0 snap-start !min-h-8 !rounded-full !border-0 !bg-transparent !px-2.5 !text-xs !font-black !shadow-none sm:!min-h-9 sm:!px-3.5 sm:!text-sm",
active
? "!text-[#6f2f21] after:absolute after:bottom-0 after:left-3 after:right-3 after:h-1 after:rounded-full after:bg-[#d9793f] after:content-['']"
: '!text-[#7a6558] hover:!bg-transparent hover:!text-[#6f2f21]',
].join(' ')
}
/>
);
}

View File

@@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformToolModalShell } from './PlatformToolModalShell';
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({ platformTheme: 'light' }),
}));
test('renders shared platform tool modal shell with remapped panel chrome', () => {
render(
<PlatformToolModalShell
open
title="发布拼图作品"
onClose={() => {}}
footer={<button type="button"></button>}
panelClassName="!max-h-[min(90vh,42rem)]"
>
<div></div>
</PlatformToolModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.className).toContain('platform-remap-surface');
expect(dialog.className).toContain('shadow-[0_24px_80px_rgba(0,0,0,0.55)]');
expect(dialog.className).toContain('!max-h-[min(90vh,42rem)]');
expect(within(dialog).getByText('这里是正文')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '取消' })).toBeTruthy();
});
test('supports fixed aria label while keeping visible title text', () => {
const onClose = vi.fn();
render(
<PlatformToolModalShell
open
title="雨夜猫街"
ariaLabel="关卡详情"
onClose={onClose}
>
<div></div>
</PlatformToolModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByRole('button', { name: '关闭关卡详情' })).toBeTruthy();
fireEvent.click(dialog.parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,90 @@
import type { ReactNode } from 'react';
import { useAuthUi } from '../auth/AuthUiContext';
import { UnifiedModal } from './UnifiedModal';
type PlatformToolModalShellProps = {
open: boolean;
title: string;
ariaLabel?: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
closeLabel?: string;
closeDisabled?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
zIndexClassName?: string;
panelClassName?: string;
titleClassName?: string;
bodyClassName?: string;
footerClassName?: string;
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
/**
* 结果页 / 工具页里的白底 portal 弹窗壳。
* 这里只收口平台主题 overlay、白底 panel 和标准 header/body/footer 节奏,不吸收各玩法正文与动作语义。
*/
export function PlatformToolModalShell({
open,
title,
ariaLabel,
description,
children,
footer,
onClose,
size = 'xl',
closeLabel,
closeDisabled = false,
closeOnBackdrop = true,
closeOnEscape = true,
zIndexClassName = 'z-[140]',
panelClassName,
titleClassName,
bodyClassName,
footerClassName,
}: PlatformToolModalShellProps) {
const resolvedPlatformTheme =
useAuthUi()?.platformTheme ?? 'light';
return (
<UnifiedModal
open={open}
title={title}
// 某些工具弹窗标题会直接显示当前关卡/物品名,但读屏和测试更适合使用稳定的弹窗语义名。
ariaLabel={ariaLabel}
description={description}
onClose={onClose}
footer={footer}
size={size}
closeLabel={closeLabel ?? `关闭${ariaLabel ?? title}`}
closeDisabled={closeDisabled}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
zIndexClassName={zIndexClassName}
overlayClassName={`platform-theme platform-theme--${resolvedPlatformTheme}`}
panelClassName={joinClassNames(
'platform-remap-surface shadow-[0_24px_80px_rgba(0,0,0,0.55)]',
panelClassName,
)}
headerClassName="!items-center !px-5 !py-4"
titleClassName={titleClassName}
bodyClassName={joinClassNames(
'!px-5 !py-4 sm:!px-5 sm:!py-4',
bodyClassName,
)}
footerClassName={joinClassNames(
'!px-5 !py-4 sm:!px-5 sm:!py-4',
footerClassName,
)}
>
{children}
</UnifiedModal>
);
}

View File

@@ -85,6 +85,25 @@ test('supports headerless dialogs while preserving the accessible name', () => {
expect(screen.getByText('窗口内容')).toBeTruthy();
});
test('supports a stable aria label while keeping the visible title', () => {
render(
<UnifiedModal
open
title="雨夜猫街"
ariaLabel="关卡详情"
onClose={() => {}}
portal={false}
>
<div></div>
</UnifiedModal>,
);
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(dialog).toBeTruthy();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
});
test('respects closeDisabled for every default close path', () => {
const onClose = vi.fn();
render(

View File

@@ -22,6 +22,7 @@ type UnifiedModalCloseIcon = ComponentProps<
type UnifiedModalProps = {
open: boolean;
title: string;
ariaLabel?: string;
titleId?: string;
description?: ReactNode;
children: ReactNode;
@@ -86,6 +87,7 @@ function getPanelStyle(
function UnifiedModalContent({
open,
title,
ariaLabel,
titleId: titleIdProp,
description,
children,
@@ -183,8 +185,8 @@ function UnifiedModalContent({
<div
role="dialog"
aria-modal="true"
aria-labelledby={showHeader ? titleId : undefined}
aria-label={showHeader ? undefined : title}
aria-labelledby={showHeader && !ariaLabel ? titleId : undefined}
aria-label={ariaLabel ?? (!showHeader ? title : undefined)}
aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
style={getPanelStyle(variant, panelStyle)}