继续收口暗色弹窗底部动作区

新增 PlatformDarkModalFooter 统一 dark modal footer 的布局壳
接入 NPC 弹窗、选择定制弹窗、任务更新弹层与物品详情 footer
补充组件级与弹窗集成测试并更新收口计划和共享决策记录
This commit is contained in:
2026-06-11 03:03:51 +08:00
parent ae1a15cee0
commit 54ff839b0b
10 changed files with 196 additions and 12 deletions

View File

@@ -9,6 +9,7 @@ import {
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformQuantityBadge } from './common/PlatformQuantityBadge';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
@@ -237,9 +238,9 @@ export function InventoryItemDetailModal({
</div>
{footer != null ? (
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
<PlatformDarkModalFooter layout="content">
{footer}
</div>
</PlatformDarkModalFooter>
) : null}
</motion.div>
</motion.div>

View File

@@ -276,6 +276,9 @@ test('NPC 弹窗标准 dark footer CTA 复用 PlatformActionButton', async () =>
const { unmount } = render(
<NpcModals gameState={createEmptyGameState()} npcUi={createEmptyNpcUi()} />,
);
const tradeFooter = screen.getByTestId('npc-trade-footer');
const giftFooter = screen.getByTestId('npc-gift-footer');
const recruitFooter = screen.getByTestId('npc-recruit-footer');
const cancelButtons = screen.getAllByRole('button', { name: '取消' });
const tradeConfirmButton = screen.getByRole('button', { name: '确认购买' });
@@ -291,6 +294,12 @@ test('NPC 弹窗标准 dark footer CTA 复用 PlatformActionButton', async () =>
].filter((button): button is HTMLElement => Boolean(button));
expect(footerButtons).toHaveLength(6);
expect(tradeFooter.className).toContain('platform-dark-modal-footer');
expect(tradeFooter.className).toContain('border-t');
expect(giftFooter.className).toContain('platform-dark-modal-footer');
expect(giftFooter.className).toContain('pb-5');
expect(recruitFooter.className).toContain('platform-dark-modal-footer');
expect(recruitFooter.className).toContain('pb-5');
footerButtons.forEach((button) => {
expect(button.className).toContain('platform-action-button--editor-dark');

View File

@@ -29,6 +29,7 @@ import {
UI_CHROME,
} from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
@@ -427,7 +428,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<PlatformDarkModalFooter data-testid="npc-trade-footer">
<PlatformActionButton
surface="editorDark"
tone="secondary"
@@ -445,7 +446,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
>
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
</PlatformActionButton>
</div>
</PlatformDarkModalFooter>
</motion.div>
</motion.div>
)}
@@ -665,7 +666,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
)}
</div>
<div className="flex justify-end gap-3 px-5 pb-5">
<PlatformDarkModalFooter
bordered={false}
padding="bottom"
data-testid="npc-gift-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
@@ -683,7 +688,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
>
</PlatformActionButton>
</div>
</PlatformDarkModalFooter>
</motion.div>
</motion.div>
)}
@@ -763,7 +768,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
)}
</div>
<div className="flex justify-end gap-3 px-5 pb-5">
<PlatformDarkModalFooter
bordered={false}
padding="bottom"
data-testid="npc-recruit-footer"
>
<PlatformActionButton
surface="editorDark"
tone="secondary"
@@ -781,7 +790,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
>
</PlatformActionButton>
</div>
</PlatformDarkModalFooter>
</motion.div>
</motion.div>
)}

View File

@@ -31,6 +31,7 @@ test('角色自定义错误提示复用暗色 PlatformStatusMessage chrome', ()
const currentCharacterPanel = screen.getByText('当前角色:试剑客');
const nameInput = screen.getByLabelText('角色名字');
const backstoryTextarea = screen.getByLabelText('背景补充');
const footer = screen.getByTestId('selection-modal-footer');
expect(errorMessage.className).toContain('platform-status-message');
expect(errorMessage.className).toContain('border-rose-300/15');
@@ -53,6 +54,8 @@ test('角色自定义错误提示复用暗色 PlatformStatusMessage chrome', ()
expect(closeButton.className).toContain(
'platform-modal-close-button--editor-dark',
);
expect(footer.className).toContain('platform-dark-modal-footer');
expect(footer.className).toContain('border-t');
expect(currentCharacterPanel.className).toContain('border-white/10');
expect(currentCharacterPanel.className).toContain('bg-black/25');
});

View File

@@ -5,6 +5,7 @@ import type {
CustomWorldGenerationMode,
} from '../types';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformModalCloseButton } from './common/PlatformModalCloseButton';
import { PlatformProgressBar } from './common/PlatformProgressBar';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
@@ -46,9 +47,13 @@ function SelectionModal({
{children}
</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<PlatformDarkModalFooter
wrap
padding="roomy"
data-testid="selection-modal-footer"
>
{footer}
</div>
</PlatformDarkModalFooter>
) : null}
</div>
</div>

View File

@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { PlatformDarkModalFooter } from './PlatformDarkModalFooter';
describe('PlatformDarkModalFooter', () => {
test('renders bordered action footer with shared action row chrome', () => {
render(
<PlatformDarkModalFooter data-testid="footer">
<button type="button"></button>
<button type="button"></button>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
expect(footer.className).toContain('platform-dark-modal-footer');
expect(footer.className).toContain('border-t');
expect(footer.className).toContain('border-white/10');
expect(footer.className).toContain('px-4');
expect(footer.className).toContain('py-3');
expect(actions?.className).toContain('justify-end');
expect(actions?.className).toContain('gap-3');
});
test('supports unbordered bottom padding actions for modal footer tails', () => {
render(
<PlatformDarkModalFooter
bordered={false}
padding="bottom"
gap="sm"
data-testid="footer"
>
<button type="button"></button>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
expect(footer.className).not.toContain('border-t');
expect(footer.className).toContain('px-5');
expect(footer.className).toContain('pb-5');
expect(actions?.className).toContain('gap-2');
});
test('supports content layout without wrapping children in an actions row', () => {
render(
<PlatformDarkModalFooter layout="content" data-testid="footer">
<div data-testid="content"></div>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
expect(screen.getByTestId('content')).toBeTruthy();
expect(
footer.querySelector('.platform-dark-modal-footer__actions'),
).toBeNull();
expect(footer.className).toContain('platform-dark-modal-footer');
});
});

View File

@@ -0,0 +1,86 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
type PlatformDarkModalFooterLayout = 'actions' | 'content';
type PlatformDarkModalFooterPadding = 'compact' | 'roomy' | 'bottom';
type PlatformDarkModalFooterGap = 'sm' | 'md';
type PlatformDarkModalFooterAlign = 'start' | 'center' | 'end' | 'between';
type PlatformDarkModalFooterProps = ComponentPropsWithoutRef<'div'> & {
children: ReactNode;
bordered?: boolean;
layout?: PlatformDarkModalFooterLayout;
padding?: PlatformDarkModalFooterPadding;
gap?: PlatformDarkModalFooterGap;
wrap?: boolean;
align?: PlatformDarkModalFooterAlign;
contentClassName?: string;
};
const PADDING_CLASS: Record<PlatformDarkModalFooterPadding, string> = {
compact: 'px-4 py-3 sm:px-5 sm:py-4',
roomy: 'px-5 py-4',
bottom: 'px-5 pb-5',
};
const GAP_CLASS: Record<PlatformDarkModalFooterGap, string> = {
sm: 'gap-2',
md: 'gap-3',
};
const ALIGN_CLASS: Record<PlatformDarkModalFooterAlign, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
};
/**
* 暗色 / 像素弹层底部 footer 骨架。
* 统一承接 border、padding 和常见的动作按钮排布,避免业务页重复手写同一套 chrome。
*/
export function PlatformDarkModalFooter({
children,
bordered = true,
layout = 'actions',
padding = 'compact',
gap = 'md',
wrap = false,
align = 'end',
className,
contentClassName,
...props
}: PlatformDarkModalFooterProps) {
const frameClassName = [
'platform-dark-modal-footer',
bordered ? 'border-t border-white/10' : null,
PADDING_CLASS[padding],
className ?? null,
]
.filter(Boolean)
.join(' ');
if (layout === 'content') {
return (
<div className={frameClassName} {...props}>
{children}
</div>
);
}
const actionsClassName = [
'platform-dark-modal-footer__actions',
'flex items-center',
ALIGN_CLASS[align],
GAP_CLASS[gap],
wrap ? 'flex-wrap' : null,
contentClassName ?? null,
]
.filter(Boolean)
.join(' ');
return (
<div className={frameClassName} {...props}>
<div className={actionsClassName}>{children}</div>
</div>
);
}

View File

@@ -46,6 +46,7 @@ import {
UI_CHROME,
} from '../../uiAssets';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformDarkModalFooter } from '../common/PlatformDarkModalFooter';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import type { PlatformPillBadgeTone } from '../common/platformPillBadgeModel';
@@ -1025,7 +1026,10 @@ export function RpgAdventurePanelOverlays({
/>
</div>
<div className="flex items-center justify-end gap-2 border-t border-white/10 px-4 py-3 sm:px-5">
<PlatformDarkModalFooter
gap="sm"
data-testid="adventure-goal-panel-footer"
>
{goalStack.activeGoal?.sourceKind === 'quest' ||
goalStack.immediateStepGoal?.sourceKind === 'quest' ? (
<PlatformActionButton
@@ -1052,7 +1056,7 @@ export function RpgAdventurePanelOverlays({
>
</PlatformActionButton>
</div>
</PlatformDarkModalFooter>
</motion.div>
</motion.div>
)}