继续收口详情页头部动作组合

沉淀 PlatformDetailTopbar 与 PlatformDetailShareActions 共享骨架
接入 RPG 世界详情与公开作品详情的返回复制分享动作组合
补充测试护栏与文档决策记录
This commit is contained in:
2026-06-11 06:32:20 +08:00
parent d08842b576
commit 7c47ad3358
8 changed files with 420 additions and 117 deletions

View File

@@ -0,0 +1,52 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailShareActions } from './PlatformDetailShareActions';
test('renders overlay detail share actions with copied share state', () => {
render(
<PlatformDetailShareActions
workCode="CW-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="copied"
onShare={vi.fn()}
shareAriaLabel="分享作品 测试世界"
leading={<span></span>}
variant="overlay"
/>,
);
const codeButton = screen.getByRole('button', { name: '复制作品号 CW-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 测试世界' });
expect(screen.getByText('已发布')).toBeTruthy();
expect(codeButton.className).toContain('bg-white/72');
expect(codeButton.className).toContain('tracking-[0.18em]');
expect(shareButton.className).toContain('bg-white/72');
expect(screen.getByText('已复制')).toBeTruthy();
});
test('renders solid detail share actions with compact work code chip', () => {
render(
<PlatformDetailShareActions
workCode="PZ-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="idle"
onShare={vi.fn()}
shareAriaLabel="分享作品 拼图世界"
leading={<span></span>}
variant="solid"
/>,
);
const codeButton = screen.getByRole('button', { name: 'PZ-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 拼图世界' });
expect(codeButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(shareButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(screen.getByText('已发布')).toBeTruthy();
});

View File

@@ -0,0 +1,143 @@
import { Copy, Share2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { CopyCodeButton } from './CopyCodeButton';
import { CopyFeedbackButton } from './CopyFeedbackButton';
import type { CopyFeedbackState } from './useCopyFeedback';
type PlatformDetailShareActionsProps = {
workCode?: string | null;
copyState: CopyFeedbackState;
onCopyWorkCode?: () => void;
shareState: CopyFeedbackState;
onShare?: () => void;
shareAriaLabel?: string;
shareTitle?: string;
leading?: ReactNode;
showCopyAction?: boolean;
showShareAction?: boolean;
variant?: 'overlay' | 'solid';
className?: string;
copyClassName?: string;
shareClassName?: string;
copyCodeLabel?: ReactNode;
copyAccessibleLabel?: string;
};
const VARIANT_COPY_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_SHARE_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_PILL_TONE = {
overlay: 'neutral',
solid: 'neutralSolid',
} as const;
const VARIANT_PILL_SIZE = {
overlay: 'xxs',
solid: 'sm',
} as const;
const VARIANT_ICON_CLASS = {
overlay: 'h-3 w-3',
solid: 'h-4 w-4',
} as const;
const VARIANT_SUFFIX_CLASS = {
overlay: 'text-xs',
solid: 'text-[11px]',
} as const;
function renderShareLabel(suffix: ReactNode | null, suffixClassName: string) {
return (
<>
<span></span>
{suffix ? <span className={suffixClassName}>{suffix}</span> : null}
</>
);
}
/**
* 详情页作品号 / 分享动作组合。
* 共享层只承接状态 badge 槽位、复制作品号和分享按钮这组稳定骨架。
*/
export function PlatformDetailShareActions({
workCode,
copyState,
onCopyWorkCode,
shareState,
onShare,
shareAriaLabel,
shareTitle = '分享作品',
leading,
showCopyAction = true,
showShareAction = true,
variant = 'overlay',
className,
copyClassName,
shareClassName,
copyCodeLabel,
copyAccessibleLabel,
}: PlatformDetailShareActionsProps) {
const canShowCopyAction = showCopyAction && Boolean(workCode);
const canShowShareAction = showShareAction && Boolean(workCode);
if (!leading && !canShowCopyAction && !canShowShareAction) {
return null;
}
const iconClassName = VARIANT_ICON_CLASS[variant];
const shareSuffixClassName = VARIANT_SUFFIX_CLASS[variant];
const resolvedCopyCodeLabel =
copyCodeLabel ?? (variant === 'solid' ? null : '作品号');
const resolvedCopyAccessibleLabel =
copyAccessibleLabel ?? (variant === 'solid' ? workCode ?? undefined : undefined);
return (
<div className={['flex flex-wrap items-center gap-2', className].filter(Boolean).join(' ')}>
{leading}
{canShowCopyAction ? (
<CopyCodeButton
state={copyState}
code={workCode ?? ''}
codeLabel={resolvedCopyCodeLabel}
accessibleLabel={resolvedCopyAccessibleLabel}
title="复制作品号"
onClick={onCopyWorkCode}
disabled={!onCopyWorkCode}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_COPY_CLASS[variant], copyClassName].filter(Boolean).join(' ')}
idleIcon={<Copy className={iconClassName} />}
copiedIcon={<Copy className={iconClassName} />}
suffixClassName={shareSuffixClassName}
/>
) : null}
{canShowShareAction ? (
<CopyFeedbackButton
state={shareState}
onClick={onShare}
disabled={!onShare}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_SHARE_CLASS[variant], shareClassName].filter(Boolean).join(' ')}
aria-label={shareAriaLabel}
title={shareTitle}
idleLabel={renderShareLabel(null, shareSuffixClassName)}
copiedLabel={renderShareLabel('已复制', shareSuffixClassName)}
failedLabel={renderShareLabel('复制失败', shareSuffixClassName)}
idleIcon={<Share2 className={iconClassName} />}
copiedIcon={<Share2 className={iconClassName} />}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,49 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailTopbar } from './PlatformDetailTopbar';
test('renders pill back action with trailing slot', () => {
const onBack = vi.fn();
render(
<PlatformDetailTopbar
onBack={onBack}
className="grid-cols-[auto,minmax(0,1fr),auto]"
backButtonClassName="px-3"
trailing={<span></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
expect(button.className).toContain('platform-button--ghost');
expect(button.className).toContain('px-3');
expect(screen.getByText('已发布')).toBeTruthy();
fireEvent.click(button);
expect(onBack).toHaveBeenCalledTimes(1);
});
test('renders icon back action and centered title', () => {
render(
<PlatformDetailTopbar
onBack={vi.fn()}
backVariant="icon"
backButtonClassName="detail-icon-back"
title="详情"
titleClassName="detail-topbar-title"
trailing={<span className="invisible"></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
const title = screen.getByText('详情');
expect(button.className).toContain('platform-icon-button');
expect(button.className).toContain('detail-icon-back');
expect(title.className).toContain('detail-topbar-title');
});

View File

@@ -0,0 +1,89 @@
import { ArrowLeft } from 'lucide-react';
import type { ReactNode } from 'react';
import { PlatformBackActionButton } from './PlatformBackActionButton';
import { PlatformIconButton } from './PlatformIconButton';
type PlatformDetailTopbarProps = {
onBack: () => void;
title?: ReactNode;
trailing?: ReactNode;
backVariant?: 'icon' | 'pill';
backLabel?: string;
className?: string;
backButtonClassName?: string;
titleClassName?: string;
trailingClassName?: string;
};
/**
* 详情页顶部动作骨架。
* 只统一返回、标题和右侧动作槽位的布局,不吸收页面自己的标题文案或业务动作。
*/
export function PlatformDetailTopbar({
onBack,
title,
trailing,
backVariant = 'pill',
backLabel = '返回',
className,
backButtonClassName,
titleClassName,
trailingClassName,
}: PlatformDetailTopbarProps) {
const backAction =
backVariant === 'icon' ? (
<PlatformIconButton
label={backLabel}
title={backLabel}
className={backButtonClassName}
onClick={onBack}
icon={<ArrowLeft className="h-6 w-6" />}
/>
) : (
<PlatformBackActionButton
onClick={onBack}
label={backLabel}
className={backButtonClassName}
/>
);
return (
<div
className={[
'grid min-w-0 grid-cols-[auto,minmax(0,1fr),auto] items-center gap-3',
className,
]
.filter(Boolean)
.join(' ')}
>
<div className="min-w-0 justify-self-start">
{backAction}
</div>
{title ? (
<div
className={[
'min-w-0 text-center',
titleClassName,
]
.filter(Boolean)
.join(' ')}
>
{title}
</div>
) : (
<div aria-hidden="true" />
)}
<div
className={[
'min-w-0 justify-self-end',
trailingClassName,
]
.filter(Boolean)
.join(' ')}
>
{trailing}
</div>
</div>
);
}