Files
Genarrative/packages/shared/src/components/PlatformSegmentedTabs.tsx
T
suzmii 8b85977828 修复 PR316 review:共享分段页签宽度下限作用域、标签契约与资源画布样式
- packages/shared/src/components/styles.css:528 [bug medium] min-width: max-content 收窄到新的 .platform-segmented-tabs--scroll 修饰类:grid 分支没有溢出容器、轨道是 minmax(0,1fr),同样的下限只会让内容画到相邻格上;grid 侧靠轨道等分与宿主 truncateLabels 收口,选中 PlatformSegmentedTabs.tsx 的 scroll 分支补修修饰类。
- packages/shared/src/components/PlatformResourceFilterBar.test.tsx 同步改成钉「下限只挂在 --scroll」+「grid 分支不带 --scroll/overflow-x-auto」(声明级断言,jsdom 不计算外部样式表,属弱验证,真机判据见提交说明)。
- packages/shared/src/components/PlatformResourceFilterBar.tsx:24 [maintainability low] Props 改为以 tagItems 为判别项的联合类型:给 tagItems 就必须给 onToggleTag,非法状态编译期不可表达;组件内部再兜 disabled={!onToggleTag} 供给不带类型检查的调用方,并补对应运行时用例。
- packages/shared/src/components/PlatformResourceFilterBar.tsx:107 [style low] 标签行补 scrollbar-hide:它是唯一还画滚动条的横向滚动容器,与 styles.css 里 .scrollbar-hide 的注释口径对齐;补类名与声明级用例。
- packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:51 [maintainability low] assetIds 排序改为确定性 code-unit sort(),不再依赖运行环境默认 locale(标签那一层仍显式钉 zh-CN),补跨输入顺序可复现的用例。
- packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:68 [bug low] assetTagsMatchSelection 两侧对称归一化:selectedTags 也过 normalizeGameCreationAppAssetTags,' 像素风 ' 这类未归一化的选中项不再静默匹配不上。
- packages/shared/src/contracts/gameCreationApp.test.ts [test low] 新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 与 TS 常量对齐的交叉校验(仿 assetKindCanonicalMapping.test.ts 解析 Rust 常量先例),钉住两侧分区顺序不漂移。
- packages/shared/src/contracts/gameCreationApp.ts 同步「读显示 / 写回」口径注释:未知 category 已在 Rust 读侧失败关闭,「非法值 → 派生」只剩内存对象语义。
- apps/ai-game-creator-shell/src/styles.css:5790 [bug medium] :has() 悬停高亮分类值换成现行 7 个分区(ui-interaction/character/scene/audio/document/unclassified/version),删掉永不渲染的 code/art 死选择器,补齐缺失栏目的高亮。
- apps/ai-game-creator-shell/src/styles.css:6873 [bug medium] hover/focus-within/is-selected 由只写 border-color 改为完整 border: 1px solid #d57b51(基规则 border: 0 下 border-color 无效果,选中/焦点态此前完全不可见),box-sizing: border-box 下不改卡片尺寸。
- apps/ai-game-creator-shell/src/styles.css:10151 [bug medium] 去掉高优先级 :focus-within 规则里的 box-shadow: none:它盖掉了 --platform-input-focus-ring 焦点光环,direct-codex 输入区此前没有任何可见焦点(无障碍回归),现在光环生效。
- apps/ai-game-creator-shell/src/styles.css:10198 [bug low] 润色状态文字与操作排改为同一行分列(grid-row: 2 + grid-column: 1 / 2),不再整行独占或与按钮重叠,状态出现/消失不改变输入框高度。
- apps/ai-game-creator-shell/src/styles.css:10206 [maintainability low] 删除永不命中的 .resource-reference-input-at direct-codex 规则(showTriggerButton={!directCodex} 不渲染该按钮),保留说明指向真正的 .project-supervisor-reference-trigger。
- 附带 prettier 对 .design-agent-reasoning summary 单行规则的换行归一(该文件本来未过 prettier --check)。
2026-09-12 19:43:58 +08:00

250 lines
7.2 KiB
TypeScript

import type { ReactNode } from 'react';
export type PlatformSegmentedTabsColumns =
| 'one'
| 'two'
| 'three'
| 'four'
| 'threeToSix';
export type PlatformSegmentedTabsGap = 'sm' | 'md';
export type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl';
export type PlatformSegmentedTabsSize =
| 'sm'
| 'md'
| 'compact'
| 'choice'
| 'tab';
export type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
export type PlatformSegmentedTabsTone =
| 'neutral'
| 'warm'
| 'rose'
| 'underline';
export type PlatformSegmentedTabsFrame = 'panel' | 'bare';
export type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
export type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
export type PlatformSegmentedTabItem<TId extends string = string> = {
id: TId;
label: ReactNode;
ariaLabel?: string;
disabled?: boolean;
};
export type PlatformSegmentedTabsProps<TId extends string = string> = {
items: readonly PlatformSegmentedTabItem<TId>[];
activeId: TId;
onChange: (id: TId) => void;
columns?: PlatformSegmentedTabsColumns;
gap?: PlatformSegmentedTabsGap;
radius?: PlatformSegmentedTabsRadius;
size?: PlatformSegmentedTabsSize;
surface?: PlatformSegmentedTabsSurface;
tone?: PlatformSegmentedTabsTone;
frame?: PlatformSegmentedTabsFrame;
semantics?: PlatformSegmentedTabsSemantics;
layout?: PlatformSegmentedTabsLayout;
ariaLabel?: string;
truncateLabels?: boolean;
disabled?: boolean;
className?: string;
itemClassName?:
| string
| ((item: PlatformSegmentedTabItem<TId>, active: boolean) => string | null);
};
const PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS: Record<
PlatformSegmentedTabsColumns,
string
> = {
one: 'grid-cols-1',
two: 'grid-cols-2',
three: 'grid-cols-3',
four: 'grid-cols-4',
threeToSix: 'grid-cols-3 sm:grid-cols-6',
};
const PLATFORM_SEGMENTED_TABS_GAP_CLASS: Record<
PlatformSegmentedTabsGap,
string
> = {
sm: 'gap-1',
md: 'gap-2',
};
const PLATFORM_SEGMENTED_TABS_RADIUS_CLASS: Record<
PlatformSegmentedTabsRadius,
string
> = {
md: 'rounded-[1rem]',
lg: 'rounded-[1.1rem]',
xl: 'rounded-[1.25rem]',
};
const PLATFORM_SEGMENTED_TABS_SURFACE_CLASS: Record<
PlatformSegmentedTabsSurface,
string
> = {
default: 'bg-white/62',
soft: 'bg-white/58',
transparent: 'bg-transparent',
};
const PLATFORM_SEGMENTED_TABS_FRAME_CLASS: Record<
PlatformSegmentedTabsFrame,
string
> = {
panel: 'border border-[var(--platform-subpanel-border)] p-1',
bare: 'border-0 p-0',
};
const PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS: Record<
PlatformSegmentedTabsSize,
string
> = {
sm: 'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold',
md: 'min-h-10 rounded-[1rem] px-3 text-sm font-bold',
compact: 'min-h-10 rounded-[0.8rem] px-2 text-xs font-black sm:text-sm',
choice: 'min-h-10 rounded-[0.9rem] px-1.5 py-2 text-center',
tab: 'relative h-12 rounded-none px-2 text-base font-semibold sm:text-lg',
};
const PLATFORM_SEGMENTED_TABS_TONE_CLASS: Record<
PlatformSegmentedTabsTone,
{ active: string; idle: string }
> = {
neutral: {
active: 'bg-white text-[var(--platform-text-strong)] shadow-sm',
idle: 'text-[var(--platform-text-base)] hover:bg-white/60',
},
warm: {
active:
'bg-[var(--platform-warm-bg)] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(204,117,76,0.18)]',
idle: 'text-[var(--platform-text-base)] hover:bg-white/58',
},
rose: {
active:
'border border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]',
idle: 'border border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-[var(--platform-surface-hover-border)] hover:bg-white',
},
underline: {
active: 'text-[var(--platform-text-strong)]',
idle: 'text-[var(--platform-text-muted)] hover:text-[var(--platform-text-base)]',
},
};
function resolveItemClassName<TId extends string>({
active,
disabled,
item,
itemClassName,
size,
tone,
}: {
active: boolean;
disabled: boolean;
item: PlatformSegmentedTabItem<TId>;
itemClassName?: PlatformSegmentedTabsProps<TId>['itemClassName'];
size: PlatformSegmentedTabsSize;
tone: PlatformSegmentedTabsTone;
}) {
const extraClassName =
typeof itemClassName === 'function'
? itemClassName(item, active)
: itemClassName;
return [
'min-w-0 whitespace-nowrap transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]',
PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS[size],
active
? PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].active
: PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].idle,
disabled ? 'cursor-not-allowed opacity-55 hover:bg-transparent' : null,
extraClassName,
]
.filter(Boolean)
.join(' ');
}
/** 平台白底分段选择控件,统一承接 tab / segment button chrome。 */
export function PlatformSegmentedTabs<TId extends string = string>({
items,
activeId,
onChange,
columns = 'two',
gap = 'md',
radius = 'xl',
size = 'md',
surface = 'default',
tone = 'neutral',
frame = 'panel',
semantics = 'segment',
layout = 'grid',
ariaLabel,
truncateLabels = false,
disabled = false,
className,
itemClassName,
}: PlatformSegmentedTabsProps<TId>) {
return (
<div
role={semantics === 'tabs' ? 'tablist' : undefined}
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
className={[
'platform-segmented-tabs',
layout === 'scroll'
? 'platform-segmented-tabs--scroll flex min-w-0 items-center overflow-x-auto scrollbar-hide'
: 'grid',
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
layout === 'grid'
? PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns]
: null,
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item) => {
const active = activeId === item.id;
const itemDisabled = disabled || Boolean(item.disabled);
return (
<button
key={item.id}
type="button"
role={semantics === 'tabs' ? 'tab' : undefined}
aria-label={item.ariaLabel}
aria-selected={semantics === 'tabs' ? active : undefined}
aria-pressed={semantics === 'segment' ? active : undefined}
disabled={itemDisabled}
onClick={() => {
if (!itemDisabled) {
onChange(item.id);
}
}}
className={resolveItemClassName({
active,
disabled: itemDisabled,
item,
itemClassName,
size,
tone,
})}
>
{truncateLabels ? (
<span className="block truncate">{item.label}</span>
) : (
item.label
)}
{tone === 'underline' && active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
})}
</div>
);
}