继续收口工具弹窗与分段切换预设
新增 PlatformToolModalShell 承接白底工具弹窗壳层和固定可访问名称 新增 PlatformSegmentedTabPresets 沉淀频道下划线、创作 pill rail 与二列 option segment 迁移拼图、抓大鹅、历史素材弹窗和首页 / 作品架 / 充值切换的重复组件写法 同步 PlatformUiKit 文档和 Hermes 决策记录
This commit is contained in:
135
src/components/common/PlatformSegmentedTabPresets.test.tsx
Normal file
135
src/components/common/PlatformSegmentedTabPresets.test.tsx
Normal 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]');
|
||||
});
|
||||
182
src/components/common/PlatformSegmentedTabPresets.tsx
Normal file
182
src/components/common/PlatformSegmentedTabPresets.tsx
Normal 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(' ')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
src/components/common/PlatformToolModalShell.test.tsx
Normal file
54
src/components/common/PlatformToolModalShell.test.tsx
Normal 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);
|
||||
});
|
||||
90
src/components/common/PlatformToolModalShell.tsx
Normal file
90
src/components/common/PlatformToolModalShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user