继续沉淀筛选工具条与裁剪弹窗壳层

新增共享 PlatformFilterToolbar 收口首页分类筛选与排序工具条
将 SquareImageCropModal 收口到 UnifiedModal 并补充薄能力透传
补充组件与调用侧回归测试并更新 PlatformUiKit 收口计划与共享决策记录
This commit is contained in:
2026-06-11 05:16:46 +08:00
parent 193e4f0e96
commit ebd958b5a0
9 changed files with 370 additions and 121 deletions

View File

@@ -0,0 +1,72 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformFilterToolbar } from './PlatformFilterToolbar';
const TAB_ITEMS = [
{ id: 'all', label: '全部' },
{ id: 'story', label: '剧情' },
] as const;
test('renders mobile platform filter toolbar with divider and prefixed sort label', () => {
const onOpenFilter = vi.fn();
const onTabChange = vi.fn();
const onToggleSort = vi.fn();
const { container } = render(
<PlatformFilterToolbar
filterLabel="筛选"
filterCount={12}
tabItems={TAB_ITEMS}
activeTabId="all"
sortLabel="最热"
layout="mobile"
onOpenFilter={onOpenFilter}
onTabChange={onTabChange}
onToggleSort={onToggleSort}
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(container.querySelector('.platform-category-filter-divider')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '剧情' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onTabChange).toHaveBeenCalledWith('story');
expect(onToggleSort).toHaveBeenCalledTimes(1);
});
test('renders desktop platform filter toolbar with inline sort button', () => {
const onOpenFilter = vi.fn();
const { container } = render(
<PlatformFilterToolbar
filterLabel="剧情"
filterCount={3}
tabItems={TAB_ITEMS}
activeTabId="story"
sortLabel="最新"
layout="desktop"
onOpenFilter={onOpenFilter}
onTabChange={vi.fn()}
onToggleSort={vi.fn()}
/>,
);
const filterButton = container.querySelector(
'.platform-category-filter-button',
) as HTMLButtonElement | null;
const sortButton = screen.getByRole('button', { name: //u });
expect(filterButton).toBeTruthy();
expect(filterButton?.textContent).toContain('剧情');
expect(sortButton).toBeTruthy();
expect(sortButton.className).toContain('shrink-0');
expect(container.querySelector('.platform-category-filter-divider')).toBeNull();
fireEvent.click(filterButton!);
expect(onOpenFilter).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,108 @@
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
export interface PlatformFilterToolbarTabItem {
id: string;
label: string;
}
export interface PlatformFilterToolbarProps {
filterLabel: string;
filterCount: number;
tabItems: readonly PlatformFilterToolbarTabItem[];
activeTabId: string;
sortLabel: string;
layout: 'mobile' | 'desktop';
onOpenFilter: () => void;
onTabChange: (id: string) => void;
onToggleSort: () => void;
}
function buildToolbarTabItemClassName(active: boolean) {
return [
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ');
}
export function PlatformFilterToolbar({
filterLabel,
filterCount,
tabItems,
activeTabId,
sortLabel,
layout,
onOpenFilter,
onTabChange,
onToggleSort,
}: PlatformFilterToolbarProps) {
const isMobileLayout = layout === 'mobile';
return (
<>
<div
className={
isMobileLayout
? 'platform-category-filter-row'
: 'mb-4 flex min-w-0 items-center gap-2'
}
>
<button
type="button"
onClick={onOpenFilter}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>{filterLabel}</span>
<span className="platform-category-filter-button__count">
{filterCount}
</span>
</button>
{isMobileLayout ? (
<span className="platform-category-filter-divider" />
) : null}
<PlatformSegmentedTabs
items={tabItems}
activeId={activeTabId}
onChange={onTabChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
className={
isMobileLayout
? 'platform-category-chip-scroll min-w-0 flex-1'
: 'min-w-0 flex-1 pb-1'
}
itemClassName={(_, active) => buildToolbarTabItemClassName(active)}
/>
{!isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button shrink-0"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</>
);
}

View File

@@ -0,0 +1,122 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { SquareImageCropModal } from './SquareImageCropModal';
function renderSquareImageCropModal(
overrideProps: Partial<Parameters<typeof SquareImageCropModal>[0]> = {},
) {
const onCropRectChange = vi.fn();
const onClose = vi.fn();
const onSubmit = vi.fn();
render(
<SquareImageCropModal
source="data:image/png;base64,preview"
imageSize={{ width: 800, height: 600 }}
cropRect={{ x: 100, y: 0, size: 600 }}
labels={{
title: '裁剪拼图图片',
close: '关闭拼图图片裁剪',
editor: '拼图图片裁剪操作区',
previewAlt: '拼图图片裁剪预览',
cancel: '取消',
submit: '应用',
saving: '裁剪中',
}}
onCropRectChange={onCropRectChange}
onClose={onClose}
onSubmit={onSubmit}
{...overrideProps}
/>,
);
return { onCropRectChange, onClose, onSubmit };
}
test('reuses unified modal shell while keeping crop close semantics disabled on backdrop and escape', () => {
const { onClose } = renderSquareImageCropModal();
const dialog = screen.getByRole('dialog', { name: '裁剪拼图图片' });
fireEvent.click(dialog.parentElement as HTMLElement);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '关闭拼图图片裁剪' }).className).toContain(
'platform-profile-icon-button',
);
expect(
screen.getByRole('button', { name: '关闭拼图图片裁剪' }).textContent,
).toContain('×');
});
test('keeps shared footer actions and saving disabled state', () => {
const { onClose, onSubmit } = renderSquareImageCropModal({ isSaving: true });
const cancelButton = screen.getByRole('button', { name: '取消' });
const submitButton = screen.getByRole('button', { name: '裁剪中' });
expect(cancelButton.className).toContain('platform-button--secondary');
expect(submitButton.hasAttribute('disabled')).toBe(true);
fireEvent.click(cancelButton);
fireEvent.click(submitButton);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
});
test('keeps pointer drag flow wired through the crop surface', () => {
const { onCropRectChange } = renderSquareImageCropModal();
const preview = screen.getByLabelText('拼图图片裁剪操作区');
const moveHandle = preview.querySelector('[class*="cursor-grab"]');
expect(moveHandle).toBeTruthy();
Object.defineProperty(preview, 'getBoundingClientRect', {
value: () => ({
width: 400,
height: 300,
top: 0,
left: 0,
right: 400,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
}),
});
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
Object.defineProperty(moveHandle as HTMLDivElement, 'setPointerCapture', {
value: setPointerCapture,
});
Object.defineProperty(moveHandle as HTMLDivElement, 'releasePointerCapture', {
value: releasePointerCapture,
});
fireEvent.pointerDown(moveHandle as Element, {
pointerId: 1,
clientX: 100,
clientY: 80,
});
fireEvent.pointerMove(moveHandle as Element, {
pointerId: 1,
clientX: 140,
clientY: 120,
});
fireEvent.pointerUp(moveHandle as Element, {
pointerId: 1,
});
expect(setPointerCapture).toHaveBeenCalledTimes(1);
expect(releasePointerCapture).toHaveBeenCalledTimes(1);
expect(onCropRectChange).toHaveBeenCalledTimes(1);
expect(onCropRectChange.mock.calls[0]?.[0]).toMatchObject({
size: 600,
});
});

View File

@@ -7,8 +7,8 @@ import {
} from 'react';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
import { PlatformStatusMessage } from './PlatformStatusMessage';
import { UnifiedModal } from './UnifiedModal';
import {
clampNumber,
clampSquareImageCropRect,
@@ -309,25 +309,35 @@ export function SquareImageCropModal({
};
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id={titleId} className="text-base font-black">
{labels.title}
</div>
<PlatformModalCloseButton
label={labels.close}
variant="profileCompact"
onClick={onClose}
icon="×"
/>
<UnifiedModal
open
title={labels.title}
titleId={titleId}
onClose={onClose}
closeOnBackdrop={false}
closeOnEscape={false}
closeLabel={labels.close}
closeVariant="profileCompact"
closeIcon="×"
portal={false}
zIndexClassName="z-[80]"
panelClassName="platform-remap-surface max-w-sm rounded-[1.4rem]"
headerClassName="border-white/10 px-5 py-4"
titleClassName="font-black"
bodyClassName="px-5 py-5"
footerClassName="border-transparent px-5 pt-0 pb-5"
footer={
<div className="grid w-full grid-cols-2 gap-3">
<PlatformActionButton tone="secondary" onClick={onClose}>
{labels.cancel}
</PlatformActionButton>
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
{isSaving ? labels.saving : labels.submit}
</PlatformActionButton>
</div>
<div className="px-5 py-5">
}
>
<div>
<div
ref={previewRef}
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
@@ -388,19 +398,7 @@ export function SquareImageCropModal({
{error}
</PlatformStatusMessage>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<PlatformActionButton tone="secondary" onClick={onClose}>
{labels.cancel}
</PlatformActionButton>
<PlatformActionButton
onClick={onSubmit}
disabled={isSaving}
>
{isSaving ? labels.saving : labels.submit}
</PlatformActionButton>
</div>
</div>
</div>
</div>
</UnifiedModal>
);
}

View File

@@ -124,6 +124,6 @@ test('uses shared pixel close button chrome', () => {
expect(screen.getByRole('dialog', { name: '像素弹窗' }).className).toContain(
'pixel-modal-shell',
);
expect(closeButton.className).toContain('bg-black/20');
expect(closeButton.className).toContain('bg-black/30');
expect(closeButton.className).toContain('text-zinc-400');
});

View File

@@ -15,10 +15,14 @@ type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
type UnifiedModalCloseVariant = NonNullable<
ComponentProps<typeof PlatformModalCloseButton>['variant']
>;
type UnifiedModalCloseIcon = ComponentProps<
typeof PlatformModalCloseButton
>['icon'];
type UnifiedModalProps = {
open: boolean;
title: string;
titleId?: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
@@ -32,6 +36,7 @@ type UnifiedModalProps = {
showCloseButton?: boolean;
closeLabel?: string;
closeVariant?: UnifiedModalCloseVariant;
closeIcon?: UnifiedModalCloseIcon;
portal?: boolean;
zIndexClassName?: string;
overlayClassName?: string;
@@ -81,6 +86,7 @@ function getPanelStyle(
function UnifiedModalContent({
open,
title,
titleId: titleIdProp,
description,
children,
footer,
@@ -94,6 +100,7 @@ function UnifiedModalContent({
showCloseButton = true,
closeLabel = '关闭',
closeVariant,
closeIcon,
zIndexClassName = 'z-[90]',
overlayClassName,
panelClassName,
@@ -104,8 +111,9 @@ function UnifiedModalContent({
footerClassName,
panelStyle,
}: Omit<UnifiedModalProps, 'portal'>) {
const titleId = useId();
const generatedTitleId = useId();
const descriptionId = useId();
const titleId = titleIdProp ?? generatedTitleId;
useEffect(() => {
if (!open || closeDisabled || !closeOnEscape) {
@@ -209,6 +217,7 @@ function UnifiedModalContent({
onClick={onClose}
disabled={closeDisabled}
variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')}
icon={closeIcon}
/>
) : null}
</div>

View File

@@ -19,7 +19,6 @@ import {
Search,
Settings,
Share2,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
@@ -84,6 +83,7 @@ import {
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
@@ -3248,50 +3248,17 @@ export function RpgEntryHomeView({
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const mobileCategoryPanelContent = activeCategoryGroup ? (
<>
<div className="platform-category-filter-row">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</span>
</button>
<span className="platform-category-filter-divider" />
<PlatformSegmentedTabs
items={categoryGroupTabs}
activeId={activeCategoryGroup.tag}
onChange={handleCategoryGroupChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
className="platform-category-chip-scroll min-w-0 flex-1"
itemClassName={(_, active) =>
[
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ')
}
/>
</div>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button"
>
<span>{activeCategorySortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<PlatformFilterToolbar
filterLabel={categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
filterCount={activeCategoryFilterCount}
tabItems={categoryGroupTabs}
activeTabId={activeCategoryGroup.tag}
sortLabel={activeCategorySortLabel}
layout="mobile"
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
onTabChange={handleCategoryGroupChange}
onToggleSort={cycleCategorySortMode}
/>
<PlatformAsyncStatePanel
isEmpty={activeCategoryEntries.length === 0}
@@ -3313,48 +3280,17 @@ export function RpgEntryHomeView({
const renderDesktopCategorySection = (cardKeyPrefix: string) => {
const desktopCategoryPanelContent = activeCategoryGroup ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</span>
</button>
<PlatformSegmentedTabs
items={categoryGroupTabs}
activeId={activeCategoryGroup.tag}
onChange={handleCategoryGroupChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
className="min-w-0 flex-1 pb-1"
itemClassName={(_, active) =>
[
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ')
}
/>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button shrink-0"
>
<span>{activeCategorySortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<PlatformFilterToolbar
filterLabel={categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
filterCount={activeCategoryFilterCount}
tabItems={categoryGroupTabs}
activeTabId={activeCategoryGroup.tag}
sortLabel={activeCategorySortLabel}
layout="desktop"
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
onTabChange={handleCategoryGroupChange}
onToggleSort={cycleCategorySortMode}
/>
<PlatformAsyncStatePanel
isEmpty={desktopCategoryGrid.length === 0}
emptyState={<PlatformEmptyState></PlatformEmptyState>}