补齐共享 UI 组件库与展示页交互
- 新增 shadcn canonical UI 源码、共享样式与组件导出 - 新增 /components 共享组件展示页及路由测试 - 补齐平台组件筛选、排序、上传预览和异步状态交互 - 同步网站与客户端构建别名、依赖和项目文档
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@source '../../../packages/shared/src/components';
|
||||
@import '@genarrative/shared/styles.css';
|
||||
@import '../../../packages/shared/src/theme.css';
|
||||
|
||||
:root {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 用 CVA,Dialog/Tabs/Switch 按需使用 Radix,Tailwind 4 继续消费现有 `--platform-*` token 与 `.genarrative-ui-*` 样式。原生 Select 暂不迁移。
|
||||
- 边界:网站与 Tauri WebView 可消费该 DOM 源码,移动端 React Native 不导入这套组件与 CSS;Radix 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` 使用 CVA,Dialog/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`。
|
||||
Generated
+961
-1
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user