收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
238
src/components/common/PlatformSegmentedTabs.tsx
Normal file
238
src/components/common/PlatformSegmentedTabs.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PlatformSegmentedTabsColumns =
|
||||
| 'one'
|
||||
| 'two'
|
||||
| 'three'
|
||||
| 'four'
|
||||
| 'threeToSix';
|
||||
type PlatformSegmentedTabsGap = 'sm' | 'md';
|
||||
type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl';
|
||||
type PlatformSegmentedTabsSize = 'sm' | 'md' | 'compact' | 'choice' | 'tab';
|
||||
type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
|
||||
type PlatformSegmentedTabsTone = 'neutral' | 'warm' | 'rose' | 'underline';
|
||||
type PlatformSegmentedTabsFrame = 'panel' | 'bare';
|
||||
type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
|
||||
|
||||
export type PlatformSegmentedTabItem<TId extends string> = {
|
||||
id: TId;
|
||||
label: ReactNode;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type PlatformSegmentedTabsProps<TId extends 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;
|
||||
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 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>({
|
||||
items,
|
||||
activeId,
|
||||
onChange,
|
||||
columns = 'two',
|
||||
gap = 'md',
|
||||
radius = 'xl',
|
||||
size = 'md',
|
||||
surface = 'default',
|
||||
tone = 'neutral',
|
||||
frame = 'panel',
|
||||
semantics = 'segment',
|
||||
ariaLabel,
|
||||
truncateLabels = false,
|
||||
disabled = false,
|
||||
className,
|
||||
itemClassName,
|
||||
}: PlatformSegmentedTabsProps<TId>) {
|
||||
return (
|
||||
<div
|
||||
role={semantics === 'tabs' ? 'tablist' : undefined}
|
||||
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
|
||||
className={[
|
||||
'grid',
|
||||
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
|
||||
PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns],
|
||||
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user