继续沉淀筛选工具条与裁剪弹窗壳层
新增共享 PlatformFilterToolbar 收口首页分类筛选与排序工具条 将 SquareImageCropModal 收口到 UnifiedModal 并补充薄能力透传 补充组件与调用侧回归测试并更新 PlatformUiKit 收口计划与共享决策记录
This commit is contained in:
72
src/components/common/PlatformFilterToolbar.test.tsx
Normal file
72
src/components/common/PlatformFilterToolbar.test.tsx
Normal 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);
|
||||
});
|
||||
108
src/components/common/PlatformFilterToolbar.tsx
Normal file
108
src/components/common/PlatformFilterToolbar.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
122
src/components/common/SquareImageCropModal.test.tsx
Normal file
122
src/components/common/SquareImageCropModal.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user