补齐共享 UI 组件库与展示页交互
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Native shell tests (pull_request) Successful in 12m36s

- 新增 shadcn canonical UI 源码、共享样式与组件导出
- 新增 /components 共享组件展示页及路由测试
- 补齐平台组件筛选、排序、上传预览和异步状态交互
- 同步网站与客户端构建别名、依赖和项目文档
This commit is contained in:
2026-08-27 15:12:50 +08:00
parent d41aaddd0e
commit bbd895d7c2
48 changed files with 5032 additions and 12 deletions
@@ -1,5 +1,6 @@
@import 'tailwindcss';
@source '../../../packages/shared/src/components';
@import '@genarrative/shared/styles.css';
@import '../../../packages/shared/src/theme.css';
:root {
+16 -1
View File
@@ -22,7 +22,22 @@
],
"@genarrative/image-canvas-react": [
"packages/image-canvas-react/src/index.ts"
]
],
"@genarrative/shared": ["packages/shared/src/index.ts"],
"@genarrative/shared/components": [
"packages/shared/src/components/index.ts"
],
"@genarrative/shared/components/account": [
"packages/shared/src/components/account.ts"
],
"@genarrative/shared/components/ui": [
"packages/shared/src/components/ui/index.ts"
],
"@genarrative/shared/components/ui/*": [
"packages/shared/src/components/ui/*"
],
"@genarrative/shared/lib/*": ["packages/shared/src/lib/*"],
"@genarrative/shared/lib": ["packages/shared/src/lib/index.ts"]
}
},
"include": ["src", "vite.config.ts"]
+40
View File
@@ -105,6 +105,46 @@ export default defineConfig({
'packages/image-canvas-react/src/index.ts',
),
},
{
find: '@genarrative/shared/styles.css',
replacement: resolve(
repoRoot,
'packages/shared/src/components/styles.css',
),
},
{
find: '@genarrative/shared/theme.css',
replacement: resolve(repoRoot, 'packages/shared/src/theme.css'),
},
{
find: '@genarrative/shared/components/ui',
replacement: resolve(
repoRoot,
'packages/shared/src/components/ui/index.ts',
),
},
{
find: '@genarrative/shared/lib',
replacement: resolve(repoRoot, 'packages/shared/src/lib'),
},
{
find: '@genarrative/shared/components/account',
replacement: resolve(
repoRoot,
'packages/shared/src/components/account.ts',
),
},
{
find: '@genarrative/shared/components',
replacement: resolve(
repoRoot,
'packages/shared/src/components/index.ts',
),
},
{
find: '@genarrative/shared',
replacement: resolve(repoRoot, 'packages/shared/src/index.ts'),
},
],
dedupe: ['react', 'react-dom'],
},
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@genarrative/shared/components",
"ui": "@genarrative/shared/components/ui",
"utils": "@genarrative/shared/lib/utils",
"lib": "@genarrative/shared/lib"
}
}
+1
View File
@@ -30,6 +30,7 @@
## 图片画布与媒体
- [共享基础组件库与展示页](./technical/【前端架构】共享基础组件库与展示页-2026-08-26.md):网站与客户端复用的无业务 UI chrome、样式边界和 `/components` 展示页。
- [客户端素材创作无限画布阶段一合同](./technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md)
- [图片画布结构化持久化与迁移回滚](./【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md)
- [编辑器生成结果原子提交与幂等重放](./technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md)
@@ -7747,3 +7747,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- DirectProject 的 Codex cwd 固定为真实 `game/` 目录时,原生文件工具和 patch 必须使用 cwd 相对路径(`index.html``style.css``game.js`);`game/...` 仅用于 AGC manifest、回执和客户端投影,不能作为 cwd 内原生 patch 路径,以避免 `writing outside of the project`
- 直连 Runtime 已取得 Developer Key 时,资源编辑的 `remote_credentials` 是该操作的完整身份边界;其中冻结平台快照为空表示 Developer 模式,禁止再从进程全局 GUI 登录态补回账号快照。平台账号模式仍只使用同一组凭据捕获的快照。
- 回归覆盖 Direct 系统提示路径合同和 Developer Key / GUI 快照隔离;未触碰用户项目 `.agent` 锁、账本或凭据。
## 2026-08-26 网站与客户端共享基础组件库
- 决策:无业务 UI chrome 统一放入 `packages/shared/src/components`,通过 `@genarrative/shared/components` 导出;组件只接受 React props、原生 DOM props、短文案、图标节点和回调,不读取账号、钱包、请求客户端、store、Tauri API 或业务实体。
- 样式边界:共享样式位于 `packages/shared/src/components/styles.css`,选择器使用 `.genarrative-ui-*` 前缀并消费 `packages/shared/src/theme.css``--platform-*` token。网站与客户端各自保留页面壳、路由、业务和玩法视觉,不导入网站总 CSS;账户 DTO 适配器只从 `@genarrative/shared/components/account` 单独导出,不进入通用组件 barrel。
- 展示页:网站 `/components``/design-system` 兼容别名)展示所有公共组件的变体、状态、Token 和移动端布局,使用本地静态示例,不经过账号 Gate 或调用业务 API。
## 2026-08-27 共享组件采用 shadcn open-code 渐进迁移
- 决策:共享 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 等具体组件迁移时按需加入。
@@ -0,0 +1,40 @@
# 共享基础组件库与展示页
更新时间:`2026-08-26`
## 目标
网站与 Tauri 客户端共享无业务依赖的基础 UI chrome,同时保留各自的页面布局、路由、账号/钱包业务和玩法视觉。共享层只接受 React props、原生 DOM props、短文案、图标节点和回调,不读取请求客户端、store、Tauri API 或业务实体。
## 包边界
组件位于 `packages/shared/src/components`,由 `@genarrative/shared` 根入口和 `@genarrative/shared/components` 子路径稳定导出。新增组件按 shadcn 的 open-code 方式归档在 `components/ui`,源码、变体和组合点归项目所有;`packages/shared/src/lib/utils.ts` 提供统一的 `cn` 工具。当前迁移阶段仍复用 `packages/shared/src/components/styles.css``.genarrative-ui-*` 选择器和 `packages/shared/src/theme.css``--platform-*` token,避免破坏既有平台视觉契约。
已有账户 DTO 适配组件单独由 `@genarrative/shared/components/account` 导出;它们不进入本样式库的通用组件 barrel,也不从该入口转出。
当前基础组件:
- `Button``IconButton`
- `TextField``SelectField`
- `Subpanel``Modal`
- `Status``EmptyState``Badge`
- `ProgressBar``SegmentedTabs``Switch`
- `Spinner``Divider`
迁移约定:`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>` 与表单事件合同。后续组件按使用面逐个迁移。
同时提供 `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 定位源码,不允许覆盖现有业务组件。
## 展示页
网站 `/components`(兼容别名 `/design-system`)是共享组件展示页,不经过账号 Gate。页面按“基础组件”“平台通用组件”“Token”“状态”分区,覆盖公共组件的主要变体、交互态、筛选/标签/媒体/上传/异步状态/指标列表等平台 chrome 和移动端布局;展示数据均为本地静态示例,不调用 API。平台组件分区中的筛选按示例素材状态过滤结果,排序按最近使用或名称重排结果,并在筛选按钮、排序按钮和独立筛选面板之间保持同一份本地状态。展示页可用于网站与客户端接入前的视觉回归和人工验收。平台通用组件示例来自 `src/components/common`,只接入不读取请求、store 或业务实体的 chrome,不把账号、发布和编辑器业务流程嵌入展示页。
## 验收
- 组件具备原生语义、键盘焦点、禁用态和可访问名称。
- 按钮、图标按钮、分段标签和开关具备明确按压态;展示页中的可操作示例在点击后提供可见状态反馈,加载态按钮保持禁用。
- `Modal` 支持 ESC、遮罩点击、可选 portal 和移动端底部面板布局。
- 组件样式不依赖网站总 CSS;Tauri 只需共享主题、共享组件样式和自己的壳层样式。
- 修改后运行 `npm run typecheck``npm run check:encoding``npm run test -- src/routing/activeAppRoutes.test.ts``git diff --check`
+961 -1
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -3,9 +3,27 @@
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts",
"./components/ui": "./src/components/ui/index.ts",
"./components/ui/button": "./src/components/ui/button.tsx",
"./components/ui/*": "./src/components/ui/*.tsx",
"./components/account": "./src/components/account.ts",
"./lib": "./src/lib/index.ts",
"./lib/utils": "./src/lib/utils.ts",
"./styles.css": "./src/components/styles.css",
"./theme.css": "./src/theme.css"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.546.0",
"qrcode": "^1.5.4",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
@@ -16,6 +34,7 @@
"vitest": "^0.34.6"
},
"peerDependencies": {
"react": "^19.0.0"
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
@@ -0,0 +1,131 @@
import type {
ButtonHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from 'react';
import { Button, type ButtonProps, type SharedButtonSize } from './ui';
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';
type PlatformActionButtonBaseProps = {
children?: ReactNode;
tone?: PlatformActionButtonTone;
surface?: PlatformActionButtonSurface;
size?: PlatformActionButtonSize;
shape?: PlatformActionButtonShape;
align?: PlatformActionButtonAlign;
fullWidth?: boolean;
};
type PlatformActionButtonButtonProps = Omit<
ButtonProps,
'children' | 'variant' | 'size' | 'fullWidth'
> &
PlatformActionButtonBaseProps & {
asChild?: false;
};
type PlatformActionButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'children'
> &
PlatformActionButtonBaseProps & {
asChild: 'label';
};
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.
*/
export function PlatformActionButton({
tone = 'primary',
surface = 'platform',
size = 'sm',
shape = 'default',
align = 'center',
fullWidth = false,
className,
children,
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,
]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(props as LabelHTMLAttributes<HTMLLabelElement>)}
className={actionClassName}
>
{children}
</label>
);
}
const { type = 'button', ...buttonProps } =
props as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<Button
{...buttonProps}
type={type}
fullWidth={fullWidth}
size={resolvedSize}
variant={toneVariant[tone]}
className={modifierClassName}
>
{children}
</Button>
);
}
@@ -0,0 +1,5 @@
export type {
BadgeProps as PlatformBadgeProps,
BadgeTone as PlatformBadgeTone,
} from './ui';
export { Badge as PlatformBadge } from './ui';
@@ -0,0 +1,2 @@
export type { EmptyStateProps as PlatformEmptyStateProps } from './ui';
export { EmptyState as PlatformEmptyState } from './ui';
@@ -0,0 +1,151 @@
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent,
LabelHTMLAttributes,
ReactNode,
Ref,
} from 'react';
import { forwardRef } from 'react';
import { IconButton, type IconButtonProps, type IconButtonSize } from './ui';
export type PlatformIconButtonVariant =
| 'platformIcon'
| 'surfaceFloating'
| 'darkMini';
export type PlatformIconButtonSize = IconButtonSize;
type PlatformIconButtonBaseProps = {
label: string;
icon: ReactNode;
children?: ReactNode;
variant?: PlatformIconButtonVariant;
};
type PlatformIconButtonButtonProps = Omit<
IconButtonProps,
'variant' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild?: false;
};
type PlatformIconButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'aria-label' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild: 'label';
};
type PlatformIconButtonSpanButtonProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'aria-label' | 'children' | 'role'
> &
PlatformIconButtonBaseProps & {
asChild: 'spanButton';
disabled?: boolean;
};
export type PlatformIconButtonProps =
| PlatformIconButtonButtonProps
| 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',
};
export const PlatformIconButton = forwardRef<
HTMLButtonElement | HTMLLabelElement | HTMLSpanElement,
PlatformIconButtonProps
>(function PlatformIconButton(
{
label,
icon,
children,
variant = 'platformIcon',
title,
className,
asChild,
...actionProps
},
ref,
) {
const resolvedVariant = variant === 'darkMini' ? 'quiet' : 'default';
const actionClassName = [
'genarrative-ui-icon-button',
`genarrative-ui-icon-button--${resolvedVariant}`,
variantClassName[variant],
className,
]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
ref={ref as Ref<HTMLLabelElement>}
title={title}
className={`${actionClassName} genarrative-ui-icon-button--md`}
>
<span className="genarrative-ui-visually-hidden">{label}</span>
{icon}
{children}
</label>
);
}
if (asChild === 'spanButton') {
const { disabled, onClick, onKeyDown, tabIndex, ...spanProps } =
actionProps as HTMLAttributes<HTMLSpanElement> & {
disabled?: boolean;
};
const handleKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.currentTarget.click();
}
};
return (
<span
{...spanProps}
ref={ref as Ref<HTMLSpanElement>}
aria-disabled={disabled || undefined}
aria-label={label}
className={`${actionClassName} genarrative-ui-icon-button--md`}
onClick={disabled ? undefined : onClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={disabled ? -1 : (tabIndex ?? 0)}
title={title ?? label}
>
{icon}
{children}
</span>
);
}
const { type = 'button', ...buttonProps } =
actionProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<IconButton
{...buttonProps}
ref={ref as Ref<HTMLButtonElement>}
type={type}
label={label}
icon={icon}
children={children}
variant={resolvedVariant}
className={actionClassName}
title={title ?? label}
/>
);
});
@@ -0,0 +1,2 @@
export type { ModalProps as PlatformModalProps } from './ui';
export { Modal as PlatformModal } from './ui';
@@ -0,0 +1,5 @@
export type {
BadgeProps as PlatformPillBadgeProps,
BadgeTone as PlatformPillBadgeTone,
} from './ui';
export { Badge as PlatformPillBadge } from './ui';
@@ -0,0 +1,5 @@
export type {
StatusProps as PlatformStatusProps,
StatusTone as PlatformStatusTone,
} from './ui';
export { Status as PlatformStatus } from './ui';
@@ -0,0 +1,5 @@
export type {
StatusProps as PlatformStatusMessageProps,
StatusTone as PlatformStatusMessageTone,
} from './ui';
export { Status as PlatformStatusMessage } from './ui';
@@ -0,0 +1,2 @@
export type { SubpanelProps as PlatformSubpanelProps } from './ui';
export { Subpanel as PlatformSubpanel } from './ui';
@@ -0,0 +1,8 @@
export type {
SelectFieldProps as PlatformSelectFieldProps,
TextFieldProps as PlatformTextFieldProps,
} from './ui';
export {
SelectField as PlatformSelectField,
TextField as PlatformTextField,
} from './ui';
@@ -0,0 +1,5 @@
// Account surfaces are kept in a separate barrel because they accept account
// DTOs. They are shared adapters, not part of the business-neutral UI chrome.
export * from './PlatformMudPointWalletEntry';
export * from './PlatformProfileRechargeModal';
export * from './PlatformProfileWalletLedgerModal';
+125
View File
@@ -0,0 +1,125 @@
export type {
PlatformActionButtonAlign,
PlatformActionButtonProps,
PlatformActionButtonShape,
PlatformActionButtonSize,
PlatformActionButtonSurface,
PlatformActionButtonTone,
} from './PlatformActionButton';
export { PlatformActionButton } from './PlatformActionButton';
export type { PlatformBadgeProps, PlatformBadgeTone } from './PlatformBadge';
export { PlatformBadge } from './PlatformBadge';
export type { PlatformEmptyStateProps } from './PlatformEmptyState';
export { PlatformEmptyState } from './PlatformEmptyState';
export type {
PlatformIconButtonProps,
PlatformIconButtonSize,
PlatformIconButtonVariant,
} from './PlatformIconButton';
export { PlatformIconButton } from './PlatformIconButton';
export type { PlatformModalProps } from './PlatformModal';
export { PlatformModal } from './PlatformModal';
export type {
PlatformPillBadgeProps,
PlatformPillBadgeTone,
} from './PlatformPillBadge';
export { PlatformPillBadge } from './PlatformPillBadge';
export type { PlatformStatusProps, PlatformStatusTone } from './PlatformStatus';
export { PlatformStatus } from './PlatformStatus';
export type {
PlatformStatusMessageProps,
PlatformStatusMessageTone,
} from './PlatformStatusMessage';
export { PlatformStatusMessage } from './PlatformStatusMessage';
export type { PlatformSubpanelProps } from './PlatformSubpanel';
export { PlatformSubpanel } from './PlatformSubpanel';
export type {
PlatformSelectFieldProps,
PlatformTextFieldProps,
} from './PlatformTextField';
export { PlatformSelectField, PlatformTextField } from './PlatformTextField';
export type {
BadgeProps,
BadgeTone,
ButtonProps,
EmptyStateProps,
IconButtonProps,
IconButtonSize,
IconButtonVariant,
ModalProps,
ProgressBarProps,
SegmentedTabItem,
SegmentedTabsProps,
SelectFieldProps,
SharedButtonSize,
SharedButtonVariant,
StatusProps,
StatusTone,
SubpanelProps,
SwitchProps,
TextFieldProps,
} from './ui';
export {
Badge,
Button,
Divider,
EmptyState,
IconButton,
Modal,
ProgressBar,
SegmentedTabs,
SelectField,
Spinner,
Status,
Subpanel,
Switch,
TextField,
} from './ui';
export type { BadgeProps as ShadcnBadgeProps } from './ui/badge';
export { badgeVariants, Badge as ShadcnBadge } from './ui/badge';
export type {
ButtonProps as ShadcnButtonProps,
ShadcnButtonSize,
ShadcnButtonVariant,
} from './ui/button';
export { buttonVariants, Button as ShadcnButton } from './ui/button';
export type { CardProps } from './ui/card';
export { Card, CardActions, CardHeader, CardTitle } from './ui/card';
export type { SharedDialogProps } from './ui/dialog';
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
export type { InputProps } from './ui/input';
export { Input, inputVariants } from './ui/input';
export type { SharedSwitchProps } from './ui/switch';
export { SwitchRoot, SwitchThumb } from './ui/switch';
export { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
export type { TextareaProps } from './ui/textarea';
export { Textarea } from './ui/textarea';
// Additional platform-prefixed aliases make the package discoverable beside
// 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';
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import {
Badge,
Button,
EmptyState,
IconButton,
Modal,
PlatformActionButton,
PlatformIconButton,
ProgressBar,
SegmentedTabs,
SelectField,
Status,
Subpanel,
Switch,
TextField,
} from './index';
describe('shared UI components', () => {
test('keeps native button semantics and states', () => {
const onClick = vi.fn();
render(
<div className="genarrative-ui">
<Button onClick={onClick}></Button>
<Button loading></Button>
<IconButton label="关闭" icon="×" />
</div>,
);
const button = screen.getByRole('button', { name: '保存' });
expect(button.getAttribute('type')).toBe('button');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
expect(
(screen.getByRole('button', { name: '稍等' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
screen.getByRole('button', { name: '关闭' }).getAttribute('title'),
).toBe('关闭');
});
test('connects field labels and descriptions to native controls', () => {
render(
<div className="genarrative-ui">
<TextField label="名称" hint="最多 20 字" error="名称已存在" />
<TextField label="描述" multiline />
<SelectField label="主题" hint="选择颜色">
<option value="warm"></option>
</SelectField>
</div>,
);
const input = screen.getByLabelText('名称');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.getAttribute('aria-describedby')).toContain('hint');
expect(screen.getByLabelText('描述').tagName).toBe('TEXTAREA');
expect(screen.getByLabelText('主题').tagName).toBe('SELECT');
expect(screen.getByRole('alert').textContent).toContain('名称已存在');
});
test('renders slots, selected tabs and semantic status content', () => {
const onChange = vi.fn();
render(
<div className="genarrative-ui">
<Subpanel title="设置" actions={<Badge></Badge>}>
</Subpanel>
<Status tone="success" icon="✓">
</Status>
<EmptyState title="暂无内容" />
<ProgressBar value={140} label="进度" />
<SegmentedTabs
items={[
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
]}
activeId="a"
onChange={onChange}
label="分区"
/>
<Switch checked label="启用" />
</div>,
);
expect(screen.getByRole('heading', { name: '设置' })).toBeTruthy();
expect(screen.getByRole('status').textContent).toContain('完成');
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe(
'100',
);
expect(
screen.getByRole('tab', { name: 'A' }).getAttribute('aria-selected'),
).toBe('true');
fireEvent.click(screen.getByRole('tab', { name: 'B' }));
expect(onChange).toHaveBeenCalledWith('b');
expect(
screen.getByRole('switch', { name: '启用' }).getAttribute('aria-checked'),
).toBe('true');
});
test('closes modal on escape and backdrop click', () => {
const onClose = vi.fn();
render(
<Modal open title="确认" onClose={onClose}>
</Modal>,
);
expect(screen.getByRole('dialog', { name: '确认' })).toBeTruthy();
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.mouseDown(
document.querySelector('.genarrative-ui-modal__backdrop')!,
);
expect(onClose).toHaveBeenCalledTimes(2);
});
test('can keep modal open when backdrop and escape closing are disabled', () => {
const onClose = vi.fn();
render(
<Modal
open
title="锁定"
onClose={onClose}
closeOnBackdrop={false}
closeOnEscape={false}
>
</Modal>,
);
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.mouseDown(
document.querySelector('.genarrative-ui-modal__backdrop')!,
);
expect(onClose).not.toHaveBeenCalled();
});
test('keeps platform wrappers generic and supports label/span hosts', () => {
const onClick = vi.fn();
render(
<div className="genarrative-ui">
<PlatformActionButton asChild="label" htmlFor="upload" tone="accent">
</PlatformActionButton>
<PlatformIconButton
asChild="spanButton"
label="打开"
icon="+"
onClick={onClick}
/>
</div>,
);
expect(screen.getByText('上传').closest('label')?.htmlFor).toBe('upload');
const spanButton = screen.getByRole('button', { name: '打开' });
fireEvent.keyDown(spanButton, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
/* eslint-disable react-refresh/only-export-components -- shadcn co-locates variants with their source */
import { cva, type VariantProps } from 'class-variance-authority';
import { type HTMLAttributes, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export const badgeVariants = cva('genarrative-ui-badge', {
variants: {
variant: {
neutral: 'genarrative-ui-badge--neutral',
accent: 'genarrative-ui-badge--accent',
success: 'genarrative-ui-badge--success',
warning: 'genarrative-ui-badge--warning',
danger: 'genarrative-ui-badge--danger',
info: 'genarrative-ui-badge--info',
},
size: {
sm: 'genarrative-ui-badge--sm',
md: 'genarrative-ui-badge--md',
},
},
defaultVariants: {
variant: 'neutral',
size: 'sm',
},
});
export type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> &
VariantProps<typeof badgeVariants> & {
children?: ReactNode;
icon?: ReactNode;
};
export function Badge({
children,
icon,
variant,
size,
className,
...props
}: BadgeProps) {
return (
<span
{...props}
data-slot="badge"
className={cn(badgeVariants({ variant, size }), className)}
>
{icon ? <span aria-hidden="true">{icon}</span> : null}
{children}
</span>
);
}

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