扩展共享组件库并接入当前项目
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / Native shell tests (pull_request) Successful in 12m4s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 12s

- 收口平台异步、筛选、图标、信息、列表、运行态和统计组件到共享包
- 新增 Label、Checkbox、Skeleton、Table 语义基础组件
- 展示页与代表性业务页面改用共享 canonical 导出并补齐交互反馈
- 同步共享样式、组件测试和前端架构决策文档
This commit is contained in:
2026-08-28 18:26:10 +08:00
parent bbd895d7c2
commit 328da5e6eb
56 changed files with 3128 additions and 2316 deletions
@@ -7759,3 +7759,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:共享 Web UI 采用 shadcn 的源码归属项目模式,新增 canonical source 放入 `packages/shared/src/components/ui`,统一通过 `packages/shared/src/lib/utils.ts``cn` 与 CVA 组织变体;不引入 MUI / Ant Design 全量组件,也不一次性重写现有业务 common。
- 首步:`Button``Modal``SegmentedTabs``Switch``Input``Textarea``Badge``Card` 已迁移到 `components/ui` canonical source,保留 `@genarrative/shared/components` 的旧 API 作为兼容适配;Button/Input/Badge 用 CVADialog/Tabs/Switch 按需使用 RadixTailwind 4 继续消费现有 `--platform-*` token 与 `.genarrative-ui-*` 样式。原生 Select 暂不迁移。
- 边界:网站与 Tauri WebView 可消费该 DOM 源码,移动端 React Native 不导入这套组件与 CSSRadix primitive 仅在 Dialog、Tabs、Switch 等具体组件迁移时按需加入。
## 2026-08-28 共享平台 chrome 与基础语义件继续扩展
- 决策:无业务依赖的 `PlatformAsyncStatePanel``PlatformFilterToolbar``PlatformIconBadge``PlatformInfoBlock``PlatformNavigableListItem``PlatformRuntimeStatusToast``PlatformStatGrid` 实现统一收口到 `packages/shared/src/components``src/components/common` 仅保留兼容导出,展示页和部分实际业务页面开始直接消费共享 barrel。
- 基础件:`components/ui` 新增语义 `Label`、原生 `Checkbox``Skeleton` 和语义 `Table` 组合件;继续沿用项目自有 shadcn open-code 源码,不引入额外运行时依赖。
- 样式:筛选工具栏与可导航列表行的 hover/active/focus/disabled 反馈、移动端断点和独立宿主所需 chrome 放入共享样式;共享主题仍只消费 `--platform-*` token,不依赖网站总 CSS。
- 验证:相关共享/兼容组件 10 个测试文件共 36 个断言、类型检查、编码检查、定向 ESLint、`git diff --check` 和生产构建均通过。
@@ -1,6 +1,6 @@
# 共享基础组件库与展示页
更新时间:`2026-08-26`
更新时间:`2026-08-28`
## 目标
@@ -19,17 +19,28 @@
- `Subpanel``Modal`
- `Status``EmptyState``Badge`
- `ProgressBar``SegmentedTabs``Switch`
- `Spinner``Divider`
- `Spinner``Divider``Label``Checkbox``Skeleton`
- `Table`(含 `TableHeader``TableBody``TableRow``TableHead``TableCell``TableCaption``TableFooter`
平台 chrome 组件(同样从 `@genarrative/shared/components` 导出):
- `PlatformActionButton``PlatformIconButton``PlatformPillBadge`
- `PlatformStatusMessage``PlatformEmptyState``PlatformTextField`
- `PlatformProgressBar``PlatformSegmentedTabs``PlatformFieldLabel``PlatformSubpanel`
- `PlatformAsyncStatePanel``PlatformFilterToolbar``PlatformIconBadge``PlatformRuntimeStatusToast`
- `PlatformInfoBlock``PlatformNavigableListItem``PlatformStatGrid`
迁移约定:`Button``Modal``SegmentedTabs``Switch``Input``Textarea``Badge``Card` 的 canonical source 位于 `packages/shared/src/components/ui`,旧的 `@genarrative/shared/components` API 作为兼容适配层继续保留。`Button``Input``Badge` 使用 CVADialog/Tabs/Switch 使用按需 Radix primitive;原生 `SelectField` 暂不迁移,避免破坏现有 `<option>` 与表单事件合同。后续组件按使用面逐个迁移。
2026-08-28 平台 chrome 的无业务依赖组件继续完成实现收口:新增 `PlatformAsyncStatePanel``PlatformFilterToolbar``PlatformIconBadge``PlatformInfoBlock``PlatformNavigableListItem``PlatformRuntimeStatusToast``PlatformStatGrid`。这些组件的实现统一位于 `packages/shared/src/components``src/components/common` 中同名文件仅保留兼容导出,因此现有业务页面无需一次性改写导入路径,实际渲染已复用共享包实现。带 API、store、编辑器或素材解析副作用的组件仍留在业务层,后续按同一边界逐项评估。
同时提供 `Platform*` 别名,便于从现有平台组件命名迁移;别名不携带平台业务语义。
Web 宿主需要显式启用 Tailwind 4,并引入 `@genarrative/shared/styles.css``@genarrative/shared/theme.css`Tauri WebView 与网站共用这套源码,移动端 React Native 不消费 DOM/CSS 组件。组件库不导入网站 `src/index.css`,也不提供页面壳或业务流程。`components.json` 只用于 shadcn CLI 定位源码,不允许覆盖现有业务组件。
Web 宿主需要显式启用 Tailwind 4,并引入 `@genarrative/shared/styles.css``@genarrative/shared/theme.css`Tauri WebView 与网站共用这套源码,移动端 React Native 不消费 DOM/CSS 组件。筛选工具栏所需的 `platform-category-*` chrome 样式也已放入共享样式文件,独立宿主无需依赖网站 `src/index.css`。组件库不提供页面壳或业务流程。`components.json` 只用于 shadcn CLI 定位源码,不允许覆盖现有业务组件。
## 展示页
网站 `/components`(兼容别名 `/design-system`)是共享组件展示页,不经过账号 Gate。页面按“基础组件”“平台通用组件”“Token”“状态”分区,覆盖公共组件的主要变体、交互态、筛选/标签/媒体/上传/异步状态/指标列表等平台 chrome 和移动端布局;展示数据均为本地静态示例,不调用 API。平台组件分区中的筛选按示例素材状态过滤结果,排序按最近使用或名称重排结果,并在筛选按钮、排序按钮和独立筛选面板之间保持同一份本地状态。展示页可用于网站与客户端接入前的视觉回归和人工验收。平台通用组件示例来自 `src/components/common`只接入不读取请求、store 或业务实体的 chrome,不把账号、发布和编辑器业务流程嵌入展示页。
网站 `/components`(兼容别名 `/design-system`)是共享组件展示页,不经过账号 Gate。页面按“基础组件”“平台通用组件”“Token”“状态”分区,覆盖公共组件的主要变体、交互态、筛选/标签/媒体/上传/异步状态/指标列表等平台 chrome 和移动端布局;展示数据均为本地静态示例,不调用 API。平台组件分区中的筛选按示例素材状态过滤结果,排序按最近使用或名称重排结果,并在筛选按钮、排序按钮和独立筛选面板之间保持同一份本地状态。展示页可用于网站与客户端接入前的视觉回归和人工验收。平台通用组件示例优先从 `@genarrative/shared/components` 直接导入,业务代码仍可通过 `src/components/common` 的兼容出口渐进迁移;展示页只接入不读取请求、store 或业务实体的 chrome,不把账号、发布和编辑器业务流程嵌入展示页。
## 验收
@@ -4,24 +4,25 @@ import type {
ReactNode,
} from 'react';
import { Button, type ButtonProps, type SharedButtonSize } from './ui';
import {
getPlatformActionButtonClassName,
type PlatformActionButtonAlign,
type PlatformActionButtonShape,
type PlatformActionButtonSize,
type PlatformActionButtonSurface,
type PlatformActionButtonTone,
} from './platformActionButtonModel';
export type PlatformActionButtonTone =
| 'primary'
| 'secondary'
| 'ghost'
| 'danger'
| 'success'
| 'warning'
| 'accent'
| 'accentSoft';
export type PlatformActionButtonSurface = 'platform' | 'profile' | 'editorDark';
export type PlatformActionButtonSize = 'xxs' | 'xs' | SharedButtonSize;
export type PlatformActionButtonShape = 'default' | 'pill';
export type PlatformActionButtonAlign = 'center' | 'start';
export type {
PlatformActionButtonAlign,
PlatformActionButtonShape,
PlatformActionButtonSize,
PlatformActionButtonSurface,
PlatformActionButtonTone,
} from './platformActionButtonModel';
type PlatformActionButtonBaseProps = {
children?: ReactNode;
children: ReactNode;
tone?: PlatformActionButtonTone;
surface?: PlatformActionButtonSurface;
size?: PlatformActionButtonSize;
@@ -31,8 +32,8 @@ type PlatformActionButtonBaseProps = {
};
type PlatformActionButtonButtonProps = Omit<
ButtonProps,
'children' | 'variant' | 'size' | 'fullWidth'
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> &
PlatformActionButtonBaseProps & {
asChild?: false;
@@ -50,25 +51,9 @@ export type PlatformActionButtonProps =
| PlatformActionButtonButtonProps
| PlatformActionButtonLabelProps;
const toneVariant: Record<PlatformActionButtonTone, ButtonProps['variant']> = {
primary: 'primary',
secondary: 'secondary',
ghost: 'ghost',
danger: 'danger',
success: 'primary',
warning: 'secondary',
accent: 'primary',
accentSoft: 'secondary',
};
function toneClassName(tone: PlatformActionButtonTone) {
return tone.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
}
/**
* Product-neutral compatibility wrapper for the existing platform action
* button naming. It keeps the optional label host useful for file inputs while
* leaving all product state and event handling with the caller.
* 平台通用动作按钮。
* 收口平台与个人中心主动作按钮的样式族、尺寸、圆角和禁用态 class。
*/
export function PlatformActionButton({
tone = 'primary',
@@ -82,23 +67,16 @@ export function PlatformActionButton({
asChild,
...props
}: PlatformActionButtonProps) {
const resolvedSize: SharedButtonSize =
size === 'xxs' || size === 'xs' ? 'sm' : size;
const modifierClassName = [
shape === 'pill' ? 'genarrative-ui-button--pill' : null,
align === 'start' ? 'genarrative-ui-button--start' : null,
fullWidth ? 'genarrative-ui-button--full-width' : null,
`genarrative-ui-button--surface-${surface}`,
`genarrative-ui-button--tone-${toneClassName(tone)}`,
className,
]
.filter(Boolean)
.join(' ');
const actionClassName = [
'genarrative-ui-button',
`genarrative-ui-button--${toneVariant[tone]}`,
`genarrative-ui-button--${resolvedSize}`,
modifierClassName,
getPlatformActionButtonClassName({
surface,
tone,
size,
shape,
align,
fullWidth,
}),
className,
]
.filter(Boolean)
.join(' ');
@@ -117,15 +95,8 @@ export function PlatformActionButton({
const { type = 'button', ...buttonProps } =
props as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<Button
{...buttonProps}
type={type}
fullWidth={fullWidth}
size={resolvedSize}
variant={toneVariant[tone]}
className={modifierClassName}
>
<button {...buttonProps} type={type} className={actionClassName}>
{children}
</Button>
</button>
);
}
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
export type PlatformAsyncStatePanelProps = {
errorState?: ReactNode;
isLoading?: boolean;
loadingState?: ReactNode;
isEmpty?: boolean;
emptyState?: ReactNode;
children: ReactNode;
};
/**
* 平台异步状态面板骨架。
* 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。
*/
export function PlatformAsyncStatePanel({
errorState,
isLoading = false,
loadingState = null,
isEmpty = false,
emptyState = null,
children,
}: PlatformAsyncStatePanelProps) {
if (errorState !== undefined && errorState !== null) {
return <>{errorState}</>;
}
if (isLoading) {
return <>{loadingState}</>;
}
if (isEmpty) {
return <>{emptyState}</>;
}
return <>{children}</>;
}
@@ -1,2 +1,128 @@
export type { EmptyStateProps as PlatformEmptyStateProps } from './ui';
export { EmptyState as PlatformEmptyState } from './ui';
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
export type PlatformEmptyStateSurface =
| 'soft'
| 'dashed'
| 'subpanel'
| 'editorDark';
export type PlatformEmptyStateSize = 'compact' | 'panel' | 'inline';
export type PlatformEmptyStateTone = 'base' | 'soft';
type PlatformEmptyStateBaseProps = {
children: ReactNode;
surface?: PlatformEmptyStateSurface;
size?: PlatformEmptyStateSize;
tone?: PlatformEmptyStateTone;
};
type PlatformEmptyStateDivProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
asChild?: false;
} & PlatformEmptyStateBaseProps;
type PlatformEmptyStateButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
asChild: 'button';
} & PlatformEmptyStateBaseProps;
export type PlatformEmptyStateProps =
| PlatformEmptyStateDivProps
| PlatformEmptyStateButtonProps;
type PlatformEmptyStateClassNameOptions = {
surface: PlatformEmptyStateSurface;
size: PlatformEmptyStateSize;
tone: PlatformEmptyStateTone;
className?: string;
};
const PLATFORM_EMPTY_STATE_SURFACE_CLASS: Record<
PlatformEmptyStateSurface,
string
> = {
soft: 'platform-surface platform-surface--soft rounded-[1.35rem]',
dashed:
'rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52',
subpanel:
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74',
editorDark: 'rounded-2xl border border-dashed border-white/12 bg-black/20',
};
const PLATFORM_EMPTY_STATE_SIZE_CLASS: Record<PlatformEmptyStateSize, string> =
{
compact: 'px-4 py-3 text-sm leading-6',
panel:
'flex min-h-[14rem] items-center justify-center px-6 text-center text-sm',
inline: 'px-4 py-5 text-center text-sm font-semibold',
};
const PLATFORM_EMPTY_STATE_TONE_CLASS: Record<PlatformEmptyStateTone, string> =
{
base: 'text-[var(--platform-text-base)]',
soft: 'text-[var(--platform-text-soft)]',
};
/**
* 平台通用空态和轻量加载态。
* 收口平台列表、作品架和素材选择弹窗中重复的空面板外观。
*/
function getPlatformEmptyStateClassName({
surface,
size,
tone,
className,
}: PlatformEmptyStateClassNameOptions) {
return [
'min-w-0',
'platform-empty-state',
PLATFORM_EMPTY_STATE_SURFACE_CLASS[surface],
PLATFORM_EMPTY_STATE_SIZE_CLASS[size],
PLATFORM_EMPTY_STATE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ');
}
export function PlatformEmptyState({
children,
surface = 'soft',
size = 'compact',
tone,
className,
asChild,
...emptyStateProps
}: PlatformEmptyStateProps) {
const resolvedTone =
tone ?? (surface === 'subpanel' || size === 'inline' ? 'soft' : 'base');
const emptyStateClassName = getPlatformEmptyStateClassName({
surface,
size,
tone: resolvedTone,
className,
});
if (asChild === 'button') {
const { type = 'button', ...buttonProps } =
emptyStateProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button {...buttonProps} type={type} className={emptyStateClassName}>
{children}
</button>
);
}
return (
<div
{...(emptyStateProps as HTMLAttributes<HTMLDivElement>)}
className={emptyStateClassName}
>
{children}
</div>
);
}
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
export type PlatformFieldLabelVariant =
| 'field'
| 'section'
| 'form'
| 'inlineForm'
| 'pill'
| 'accentPill';
export type PlatformFieldLabelProps = {
children: ReactNode;
variant?: PlatformFieldLabelVariant;
className?: string;
};
const PLATFORM_FIELD_LABEL_CLASS: Record<PlatformFieldLabelVariant, string> = {
field: 'text-xs font-bold text-[var(--platform-text-soft)]',
section:
'text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]',
form: 'mb-2 block text-sm font-black text-[var(--platform-text-strong)]',
inlineForm:
'inline-flex items-center text-sm font-bold text-[var(--platform-text-base)]',
pill: 'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]',
accentPill:
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm',
};
/** 平台字段标签,供子面板和表单控件复用。 */
export function PlatformFieldLabel({
children,
variant = 'field',
className,
}: PlatformFieldLabelProps) {
return (
<span
className={[PLATFORM_FIELD_LABEL_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}
@@ -0,0 +1,112 @@
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
export interface PlatformFilterToolbarTabItem {
id: string;
label: string;
}
export interface PlatformFilterToolbarProps {
filterLabel: string;
filterCount: number;
tabItems: readonly PlatformFilterToolbarTabItem[];
activeTabId: string;
sortLabel: string;
layout: 'mobile' | 'desktop';
onOpenFilter: () => void;
onTabChange: (id: string) => void;
onToggleSort: () => void;
}
function buildToolbarTabItemClassName(active: boolean) {
return [
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台内容列表的筛选、分类和排序工具栏。
* 只负责展示入口与转发回调,筛选条件和排序规则由宿主页面持有。
*/
export function PlatformFilterToolbar({
filterLabel,
filterCount,
tabItems,
activeTabId,
sortLabel,
layout,
onOpenFilter,
onTabChange,
onToggleSort,
}: PlatformFilterToolbarProps) {
const isMobileLayout = layout === 'mobile';
return (
<>
<div
className={
isMobileLayout
? 'platform-category-filter-row'
: 'mb-4 flex min-w-0 items-center gap-2'
}
>
<button
type="button"
onClick={onOpenFilter}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>{filterLabel}</span>
<span className="platform-category-filter-button__count">
{filterCount}
</span>
</button>
{isMobileLayout ? (
<span className="platform-category-filter-divider" />
) : null}
<PlatformSegmentedTabs
items={tabItems}
activeId={activeTabId}
onChange={onTabChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
className={
isMobileLayout
? 'platform-category-chip-scroll min-w-0 flex-1'
: 'min-w-0 flex-1 pb-1'
}
itemClassName={(_, active) => buildToolbarTabItemClassName(active)}
/>
{!isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button shrink-0"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</>
);
}
@@ -0,0 +1,92 @@
import type { ReactNode } from 'react';
export type PlatformIconBadgeSize =
| 'xs'
| 'sm'
| 'base'
| 'md'
| 'lg'
| 'xl'
| 'xxl';
export type PlatformIconBadgeShape = 'circle' | 'rounded' | 'xl';
export type PlatformIconBadgeTone =
| 'neutral'
| 'soft'
| 'softBright'
| 'hero'
| 'heroMuted'
| 'darkAmber'
| 'success'
| 'danger';
export type PlatformIconBadgeProps = {
icon: ReactNode;
label?: string;
size?: PlatformIconBadgeSize;
shape?: PlatformIconBadgeShape;
tone?: PlatformIconBadgeTone;
className?: string;
};
const PLATFORM_ICON_BADGE_SIZE_CLASS: Record<PlatformIconBadgeSize, string> = {
xs: 'h-7 w-7',
sm: 'h-9 w-9',
base: 'h-10 w-10',
md: 'h-11 w-11',
lg: 'h-12 w-12',
xl: 'h-14 w-14',
xxl: 'h-20 w-20',
};
const PLATFORM_ICON_BADGE_SHAPE_CLASS: Record<PlatformIconBadgeShape, string> =
{
circle: 'rounded-full',
rounded: 'rounded-[0.85rem]',
xl: 'rounded-2xl',
};
const PLATFORM_ICON_BADGE_TONE_CLASS: Record<PlatformIconBadgeTone, string> = {
neutral:
'bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]',
soft: 'bg-white/82 text-[var(--platform-text-strong)] shadow-sm',
softBright: 'bg-white/84 text-[var(--platform-text-strong)] shadow-sm',
hero: 'bg-white/18 text-white',
heroMuted: 'bg-white/18 text-white/72',
darkAmber: 'border border-amber-400/30 bg-amber-500/15 text-amber-50',
success:
'bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
danger:
'bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
};
/**
* 平台中性图标徽章。
* 统一承接弹窗标题、列表项和小卡片里的非交互图标槽。
*/
export function PlatformIconBadge({
icon,
label,
size = 'sm',
shape = 'circle',
tone = 'neutral',
className,
}: PlatformIconBadgeProps) {
return (
<span
aria-label={label}
aria-hidden={label ? undefined : true}
className={[
'platform-icon-badge',
'grid shrink-0 place-items-center',
PLATFORM_ICON_BADGE_SIZE_CLASS[size],
PLATFORM_ICON_BADGE_SHAPE_CLASS[shape],
PLATFORM_ICON_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{icon}
</span>
);
}
@@ -8,13 +8,11 @@ import type {
} from 'react';
import { forwardRef } from 'react';
import { IconButton, type IconButtonProps, type IconButtonSize } from './ui';
export type PlatformIconButtonVariant =
| 'platformIcon'
| 'surfaceFloating'
| 'darkMini';
export type PlatformIconButtonSize = IconButtonSize;
export type PlatformIconButtonSize = 'sm' | 'md' | 'lg';
type PlatformIconButtonBaseProps = {
label: string;
@@ -24,8 +22,8 @@ type PlatformIconButtonBaseProps = {
};
type PlatformIconButtonButtonProps = Omit<
IconButtonProps,
'variant' | 'children'
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-label' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild?: false;
@@ -53,12 +51,10 @@ export type PlatformIconButtonProps =
| PlatformIconButtonLabelProps
| PlatformIconButtonSpanButtonProps;
const variantClassName: Record<PlatformIconButtonVariant, string> = {
platformIcon: 'genarrative-ui-icon-button--surface-platform',
surfaceFloating: 'genarrative-ui-icon-button--surface-floating',
darkMini: 'genarrative-ui-icon-button--surface-dark-mini',
};
/**
* 平台通用图标动作按钮。
* 统一承接纯图标动作、图标上传 label 和带短标签的浮动图标动作。
*/
export const PlatformIconButton = forwardRef<
HTMLButtonElement | HTMLLabelElement | HTMLSpanElement,
PlatformIconButtonProps
@@ -75,13 +71,15 @@ export const PlatformIconButton = forwardRef<
},
ref,
) {
const resolvedVariant = variant === 'darkMini' ? 'quiet' : 'default';
const actionClassName = [
'genarrative-ui-icon-button',
`genarrative-ui-icon-button--${resolvedVariant}`,
variantClassName[variant],
className,
]
const variantClassName = {
platformIcon: 'platform-icon-button',
surfaceFloating:
'inline-flex items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55',
darkMini:
'inline-flex items-center justify-center rounded-full border border-white/16 bg-black/55 text-white transition-colors hover:bg-black/70 disabled:cursor-not-allowed disabled:opacity-55',
}[variant];
const actionClassName = [variantClassName, className]
.filter(Boolean)
.join(' ');
@@ -91,9 +89,9 @@ export const PlatformIconButton = forwardRef<
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
ref={ref as Ref<HTMLLabelElement>}
title={title}
className={`${actionClassName} genarrative-ui-icon-button--md`}
className={actionClassName}
>
<span className="genarrative-ui-visually-hidden">{label}</span>
<span className="sr-only">{label}</span>
{icon}
{children}
</label>
@@ -107,7 +105,9 @@ export const PlatformIconButton = forwardRef<
};
const handleKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.defaultPrevented || disabled) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.currentTarget.click();
@@ -118,14 +118,14 @@ export const PlatformIconButton = forwardRef<
<span
{...spanProps}
ref={ref as Ref<HTMLSpanElement>}
aria-disabled={disabled || undefined}
aria-disabled={disabled}
aria-label={label}
className={`${actionClassName} genarrative-ui-icon-button--md`}
className={actionClassName}
onClick={disabled ? undefined : onClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={disabled ? -1 : (tabIndex ?? 0)}
title={title ?? label}
title={title}
>
{icon}
{children}
@@ -135,17 +135,18 @@ export const PlatformIconButton = forwardRef<
const { type = 'button', ...buttonProps } =
actionProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<IconButton
<button
{...buttonProps}
ref={ref as Ref<HTMLButtonElement>}
type={type}
label={label}
icon={icon}
children={children}
variant={resolvedVariant}
aria-label={label}
title={title}
className={actionClassName}
title={title ?? label}
/>
>
{icon}
{children}
</button>
);
});
@@ -0,0 +1,100 @@
import type { ReactNode } from 'react';
export type PlatformInfoBlockVariant = 'default' | 'compactRow';
export type PlatformInfoBlockProps = {
label?: ReactNode;
children: ReactNode;
variant?: PlatformInfoBlockVariant;
multiline?: boolean;
className?: string;
labelClassName?: string;
valueClassName?: string;
};
const PLATFORM_INFO_BLOCK_CLASS: Record<PlatformInfoBlockVariant, string> = {
default:
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2',
compactRow:
'flex justify-between gap-2 rounded-[0.85rem] border-0 bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2',
};
const PLATFORM_INFO_BLOCK_LABEL_CLASS: Record<
PlatformInfoBlockVariant,
string
> = {
default: 'text-xs font-bold text-[var(--platform-text-soft)]',
compactRow: 'text-xs font-bold text-[var(--platform-text-muted)] sm:text-sm',
};
function getValueClassName({
hasLabel,
multiline,
variant,
valueClassName,
}: {
hasLabel: boolean;
multiline: boolean;
variant: PlatformInfoBlockVariant;
valueClassName?: string;
}) {
if (variant === 'compactRow') {
return [
'break-words text-right text-xs font-black leading-5 text-[var(--platform-text-strong)] sm:text-sm',
valueClassName,
]
.filter(Boolean)
.join(' ');
}
return [
'break-words text-sm text-[var(--platform-text-strong)]',
hasLabel ? 'mt-1' : null,
multiline ? 'whitespace-pre-wrap leading-6' : 'font-semibold leading-5',
valueClassName,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台信息展示块。
* 统一承接弹窗和详情页中“短标签 + 白底内容”的只读信息 chrome。
*/
export function PlatformInfoBlock({
label,
children,
variant = 'default',
multiline = false,
className,
labelClassName,
valueClassName,
}: PlatformInfoBlockProps) {
return (
<div
className={[PLATFORM_INFO_BLOCK_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
>
{label ? (
<div
className={[PLATFORM_INFO_BLOCK_LABEL_CLASS[variant], labelClassName]
.filter(Boolean)
.join(' ')}
>
{label}
</div>
) : null}
<div
className={getValueClassName({
hasLabel: Boolean(label),
multiline,
variant,
valueClassName,
})}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
export type PlatformNavigableListItemAlign = 'center' | 'start';
export type PlatformNavigableListItemProps = Omit<
ComponentPropsWithoutRef<'button'>,
'children' | 'type'
> & {
children: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
align?: PlatformNavigableListItemAlign;
leadingClassName?: string;
trailingClassName?: string;
bodyClassName?: string;
};
const ALIGN_CLASS: Record<PlatformNavigableListItemAlign, string> = {
center: 'items-center gap-3',
start: 'items-start gap-4',
};
/**
* 轻量可导航列表行骨架。
* 只统一 button 语义与 left-content/right-affordance 结构,不持有业务文案、badge 或封面规则。
*/
export function PlatformNavigableListItem({
children,
leading = null,
trailing = null,
align = 'center',
className,
leadingClassName,
trailingClassName,
bodyClassName,
...props
}: PlatformNavigableListItemProps) {
return (
<button
type="button"
className={[
'platform-navigable-list-item flex w-full min-w-0 text-left',
ALIGN_CLASS[align],
className ?? null,
]
.filter(Boolean)
.join(' ')}
{...props}
>
{leading ? (
<div
className={[
'platform-navigable-list-item__leading shrink-0',
leadingClassName ?? null,
]
.filter(Boolean)
.join(' ')}
>
{leading}
</div>
) : null}
<div
className={[
'platform-navigable-list-item__body min-w-0 flex-1',
bodyClassName ?? null,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
{trailing ? (
<div
className={[
'platform-navigable-list-item__trailing shrink-0',
trailingClassName ?? null,
]
.filter(Boolean)
.join(' ')}
>
{trailing}
</div>
) : null}
</button>
);
}
@@ -1,5 +1,45 @@
import type { HTMLAttributes, ReactNode } from 'react';
import {
getPlatformPillBadgeClassName,
type PlatformPillBadgeSize,
type PlatformPillBadgeTone,
} from './platformPillBadgeModel';
export type {
BadgeProps as PlatformPillBadgeProps,
BadgeTone as PlatformPillBadgeTone,
} from './ui';
export { Badge as PlatformPillBadge } from './ui';
PlatformPillBadgeSize,
PlatformPillBadgeTone,
} from './platformPillBadgeModel';
export type PlatformPillBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
tone?: PlatformPillBadgeTone;
size?: PlatformPillBadgeSize;
icon?: ReactNode;
children: ReactNode;
};
/**
* 平台胶囊状态标签。
* 统一承接结果页、作品卡和配置摘要里的小型状态 / 标签 chip。
*/
export function PlatformPillBadge({
tone = 'neutral',
size = 'sm',
icon,
children,
className,
...spanProps
}: PlatformPillBadgeProps) {
return (
<span
{...spanProps}
className={getPlatformPillBadgeClassName({ tone, size, className })}
>
{icon}
{children}
</span>
);
}
@@ -0,0 +1,90 @@
import type { CSSProperties, ReactNode } from 'react';
export type PlatformProgressBarSize = 'xs' | 'sm' | 'md' | 'lg';
export type PlatformProgressBarProps = {
value: number;
minVisibleValue?: number;
size?: PlatformProgressBarSize;
ariaLabel?: string;
labelledBy?: string;
indeterminate?: boolean;
className?: string;
fillClassName?: string;
fillStyle?: CSSProperties;
trackStyle?: CSSProperties;
children?: ReactNode;
};
const PLATFORM_PROGRESS_BAR_SIZE_CLASS: Record<
PlatformProgressBarSize,
string
> = {
xs: 'h-2',
sm: 'h-2.5',
md: 'h-3',
lg: 'h-12',
};
function clampProgressValue(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(value)));
}
/**
* 平台通用进度条。
* 统一承接 progressbar 语义、platform-progress-track 壳和填充宽度计算。
*/
export function PlatformProgressBar({
value,
minVisibleValue = 0,
size = 'xs',
ariaLabel,
labelledBy,
indeterminate = false,
className,
fillClassName,
fillStyle,
trackStyle,
children,
}: PlatformProgressBarProps) {
const progress = clampProgressValue(value);
const visibleProgress =
progress <= 0 ? 0 : Math.max(minVisibleValue, progress);
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : progress}
aria-label={ariaLabel}
aria-labelledby={labelledBy}
className={[
'platform-progress-track relative overflow-hidden rounded-full',
PLATFORM_PROGRESS_BAR_SIZE_CLASS[size],
className,
]
.filter(Boolean)
.join(' ')}
style={trackStyle}
>
<div
className={[
'h-full rounded-full transition-[width] duration-300',
fillClassName,
]
.filter(Boolean)
.join(' ')}
style={{
width: `${visibleProgress}%`,
...fillStyle,
}}
/>
{children}
</div>
);
}
@@ -0,0 +1,103 @@
import type { HTMLAttributes, ReactNode } from 'react';
export type PlatformRuntimeStatusTone =
| 'error'
| 'success'
| 'info'
| 'warning'
| 'neutral';
export type PlatformRuntimeStatusSurface = 'light' | 'dark' | 'solid';
export type PlatformRuntimeStatusSize = 'xs' | 'sm' | 'md';
export type PlatformRuntimeStatusShape = 'pill' | 'rounded';
export type PlatformRuntimeStatusToastProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
tone: PlatformRuntimeStatusTone;
surface?: PlatformRuntimeStatusSurface;
size?: PlatformRuntimeStatusSize;
shape?: PlatformRuntimeStatusShape;
children: ReactNode;
};
const PLATFORM_RUNTIME_STATUS_SURFACE_CLASS: Record<
PlatformRuntimeStatusSurface,
Record<PlatformRuntimeStatusTone, string>
> = {
light: {
error:
'border-white/70 bg-white/86 text-[var(--platform-button-danger-text)]',
success: 'border-white/70 bg-white/86 text-[var(--platform-success-text)]',
info: 'border-white/70 bg-white/86 text-[var(--platform-cool-text)]',
warning: 'border-white/70 bg-white/86 text-[var(--platform-warm-text)]',
neutral: 'border-white/70 bg-white/86 text-[var(--platform-text-base)]',
},
dark: {
error: 'border-rose-200/35 bg-rose-400/18 text-rose-50',
success: 'border-emerald-200/35 bg-emerald-400/18 text-emerald-50',
info: 'border-sky-200/30 bg-sky-400/16 text-sky-50',
warning: 'border-amber-200/35 bg-amber-400/18 text-amber-50',
neutral: 'border-white/15 bg-white/12 text-white/82',
},
solid: {
error: 'border-transparent bg-rose-600 text-white',
success: 'border-transparent bg-emerald-600 text-white',
info: 'border-transparent bg-sky-600 text-white',
warning: 'border-transparent bg-amber-500 text-amber-950',
neutral: 'border-transparent bg-slate-800 text-white',
},
};
const PLATFORM_RUNTIME_STATUS_SIZE_CLASS: Record<
PlatformRuntimeStatusSize,
string
> = {
xs: 'px-3 py-1 text-xs leading-5',
sm: 'px-3 py-2 text-sm leading-5',
md: 'px-4 py-3 text-sm leading-6',
};
const PLATFORM_RUNTIME_STATUS_SHAPE_CLASS: Record<
PlatformRuntimeStatusShape,
string
> = {
pill: 'rounded-full',
rounded: 'rounded-[1.2rem]',
};
/**
* 运行态 HUD 状态提示。
* 收口游戏内短错误、成功和反馈 chip,位置与玩法强品牌视觉仍由调用方控制。
*/
export function PlatformRuntimeStatusToast({
tone,
surface = 'light',
size = 'sm',
shape = 'pill',
children,
className,
role,
...divProps
}: PlatformRuntimeStatusToastProps) {
const ariaLive = divProps['aria-live'];
return (
<div
{...divProps}
role={role ?? (tone === 'error' ? 'alert' : 'status')}
aria-live={ariaLive ?? (tone === 'error' ? 'assertive' : 'polite')}
className={[
'platform-runtime-status-toast inline-flex max-w-full items-center justify-center gap-1.5 border text-center font-black shadow-sm backdrop-blur',
PLATFORM_RUNTIME_STATUS_SIZE_CLASS[size],
PLATFORM_RUNTIME_STATUS_SHAPE_CLASS[shape],
PLATFORM_RUNTIME_STATUS_SURFACE_CLASS[surface][tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
}
@@ -0,0 +1,248 @@
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 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={[
layout === '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>
);
}
@@ -0,0 +1,158 @@
import type { ReactNode } from 'react';
export type PlatformStatGridColumns = 'two' | 'three' | 'four' | 'twoToFour';
export type PlatformStatGridDensity = 'compact' | 'default';
export type PlatformStatGridOrder = 'valueFirst' | 'labelFirst';
export type PlatformStatGridSurface = 'soft' | 'plain';
export type PlatformStatGridTextAlign = 'left' | 'center';
export type PlatformStatGridItem = {
label?: ReactNode;
value: ReactNode;
key?: string;
};
export type PlatformStatGridProps = {
items: readonly PlatformStatGridItem[];
columns?: PlatformStatGridColumns;
density?: PlatformStatGridDensity;
order?: PlatformStatGridOrder;
surface?: PlatformStatGridSurface;
textAlign?: PlatformStatGridTextAlign;
className?: string;
itemClassName?:
| string
| ((item: PlatformStatGridItem, index: number) => string | null);
};
const PLATFORM_STAT_GRID_COLUMNS_CLASS: Record<
PlatformStatGridColumns,
string
> = {
two: 'grid-cols-2',
three: 'grid-cols-3',
four: 'grid-cols-4',
twoToFour: 'grid-cols-2 sm:grid-cols-4',
};
const PLATFORM_STAT_GRID_DENSITY_CLASS: Record<
PlatformStatGridDensity,
{ item: string; value: string; label: string }
> = {
compact: {
item: 'rounded-[1rem] px-2 py-2',
value: 'text-sm font-black',
label: 'mt-1 text-[0.68rem] font-bold tracking-[0.14em]',
},
default: {
item: 'rounded-[1rem] px-3 py-3',
value: 'text-lg font-black',
label: 'mt-1 text-[11px] font-bold tracking-[0.14em]',
},
};
const PLATFORM_STAT_GRID_SURFACE_CLASS: Record<
PlatformStatGridSurface,
string
> = {
soft: 'bg-white/76',
plain: 'border border-[var(--platform-subpanel-border)] bg-white/68',
};
const PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS: Record<
PlatformStatGridTextAlign,
string
> = {
left: 'text-left',
center: 'text-center',
};
/**
* 平台统计小卡网格。
* 统一承接结果页里的“数值 / 标签”或轻量状态 chip 布局。
*/
export function PlatformStatGrid({
items,
columns = 'three',
density = 'default',
order = 'valueFirst',
surface = 'soft',
textAlign = 'center',
className,
itemClassName,
}: PlatformStatGridProps) {
const densityClass = PLATFORM_STAT_GRID_DENSITY_CLASS[density];
return (
<div
className={[
'grid gap-2',
PLATFORM_STAT_GRID_COLUMNS_CLASS[columns],
PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS[textAlign],
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item, index) => {
const extraClassName =
typeof itemClassName === 'function'
? itemClassName(item, index)
: itemClassName;
const key =
item.key ??
(typeof item.label === 'string'
? item.label
: typeof item.value === 'string'
? item.value
: index);
const valueNode = (
<div
className={[
densityClass.value,
'text-[var(--platform-text-strong)]',
].join(' ')}
>
{item.value}
</div>
);
const labelNode = item.label ? (
<div
className={[
densityClass.label,
'text-[var(--platform-text-soft)]',
].join(' ')}
>
{item.label}
</div>
) : null;
return (
<div
key={key}
className={[
densityClass.item,
PLATFORM_STAT_GRID_SURFACE_CLASS[surface],
extraClassName,
]
.filter(Boolean)
.join(' ')}
>
{order === 'labelFirst' ? (
<>
{labelNode}
{valueNode}
</>
) : (
<>
{valueNode}
{labelNode}
</>
)}
</div>
);
})}
</div>
);
}
@@ -1,5 +1,113 @@
export type {
StatusProps as PlatformStatusMessageProps,
StatusTone as PlatformStatusMessageTone,
} from './ui';
export { Status as PlatformStatusMessage } from './ui';
import type { HTMLAttributes, ReactNode } from 'react';
export type PlatformStatusMessageTone =
| 'error'
| 'success'
| 'info'
| 'warning'
| 'neutral';
export type PlatformStatusMessageSurface =
| 'light'
| 'tinted'
| 'platform'
| 'profile'
| 'editorDark';
export type PlatformStatusMessageSize = 'xs' | 'sm' | 'md';
export type PlatformStatusMessageProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
tone: PlatformStatusMessageTone;
surface?: PlatformStatusMessageSurface;
size?: PlatformStatusMessageSize;
remapSurface?: boolean;
children: ReactNode;
};
const PLATFORM_STATUS_MESSAGE_CLASS_BY_SURFACE_AND_TONE: Record<
PlatformStatusMessageSurface,
Record<PlatformStatusMessageTone, string>
> = {
light: {
error: 'border-rose-200 bg-rose-50 text-rose-700',
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning: 'border-amber-200 bg-amber-50 text-amber-900',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
},
tinted: {
error: 'border-rose-400/20 bg-rose-500/10 text-rose-700',
success: 'border-emerald-300/25 bg-emerald-500/12 text-emerald-700',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning: 'border-amber-300/30 bg-amber-500/12 text-amber-900',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/20 text-[var(--platform-text-base)]',
},
platform: {
error:
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
success:
'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning:
'border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
},
profile: {
error:
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
success:
'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning: 'border-amber-300 bg-amber-500/12 text-amber-900',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
},
editorDark: {
error: 'border-rose-300/15 bg-rose-500/10 text-rose-50/90',
success: 'border-emerald-300/15 bg-emerald-500/10 text-emerald-50/90',
info: 'border-sky-300/15 bg-sky-500/10 text-sky-50/90',
warning: 'border-amber-300/15 bg-amber-500/10 text-amber-50/90',
neutral: 'border-white/10 bg-black/20 text-zinc-300',
},
};
const PLATFORM_STATUS_MESSAGE_SIZE_CLASS: Record<
PlatformStatusMessageSize,
string
> = {
xs: 'px-3 py-2 text-xs',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-3 text-sm leading-6',
};
/** 平台通用状态提示条。 */
export function PlatformStatusMessage({
tone,
surface = 'light',
size = 'sm',
remapSurface = false,
children,
className,
...divProps
}: PlatformStatusMessageProps) {
return (
<div
{...divProps}
className={[
remapSurface ? 'platform-remap-surface' : null,
'platform-status-message rounded-xl border',
PLATFORM_STATUS_MESSAGE_SIZE_CLASS[size],
PLATFORM_STATUS_MESSAGE_CLASS_BY_SURFACE_AND_TONE[surface][tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
}
@@ -1,2 +1,223 @@
export type { SubpanelProps as PlatformSubpanelProps } from './ui';
export { Subpanel as PlatformSubpanel } from './ui';
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { PlatformFieldLabel } from './PlatformFieldLabel';
export type PlatformSubpanelStaticElement =
| 'section'
| 'div'
| 'article'
| 'aside';
export type PlatformSubpanelElement = PlatformSubpanelStaticElement | 'button';
export type PlatformSubpanelPadding =
| 'tight'
| 'row'
| 'xs'
| 'sm'
| 'md'
| 'lg'
| 'none';
export type PlatformSubpanelRadius = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type PlatformSubpanelSurface =
| 'platform'
| 'flat'
| 'soft'
| 'dark'
| 'darkSky'
| 'darkEmerald'
| 'darkAmber'
| 'darkRose'
| 'danger';
export type PlatformSubpanelTitleVariant = 'section' | 'strong';
type PlatformSubpanelBaseProps = {
as?: PlatformSubpanelElement;
title?: ReactNode;
titleVariant?: PlatformSubpanelTitleVariant;
actions?: ReactNode;
children: ReactNode;
interactive?: boolean;
padding?: PlatformSubpanelPadding;
radius?: PlatformSubpanelRadius;
surface?: PlatformSubpanelSurface;
className?: string;
headerClassName?: string;
titleClassName?: string;
actionsClassName?: string;
bodyClassName?: string;
};
type PlatformSubpanelStaticProps = PlatformSubpanelBaseProps &
Omit<HTMLAttributes<HTMLElement>, 'children' | 'className' | 'title'> & {
as?: PlatformSubpanelStaticElement;
};
type PlatformSubpanelButtonProps = PlatformSubpanelBaseProps &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'title'> & {
as: 'button';
};
export type PlatformSubpanelProps =
| PlatformSubpanelStaticProps
| PlatformSubpanelButtonProps;
const PLATFORM_SUBPANEL_PADDING_CLASS: Record<PlatformSubpanelPadding, string> =
{
tight: 'p-2',
row: 'px-3 py-2',
xs: 'px-3 py-2.5',
sm: 'p-3',
md: 'p-4',
lg: 'p-4 sm:p-5',
none: 'p-0',
};
const PLATFORM_SUBPANEL_RADIUS_CLASS: Record<PlatformSubpanelRadius, string> = {
xs: 'rounded-xl',
sm: 'rounded-[1rem]',
md: 'rounded-[1.25rem]',
lg: 'rounded-[1.35rem]',
xl: 'rounded-[1.5rem]',
};
const PLATFORM_SUBPANEL_SURFACE_CLASS: Record<PlatformSubpanelSurface, string> =
{
platform: 'platform-subpanel',
flat: 'border border-[var(--platform-subpanel-border)] bg-white/72',
soft: 'border border-[var(--platform-subpanel-border)] bg-white/68',
dark: 'border border-white/10 bg-black/25 text-zinc-100',
darkSky: 'border border-sky-400/18 bg-sky-500/8 text-sky-50',
darkEmerald:
'border border-emerald-400/18 bg-emerald-500/8 text-emerald-100/85',
darkAmber: 'border border-amber-300/18 bg-amber-500/8 text-amber-50',
darkRose: 'border border-rose-300/18 bg-rose-500/8 text-rose-50',
danger:
'border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]',
};
const PLATFORM_SUBPANEL_INTERACTIVE_CLASS =
'text-left transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] disabled:cursor-not-allowed disabled:opacity-55';
function renderSubpanelTitle({
title,
titleClassName,
titleVariant,
}: {
title: ReactNode;
titleClassName?: string;
titleVariant: PlatformSubpanelTitleVariant;
}) {
if (titleVariant === 'strong') {
return (
<div
className={[
'text-sm font-black text-[var(--platform-text-strong)]',
titleClassName,
]
.filter(Boolean)
.join(' ')}
>
{title}
</div>
);
}
return (
<PlatformFieldLabel variant="section" className={titleClassName}>
{title}
</PlatformFieldLabel>
);
}
/** 平台白底子面板,统一承接结果页和创作工作台里的子面板外壳。 */
export function PlatformSubpanel({
as: Component = 'section',
title,
titleVariant = 'section',
actions,
children,
interactive = false,
padding = 'md',
radius = 'md',
surface = 'platform',
className,
headerClassName,
titleClassName,
actionsClassName,
bodyClassName,
...elementProps
}: PlatformSubpanelProps) {
const hasHeader = Boolean(title) || Boolean(actions);
const subpanelClassName = [
PLATFORM_SUBPANEL_SURFACE_CLASS[surface],
PLATFORM_SUBPANEL_RADIUS_CLASS[radius],
PLATFORM_SUBPANEL_PADDING_CLASS[padding],
interactive ? PLATFORM_SUBPANEL_INTERACTIVE_CLASS : null,
className,
]
.filter(Boolean)
.join(' ');
const content = (
<>
{hasHeader ? (
<div
className={[
'flex items-center justify-between gap-3',
headerClassName,
]
.filter(Boolean)
.join(' ')}
>
<div className="min-w-0">
{title
? renderSubpanelTitle({
title,
titleClassName,
titleVariant,
})
: null}
</div>
{actions ? (
<div
className={['flex shrink-0 items-center gap-2', actionsClassName]
.filter(Boolean)
.join(' ')}
>
{actions}
</div>
) : null}
</div>
) : null}
{bodyClassName ? (
<div className={bodyClassName}>{children}</div>
) : (
children
)}
</>
);
if (Component === 'button') {
const { type = 'button', ...buttonProps } = elementProps as Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children' | 'title'
>;
return (
<button {...buttonProps} type={type} className={subpanelClassName}>
{content}
</button>
);
}
const StaticComponent = Component;
const staticProps = elementProps as Omit<
HTMLAttributes<HTMLElement>,
'children' | 'className' | 'title'
>;
return (
<StaticComponent {...staticProps} className={subpanelClassName}>
{content}
</StaticComponent>
);
}
@@ -1,8 +1,184 @@
export type {
SelectFieldProps as PlatformSelectFieldProps,
TextFieldProps as PlatformTextFieldProps,
} from './ui';
export {
SelectField as PlatformSelectField,
TextField as PlatformTextField,
} from './ui';
import type {
InputHTMLAttributes,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
export type PlatformTextFieldSize = 'xs' | 'sm' | 'md' | 'lg';
export type PlatformTextFieldDensity = 'default' | 'compact' | 'roomy';
export type PlatformTextFieldTone = 'warm' | 'rose' | 'emerald' | 'sky';
export type PlatformTextFieldSurface = 'platform' | 'editorDark';
type PlatformTextFieldBaseProps = {
size?: PlatformTextFieldSize;
density?: PlatformTextFieldDensity;
tone?: PlatformTextFieldTone;
surface?: PlatformTextFieldSurface;
};
type PlatformTextFieldInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'size'
> &
PlatformTextFieldBaseProps & {
variant?: 'input';
};
type PlatformTextFieldTextareaProps =
TextareaHTMLAttributes<HTMLTextAreaElement> &
PlatformTextFieldBaseProps & {
variant: 'textarea';
};
export type PlatformTextFieldProps =
| PlatformTextFieldInputProps
| PlatformTextFieldTextareaProps;
export type PlatformSelectFieldProps = Omit<
SelectHTMLAttributes<HTMLSelectElement>,
'size'
> &
PlatformTextFieldBaseProps;
const PLATFORM_TEXT_FIELD_SIZE_CLASS: Record<PlatformTextFieldSize, string> = {
xs: 'text-sm leading-5',
sm: 'text-sm font-semibold',
md: 'text-sm leading-6',
lg: 'text-base font-semibold',
};
const PLATFORM_TEXT_FIELD_PLATFORM_DENSITY_CLASS: Record<
PlatformTextFieldDensity,
string
> = {
default: 'rounded-[1rem] bg-white/86 px-3 py-3',
compact: 'rounded-[0.85rem] bg-white/90 px-3 py-2',
roomy: 'rounded-[1rem] bg-white/90 px-4 py-3',
};
const PLATFORM_TEXT_FIELD_EDITOR_DARK_DENSITY_CLASS: Record<
PlatformTextFieldDensity,
string
> = {
default: 'rounded-[1rem] bg-black/30 px-3 py-3',
compact: 'rounded-[0.85rem] bg-black/30 px-3 py-2',
roomy: 'rounded-[1rem] bg-black/30 px-4 py-3',
};
const PLATFORM_TEXT_FIELD_PLATFORM_TONE_CLASS: Record<
PlatformTextFieldTone,
string
> = {
warm: 'focus:border-[var(--platform-surface-hover-border)] focus:ring-[var(--platform-warm-border)]',
rose: 'focus:border-rose-200 focus:ring-rose-100',
emerald: 'focus:border-emerald-200 focus:ring-emerald-100',
sky: 'focus:border-sky-200 focus:ring-sky-100',
};
const PLATFORM_TEXT_FIELD_EDITOR_DARK_TONE_CLASS: Record<
PlatformTextFieldTone,
string
> = {
warm: 'focus:border-amber-300/40',
rose: 'focus:border-rose-300/40',
emerald: 'focus:border-emerald-400/40',
sky: 'focus:border-sky-400/40',
};
function buildPlatformTextFieldClassName({
surface,
size,
density,
tone,
multiline,
className,
}: {
surface: PlatformTextFieldSurface;
size: PlatformTextFieldSize;
density: PlatformTextFieldDensity;
tone: PlatformTextFieldTone;
multiline?: boolean;
className?: string;
}) {
const isEditorDark = surface === 'editorDark';
return [
'w-full platform-text-field outline-none transition disabled:cursor-not-allowed disabled:opacity-60',
isEditorDark
? 'platform-text-field--editor-dark border border-white/10 text-white'
: 'border border-[var(--platform-subpanel-border)] text-[var(--platform-text-strong)] focus:bg-white focus:ring-2',
isEditorDark
? PLATFORM_TEXT_FIELD_EDITOR_DARK_DENSITY_CLASS[density]
: PLATFORM_TEXT_FIELD_PLATFORM_DENSITY_CLASS[density],
PLATFORM_TEXT_FIELD_SIZE_CLASS[size],
isEditorDark
? PLATFORM_TEXT_FIELD_EDITOR_DARK_TONE_CLASS[tone]
: PLATFORM_TEXT_FIELD_PLATFORM_TONE_CLASS[tone],
multiline ? 'resize-none' : null,
className,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台白底表单输入框。
* 统一承接结果页 / 工作台白底圆角输入框和文本域的基础 chrome。
*/
export function PlatformTextField({
variant = 'input',
size = 'sm',
density = 'default',
tone = 'warm',
surface = 'platform',
className,
...fieldProps
}: PlatformTextFieldProps) {
const fieldClassName = buildPlatformTextFieldClassName({
surface,
size,
density,
tone,
multiline: variant === 'textarea',
className,
});
if (variant === 'textarea') {
return (
<textarea
{...(fieldProps as TextareaHTMLAttributes<HTMLTextAreaElement>)}
className={fieldClassName}
/>
);
}
return (
<input
{...(fieldProps as InputHTMLAttributes<HTMLInputElement>)}
className={fieldClassName}
/>
);
}
/**
* 平台白底下拉框。
* 与 PlatformTextField 共享输入 chrome,避免业务页为同一组表单控件重复拼样式。
*/
export function PlatformSelectField({
size = 'sm',
density = 'default',
tone = 'warm',
surface = 'platform',
className,
...selectProps
}: PlatformSelectFieldProps) {
const fieldClassName = buildPlatformTextFieldClassName({
surface,
size,
density,
tone,
className,
});
return <select {...selectProps} className={fieldClassName} />;
}
+101 -7
View File
@@ -7,31 +7,115 @@ export type {
PlatformActionButtonTone,
} from './PlatformActionButton';
export { PlatformActionButton } from './PlatformActionButton';
export { getPlatformActionButtonClassName } from './platformActionButtonModel';
export type { PlatformAsyncStatePanelProps } from './PlatformAsyncStatePanel';
export { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
export type { PlatformBadgeProps, PlatformBadgeTone } from './PlatformBadge';
export { PlatformBadge } from './PlatformBadge';
export type { PlatformEmptyStateProps } from './PlatformEmptyState';
export type {
PlatformEmptyStateProps,
PlatformEmptyStateSize,
PlatformEmptyStateSurface,
PlatformEmptyStateTone,
} from './PlatformEmptyState';
export { PlatformEmptyState } from './PlatformEmptyState';
export type {
PlatformFieldLabelProps,
PlatformFieldLabelVariant,
} from './PlatformFieldLabel';
export { PlatformFieldLabel } from './PlatformFieldLabel';
export type {
PlatformFilterToolbarProps,
PlatformFilterToolbarTabItem,
} from './PlatformFilterToolbar';
export { PlatformFilterToolbar } from './PlatformFilterToolbar';
export type {
PlatformIconBadgeProps,
PlatformIconBadgeShape,
PlatformIconBadgeSize,
PlatformIconBadgeTone,
} from './PlatformIconBadge';
export { PlatformIconBadge } from './PlatformIconBadge';
export type {
PlatformIconButtonProps,
PlatformIconButtonSize,
PlatformIconButtonVariant,
} from './PlatformIconButton';
export { PlatformIconButton } from './PlatformIconButton';
export type {
PlatformInfoBlockProps,
PlatformInfoBlockVariant,
} from './PlatformInfoBlock';
export { PlatformInfoBlock } from './PlatformInfoBlock';
export type { PlatformModalProps } from './PlatformModal';
export { PlatformModal } from './PlatformModal';
export type {
PlatformNavigableListItemAlign,
PlatformNavigableListItemProps,
} from './PlatformNavigableListItem';
export { PlatformNavigableListItem } from './PlatformNavigableListItem';
export type {
PlatformPillBadgeProps,
PlatformPillBadgeSize,
PlatformPillBadgeTone,
} from './PlatformPillBadge';
export { PlatformPillBadge } from './PlatformPillBadge';
export { getPlatformPillBadgeClassName } from './platformPillBadgeModel';
export type {
PlatformProgressBarProps,
PlatformProgressBarSize,
} from './PlatformProgressBar';
export { PlatformProgressBar } from './PlatformProgressBar';
export type {
PlatformRuntimeStatusShape,
PlatformRuntimeStatusSize,
PlatformRuntimeStatusSurface,
PlatformRuntimeStatusToastProps,
PlatformRuntimeStatusTone,
} from './PlatformRuntimeStatusToast';
export { PlatformRuntimeStatusToast } from './PlatformRuntimeStatusToast';
export type {
PlatformSegmentedTabItem,
PlatformSegmentedTabsColumns,
PlatformSegmentedTabsFrame,
PlatformSegmentedTabsGap,
PlatformSegmentedTabsLayout,
PlatformSegmentedTabsProps,
PlatformSegmentedTabsRadius,
PlatformSegmentedTabsSemantics,
PlatformSegmentedTabsSize,
PlatformSegmentedTabsSurface,
PlatformSegmentedTabsTone,
} from './PlatformSegmentedTabs';
export { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
export type {
PlatformStatGridColumns,
PlatformStatGridDensity,
PlatformStatGridItem,
PlatformStatGridOrder,
PlatformStatGridProps,
PlatformStatGridSurface,
PlatformStatGridTextAlign,
} from './PlatformStatGrid';
export { PlatformStatGrid } from './PlatformStatGrid';
export type { PlatformStatusProps, PlatformStatusTone } from './PlatformStatus';
export { PlatformStatus } from './PlatformStatus';
export type {
PlatformStatusMessageProps,
PlatformStatusMessageSize,
PlatformStatusMessageSurface,
PlatformStatusMessageTone,
} from './PlatformStatusMessage';
export { PlatformStatusMessage } from './PlatformStatusMessage';
export type { PlatformSubpanelProps } from './PlatformSubpanel';
export type {
PlatformSubpanelElement,
PlatformSubpanelPadding,
PlatformSubpanelProps,
PlatformSubpanelRadius,
PlatformSubpanelStaticElement,
PlatformSubpanelSurface,
PlatformSubpanelTitleVariant,
} from './PlatformSubpanel';
export { PlatformSubpanel } from './PlatformSubpanel';
export type {
PlatformSelectFieldProps,
@@ -42,10 +126,12 @@ export type {
BadgeProps,
BadgeTone,
ButtonProps,
CheckboxProps,
EmptyStateProps,
IconButtonProps,
IconButtonSize,
IconButtonVariant,
LabelProps,
ModalProps,
ProgressBarProps,
SegmentedTabItem,
@@ -53,26 +139,39 @@ export type {
SelectFieldProps,
SharedButtonSize,
SharedButtonVariant,
SkeletonProps,
StatusProps,
StatusTone,
SubpanelProps,
SwitchProps,
TableProps,
TextFieldProps,
} from './ui';
export {
Badge,
Button,
Checkbox,
Divider,
EmptyState,
IconButton,
Label,
Modal,
ProgressBar,
SegmentedTabs,
SelectField,
Skeleton,
Spinner,
Status,
Subpanel,
Switch,
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TextField,
} from './ui';
export type { BadgeProps as ShadcnBadgeProps } from './ui/badge';
@@ -110,16 +209,11 @@ export { Textarea } from './ui/textarea';
// existing application adapters while keeping the public API product-neutral.
export type {
ButtonProps as PlatformButtonProps,
ProgressBarProps as PlatformProgressBarProps,
SegmentedTabItem as PlatformSegmentedTabItem,
SegmentedTabsProps as PlatformSegmentedTabsProps,
SwitchProps as PlatformSwitchProps,
} from './ui';
export {
Button as PlatformButton,
Divider as PlatformDivider,
ProgressBar as PlatformProgressBar,
SegmentedTabs as PlatformSegmentedTabs,
Spinner as PlatformSpinner,
Switch as PlatformSwitch,
} from './ui';
@@ -0,0 +1,134 @@
export type PlatformActionButtonTone =
| 'primary'
| 'secondary'
| 'ghost'
| 'danger'
| 'success'
| 'warning'
| 'accent'
| 'accentSoft';
export type PlatformActionButtonSurface = 'platform' | 'profile' | 'editorDark';
export type PlatformActionButtonSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg';
export type PlatformActionButtonShape = 'default' | 'pill';
export type PlatformActionButtonAlign = 'center' | 'start';
const PLATFORM_ACTION_BUTTON_PLATFORM_TONE_CLASS: Record<
PlatformActionButtonTone,
string
> = {
primary: 'platform-button platform-button--primary',
secondary: 'platform-button platform-button--secondary',
ghost: 'platform-button platform-button--ghost',
danger: 'platform-button platform-button--danger',
success: 'platform-button platform-button--primary',
warning: 'platform-button platform-button--secondary',
accent:
'platform-action-button platform-action-button--accent border border-amber-200/70 bg-amber-200 text-slate-950 hover:bg-amber-100',
accentSoft:
'platform-action-button platform-action-button--accent-soft [border-color:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_24%,transparent)] [background:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,var(--platform-panel-fill)_90%)] [color:var(--platform-action-accent,#c7653d)] [box-shadow:0_0.55rem_1.2rem_color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,transparent)]',
};
const PLATFORM_ACTION_BUTTON_PROFILE_TONE_CLASS: Record<
PlatformActionButtonTone,
string
> = {
primary: 'platform-primary-button',
secondary: 'platform-button platform-button--secondary',
ghost: 'platform-button platform-button--ghost',
danger: 'platform-button platform-button--danger',
success: 'platform-primary-button',
warning: 'platform-button platform-button--secondary',
accent:
'platform-action-button platform-action-button--accent border border-amber-200/70 bg-amber-200 text-slate-950 hover:bg-amber-100',
accentSoft:
'platform-action-button platform-action-button--accent-soft [border-color:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_24%,transparent)] [background:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,var(--platform-panel-fill)_90%)] [color:var(--platform-action-accent,#c7653d)] [box-shadow:0_0.55rem_1.2rem_color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,transparent)]',
};
const PLATFORM_ACTION_BUTTON_EDITOR_DARK_TONE_CLASS: Record<
PlatformActionButtonTone,
string
> = {
primary:
'platform-action-button platform-action-button--editor-dark border border-sky-300/35 bg-sky-400 text-slate-950 hover:bg-sky-300',
secondary:
'platform-action-button platform-action-button--editor-dark border border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10 hover:text-white',
ghost:
'platform-action-button platform-action-button--editor-dark border border-white/10 bg-black/20 text-zinc-200 hover:text-white',
danger:
'platform-action-button platform-action-button--editor-dark border border-rose-300/25 bg-rose-500/10 text-rose-100 hover:bg-rose-500/15',
success:
'platform-action-button platform-action-button--editor-dark border border-emerald-300/35 bg-emerald-400 text-slate-950 hover:bg-emerald-300',
warning:
'platform-action-button platform-action-button--editor-dark border border-amber-300/30 bg-amber-500/20 text-amber-50 hover:bg-amber-500/25',
accent:
'platform-action-button platform-action-button--accent border border-amber-200/70 bg-amber-200 text-slate-950 hover:bg-amber-100',
accentSoft:
'platform-action-button platform-action-button--accent-soft [border-color:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_24%,transparent)] [background:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,rgba(0,0,0,0.72)_90%)] [color:var(--platform-action-accent,#f5c36a)] [box-shadow:0_0.55rem_1.2rem_color-mix(in_srgb,var(--platform-action-accent,#f5c36a)_10%,transparent)]',
};
const PLATFORM_ACTION_BUTTON_SIZE_CLASS: Record<
PlatformActionButtonSize,
string
> = {
xxs: 'px-3 py-1 text-[10px]',
xs: 'px-4 py-2 text-xs',
sm: 'px-4 py-2.5 text-sm',
md: 'px-4 py-3 text-sm',
lg: 'h-12 px-4 text-base',
};
const PLATFORM_ACTION_BUTTON_SHAPE_CLASS: Record<
PlatformActionButtonShape,
string
> = {
default: 'rounded-2xl',
pill: 'rounded-full',
};
const PLATFORM_ACTION_BUTTON_ALIGN_CLASS: Record<
PlatformActionButtonAlign,
string
> = {
center: 'justify-center',
start: 'justify-start text-left',
};
function getPlatformActionButtonToneClass(
surface: PlatformActionButtonSurface,
tone: PlatformActionButtonTone,
) {
if (surface === 'editorDark') {
return PLATFORM_ACTION_BUTTON_EDITOR_DARK_TONE_CLASS[tone];
}
return surface === 'profile'
? PLATFORM_ACTION_BUTTON_PROFILE_TONE_CLASS[tone]
: PLATFORM_ACTION_BUTTON_PLATFORM_TONE_CLASS[tone];
}
export function getPlatformActionButtonClassName({
surface = 'platform',
tone = 'primary',
size = 'sm',
shape = 'default',
align = 'center',
fullWidth = false,
}: {
surface?: PlatformActionButtonSurface;
tone?: PlatformActionButtonTone;
size?: PlatformActionButtonSize;
shape?: PlatformActionButtonShape;
align?: PlatformActionButtonAlign;
fullWidth?: boolean;
}) {
return [
getPlatformActionButtonToneClass(surface, tone),
PLATFORM_ACTION_BUTTON_SIZE_CLASS[size],
PLATFORM_ACTION_BUTTON_SHAPE_CLASS[shape],
PLATFORM_ACTION_BUTTON_ALIGN_CLASS[align],
fullWidth ? 'w-full' : null,
'inline-flex items-center gap-2 transition-colors font-black disabled:cursor-not-allowed disabled:opacity-60',
]
.filter(Boolean)
.join(' ');
}
@@ -0,0 +1,108 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import {
PlatformAsyncStatePanel,
PlatformFilterToolbar,
PlatformIconBadge,
PlatformInfoBlock,
PlatformNavigableListItem,
PlatformStatGrid,
} from './index';
describe('shared platform components', () => {
test('exposes display primitives with their visual variants', () => {
render(
<>
<PlatformInfoBlock label="来源" variant="compactRow">
稿
</PlatformInfoBlock>
<PlatformIconBadge icon="+" label="新增" size="lg" tone="success" />
<PlatformStatGrid
items={[{ label: '作品数', value: '24' }]}
columns="two"
order="labelFirst"
surface="plain"
/>
</>,
);
expect(screen.getByText('来源').className).toContain('text-xs');
expect(screen.getByLabelText('新增')).toBeTruthy();
expect(screen.getByLabelText('新增').className).toContain('h-12');
expect(screen.getByText('作品数').parentElement?.className).toContain(
'border',
);
});
test('preserves list row button semantics and callback slots', () => {
const onClick = vi.fn();
render(
<PlatformNavigableListItem
leading={<span></span>}
trailing={<span></span>}
onClick={onClick}
>
</PlatformNavigableListItem>,
);
const button = screen.getByRole('button', {
name: /.*.*/u,
});
expect(button.getAttribute('type')).toBe('button');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
test('keeps async state precedence explicit', () => {
const { rerender } = render(
<PlatformAsyncStatePanel
errorState={<span></span>}
isLoading
loadingState={<span></span>}
isEmpty
emptyState={<span></span>}
>
<span></span>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('错误')).toBeTruthy();
rerender(
<PlatformAsyncStatePanel isLoading loadingState={<span></span>}>
<span></span>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('加载中')).toBeTruthy();
});
test('forwards filter, tab and sort actions in both layout modes', () => {
const onOpenFilter = vi.fn();
const onTabChange = vi.fn();
const onToggleSort = vi.fn();
render(
<PlatformFilterToolbar
filterLabel="筛选"
filterCount={2}
tabItems={[{ id: 'all', label: '全部' }]}
activeTabId="all"
sortLabel="最新"
layout="mobile"
onOpenFilter={onOpenFilter}
onTabChange={onTabChange}
onToggleSort={onToggleSort}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onOpenFilter).toHaveBeenCalledTimes(1);
expect(onToggleSort).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '全部' }));
expect(onTabChange).toHaveBeenCalledWith('all');
});
});
@@ -0,0 +1,68 @@
export type PlatformPillBadgeTone =
| 'muted'
| 'neutral'
| 'neutralSolid'
| 'lightOverlay'
| 'success'
| 'warning'
| 'danger'
| 'cool'
| 'profile'
| 'profileAccent'
| 'darkSoft'
| 'darkNeutral'
| 'darkSky'
| 'darkEmerald'
| 'darkAmber'
| 'darkRose';
export type PlatformPillBadgeSize = 'xxs' | 'xs' | 'sm';
const PLATFORM_PILL_BADGE_TONE_CLASS: Record<PlatformPillBadgeTone, string> = {
muted:
'border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)]',
neutralSolid:
'border-transparent bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]',
lightOverlay: 'border-white/30 bg-white/24 text-current',
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
warning:
'border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]',
danger:
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
cool: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
profile: 'border-rose-100 bg-rose-50 text-zinc-600',
profileAccent: 'border-rose-100 bg-rose-50 text-[#ff4056]',
darkSoft: 'border-white/10 bg-white/6 text-zinc-100',
darkNeutral: 'border-white/10 bg-black/20 text-zinc-200',
darkSky: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
darkEmerald: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
darkAmber: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
darkRose: 'border-rose-300/20 bg-rose-500/10 text-rose-100',
};
const PLATFORM_PILL_BADGE_SIZE_CLASS: Record<PlatformPillBadgeSize, string> = {
xxs: 'px-2.5 py-1 text-[10px]',
xs: 'px-2.5 py-0.5 text-[11px]',
sm: 'px-3 py-1 text-xs',
};
export function getPlatformPillBadgeClassName({
tone = 'neutral',
size = 'sm',
className,
}: {
tone?: PlatformPillBadgeTone;
size?: PlatformPillBadgeSize;
className?: string;
} = {}) {
return [
'inline-flex items-center gap-1 rounded-full border font-black',
PLATFORM_PILL_BADGE_SIZE_CLASS[size],
PLATFORM_PILL_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ');
}
+429 -1
View File
@@ -25,6 +25,140 @@
box-sizing: border-box;
}
.genarrative-ui-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--platform-text-strong, #3d1f10);
font-size: 0.8125rem;
font-weight: 700;
line-height: 1.25;
cursor: pointer;
}
.genarrative-ui-checkbox {
display: inline-grid;
width: 1.125rem;
height: 1.125rem;
flex: 0 0 auto;
place-items: center;
appearance: none;
border: 1px solid var(--platform-neutral-border, rgba(226, 203, 184, 0.88));
border-radius: 0.35rem;
background: var(--platform-button-secondary-fill, rgba(255, 253, 250, 0.88));
color: var(--platform-button-primary-text, #fffaf5);
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.genarrative-ui-checkbox::after {
content: '✓';
font-size: 0.75rem;
font-weight: 900;
line-height: 1;
opacity: 0;
transform: scale(0.7);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.genarrative-ui-checkbox:checked {
border-color: var(--platform-button-primary-border, rgba(182, 98, 63, 0.32));
background: var(--platform-button-primary-fill, #c7653d);
}
.genarrative-ui-checkbox:checked::after {
opacity: 1;
transform: scale(1);
}
.genarrative-ui-checkbox:hover:not(:disabled) {
transform: translateY(-1px);
}
.genarrative-ui-checkbox:active:not(:disabled) {
transform: translateY(1px);
}
.genarrative-ui-checkbox:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--genarrative-ui-focus-ring);
}
.genarrative-ui-checkbox:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.genarrative-ui-skeleton {
min-height: 1rem;
border-radius: 0.55rem;
background: linear-gradient(
100deg,
rgba(226, 203, 184, 0.42) 20%,
rgba(255, 253, 250, 0.92) 40%,
rgba(226, 203, 184, 0.42) 60%
);
background-size: 200% 100%;
animation: genarrative-ui-skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes genarrative-ui-skeleton-shimmer {
to {
background-position: -200% 0;
}
}
.genarrative-ui-table__container {
width: 100%;
overflow-x: auto;
border: 1px solid var(--platform-subpanel-border, rgba(226, 203, 184, 0.88));
border-radius: 0.9rem;
background: var(--platform-subpanel-fill, rgba(255, 253, 250, 0.72));
}
.genarrative-ui-table {
width: 100%;
border-collapse: collapse;
color: var(--platform-text-strong, #3d1f10);
font-size: 0.8125rem;
text-align: left;
}
.genarrative-ui-table__head,
.genarrative-ui-table__cell {
padding: 0.7rem 0.85rem;
white-space: nowrap;
}
.genarrative-ui-table__header {
background: rgba(226, 203, 184, 0.2);
color: var(--platform-text-soft, #755a49);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.genarrative-ui-table__row + .genarrative-ui-table__row {
border-top: 1px solid var(--platform-line-soft, rgba(226, 203, 184, 0.72));
}
.genarrative-ui-table__body .genarrative-ui-table__row:hover {
background: rgba(255, 255, 255, 0.58);
}
.genarrative-ui-table__caption {
padding: 0.7rem 0.85rem;
color: var(--platform-text-soft, #755a49);
font-size: 0.75rem;
text-align: left;
}
.genarrative-ui-button,
.genarrative-ui-icon-button,
.genarrative-ui-tabs__item,
@@ -190,6 +324,299 @@
box-shadow: 0 0 0 3px var(--genarrative-ui-focus-ring);
}
/* Platform-prefixed adapters share these small chrome primitives with the
* web host. Keeping the selectors in the package means Tauri consumers do not
* need to import the website's large application stylesheet. */
.platform-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
min-height: 2.9rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1rem;
padding: 0.7rem 1rem;
font-size: 0.9375rem;
font-weight: 600;
transition:
transform 180ms ease,
border-color 180ms ease,
background-color 180ms ease,
color 180ms ease,
opacity 180ms ease,
box-shadow 180ms ease;
}
.platform-button:hover {
transform: translateY(-1px);
}
.platform-button:active {
transform: translateY(0);
}
.platform-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.platform-button--primary {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
box-shadow: 0 16px 34px rgba(182, 98, 63, 0.18);
}
.platform-button--secondary {
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
}
.platform-button--ghost {
background: var(--platform-button-ghost-fill);
color: var(--platform-button-ghost-text);
}
.platform-button--danger {
border-color: var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
.platform-icon-button {
display: inline-flex;
width: 2.5rem;
height: 2.5rem;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-icon-border);
border-radius: 9999px;
background: var(--platform-icon-fill);
color: var(--platform-icon-text);
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
transform 180ms ease;
}
.platform-icon-button:hover {
transform: translateY(-1px);
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-active-fill);
color: var(--platform-text-strong);
}
.platform-subpanel {
border: 1px solid var(--platform-subpanel-border);
background: var(--platform-subpanel-fill);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.platform-primary-button {
border: 1px solid var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
box-shadow: var(--platform-profile-action-shadow);
transition:
transform 180ms ease,
filter 180ms ease,
opacity 180ms ease;
}
.platform-primary-button:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.02);
}
.platform-progress-track {
border: 1px solid var(--platform-track-border);
background: var(--platform-track-fill);
}
/* Filter toolbar chrome is part of the shared platform surface so the
* canonical toolbar renders the same in the web app and standalone hosts. */
.platform-category-filter-row {
display: flex;
min-width: 0;
align-items: center;
gap: 0.65rem;
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
border-radius: 1.25rem;
background: var(--platform-subpanel-fill);
padding: 0.55rem 0.65rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.platform-category-filter-button,
.platform-category-sort-button,
.platform-category-chip {
cursor: pointer;
transition:
transform 160ms ease,
border-color 160ms ease,
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.platform-category-filter-button {
display: inline-flex;
position: relative;
min-height: 2.25rem;
flex: 0 0 auto;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--platform-cool-border);
border-radius: 9999px;
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
padding: 0 0.75rem;
font-size: 0.86rem;
font-weight: 900;
white-space: nowrap;
}
.platform-category-filter-button__count {
display: inline-flex;
min-width: 1.18rem;
height: 1.18rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: var(--platform-warm-text);
color: var(--platform-button-primary-text);
padding: 0 0.28rem;
font-size: 0.72rem;
line-height: 1;
}
.platform-category-filter-divider {
width: 1px;
height: 1.85rem;
flex: 0 0 auto;
background: var(--platform-line-soft);
}
.platform-category-chip-scroll {
display: flex;
min-width: 0;
flex: 1 1 auto;
gap: 0.55rem;
overflow-x: auto;
padding-right: 0.1rem;
}
.platform-category-chip {
display: inline-flex;
min-height: 2.25rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.78rem;
background: rgba(255, 255, 255, 0.04);
color: var(--platform-text-base);
padding: 0 0.92rem;
font-size: 0.88rem;
font-weight: 800;
white-space: nowrap;
}
.platform-category-chip--active {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
}
.platform-category-sort-button {
display: inline-flex;
width: fit-content;
min-height: 1.75rem;
align-items: center;
gap: 0.2rem;
border: 0;
background: transparent;
color: var(--platform-text-soft);
padding: 0 0.2rem;
font-size: 0.88rem;
font-weight: 800;
}
.platform-category-filter-button:hover,
.platform-category-chip:hover,
.platform-category-sort-button:hover {
transform: translateY(-1px);
filter: brightness(1.03);
}
.platform-category-filter-button:active,
.platform-category-chip:active,
.platform-category-sort-button:active {
transform: translateY(1px);
filter: brightness(0.96);
transition-duration: 80ms;
}
.platform-category-filter-button:focus-visible,
.platform-category-chip:focus-visible,
.platform-category-sort-button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.platform-navigable-list-item {
cursor: pointer;
border-radius: 0.9rem;
transition:
transform 160ms ease,
background-color 160ms ease,
box-shadow 160ms ease,
opacity 160ms ease;
}
.platform-navigable-list-item:hover:not(:disabled) {
background: var(--platform-button-ghost-fill);
}
.platform-navigable-list-item:active:not(:disabled) {
transform: translateY(1px);
box-shadow: inset 0 2px 4px rgba(50, 22, 8, 0.1);
transition-duration: 80ms;
}
.platform-navigable-list-item:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.platform-navigable-list-item:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
@media (max-width: 480px) {
.platform-category-filter-row {
border-radius: 1rem;
gap: 0.5rem;
padding: 0.46rem 0.5rem;
}
.platform-category-filter-button {
min-height: 2.05rem;
padding-inline: 0.62rem;
font-size: 0.8rem;
}
.platform-category-chip {
min-height: 2.05rem;
border-radius: 0.68rem;
padding-inline: 0.78rem;
font-size: 0.8rem;
}
}
.genarrative-ui-button:disabled,
.genarrative-ui-icon-button:disabled,
.genarrative-ui-icon-button[aria-disabled='true'],
@@ -809,7 +1236,8 @@ textarea.genarrative-ui-text-field__control {
.genarrative-ui-tabs__item,
.genarrative-ui-switch,
.genarrative-ui-progress__fill,
.genarrative-ui-spinner {
.genarrative-ui-spinner,
.genarrative-ui-skeleton {
transition: none;
animation: none;
}
@@ -6,17 +6,27 @@ import { describe, expect, test, vi } from 'vitest';
import {
Badge,
Button,
Checkbox,
EmptyState,
IconButton,
Label,
Modal,
PlatformActionButton,
PlatformIconButton,
ProgressBar,
SegmentedTabs,
SelectField,
Skeleton,
Status,
Subpanel,
Switch,
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
TextField,
} from './index';
@@ -103,6 +113,40 @@ describe('shared UI components', () => {
).toBe('true');
});
test('provides semantic label, checkbox, skeleton and table primitives', () => {
render(
<div className="genarrative-ui">
<div>
<Checkbox id="autosave" defaultChecked />
<Label htmlFor="autosave"></Label>
</div>
<Skeleton aria-label="读取中" className="h-3 w-20" />
<Table>
<TableCaption></TableCaption>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</div>,
);
expect(screen.getByLabelText('自动保存')).toHaveProperty('checked', true);
expect(screen.getByLabelText('读取中').getAttribute('data-slot')).toBe(
'skeleton',
);
expect(screen.getByRole('table', { name: '最近项目' })).toBeTruthy();
expect(screen.getByRole('cell', { name: '珊瑚岛' })).toBeTruthy();
});
test('closes modal on escape and backdrop click', () => {
const onClose = vi.fn();
render(

Some files were not shown because too many files have changed in this diff Show More