e072c66ce9
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
- 模板库筛选区改用共享筛选条与标签 chip:选中为实心品牌填充 + 反白文字,未选为浅底描边 - 未选中悬停不再借用品牌色(品牌色专属已选中),状态阶梯固定为静止 / 悬停 / 按下 / 选中 - 新增 getPlatformCategoryChipClassName 收敛筛选 chip 类名口径,模板库、资源画布、参考图弹窗与平台 Web 端共用 - PlatformSegmentedTabs 增加 accent 选中口径,与筛选 chip 共用 --platform-chip-* 语义色 - 修复卡片文字区按 grid auto 行排版导致的标题 / 简介 / 标签被裁:行高契约拆为分项常量,文字区固定 174 - 修复忙状态下动作行塞入第三个元素导致的按钮文案换行顶出卡片:忙状态写在触发它的按钮上 - 修复经典滚动条占宽导致的卡片区横向滚动条:按竖滚动条预留列宽后再算列宽行高 - 焦点环由 15% 透明度改为实心色并用 outline 绘制,选中项聚焦不再被状态投影盖掉 - 封面加载失败改为隐藏图片留中性底色,不再出现浏览器裂图图标 - 补充状态对比度、行高契约、滚动条预留、封面兜底与忙状态文案的回归测试 - 同步技术方案、踩坑记录与决策记录的口径
257 lines
7.6 KiB
TypeScript
257 lines
7.6 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'
|
|
| 'accent'
|
|
| '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',
|
|
},
|
|
// 与筛选 chip 的选中态共用同一组语义色:实心填充 + 反白文字。
|
|
// 未选中的悬停只加淡暖底 + 加深文字,不换成品牌色 —— 品牌色是「已选中」专用的。
|
|
accent: {
|
|
active: 'platform-segmented-tabs__item--solid',
|
|
idle: 'text-[var(--platform-text-base)] hover:bg-[var(--platform-warm-bg)] hover:text-[var(--platform-text-strong)]',
|
|
},
|
|
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>
|
|
);
|
|
}
|