init: @ui-arch 私人组件库 monorepo
- core/table-core: Headless 表格内核(排序/筛选/分页/选择插件 + 冒烟 9 项) - core/frame: signal/computed/effect + hyperscript 微框架(零依赖) - skin/ui: 16 个 DOM 组件(含自绘 select、滑动指示条 tabs、行选择 dataTable、form 表单校验) - design/tokens: --ua-* 设计令牌(light/dark) - tools/cli: ua 命令行(index/list/info/docs/style/mcp)+ MCP 注册表服务 - apps: demo 演示站(esbuild 单文件)+ docs 双轨文档(人类版 + llms-full.txt)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.turbo/
|
||||||
|
.ua/cache/
|
||||||
|
apps/docs/*.html
|
||||||
|
.workbuddy/
|
||||||
+711
@@ -0,0 +1,711 @@
|
|||||||
|
{
|
||||||
|
"$schema": "ui-arch-registry-index/0.1",
|
||||||
|
"generatedAt": "2026-10-23T06:57:51.085Z",
|
||||||
|
"repoRoot": ".",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "@ui-arch/frame",
|
||||||
|
"type": "headless-core",
|
||||||
|
"description": "自研微框架:signal / computed / effect 信号系统 + hyperscript(h)DOM 层。函数值属性与函数型子节点自动响应式;renderList 做列表整体替换;零依赖、无虚拟 DOM。",
|
||||||
|
"tags": [
|
||||||
|
"framework",
|
||||||
|
"signal",
|
||||||
|
"reactive",
|
||||||
|
"hyperscript"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "src/index.ts",
|
||||||
|
"exports": [
|
||||||
|
"signal(initial) -> Signal<T>:无参读值并收集依赖,传参写值并触发订阅",
|
||||||
|
"effect(fn):立即执行并追踪依赖,信号变化自动重跑",
|
||||||
|
"computed(fn):由信号派生的只读计算值",
|
||||||
|
"h(tag, props, ...children):hyperscript;on* 绑事件,函数值属性/子节点自动响应式",
|
||||||
|
"renderList(el, fn):信号变化时整体替换容器子节点",
|
||||||
|
"mount(target, view):挂载视图"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"Signal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "const count = signal(0); mount('#app', () => h('button', { onClick: () => count(count() + 1) }, () => `count = ${count()}`))",
|
||||||
|
"conventions": [
|
||||||
|
"组件 = 返回 DOM 节点的普通函数,不做类组件与生命周期抽象",
|
||||||
|
"跨系统状态(如 table-core)用桥接信号:subscribe 里递增 version 信号"
|
||||||
|
],
|
||||||
|
"docs": {
|
||||||
|
"intro": "零依赖微框架:组件 = 返回 DOM 的普通函数。signal/effect/computed 管状态,h() 管 DOM,无虚拟 DOM 无生命周期抽象。",
|
||||||
|
"steps": [
|
||||||
|
"引入:import { signal, computed, effect, h, mount } from '@ui-arch/frame'",
|
||||||
|
"声明状态:const count = signal(0);无参调用读值 count(),传参写值 count(1)",
|
||||||
|
"渲染视图:mount('#app', () => h('div', null, () => `count = ${count()}`))——函数型子节点即响应式文本",
|
||||||
|
"绑定事件:props 里 on 开头且值为函数的键(onClick 等)自动 addEventListener",
|
||||||
|
"派生数据:const double = computed(() => count() * 2),视图里直接调用 double(),依赖自动追踪",
|
||||||
|
"列表渲染:renderList(tbody, () => rows.map(r => h('tr', null, r.name))),信号变化整体替换子节点"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dir": "packages/core/frame"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@ui-arch/table-core",
|
||||||
|
"type": "headless-core",
|
||||||
|
"description": "Headless table state machine: immutable state, dispatch/reducer, composable derive pipeline (filter -> sort -> paginate) with per-stage memoization. No DOM, no framework dependency.",
|
||||||
|
"tags": [
|
||||||
|
"table",
|
||||||
|
"headless",
|
||||||
|
"state-machine",
|
||||||
|
"datagrid"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "src/index.ts",
|
||||||
|
"exports": [
|
||||||
|
"createTable(options) -> TableInstance: create headless table instance",
|
||||||
|
"sortPlugin(options?) -> multi-column sorting plugin (asc -> desc -> off cycle)",
|
||||||
|
"filterPlugin(options?) -> column filter plugin (default substring match, custom predicate supported)",
|
||||||
|
"paginationPlugin() -> client-side pagination with page clamping, exposes setPage/nextPage/prevPage/getPageInfo",
|
||||||
|
"selectionPlugin() -> rowKey-based selection, exposes selectAllVisible"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"ColumnDef",
|
||||||
|
"TableState",
|
||||||
|
"TablePlugin",
|
||||||
|
"RowModel",
|
||||||
|
"Action"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()], initialState: { pagination: { page: 1, pageSize: 20 } } }); table.dispatch({ type: 'toggleSort', key: 'name' }); table.getVisibleRows()",
|
||||||
|
"conventions": [
|
||||||
|
"selection/expanded must store rowKey, never row index",
|
||||||
|
"derive pipeline order is fixed: filter -> sort -> paginate",
|
||||||
|
"actions are the only way to mutate state; state is immutable"
|
||||||
|
],
|
||||||
|
"docs": {
|
||||||
|
"intro": "Headless 表格内核:不渲染任何 DOM,只管理状态与派生。任何渲染层通过 getVisibleRows() 拿行模型,配 subscribe() 刷新。",
|
||||||
|
"steps": [
|
||||||
|
"创建实例:const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()] })",
|
||||||
|
"取派生数据:table.getVisibleRows() 返回 { rows, total };total 语义固定 = 筛选后、分页前的行数",
|
||||||
|
"变更状态:只能 dispatch action,如 table.dispatch({ type: 'toggleSort', key: 'name' });状态不可变",
|
||||||
|
"订阅刷新:table.subscribe(fn),fn 里重新 getVisibleRows() 并交给渲染层",
|
||||||
|
"分页信息:api.getPageInfo() 返回 { page, pageSize, totalPages };越界页码自动钳制",
|
||||||
|
"扩展能力:实现 TablePlugin(derive / patchState / api 三钩子)传入 plugins 即插入管线"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dir": "packages/core/table-core"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@ui-arch/tokens",
|
||||||
|
"type": "design-tokens",
|
||||||
|
"description": "Single source of design tokens. Skin packages consume CSS variables only - hardcoded colors inside skins are forbidden. Dark theme overrides values under [data-theme='dark'], never names.",
|
||||||
|
"tags": [
|
||||||
|
"tokens",
|
||||||
|
"theme",
|
||||||
|
"css-variables"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "tokens.css",
|
||||||
|
"exports": [
|
||||||
|
"tokens.css: import '@ui-arch/tokens/tokens.css' once at app root",
|
||||||
|
"tokens.json: programmatic access for tools and agents"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"--ua-color-*",
|
||||||
|
"--ua-space-*",
|
||||||
|
"--ua-radius-*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "import '@ui-arch/tokens/tokens.css'; then use var(--ua-color-accent) etc. in any skin package",
|
||||||
|
"docs": {
|
||||||
|
"intro": "设计令牌唯一来源:CSS 变量 --ua-* + 同名 JSON。皮肤只允许消费变量,禁止硬编码颜色;暗色只换值不换名。",
|
||||||
|
"steps": [
|
||||||
|
"引入:在应用入口 import '@ui-arch/tokens/tokens.css'",
|
||||||
|
"消费:组件样式一律 var(--ua-color-accent) 这类变量,不写死色值",
|
||||||
|
"暗色:根元素设 data-theme=dark,变量值自动切换",
|
||||||
|
"扩展:新令牌先同时加进 tokens.css 与 tokens.json,再重新生成文档站"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dir": "packages/design/tokens"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@ui-arch/ui",
|
||||||
|
"type": "skin-dom",
|
||||||
|
"description": "DOM 皮肤组件库:button / textField / textarea / checkbox / switch / select / radioGroup / card / badge / alert / spinner / progress / tabs / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
|
||||||
|
"tags": [
|
||||||
|
"components",
|
||||||
|
"dom",
|
||||||
|
"skin",
|
||||||
|
"table",
|
||||||
|
"form",
|
||||||
|
"feedback"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "src/index.ts",
|
||||||
|
"exports": [
|
||||||
|
"button(props):按钮,variant = primary | ghost | danger",
|
||||||
|
"textField(props):文本输入,value 信号双向绑定",
|
||||||
|
"textarea(props):多行输入,value 信号双向绑定",
|
||||||
|
"checkbox(props):复选框,checked 信号双向绑定",
|
||||||
|
"switchButton(props):开关,checked 信号双向绑定",
|
||||||
|
"select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画",
|
||||||
|
"radioGroup(props):单选组,options + value 信号双向绑定",
|
||||||
|
"card(props, ...children):卡片容器",
|
||||||
|
"badge(props):徽标,variant = default | success | warning | danger",
|
||||||
|
"alert(props):提示条,variant = info | success | warning | danger",
|
||||||
|
"spinner(props):加载指示器,size 控制直径",
|
||||||
|
"progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色",
|
||||||
|
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签",
|
||||||
|
"modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭",
|
||||||
|
"form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit",
|
||||||
|
"formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄",
|
||||||
|
"dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"ButtonProps",
|
||||||
|
"TextFieldProps",
|
||||||
|
"TextareaProps",
|
||||||
|
"CheckboxProps",
|
||||||
|
"SwitchProps",
|
||||||
|
"SelectProps",
|
||||||
|
"RadioGroupProps",
|
||||||
|
"OptionItem",
|
||||||
|
"CardProps",
|
||||||
|
"BadgeProps",
|
||||||
|
"AlertProps",
|
||||||
|
"SpinnerProps",
|
||||||
|
"ProgressProps",
|
||||||
|
"TabsProps",
|
||||||
|
"ModalProps",
|
||||||
|
"FormRule",
|
||||||
|
"FormItemProps",
|
||||||
|
"FormItemHandle",
|
||||||
|
"FormProps",
|
||||||
|
"DataTableProps"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')",
|
||||||
|
"conventions": [
|
||||||
|
"皮肤不持有业务状态,一律委托 headless 内核或调用方信号",
|
||||||
|
"表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调",
|
||||||
|
"语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色"
|
||||||
|
],
|
||||||
|
"docs": {
|
||||||
|
"intro": "16 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
|
||||||
|
"steps": [
|
||||||
|
"安装依赖:pnpm add @ui-arch/ui @ui-arch/frame(workspace 内自动链接)",
|
||||||
|
"引入样式:在应用入口 import '@ui-arch/ui/ui.css',暗色主题在根元素设 data-theme='dark'",
|
||||||
|
"创建状态:import { signal } from '@ui-arch/frame',如 const name = signal('')",
|
||||||
|
"调用组件:const el = textField({ value: name }),把 el 挂到你的容器或配合 h() 组合",
|
||||||
|
"读写状态:name() 读、name('张三') 写,界面自动同步,无需手动操作 DOM"
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "button",
|
||||||
|
"title": "按钮 Button",
|
||||||
|
"demo": "sec-counter",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "按钮文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "variant",
|
||||||
|
"t": "'primary' | 'ghost' | 'danger'",
|
||||||
|
"d": "视觉强度;缺省 primary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onClick",
|
||||||
|
"t": "() => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "点击回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "button({ label: '提交', variant: 'primary', onClick: () => console.log('clicked') })",
|
||||||
|
"steps": [
|
||||||
|
"确定语义强度:主操作用 primary(默认),次要操作用 ghost,危险操作用 danger",
|
||||||
|
"绑定 onClick 回调处理点击逻辑",
|
||||||
|
"需要禁用态时自行在回调首行卫语句判断,或外层包条件渲染"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "textField",
|
||||||
|
"title": "文本输入 TextField",
|
||||||
|
"demo": "sec-bind",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "双向绑定信号:输入写回信号,信号变化写回输入框"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "占位文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onInput",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "输入回调,每次按键触发"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const name = signal(''); const el = textField({ value: name, placeholder: '输入名字…', onInput: (v) => console.log(v) })",
|
||||||
|
"steps": [
|
||||||
|
"先用 signal('') 创建字符串信号",
|
||||||
|
"传入 value 完成双向绑定:用户输入自动写回,外部赋值自动回显",
|
||||||
|
"在任意响应式位置用 name() 读取最新值,如 () => `你好,${name()}`"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "textarea",
|
||||||
|
"title": "多行输入 Textarea",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "双向绑定信号"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "rows",
|
||||||
|
"t": "number",
|
||||||
|
"d": "初始行数;缺省 3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "占位文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onInput",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "输入回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话介绍…' })",
|
||||||
|
"steps": [
|
||||||
|
"创建信号并传入 value",
|
||||||
|
"用 rows 控制初始高度,用户可纵向拉伸",
|
||||||
|
"字数统计等派生逻辑直接在响应式文本里读 bio().length"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "checkbox",
|
||||||
|
"title": "复选框 Checkbox",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "checked",
|
||||||
|
"t": "Signal<boolean>",
|
||||||
|
"d": "勾选状态信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"d": "右侧文案;缺省只渲染勾选框"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(checked: boolean) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "变更回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const agreed = signal(false); checkbox({ label: '同意条款', checked: agreed, onChange: (v) => console.log(v) })",
|
||||||
|
"steps": [
|
||||||
|
"创建布尔信号 agreed = signal(false)",
|
||||||
|
"传入 checked 与 label,勾选即写回信号",
|
||||||
|
"表单提交前用 agreed() 判断是否勾选"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switchButton",
|
||||||
|
"title": "开关 Switch",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "checked",
|
||||||
|
"t": "Signal<boolean>",
|
||||||
|
"d": "开关状态信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"d": "左侧文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(checked: boolean) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "变更回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const night = signal(true); switchButton({ label: '夜间模式', checked: night })",
|
||||||
|
"steps": [
|
||||||
|
"创建布尔信号并传入 checked",
|
||||||
|
"轨道与滑块的动画由 CSS 完成,无需干预",
|
||||||
|
"状态变化在 onChange 里做副作用(如切换主题)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select",
|
||||||
|
"title": "下拉选择 Select(自绘弹层)",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "options",
|
||||||
|
"t": "OptionItem[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "选项列表,每项 { value, label }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "选中值信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "未选中时触发器显示的占位文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "选中回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], value: city, placeholder: '选择城市…' })",
|
||||||
|
"steps": [
|
||||||
|
"准备 options 数组,value 是提交用值、label 是展示文案",
|
||||||
|
"创建信号并传入 value;未传时组件内部自持状态",
|
||||||
|
"点击触发器展开弹层,点选项写回信号并收起;点外部或按 Esc 也会收起",
|
||||||
|
"选中项在弹层中用淡色弱化标记,不抢视觉焦点"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "radioGroup",
|
||||||
|
"title": "单选组 RadioGroup",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "options",
|
||||||
|
"t": "OptionItem[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "选项列表"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "选中值信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "变更回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const size = signal('m'); radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size })",
|
||||||
|
"steps": [
|
||||||
|
"创建字符串信号,初值设为默认选项的 value",
|
||||||
|
"传入 options 与 value,组内自动互斥",
|
||||||
|
"读取 size() 获取当前选中值"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card",
|
||||||
|
"title": "卡片 Card",
|
||||||
|
"demo": "sec-card",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "title",
|
||||||
|
"t": "string",
|
||||||
|
"d": "标题;缺省不渲染标题行"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "...children",
|
||||||
|
"t": "unknown[]",
|
||||||
|
"d": "任意子节点:字符串 / DOM / 响应式函数"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "card({ title: '用户表' }, dataTable({ data, columns }))",
|
||||||
|
"steps": [
|
||||||
|
"card 是容器组件:第一个参数是 props,其余参数是任意子节点",
|
||||||
|
"传 title 显示标题行,否则只有内容区",
|
||||||
|
"子节点可以混合字符串、DOM 节点与响应式函数"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "badge",
|
||||||
|
"title": "徽标 Badge",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "徽标文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "variant",
|
||||||
|
"t": "'default' | 'success' | 'warning' | 'danger'",
|
||||||
|
"d": "语义色;缺省 default"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "badge({ label: '成功', variant: 'success' })",
|
||||||
|
"steps": [
|
||||||
|
"选择语义色:default 中性、success 成功、warning 警告、danger 危险",
|
||||||
|
"传入 label 即可,纯展示组件无状态"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alert",
|
||||||
|
"title": "提示条 Alert",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "message",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "提示内容"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "variant",
|
||||||
|
"t": "'info' | 'success' | 'warning' | 'danger'",
|
||||||
|
"d": "语义色;缺省 info"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "alert({ message: '操作可能存在风险', variant: 'warning' })",
|
||||||
|
"steps": [
|
||||||
|
"按信息级别选 variant:info 提示、success 成功、warning 警告、danger 错误",
|
||||||
|
"传入 message 即可,入场自带下滑动画"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spinner",
|
||||||
|
"title": "加载指示器 Spinner",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "size",
|
||||||
|
"t": "number",
|
||||||
|
"d": "直径像素;缺省 18"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "spinner(); spinner({ size: 26 })",
|
||||||
|
"steps": [
|
||||||
|
"数据加载时插入 spinner(),加载完成移除",
|
||||||
|
"用 size 匹配上下文尺寸(按钮内用小号,整页 loading 用大号)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "progress",
|
||||||
|
"title": "进度条 Progress",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<number>",
|
||||||
|
"r": 1,
|
||||||
|
"d": "进度 0-100 信号,变化自动平滑过渡"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "showText",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "是否显示百分比文字;缺省 true"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() + 5) % 105), 400)",
|
||||||
|
"steps": [
|
||||||
|
"创建数值信号 pct = signal(0),范围 0-100(越界自动钳制)",
|
||||||
|
"传入 value,填充宽度与百分比文字随信号平滑过渡",
|
||||||
|
"进行中填充带流动条纹,到 100% 自动变 success 色并停止条纹",
|
||||||
|
"不需要百分比文字时传 showText: false"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tabs",
|
||||||
|
"title": "标签页 Tabs",
|
||||||
|
"demo": "sec-tabs-modal",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "tabs",
|
||||||
|
"t": "OptionItem[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "页签列表,每项 { value, label }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "active",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "当前激活页签 value 信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "切换回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })",
|
||||||
|
"steps": [
|
||||||
|
"创建信号 tab = signal('a'),初值为默认页签",
|
||||||
|
"tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB",
|
||||||
|
"点击或方向键(←→ 环形)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点",
|
||||||
|
"键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "modal",
|
||||||
|
"title": "弹窗 Modal",
|
||||||
|
"demo": "sec-tabs-modal",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "open",
|
||||||
|
"t": "Signal<boolean>",
|
||||||
|
"r": 1,
|
||||||
|
"d": "开关信号:true 显示 / false 隐藏"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "title",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "标题"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onClose",
|
||||||
|
"t": "() => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "关闭回调(点遮罩、× 或 Esc 触发)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "...children",
|
||||||
|
"t": "unknown[]",
|
||||||
|
"d": "弹窗内容子节点"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const open = signal(false); modal({ title: '确认操作', open, onClose: () => open(false) }, h('p', null, '内容'))",
|
||||||
|
"steps": [
|
||||||
|
"创建布尔信号 open = signal(false),弹窗初始隐藏",
|
||||||
|
"把 open 传给 modal,其余参数为弹窗内容子节点",
|
||||||
|
"打开:open(true);关闭:open(false),点遮罩或 × 会自动写回 false",
|
||||||
|
"入场自带遮罩淡入 + 弹窗上浮动画"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "form",
|
||||||
|
"title": "表单 Form / 表单字段 FormItem",
|
||||||
|
"demo": "sec-formlab",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "layout",
|
||||||
|
"t": "'vertical' | 'horizontal'",
|
||||||
|
"d": "布局方向;缺省 vertical(horizontal 为标签左置)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "submitText",
|
||||||
|
"t": "string",
|
||||||
|
"d": "提交按钮文案;缺省「提交」"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "resetText",
|
||||||
|
"t": "string",
|
||||||
|
"d": "重置按钮文案;缺省不渲染"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onSubmit",
|
||||||
|
"t": "() => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "全部字段校验通过后的提交回调"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)label",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "字段标签"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"r": 1,
|
||||||
|
"d": "字段值信号,校验数据源"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)required",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "必填:显示红星并自动追加非空规则"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)rules",
|
||||||
|
"t": "FormRule[]",
|
||||||
|
"d": "校验规则:required / min / max / pattern / validator,按序执行取首个失败"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)validateOn",
|
||||||
|
"t": "'input' | 'blur' | 'submit'",
|
||||||
|
"d": "校验时机;缺省 blur(输入时自动清已报错误)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])",
|
||||||
|
"steps": [
|
||||||
|
"为每个字段创建 signal 与控件,如 const name = signal(''); textField({ value: name })",
|
||||||
|
"用 formItem 包住控件:formItem({ label, required, value, rules }, control),返回 { el, validate } 句柄",
|
||||||
|
"把句柄数组交给 form:form({ onSubmit }, items),form 渲染全部字段 + 提交/重置按钮",
|
||||||
|
"提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项",
|
||||||
|
"规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)",
|
||||||
|
"非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dataTable",
|
||||||
|
"title": "数据表格 DataTable",
|
||||||
|
"demo": "sec-table",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "data",
|
||||||
|
"t": "T[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "全量行数据,每行须含 id 字段作 rowKey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "columns",
|
||||||
|
"t": "ColumnDef<T>[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "列定义:{ key, header?, sortable? }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "filterKey",
|
||||||
|
"t": "string",
|
||||||
|
"d": "工具栏筛选的列;缺省取第一列"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "pageSize",
|
||||||
|
"t": "number",
|
||||||
|
"d": "每页行数;缺省 5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "selectable",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "启用行选择列:全选三态(含半选)、行 checkbox、已选计数;缺省 false"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })",
|
||||||
|
"steps": [
|
||||||
|
"准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)",
|
||||||
|
"定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕",
|
||||||
|
"selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数",
|
||||||
|
"选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失",
|
||||||
|
"工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行",
|
||||||
|
"表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dir": "packages/skin/ui"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# AGENTS.md - Agent 接入手册
|
||||||
|
|
||||||
|
面向 AI Agent(以及新加入的人类协作者)的组件库使用规范。本文件同时是项目入口文档。
|
||||||
|
|
||||||
|
## 环境准备(新 Agent 必读,先跑通再干活)
|
||||||
|
|
||||||
|
1. **包管理器必须用 pnpm**:包间依赖用 `workspace:*` 协议,npm 不支持。安装依赖:`pnpm install`。
|
||||||
|
2. **构建工具**:TypeScript 5(`tsc`)。若环境未全局安装,可使用托管工作区的副本:`C:/Users/shawn/.workbuddy/binaries/node/workspace/node_modules/typescript`(本仓库历史即如此构建)。
|
||||||
|
3. **Node**:>= 18,ESM(`"type": "module"`)。
|
||||||
|
4. **验证自检**:装好后先跑 `npm run check`(build → style → typecheck → smoke → index)确认基线全绿,再开始改代码。
|
||||||
|
|
||||||
|
## 编码规范(强制)
|
||||||
|
|
||||||
|
所有 TS / JS 代码遵循 `CODING.md`:注释一律中文(函数级 `/*** 作用 ***/`、行尾 `// 短注释`),代码紧凑 + 行工整、卫语句单行,高可扩展、低抽象、可读性优先。
|
||||||
|
|
||||||
|
**注释强制门禁**:`npm run style`(即 `ua style`)机器检查——导出必须有 `/*** ***/` 注释、接口字段必须有行尾注释、行注释必须中文;有违规即退出码 1,改完才能提交。
|
||||||
|
|
||||||
|
## 项目结构(权威定义见 ARCHITECTURE.md)
|
||||||
|
|
||||||
|
分层目录,依赖方向单向 `apps → skin → core → design`:
|
||||||
|
|
||||||
|
- `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核
|
||||||
|
- `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架
|
||||||
|
- `packages/skin/ui`(@ui-arch/ui):16 个 DOM 组件,样式只消费令牌
|
||||||
|
- `packages/design/tokens`(@ui-arch/tokens):设计令牌
|
||||||
|
- `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private)
|
||||||
|
- `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html
|
||||||
|
- `apps/docs`:文档站,纯产物(ua docs 生成)
|
||||||
|
|
||||||
|
**门禁一条龙**:`npm run check` = style + typecheck + smoke + index,提交前必过。
|
||||||
|
|
||||||
|
## Agent 查询组件库的三种方式
|
||||||
|
|
||||||
|
1. **MCP(推荐)**:在 `~/.workbuddy/mcp.json` 注册:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ui-arch-registry": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["<仓库绝对路径>/packages/tools/cli/bin/ua.js", "mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
可用工具:`list_components` / `get_component` / `search_components`。
|
||||||
|
|
||||||
|
2. **CLI**:`node packages/tools/cli/bin/ua.js list | info <name> | index`
|
||||||
|
|
||||||
|
3. **静态索引**:`.ua/index.json`(机器可读索引)、`llms.txt`(速览)或 **`llms-full.txt`(全量 Agent 文档:逐组件参数 / 回调 / 手把手步骤 / 令牌,无图形纯文本,喂给 Agent 首选)**。
|
||||||
|
|
||||||
|
## 贡献新组件的强制约定
|
||||||
|
|
||||||
|
1. 按 `ARCHITECTURE.md` 分层模型确定目录归属:逻辑→core,渲染→skin,令牌→design,脚本→tools
|
||||||
|
2. 新包必须包含 `ua.manifest.json`(name/type/description/tags/api/usage),否则不会被索引;type 取值:headless-core / skin-dom / design-tokens / tooling
|
||||||
|
3. 行为逻辑放 core 包,DOM 渲染放 skin 包;皮肤间不互相依赖,公共逻辑下沉 core
|
||||||
|
4. 皮肤包禁止硬编码颜色/间距,一律使用 `--ua-*` 令牌
|
||||||
|
5. 选择类状态一律存 rowKey,不存行索引
|
||||||
|
6. 改动后运行 `npm run index` 刷新注册表;新增组件记得在 manifest 的 `docs.components` 里补参数表与手把手步骤,`npm run docs` 会同步生成人类版文档站与 llms-full.txt
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ARCHITECTURE.md - 项目结构规范
|
||||||
|
|
||||||
|
分层架构 + 目录结构 + 依赖方向的唯一权威定义。目录调整必须先改本文件再动代码。
|
||||||
|
|
||||||
|
## 分层模型(依赖方向单向,禁止反向)
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/* 应用层:可依赖任何 packages
|
||||||
|
↓
|
||||||
|
skin/* 皮肤层:DOM 渲染,可依赖 core/* + design/tokens
|
||||||
|
↓
|
||||||
|
core/* 逻辑层:框架无关 headless,可依赖 core 兄弟包 + design/tokens(约定级)
|
||||||
|
↓
|
||||||
|
design/* 资产层:设计令牌,不依赖任何包
|
||||||
|
|
||||||
|
tools/* 工程层:只在构建期读各包 ua.manifest.json / tokens.json,
|
||||||
|
不 import 任何运行时代码
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ui-arch-mvp/
|
||||||
|
├── apps/
|
||||||
|
│ ├── demo/ # @ui-arch/demo 演示应用(private,esbuild 打单文件)
|
||||||
|
│ │ ├── main.ts # 入口
|
||||||
|
│ │ ├── build.mjs # 打包脚本
|
||||||
|
│ │ └── dist/demo.html # 产物(gitignore)
|
||||||
|
│ └── docs/ # 文档站(纯产物,由 ua docs 生成)
|
||||||
|
│ ├── index.html # 人类版文档(含演示 iframe)
|
||||||
|
│ └── demo.html # ua docs 自动从 apps/demo/dist/ 拷贝的演示页副本
|
||||||
|
├── packages/
|
||||||
|
│ ├── core/ # 逻辑层(headless,可发布)
|
||||||
|
│ │ ├── table-core/ # 表格状态内核
|
||||||
|
│ │ └── frame/ # 信号 + hyperscript 微框架
|
||||||
|
│ ├── skin/ # 皮肤层(DOM 组件,可发布)
|
||||||
|
│ │ └── ui/ # 15 个组件 + ui.css
|
||||||
|
│ ├── design/ # 资产层(可发布)
|
||||||
|
│ │ └── tokens/ # --ua-* 设计令牌
|
||||||
|
│ └── tools/ # 工程层(private,不发布)
|
||||||
|
│ └── cli/ # ua 命令行 + MCP 注册表服务
|
||||||
|
├── ARCHITECTURE.md # 本文件:结构规范
|
||||||
|
├── AGENTS.md # Agent 接入手册(兼项目入口文档)
|
||||||
|
├── CODING.md # 编码规范
|
||||||
|
├── llms-full.txt # Agent 全量文档(ua docs 生成)
|
||||||
|
└── .ua/index.json + llms.txt # 注册表索引(ua index 生成,提交入库)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 包规范(每包强制)
|
||||||
|
|
||||||
|
1. **package.json**:`main`/`types`/`exports` 一律指向 `dist/`(纯资产包如 tokens 除外);依赖必须显式声明,工作区内用 `workspace:*`;不发布的包加 `"private": true`
|
||||||
|
2. **tsconfig**:一律 `extends` 根目录 `tsconfig.base.json`,只写差异项;浏览器包覆盖 `module: ESNext + moduleResolution: Bundler`
|
||||||
|
3. **ua.manifest.json**:可发布包必须有(否则不进注册表);`type` 字段枚举:`headless-core` / `skin-dom` / `design-tokens` / `tooling`。豁免:`tools/*` 与 `apps/*` 为工程/应用包,不进组件注册表,可不带 manifest
|
||||||
|
4. **产物**:构建产物只进包内 `dist/`(或 `apps/demo/dist/`),源码与产物永不混放;`dist/` 一律 gitignore
|
||||||
|
|
||||||
|
## 新增包流程
|
||||||
|
|
||||||
|
1. 按`分层模型`确定归属目录(逻辑→core,渲染→skin,令牌/图标→design,脚本→tools)
|
||||||
|
2. 建目录 + package.json + tsconfig.json(extends base)+ ua.manifest.json
|
||||||
|
3. `npm run index` 刷新注册表,`npm run check` 过全套门禁
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# CODING.md — 个人编码规范(固定资产)
|
||||||
|
|
||||||
|
> 适用于本仓库所有 TS / JS 代码。人与 Agent 共同遵守,改代码前先读此文件。
|
||||||
|
|
||||||
|
## 1. 注释规范(强制)
|
||||||
|
|
||||||
|
- **强制写注释**:所有导出函数、接口 / 类型、非平凡内部函数必须有注释;无注释的导出视为未完成。
|
||||||
|
- **语言**:注释一律中文(标识符、命令、路径除外)。
|
||||||
|
- **函数级**:`/*** 作用 ***/` 置于函数 / 方法上方,一句话讲清"做什么",必要时补一句"返回什么";不复述签名、不写废话。
|
||||||
|
- **类型 / 接口**:`/*** 用途 ***/` 单行置于上方。
|
||||||
|
- **逐字段**:接口 / 类型别名每个字段都在行尾跟一句 `// 注释`,包括联合类型的每个分支。
|
||||||
|
- **行级**:其他说明用 `// 短注释` 写在行尾,只解释"为什么"或标注关键分支意图;禁止流水账、禁止翻译代码。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/*** 值比较:数值按大小、其余按字符串序,null 恒排末尾 ***/
|
||||||
|
function compareValues(a: unknown, b: unknown): number {
|
||||||
|
if (a == null) return 1; // 卫语句:空值排后
|
||||||
|
if (typeof a === 'number') return a - (b as number);
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 一条排序规则 ***/
|
||||||
|
export interface SortRule {
|
||||||
|
key: string; // 排序字段,对应 ColumnDef.key
|
||||||
|
dir: 'asc' | 'desc'; // 排序方向
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 类型工程化
|
||||||
|
|
||||||
|
- **与项目平行**:类型集中放在与源码目录平行的类型文件(如 `src/types.ts`),与实现文件镜像对应;类型不散落在实现文件里。
|
||||||
|
- **紧凑**:每行一个字段(或一个联合分支),不留空行;简单接口可整体单行。
|
||||||
|
- **逐字段注释**:见上,字段注释写清"是什么、什么语义、缺省行为"。
|
||||||
|
- **命名一致**:`*Options`(入参)/ `*State`(状态)/ `*Plugin`(插件)/ `*Info`(派生快照),同类东西同名后缀,方便统一改动。
|
||||||
|
|
||||||
|
## 3. 变量与状态声明
|
||||||
|
|
||||||
|
- **集中顶部、显式声明**:模块级 / 闭包级状态(缓存、计数器、句柄)集中在作用域顶部声明;函数内变量也尽量集中顶部。
|
||||||
|
- 仅单个分支内部使用的临时变量允许就近声明,但禁止跨分支复用"就近变量"。
|
||||||
|
- 禁止隐式全局、禁止"用到才顺手声明"的散落写法——统一管理的前提是声明可一眼盘点。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let lastInput: RowModel | null = null; // memo:上一次输入
|
||||||
|
let lastRules: SortRule[] | null = null; // memo:上一次排序切片
|
||||||
|
let cached: RowModel | null = null; // memo:缓存产物
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 代码风格
|
||||||
|
|
||||||
|
- **紧凑 + 行工整**:能一行写清就一行;同构代码块(平行赋值、字段表)纵向对齐。
|
||||||
|
- **卫语句单行**:`if (!x) return;` 提前返回;嵌套超过两层必须提前返回或提取小函数。
|
||||||
|
- **可读性优先**:命名直白(见名知意),不炫技;三目只用于纯取值,复杂条件先提具名变量。
|
||||||
|
|
||||||
|
## 5. 低侵入与派生一致性
|
||||||
|
|
||||||
|
- **低侵入**:新能力一律以插件 / 钩子 / 可选参数接入,不改核心签名、不动既有调用方;扩展字段必须可选,破坏性变更走新版本而非就地修改。
|
||||||
|
- **派生语义一致**:派生量只从上游单一来源推导,命名与上游字段对齐(如 `RowModel.total` 在整条管线中始终表示"筛选后、分页前的行数");禁止旁路重复计算同一派生量,保证"改一处、全局一致"。
|
||||||
|
- 派生链路顺序固定并写进注释(如 filter → sort → paginate),谁改变语义谁负责同步所有下游注释与文档。
|
||||||
|
|
||||||
|
## 6. 架构取向
|
||||||
|
|
||||||
|
- **高可扩展是核心**:扩展点 = 插件 / 钩子 / 数据管线,而不是继承层级。
|
||||||
|
- **低复杂度、低抽象**:不为"将来可能"预先抽象;同一模式出现第二次再提取。
|
||||||
|
- **单文件尽量 < 200 行**:超出先拆职责,不拆"层"。
|
||||||
|
|
||||||
|
## 7. 落地方式
|
||||||
|
|
||||||
|
- 新代码必须符合本规范;旧代码顺手迁移,不强制一次性全量重写。
|
||||||
|
- 后续可做成 `ua style` 检查项:导出缺注释、接口字段缺注释、类型散落实现文件、闭包状态未集中顶部。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 演示站打包:esbuild 从 TS 源码打包(alias 直连各包 src),
|
||||||
|
* 连同 tokens.css / ui.css 内嵌进单文件 apps/demo/dist/demo.html。
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(__dirname, '..', '..'); // apps/demo 上两级 = 仓库根
|
||||||
|
// esbuild 装在托管 node workspace,用 createRequire 指过去
|
||||||
|
const require = createRequire('C:/Users/shawn/.workbuddy/binaries/node/workspace/index.js');
|
||||||
|
const esbuild = require('esbuild');
|
||||||
|
|
||||||
|
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
|
||||||
|
|
||||||
|
const result = await esbuild.build({
|
||||||
|
entryPoints: [path.join(root, 'apps/demo/main.ts')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'iife',
|
||||||
|
target: 'es2020',
|
||||||
|
charset: 'utf8', // 中文保留原文,不转义为 \uXXXX
|
||||||
|
write: false,
|
||||||
|
legalComments: 'none',
|
||||||
|
alias: {
|
||||||
|
'@ui-arch/frame': path.join(root, 'packages/core/frame/src/index.ts'),
|
||||||
|
'@ui-arch/ui': path.join(root, 'packages/skin/ui/src/index.ts'),
|
||||||
|
'@ui-arch/table-core': path.join(root, 'packages/core/table-core/src/index.ts'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const js = result.outputFiles[0].text.replace(/<\/script>/gi, '<\\/script>'); // 防提前闭合
|
||||||
|
const css = [read('packages/design/tokens/tokens.css'), read('packages/skin/ui/ui.css')].join('\n');
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>@ui-arch 组件库 · 现场演示</title>
|
||||||
|
<style>
|
||||||
|
${css}
|
||||||
|
:root{--page-bg:#ffffff;--page-text:#1c1e21;--page-muted:#6b7280;--page-border:rgba(0,0,0,.14)}
|
||||||
|
@media (prefers-color-scheme:dark){:root{--page-bg:#101216;--page-text:#e7e9ec;--page-muted:#888780;--page-border:rgba(255,255,255,.15)}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--page-bg);color:var(--page-text);font:14px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
||||||
|
.page{max-width:880px;margin:0 auto;padding:36px 20px 80px}
|
||||||
|
header{margin-bottom:30px}
|
||||||
|
h1{font-size:20px;font-weight:500;margin:0}
|
||||||
|
.sub{color:var(--page-muted);font-size:13px;margin:8px 0 0}
|
||||||
|
.sec{margin-bottom:40px}
|
||||||
|
h2{font-size:16px;font-weight:500;margin:0 0 4px}
|
||||||
|
.desc{color:var(--page-muted);font-size:13px;margin:0 0 14px}
|
||||||
|
.stage{display:flex;flex-wrap:wrap;gap:12px;align-items:center;border:1px dashed var(--page-border);border-radius:12px;padding:18px 20px}
|
||||||
|
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||||||
|
.stage .ua-table,.stage .ua-card{width:100%}
|
||||||
|
.out{color:var(--ua-color-accent,#185fa5);font-size:13px}
|
||||||
|
footer{color:var(--page-muted);font-size:12px;text-align:center;margin-top:50px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<footer>由 @ui-arch/frame + @ui-arch/ui 驱动 · 构建产物单文件,无运行时依赖</footer>
|
||||||
|
<script>
|
||||||
|
${js}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const outDir = path.join(root, 'apps/demo/dist');
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outDir, 'demo.html'), html);
|
||||||
|
console.log(`demo built -> apps/demo/dist/demo.html (${Math.round(html.length / 1024)} KB)`);
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { h, mount, signal } from '@ui-arch/frame';
|
||||||
|
import {
|
||||||
|
alert,
|
||||||
|
badge,
|
||||||
|
button,
|
||||||
|
card,
|
||||||
|
checkbox,
|
||||||
|
dataTable,
|
||||||
|
form,
|
||||||
|
formItem,
|
||||||
|
modal,
|
||||||
|
progress,
|
||||||
|
radioGroup,
|
||||||
|
select,
|
||||||
|
spinner,
|
||||||
|
switchButton,
|
||||||
|
tabs,
|
||||||
|
textarea,
|
||||||
|
textField,
|
||||||
|
} from '@ui-arch/ui';
|
||||||
|
|
||||||
|
/*** 生成演示区块;id 供文档站 iframe 锚点定位 ***/
|
||||||
|
function section(id: string, title: string, desc: string, ...body: unknown[]): HTMLElement {
|
||||||
|
return h(
|
||||||
|
'section',
|
||||||
|
{ class: 'sec', id },
|
||||||
|
h('h2', null, title),
|
||||||
|
h('p', { class: 'desc' }, desc),
|
||||||
|
h('div', { class: 'stage' }, ...body),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 演示数据 ——
|
||||||
|
const cities = ['北京', '上海', '深圳', '杭州'];
|
||||||
|
const users = [
|
||||||
|
'Alice', 'Bob', 'Charlie', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank',
|
||||||
|
'Ivy', 'Jack', 'Kate', 'Liam', 'Mia', 'Noah', 'Olivia', 'Paul',
|
||||||
|
'Quinn', 'Rose', 'Sam', 'Tina', 'Uma', 'Victor', 'Wendy', 'Xavier',
|
||||||
|
].map((name, i) => ({ id: i + 1, name, age: 22 + ((i * 7) % 20), city: cities[i % cities.length] }));
|
||||||
|
|
||||||
|
// —— 响应式状态演示 ——
|
||||||
|
const count = signal(0);
|
||||||
|
|
||||||
|
// —— 双向绑定演示 ——
|
||||||
|
const name = signal('');
|
||||||
|
|
||||||
|
// —— 表单控件状态集中声明 ——
|
||||||
|
const agreed = signal(false);
|
||||||
|
const nightMode = signal(true);
|
||||||
|
const city = signal('');
|
||||||
|
const size = signal('m');
|
||||||
|
const bio = signal('');
|
||||||
|
|
||||||
|
// —— 反馈组件状态 ——
|
||||||
|
const percent = signal(0);
|
||||||
|
setInterval(() => percent((percent() + 5) % 105), 400); // 自动推进演示进度
|
||||||
|
|
||||||
|
// —— 标签页 / 弹窗状态 ——
|
||||||
|
const activeTab = signal('a');
|
||||||
|
const modalOpen = signal(false);
|
||||||
|
|
||||||
|
// —— 表单校验状态集中声明 ——
|
||||||
|
const username = signal('');
|
||||||
|
const email = signal('');
|
||||||
|
const formResult = signal('');
|
||||||
|
|
||||||
|
const app = h(
|
||||||
|
'div',
|
||||||
|
{ class: 'page' },
|
||||||
|
h(
|
||||||
|
'header',
|
||||||
|
null,
|
||||||
|
h('h1', null, '@ui-arch 组件库 · 现场演示'),
|
||||||
|
h('p', { class: 'sub' }, '驱动:@ui-arch/frame(自研微框架) + @ui-arch/ui(组件库) + @ui-arch/table-core(表格内核)'),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-counter',
|
||||||
|
'响应式状态',
|
||||||
|
'signal 驱动:按钮修改信号,文本自动更新,无虚拟 DOM。',
|
||||||
|
button({ label: 'count +1', onClick: () => count(count() + 1) }),
|
||||||
|
button({ label: '重置', variant: 'ghost', onClick: () => count(0) }),
|
||||||
|
h('span', { class: 'out' }, () => `count = ${count()}`),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-bind',
|
||||||
|
'双向绑定',
|
||||||
|
'textField 与信号双向同步:输入即回显。',
|
||||||
|
textField({ placeholder: '输入名字…', value: name }),
|
||||||
|
h('span', { class: 'out' }, () => (name() ? `你好,${name()}!` : '等待输入…')),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-form',
|
||||||
|
'表单控件',
|
||||||
|
'checkbox / switch / select / radioGroup / textarea:状态一律由调用方信号持有。',
|
||||||
|
h('div', { class: 'row' }, checkbox({ label: '同意条款', checked: agreed }), h('span', { class: 'out' }, () => (agreed() ? '已同意' : '未同意'))),
|
||||||
|
h('div', { class: 'row' }, switchButton({ label: '夜间模式', checked: nightMode }), h('span', { class: 'out' }, () => (nightMode() ? '开' : '关'))),
|
||||||
|
h('div', { class: 'row' }, select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '选择城市…' }), h('span', { class: 'out' }, () => (city() ? `城市:${city()}` : '未选择'))),
|
||||||
|
h('div', { class: 'row' }, radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }, { value: 'l', label: '大' }], value: size })),
|
||||||
|
textarea({ placeholder: '一句话介绍…', value: bio, rows: 2 }),
|
||||||
|
h('span', { class: 'out' }, () => `简介 ${bio().length} 字`),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-feedback',
|
||||||
|
'反馈与提示',
|
||||||
|
'badge / alert / spinner / progress:语义色全部来自令牌,暗色自动切换。',
|
||||||
|
badge({ label: '默认' }),
|
||||||
|
badge({ label: '成功', variant: 'success' }),
|
||||||
|
badge({ label: '警告', variant: 'warning' }),
|
||||||
|
badge({ label: '危险', variant: 'danger' }),
|
||||||
|
alert({ message: '信息提示:这是一条 info 提示条。' }),
|
||||||
|
alert({ message: '警告提示:操作可能存在风险。', variant: 'warning' }),
|
||||||
|
h('div', { class: 'row' }, spinner(), spinner({ size: 26 })),
|
||||||
|
progress({ value: percent }),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-formlab',
|
||||||
|
'表单校验',
|
||||||
|
'form + formItem:必填 / 长度 / 正则校验,全部通过才触发提交;失焦校验、输入清错。',
|
||||||
|
form(
|
||||||
|
{ submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()}`) },
|
||||||
|
[
|
||||||
|
formItem({ label: '用户名', required: true, value: username, rules: [{ min: 2, max: 12, message: '用户名需 2-12 个字符' }] }, textField({ placeholder: '2-12 个字符', value: username })),
|
||||||
|
formItem({ label: '邮箱', required: true, value: email, rules: [{ pattern: /^[\w.-]+@[\w-]+\.[\w.]+$/, message: '邮箱格式不正确' }] }, textField({ placeholder: 'you@example.com', value: email })),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h('span', { class: 'out' }, () => formResult() || '等待提交…'),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-tabs-modal',
|
||||||
|
'标签页与弹窗',
|
||||||
|
'tabs 滑动指示条 + 方向键切换(←→),禁用页签置灰;modal 支持 Esc / 遮罩 / × 关闭。',
|
||||||
|
tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细' }, { value: 'c', label: '设置' }, { value: 'd', label: '已停用', disabled: true }], active: activeTab }),
|
||||||
|
h('span', { class: 'out' }, () => `当前页签:${activeTab()}`),
|
||||||
|
button({ label: '打开弹窗', onClick: () => modalOpen(true) }),
|
||||||
|
modal(
|
||||||
|
{ title: '确认操作', open: modalOpen, onClose: () => modalOpen(false) },
|
||||||
|
h('p', null, '这是一个由信号驱动的弹窗组件。'),
|
||||||
|
h('div', { class: 'row' }, button({ label: '知道了', onClick: () => modalOpen(false) }), badge({ label: 'modal', variant: 'success' })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-table',
|
||||||
|
'数据表格',
|
||||||
|
'粘性表头、斑马纹、行选择(全选三态 + 已选计数,选中行低视觉比重)、点表头排序、工具栏筛选、底部分页。',
|
||||||
|
card({ title: '用户表' }, dataTable({
|
||||||
|
data: users,
|
||||||
|
selectable: true,
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', header: '姓名', sortable: true },
|
||||||
|
{ key: 'age', header: '年龄', sortable: true },
|
||||||
|
{ key: 'city', header: '城市' },
|
||||||
|
],
|
||||||
|
pageSize: 5,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
section(
|
||||||
|
'sec-card',
|
||||||
|
'卡片容器',
|
||||||
|
'card 组件:标题 + 任意子内容,样式全部来自设计令牌。',
|
||||||
|
card({ title: '令牌驱动' }, h('p', { class: 'desc' }, '这里没有硬编码颜色 —— 全部消费 --ua-* CSS 变量,暗色模式自动切换。')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
mount('#app', () => app);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/demo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Interactive demo app for the @ui-arch component library, bundled to a single HTML file",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"typecheck": "tsc -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ui-arch/frame": "workspace:*",
|
||||||
|
"@ui-arch/ui": "workspace:*"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"paths": {
|
||||||
|
"@ui-arch/frame": ["../../packages/core/frame/src/index.ts"],
|
||||||
|
"@ui-arch/ui": ["../../packages/skin/ui/src/index.ts"],
|
||||||
|
"@ui-arch/table-core": ["../../packages/core/table-core/src/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["."]
|
||||||
|
}
|
||||||
+533
@@ -0,0 +1,533 @@
|
|||||||
|
# @ui-arch 组件库 Agent 文档(无图形版)
|
||||||
|
|
||||||
|
> 自动生成于 2026-10-23T06:57:51.085Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||||
|
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
||||||
|
|
||||||
|
## 快速上手
|
||||||
|
|
||||||
|
### @ui-arch/frame
|
||||||
|
|
||||||
|
1. 引入:import { signal, computed, effect, h, mount } from '@ui-arch/frame'
|
||||||
|
2. 声明状态:const count = signal(0);无参调用读值 count(),传参写值 count(1)
|
||||||
|
3. 渲染视图:mount('#app', () => h('div', null, () => `count = ${count()}`))——函数型子节点即响应式文本
|
||||||
|
4. 绑定事件:props 里 on 开头且值为函数的键(onClick 等)自动 addEventListener
|
||||||
|
5. 派生数据:const double = computed(() => count() * 2),视图里直接调用 double(),依赖自动追踪
|
||||||
|
6. 列表渲染:renderList(tbody, () => rows.map(r => h('tr', null, r.name))),信号变化整体替换子节点
|
||||||
|
|
||||||
|
### @ui-arch/table-core
|
||||||
|
|
||||||
|
1. 创建实例:const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()] })
|
||||||
|
2. 取派生数据:table.getVisibleRows() 返回 { rows, total };total 语义固定 = 筛选后、分页前的行数
|
||||||
|
3. 变更状态:只能 dispatch action,如 table.dispatch({ type: 'toggleSort', key: 'name' });状态不可变
|
||||||
|
4. 订阅刷新:table.subscribe(fn),fn 里重新 getVisibleRows() 并交给渲染层
|
||||||
|
5. 分页信息:api.getPageInfo() 返回 { page, pageSize, totalPages };越界页码自动钳制
|
||||||
|
6. 扩展能力:实现 TablePlugin(derive / patchState / api 三钩子)传入 plugins 即插入管线
|
||||||
|
|
||||||
|
### @ui-arch/tokens
|
||||||
|
|
||||||
|
1. 引入:在应用入口 import '@ui-arch/tokens/tokens.css'
|
||||||
|
2. 消费:组件样式一律 var(--ua-color-accent) 这类变量,不写死色值
|
||||||
|
3. 暗色:根元素设 data-theme=dark,变量值自动切换
|
||||||
|
4. 扩展:新令牌先同时加进 tokens.css 与 tokens.json,再重新生成文档站
|
||||||
|
|
||||||
|
### @ui-arch/ui
|
||||||
|
|
||||||
|
1. 安装依赖:pnpm add @ui-arch/ui @ui-arch/frame(workspace 内自动链接)
|
||||||
|
2. 引入样式:在应用入口 import '@ui-arch/ui/ui.css',暗色主题在根元素设 data-theme='dark'
|
||||||
|
3. 创建状态:import { signal } from '@ui-arch/frame',如 const name = signal('')
|
||||||
|
4. 调用组件:const el = textField({ value: name }),把 el 挂到你的容器或配合 h() 组合
|
||||||
|
5. 读写状态:name() 读、name('张三') 写,界面自动同步,无需手动操作 DOM
|
||||||
|
|
||||||
|
## @ui-arch/ui(skin-dom)v0.1.0
|
||||||
|
|
||||||
|
16 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。
|
||||||
|
|
||||||
|
### button — 按钮 Button
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- label(必填):string —— 按钮文案
|
||||||
|
- variant:'primary' | 'ghost' | 'danger' —— 视觉强度;缺省 primary
|
||||||
|
- onClick(回调):() => void —— 点击回调
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
button({ label: '提交', variant: 'primary', onClick: () => console.log('clicked') })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 确定语义强度:主操作用 primary(默认),次要操作用 ghost,危险操作用 danger
|
||||||
|
2. 绑定 onClick 回调处理点击逻辑
|
||||||
|
3. 需要禁用态时自行在回调首行卫语句判断,或外层包条件渲染
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-counter
|
||||||
|
|
||||||
|
### textField — 文本输入 TextField
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- value:Signal<string> —— 双向绑定信号:输入写回信号,信号变化写回输入框
|
||||||
|
- placeholder:string —— 占位文案
|
||||||
|
- onInput(回调):(value: string) => void —— 输入回调,每次按键触发
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const name = signal(''); const el = textField({ value: name, placeholder: '输入名字…', onInput: (v) => console.log(v) })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 先用 signal('') 创建字符串信号
|
||||||
|
2. 传入 value 完成双向绑定:用户输入自动写回,外部赋值自动回显
|
||||||
|
3. 在任意响应式位置用 name() 读取最新值,如 () => `你好,${name()}`
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-bind
|
||||||
|
|
||||||
|
### textarea — 多行输入 Textarea
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- value:Signal<string> —— 双向绑定信号
|
||||||
|
- rows:number —— 初始行数;缺省 3
|
||||||
|
- placeholder:string —— 占位文案
|
||||||
|
- onInput(回调):(value: string) => void —— 输入回调
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话介绍…' })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 创建信号并传入 value
|
||||||
|
2. 用 rows 控制初始高度,用户可纵向拉伸
|
||||||
|
3. 字数统计等派生逻辑直接在响应式文本里读 bio().length
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-form
|
||||||
|
|
||||||
|
### checkbox — 复选框 Checkbox
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- checked:Signal<boolean> —— 勾选状态信号,双向绑定
|
||||||
|
- label:string —— 右侧文案;缺省只渲染勾选框
|
||||||
|
- onChange(回调):(checked: boolean) => void —— 变更回调
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const agreed = signal(false); checkbox({ label: '同意条款', checked: agreed, onChange: (v) => console.log(v) })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 创建布尔信号 agreed = signal(false)
|
||||||
|
2. 传入 checked 与 label,勾选即写回信号
|
||||||
|
3. 表单提交前用 agreed() 判断是否勾选
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-form
|
||||||
|
|
||||||
|
### switchButton — 开关 Switch
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- checked:Signal<boolean> —— 开关状态信号,双向绑定
|
||||||
|
- label:string —— 左侧文案
|
||||||
|
- onChange(回调):(checked: boolean) => void —— 变更回调
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const night = signal(true); switchButton({ label: '夜间模式', checked: night })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 创建布尔信号并传入 checked
|
||||||
|
2. 轨道与滑块的动画由 CSS 完成,无需干预
|
||||||
|
3. 状态变化在 onChange 里做副作用(如切换主题)
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-form
|
||||||
|
|
||||||
|
### select — 下拉选择 Select(自绘弹层)
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- options(必填):OptionItem[] —— 选项列表,每项 { value, label }
|
||||||
|
- value:Signal<string> —— 选中值信号,双向绑定
|
||||||
|
- placeholder:string —— 未选中时触发器显示的占位文案
|
||||||
|
- onChange(回调):(value: string) => void —— 选中回调
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], value: city, placeholder: '选择城市…' })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 准备 options 数组,value 是提交用值、label 是展示文案
|
||||||
|
2. 创建信号并传入 value;未传时组件内部自持状态
|
||||||
|
3. 点击触发器展开弹层,点选项写回信号并收起;点外部或按 Esc 也会收起
|
||||||
|
4. 选中项在弹层中用淡色弱化标记,不抢视觉焦点
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-form
|
||||||
|
|
||||||
|
### radioGroup — 单选组 RadioGroup
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- options(必填):OptionItem[] —— 选项列表
|
||||||
|
- value:Signal<string> —— 选中值信号,双向绑定
|
||||||
|
- onChange(回调):(value: string) => void —— 变更回调
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const size = signal('m'); radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 创建字符串信号,初值设为默认选项的 value
|
||||||
|
2. 传入 options 与 value,组内自动互斥
|
||||||
|
3. 读取 size() 获取当前选中值
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-form
|
||||||
|
|
||||||
|
### card — 卡片 Card
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- title:string —— 标题;缺省不渲染标题行
|
||||||
|
- ...children:unknown[] —— 任意子节点:字符串 / DOM / 响应式函数
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
card({ title: '用户表' }, dataTable({ data, columns }))
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. card 是容器组件:第一个参数是 props,其余参数是任意子节点
|
||||||
|
2. 传 title 显示标题行,否则只有内容区
|
||||||
|
3. 子节点可以混合字符串、DOM 节点与响应式函数
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-card
|
||||||
|
|
||||||
|
### badge — 徽标 Badge
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- label(必填):string —— 徽标文案
|
||||||
|
- variant:'default' | 'success' | 'warning' | 'danger' —— 语义色;缺省 default
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
badge({ label: '成功', variant: 'success' })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 选择语义色:default 中性、success 成功、warning 警告、danger 危险
|
||||||
|
2. 传入 label 即可,纯展示组件无状态
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-feedback
|
||||||
|
|
||||||
|
### alert — 提示条 Alert
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- message(必填):string —— 提示内容
|
||||||
|
- variant:'info' | 'success' | 'warning' | 'danger' —— 语义色;缺省 info
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
alert({ message: '操作可能存在风险', variant: 'warning' })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 按信息级别选 variant:info 提示、success 成功、warning 警告、danger 错误
|
||||||
|
2. 传入 message 即可,入场自带下滑动画
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-feedback
|
||||||
|
|
||||||
|
### spinner — 加载指示器 Spinner
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- size:number —— 直径像素;缺省 18
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
spinner(); spinner({ size: 26 })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 数据加载时插入 spinner(),加载完成移除
|
||||||
|
2. 用 size 匹配上下文尺寸(按钮内用小号,整页 loading 用大号)
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-feedback
|
||||||
|
|
||||||
|
### progress — 进度条 Progress
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- value(必填):Signal<number> —— 进度 0-100 信号,变化自动平滑过渡
|
||||||
|
- showText:boolean —— 是否显示百分比文字;缺省 true
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() + 5) % 105), 400)
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 创建数值信号 pct = signal(0),范围 0-100(越界自动钳制)
|
||||||
|
2. 传入 value,填充宽度与百分比文字随信号平滑过渡
|
||||||
|
3. 进行中填充带流动条纹,到 100% 自动变 success 色并停止条纹
|
||||||
|
4. 不需要百分比文字时传 showText: false
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-feedback
|
||||||
|
|
||||||
|
### tabs — 标签页 Tabs
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- tabs(必填):OptionItem[] —— 页签列表,每项 { value, label }
|
||||||
|
- active:Signal<string> —— 当前激活页签 value 信号,双向绑定
|
||||||
|
- onChange(回调):(value: string) => void —— 切换回调
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 创建信号 tab = signal('a'),初值为默认页签
|
||||||
|
2. tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB
|
||||||
|
3. 点击或方向键(←→ 环形)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点
|
||||||
|
4. 键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-tabs-modal
|
||||||
|
|
||||||
|
### modal — 弹窗 Modal
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- open(必填):Signal<boolean> —— 开关信号:true 显示 / false 隐藏
|
||||||
|
- title(必填):string —— 标题
|
||||||
|
- onClose(回调):() => void —— 关闭回调(点遮罩、× 或 Esc 触发)
|
||||||
|
- ...children:unknown[] —— 弹窗内容子节点
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const open = signal(false); modal({ title: '确认操作', open, onClose: () => open(false) }, h('p', null, '内容'))
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 创建布尔信号 open = signal(false),弹窗初始隐藏
|
||||||
|
2. 把 open 传给 modal,其余参数为弹窗内容子节点
|
||||||
|
3. 打开:open(true);关闭:open(false),点遮罩或 × 会自动写回 false
|
||||||
|
4. 入场自带遮罩淡入 + 弹窗上浮动画
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-tabs-modal
|
||||||
|
|
||||||
|
### form — 表单 Form / 表单字段 FormItem
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- layout:'vertical' | 'horizontal' —— 布局方向;缺省 vertical(horizontal 为标签左置)
|
||||||
|
- submitText:string —— 提交按钮文案;缺省「提交」
|
||||||
|
- resetText:string —— 重置按钮文案;缺省不渲染
|
||||||
|
- onSubmit(回调):() => void —— 全部字段校验通过后的提交回调
|
||||||
|
- (formItem)label(必填):string —— 字段标签
|
||||||
|
- (formItem)value(必填):Signal<string> —— 字段值信号,校验数据源
|
||||||
|
- (formItem)required:boolean —— 必填:显示红星并自动追加非空规则
|
||||||
|
- (formItem)rules:FormRule[] —— 校验规则:required / min / max / pattern / validator,按序执行取首个失败
|
||||||
|
- (formItem)validateOn:'input' | 'blur' | 'submit' —— 校验时机;缺省 blur(输入时自动清已报错误)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 为每个字段创建 signal 与控件,如 const name = signal(''); textField({ value: name })
|
||||||
|
2. 用 formItem 包住控件:formItem({ label, required, value, rules }, control),返回 { el, validate } 句柄
|
||||||
|
3. 把句柄数组交给 form:form({ onSubmit }, items),form 渲染全部字段 + 提交/重置按钮
|
||||||
|
4. 提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项
|
||||||
|
5. 规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)
|
||||||
|
6. 非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-formlab
|
||||||
|
|
||||||
|
### dataTable — 数据表格 DataTable
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- data(必填):T[] —— 全量行数据,每行须含 id 字段作 rowKey
|
||||||
|
- columns(必填):ColumnDef<T>[] —— 列定义:{ key, header?, sortable? }
|
||||||
|
- filterKey:string —— 工具栏筛选的列;缺省取第一列
|
||||||
|
- pageSize:number —— 每页行数;缺省 5
|
||||||
|
- selectable:boolean —— 启用行选择列:全选三态(含半选)、行 checkbox、已选计数;缺省 false
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)
|
||||||
|
2. 定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕
|
||||||
|
3. selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数
|
||||||
|
4. 选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失
|
||||||
|
5. 工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行
|
||||||
|
6. 表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-table
|
||||||
|
|
||||||
|
API 速览:
|
||||||
|
|
||||||
|
- button(props):按钮,variant = primary | ghost | danger
|
||||||
|
- textField(props):文本输入,value 信号双向绑定
|
||||||
|
- textarea(props):多行输入,value 信号双向绑定
|
||||||
|
- checkbox(props):复选框,checked 信号双向绑定
|
||||||
|
- switchButton(props):开关,checked 信号双向绑定
|
||||||
|
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画
|
||||||
|
- radioGroup(props):单选组,options + value 信号双向绑定
|
||||||
|
- card(props, ...children):卡片容器
|
||||||
|
- badge(props):徽标,variant = default | success | warning | danger
|
||||||
|
- alert(props):提示条,variant = info | success | warning | danger
|
||||||
|
- spinner(props):加载指示器,size 控制直径
|
||||||
|
- progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色
|
||||||
|
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
|
||||||
|
- modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭
|
||||||
|
- form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit
|
||||||
|
- formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄
|
||||||
|
- dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core
|
||||||
|
|
||||||
|
一行用法:
|
||||||
|
|
||||||
|
```
|
||||||
|
const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')
|
||||||
|
```
|
||||||
|
|
||||||
|
约定:
|
||||||
|
|
||||||
|
- 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
|
||||||
|
- 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
|
||||||
|
- 语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## @ui-arch/frame(headless-core)v0.1.0
|
||||||
|
|
||||||
|
零依赖微框架:组件 = 返回 DOM 的普通函数。signal/effect/computed 管状态,h() 管 DOM,无虚拟 DOM 无生命周期抽象。
|
||||||
|
|
||||||
|
API 速览:
|
||||||
|
|
||||||
|
- signal(initial) -> Signal<T>:无参读值并收集依赖,传参写值并触发订阅
|
||||||
|
- effect(fn):立即执行并追踪依赖,信号变化自动重跑
|
||||||
|
- computed(fn):由信号派生的只读计算值
|
||||||
|
- h(tag, props, ...children):hyperscript;on* 绑事件,函数值属性/子节点自动响应式
|
||||||
|
- renderList(el, fn):信号变化时整体替换容器子节点
|
||||||
|
- mount(target, view):挂载视图
|
||||||
|
|
||||||
|
一行用法:
|
||||||
|
|
||||||
|
```
|
||||||
|
const count = signal(0); mount('#app', () => h('button', { onClick: () => count(count() + 1) }, () => `count = ${count()}`))
|
||||||
|
```
|
||||||
|
|
||||||
|
约定:
|
||||||
|
|
||||||
|
- 组件 = 返回 DOM 节点的普通函数,不做类组件与生命周期抽象
|
||||||
|
- 跨系统状态(如 table-core)用桥接信号:subscribe 里递增 version 信号
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## @ui-arch/table-core(headless-core)v0.1.0
|
||||||
|
|
||||||
|
Headless 表格内核:不渲染任何 DOM,只管理状态与派生。任何渲染层通过 getVisibleRows() 拿行模型,配 subscribe() 刷新。
|
||||||
|
|
||||||
|
API 速览:
|
||||||
|
|
||||||
|
- createTable(options) -> TableInstance: create headless table instance
|
||||||
|
- sortPlugin(options?) -> multi-column sorting plugin (asc -> desc -> off cycle)
|
||||||
|
- filterPlugin(options?) -> column filter plugin (default substring match, custom predicate supported)
|
||||||
|
- paginationPlugin() -> client-side pagination with page clamping, exposes setPage/nextPage/prevPage/getPageInfo
|
||||||
|
- selectionPlugin() -> rowKey-based selection, exposes selectAllVisible
|
||||||
|
|
||||||
|
一行用法:
|
||||||
|
|
||||||
|
```
|
||||||
|
const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()], initialState: { pagination: { page: 1, pageSize: 20 } } }); table.dispatch({ type: 'toggleSort', key: 'name' }); table.getVisibleRows()
|
||||||
|
```
|
||||||
|
|
||||||
|
约定:
|
||||||
|
|
||||||
|
- selection/expanded must store rowKey, never row index
|
||||||
|
- derive pipeline order is fixed: filter -> sort -> paginate
|
||||||
|
- actions are the only way to mutate state; state is immutable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## @ui-arch/tokens(design-tokens)v0.1.0
|
||||||
|
|
||||||
|
设计令牌唯一来源:CSS 变量 --ua-* + 同名 JSON。皮肤只允许消费变量,禁止硬编码颜色;暗色只换值不换名。
|
||||||
|
|
||||||
|
API 速览:
|
||||||
|
|
||||||
|
- tokens.css: import '@ui-arch/tokens/tokens.css' once at app root
|
||||||
|
- tokens.json: programmatic access for tools and agents
|
||||||
|
|
||||||
|
一行用法:
|
||||||
|
|
||||||
|
```
|
||||||
|
import '@ui-arch/tokens/tokens.css'; then use var(--ua-color-accent) etc. in any skin package
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计令牌(--ua-*)
|
||||||
|
|
||||||
|
- --ua-color-bg: #ffffff(暗色 #101216)
|
||||||
|
- --ua-color-surface: #f6f7f9(暗色 #1a1c22)
|
||||||
|
- --ua-color-text: #1c1e21(暗色 #e7e9ec)
|
||||||
|
- --ua-color-textMuted: #6b7280(暗色 #888780)
|
||||||
|
- --ua-color-accent: #185fa5(暗色 #378add)
|
||||||
|
- --ua-color-danger: #a32d2d
|
||||||
|
- --ua-color-success: #0f6e56
|
||||||
|
|
||||||
|
### radius
|
||||||
|
|
||||||
|
- --ua-radius-sm: 4px
|
||||||
|
- --ua-radius-md: 8px
|
||||||
|
- --ua-radius-lg: 12px
|
||||||
|
|
||||||
|
### space
|
||||||
|
|
||||||
|
- --ua-space-1: 4px
|
||||||
|
- --ua-space-2: 8px
|
||||||
|
- --ua-space-3: 12px
|
||||||
|
- --ua-space-4: 16px
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# @ui-arch 私人组件库索引
|
||||||
|
|
||||||
|
> 本文件由 `ua index` 自动生成,请勿手改。机器可读版:`.ua/index.json`。
|
||||||
|
> Agent 可通过 `ua mcp`(stdio MCP server)直接查询注册表。
|
||||||
|
|
||||||
|
## @ui-arch/frame (v0.1.0)
|
||||||
|
|
||||||
|
- 类型: headless-core · 目录: packages/core/frame
|
||||||
|
- 描述: 自研微框架:signal / computed / effect 信号系统 + hyperscript(h)DOM 层。函数值属性与函数型子节点自动响应式;renderList 做列表整体替换;零依赖、无虚拟 DOM。
|
||||||
|
- API:
|
||||||
|
- signal(initial) -> Signal<T>:无参读值并收集依赖,传参写值并触发订阅
|
||||||
|
- effect(fn):立即执行并追踪依赖,信号变化自动重跑
|
||||||
|
- computed(fn):由信号派生的只读计算值
|
||||||
|
- h(tag, props, ...children):hyperscript;on* 绑事件,函数值属性/子节点自动响应式
|
||||||
|
- renderList(el, fn):信号变化时整体替换容器子节点
|
||||||
|
- mount(target, view):挂载视图
|
||||||
|
- 用法: `const count = signal(0); mount('#app', () => h('button', { onClick: () => count(count() + 1) }, () => `count = ${count()}`))`
|
||||||
|
- 约定: 组件 = 返回 DOM 节点的普通函数,不做类组件与生命周期抽象
|
||||||
|
- 约定: 跨系统状态(如 table-core)用桥接信号:subscribe 里递增 version 信号
|
||||||
|
|
||||||
|
## @ui-arch/table-core (v0.1.0)
|
||||||
|
|
||||||
|
- 类型: headless-core · 目录: packages/core/table-core
|
||||||
|
- 描述: Headless table state machine: immutable state, dispatch/reducer, composable derive pipeline (filter -> sort -> paginate) with per-stage memoization. No DOM, no framework dependency.
|
||||||
|
- API:
|
||||||
|
- createTable(options) -> TableInstance: create headless table instance
|
||||||
|
- sortPlugin(options?) -> multi-column sorting plugin (asc -> desc -> off cycle)
|
||||||
|
- filterPlugin(options?) -> column filter plugin (default substring match, custom predicate supported)
|
||||||
|
- paginationPlugin() -> client-side pagination with page clamping, exposes setPage/nextPage/prevPage/getPageInfo
|
||||||
|
- selectionPlugin() -> rowKey-based selection, exposes selectAllVisible
|
||||||
|
- 用法: `const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()], initialState: { pagination: { page: 1, pageSize: 20 } } }); table.dispatch({ type: 'toggleSort', key: 'name' }); table.getVisibleRows()`
|
||||||
|
- 约定: selection/expanded must store rowKey, never row index
|
||||||
|
- 约定: derive pipeline order is fixed: filter -> sort -> paginate
|
||||||
|
- 约定: actions are the only way to mutate state; state is immutable
|
||||||
|
|
||||||
|
## @ui-arch/tokens (v0.1.0)
|
||||||
|
|
||||||
|
- 类型: design-tokens · 目录: packages/design/tokens
|
||||||
|
- 描述: Single source of design tokens. Skin packages consume CSS variables only - hardcoded colors inside skins are forbidden. Dark theme overrides values under [data-theme='dark'], never names.
|
||||||
|
- API:
|
||||||
|
- tokens.css: import '@ui-arch/tokens/tokens.css' once at app root
|
||||||
|
- tokens.json: programmatic access for tools and agents
|
||||||
|
- 用法: `import '@ui-arch/tokens/tokens.css'; then use var(--ua-color-accent) etc. in any skin package`
|
||||||
|
|
||||||
|
## @ui-arch/ui (v0.1.0)
|
||||||
|
|
||||||
|
- 类型: skin-dom · 目录: packages/skin/ui
|
||||||
|
- 描述: DOM 皮肤组件库:button / textField / textarea / checkbox / switch / select / radioGroup / card / badge / alert / spinner / progress / tabs / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。
|
||||||
|
- API:
|
||||||
|
- button(props):按钮,variant = primary | ghost | danger
|
||||||
|
- textField(props):文本输入,value 信号双向绑定
|
||||||
|
- textarea(props):多行输入,value 信号双向绑定
|
||||||
|
- checkbox(props):复选框,checked 信号双向绑定
|
||||||
|
- switchButton(props):开关,checked 信号双向绑定
|
||||||
|
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画
|
||||||
|
- radioGroup(props):单选组,options + value 信号双向绑定
|
||||||
|
- card(props, ...children):卡片容器
|
||||||
|
- badge(props):徽标,variant = default | success | warning | danger
|
||||||
|
- alert(props):提示条,variant = info | success | warning | danger
|
||||||
|
- spinner(props):加载指示器,size 控制直径
|
||||||
|
- progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色
|
||||||
|
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
|
||||||
|
- modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭
|
||||||
|
- form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit
|
||||||
|
- formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄
|
||||||
|
- dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core
|
||||||
|
- 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')`
|
||||||
|
- 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
|
||||||
|
- 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
|
||||||
|
- 约定: 语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "ui-arch",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Private component library monorepo (@ui-arch)。包管理器固定 pnpm:包间依赖用 workspace:*,npm 不支持。",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p packages/core/table-core/tsconfig.json && tsc -p packages/core/frame/tsconfig.json && tsc -p packages/skin/ui/tsconfig.json",
|
||||||
|
"typecheck": "tsc -p packages/core/table-core/tsconfig.json --noEmit && tsc -p packages/core/frame/tsconfig.json --noEmit && tsc -p packages/skin/ui/tsconfig.json --noEmit && tsc -p apps/demo/tsconfig.json",
|
||||||
|
"smoke": "node packages/core/table-core/scripts/smoke.mjs",
|
||||||
|
"demo": "node apps/demo/build.mjs",
|
||||||
|
"style": "node packages/tools/cli/bin/ua.js style",
|
||||||
|
"index": "node packages/tools/cli/bin/ua.js index",
|
||||||
|
"docs": "node packages/tools/cli/bin/ua.js index && node packages/tools/cli/bin/ua.js docs",
|
||||||
|
"check": "npm run build && npm run style && npm run typecheck && npm run smoke && npm run index",
|
||||||
|
"ua": "node packages/tools/cli/bin/ua.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/frame",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Minimal reactive DOM framework: signal / computed / effect + hyperscript, zero dependency",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"signal",
|
||||||
|
"reactive",
|
||||||
|
"dom",
|
||||||
|
"micro-framework"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { effect } from './signal.js';
|
||||||
|
|
||||||
|
type Child = Node | string | number | boolean | null | undefined | (() => unknown);
|
||||||
|
type Props = Record<string, unknown>;
|
||||||
|
|
||||||
|
/*** 响应式属性绑定:函数值属性在依赖变化时自动写回 DOM ***/
|
||||||
|
function bindProp(el: HTMLElement, key: string, fn: () => unknown): void {
|
||||||
|
effect(() => {
|
||||||
|
const v = fn();
|
||||||
|
if (key === 'value') (el as unknown as HTMLInputElement).value = String(v);
|
||||||
|
else if (key === 'checked') (el as unknown as HTMLInputElement).checked = Boolean(v);
|
||||||
|
else if (v === false || v == null) el.removeAttribute(key);
|
||||||
|
else el.setAttribute(key, v === true ? '' : String(v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 静态属性写入:false / null 移除属性,true 置空标记属性 ***/
|
||||||
|
function bindStatic(el: HTMLElement, key: string, value: unknown): void {
|
||||||
|
if (value === false || value == null) el.removeAttribute(key);
|
||||||
|
else if (value === true) el.setAttribute(key, '');
|
||||||
|
else el.setAttribute(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 挂载子节点:函数型子节点转为响应式文本节点 ***/
|
||||||
|
function appendChild(el: HTMLElement, child: unknown): void {
|
||||||
|
if (child == null || child === false) return;
|
||||||
|
if (typeof child === 'function') {
|
||||||
|
const node = document.createTextNode('');
|
||||||
|
effect(() => {
|
||||||
|
node.textContent = String(child());
|
||||||
|
});
|
||||||
|
el.append(node);
|
||||||
|
} else if (typeof child === 'object') {
|
||||||
|
el.append(child as Node);
|
||||||
|
} else {
|
||||||
|
el.append(String(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** hyperscript 入口:h(tag, props, ...children);on* 为事件,函数值为响应式属性 ***/
|
||||||
|
export function h(tag: string, props?: Props | null, ...children: unknown[]): HTMLElement {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
for (const [key, value] of Object.entries(props ?? {})) {
|
||||||
|
if (key.startsWith('on') && typeof value === 'function') {
|
||||||
|
el.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
|
||||||
|
} else if (typeof value === 'function') {
|
||||||
|
bindProp(el, key, value as () => unknown);
|
||||||
|
} else if (key === 'class') {
|
||||||
|
el.className = String(value);
|
||||||
|
} else {
|
||||||
|
bindStatic(el, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of children.flat(Infinity)) appendChild(el, child);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 列表渲染:信号变化时整体替换容器子节点(tbody / 列表等场景) ***/
|
||||||
|
export function renderList(el: HTMLElement, fn: () => Node[]): void {
|
||||||
|
effect(() => {
|
||||||
|
el.replaceChildren(...fn());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 挂载视图到容器;容器不存在时静默跳过 ***/
|
||||||
|
export function mount(target: Element | string, view: () => Node): void {
|
||||||
|
const root = typeof target === 'string' ? document.querySelector(target) : target;
|
||||||
|
if (!root) return; // 卫语句:容器不存在
|
||||||
|
root.replaceChildren(view());
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './signal.js';
|
||||||
|
export * from './dom.js';
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 当前正在收集依赖的 effect;null 表示非追踪态
|
||||||
|
let active: (() => void) | null = null;
|
||||||
|
|
||||||
|
/*** 响应式信号:无参调用读值并收集依赖,传参写值并触发订阅 ***/
|
||||||
|
export interface Signal<T> {
|
||||||
|
(): T;
|
||||||
|
(value: T): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 创建信号:框架状态的最小单元 ***/
|
||||||
|
export function signal<T>(initial: T): Signal<T> {
|
||||||
|
let value = initial;
|
||||||
|
const subs = new Set<() => void>();
|
||||||
|
const read = (...args: unknown[]) => {
|
||||||
|
if (args.length === 0) {
|
||||||
|
if (active) subs.add(active); // 依赖收集
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const v = args[0] as T;
|
||||||
|
if (Object.is(v, value)) return; // 值未变不触发
|
||||||
|
value = v;
|
||||||
|
for (const fn of [...subs]) fn(); // 拷贝后触发,防遍历中修改
|
||||||
|
};
|
||||||
|
return read as Signal<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑 ***/
|
||||||
|
export function effect(fn: () => void): () => void {
|
||||||
|
const run = () => {
|
||||||
|
const prev = active;
|
||||||
|
active = run;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
active = prev;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
/* 演示级实现:不做依赖清理 */
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 计算值:由信号派生的只读视图,同样参与上层依赖收集 ***/
|
||||||
|
export function computed<T>(fn: () => T): () => T {
|
||||||
|
const out = signal<T>(undefined as T);
|
||||||
|
effect(() => out(fn()));
|
||||||
|
return () => out();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/frame",
|
||||||
|
"type": "headless-core",
|
||||||
|
"description": "自研微框架:signal / computed / effect 信号系统 + hyperscript(h)DOM 层。函数值属性与函数型子节点自动响应式;renderList 做列表整体替换;零依赖、无虚拟 DOM。",
|
||||||
|
"tags": [
|
||||||
|
"framework",
|
||||||
|
"signal",
|
||||||
|
"reactive",
|
||||||
|
"hyperscript"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "src/index.ts",
|
||||||
|
"exports": [
|
||||||
|
"signal(initial) -> Signal<T>:无参读值并收集依赖,传参写值并触发订阅",
|
||||||
|
"effect(fn):立即执行并追踪依赖,信号变化自动重跑",
|
||||||
|
"computed(fn):由信号派生的只读计算值",
|
||||||
|
"h(tag, props, ...children):hyperscript;on* 绑事件,函数值属性/子节点自动响应式",
|
||||||
|
"renderList(el, fn):信号变化时整体替换容器子节点",
|
||||||
|
"mount(target, view):挂载视图"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"Signal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "const count = signal(0); mount('#app', () => h('button', { onClick: () => count(count() + 1) }, () => `count = ${count()}`))",
|
||||||
|
"conventions": [
|
||||||
|
"组件 = 返回 DOM 节点的普通函数,不做类组件与生命周期抽象",
|
||||||
|
"跨系统状态(如 table-core)用桥接信号:subscribe 里递增 version 信号"
|
||||||
|
],
|
||||||
|
"docs": {
|
||||||
|
"intro": "零依赖微框架:组件 = 返回 DOM 的普通函数。signal/effect/computed 管状态,h() 管 DOM,无虚拟 DOM 无生命周期抽象。",
|
||||||
|
"steps": [
|
||||||
|
"引入:import { signal, computed, effect, h, mount } from '@ui-arch/frame'",
|
||||||
|
"声明状态:const count = signal(0);无参调用读值 count(),传参写值 count(1)",
|
||||||
|
"渲染视图:mount('#app', () => h('div', null, () => `count = ${count()}`))——函数型子节点即响应式文本",
|
||||||
|
"绑定事件:props 里 on 开头且值为函数的键(onClick 等)自动 addEventListener",
|
||||||
|
"派生数据:const double = computed(() => count() * 2),视图里直接调用 double(),依赖自动追踪",
|
||||||
|
"列表渲染:renderList(tbody, () => rows.map(r => h('tr', null, r.name))),信号变化整体替换子节点"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/table-core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Headless, framework-agnostic table state core with a composable plugin pipeline",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"smoke": "node scripts/smoke.mjs"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"table",
|
||||||
|
"headless",
|
||||||
|
"datagrid",
|
||||||
|
"framework-agnostic"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
createTable,
|
||||||
|
filterPlugin,
|
||||||
|
paginationPlugin,
|
||||||
|
selectionPlugin,
|
||||||
|
sortPlugin,
|
||||||
|
} from '../dist/index.js';
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{ id: 1, name: 'Charlie', age: 35, city: 'Shenzhen' },
|
||||||
|
{ id: 2, name: 'Alice', age: 28, city: 'Beijing' },
|
||||||
|
{ id: 3, name: 'Bob', age: 31, city: 'Shanghai' },
|
||||||
|
{ id: 4, name: 'Dave', age: 22, city: 'Beijing' },
|
||||||
|
{ id: 5, name: 'Eve', age: 29, city: 'Shanghai' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = createTable({
|
||||||
|
data: users,
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', header: 'Name', sortable: true },
|
||||||
|
{ key: 'age', header: 'Age', sortable: true },
|
||||||
|
{ key: 'city', header: 'City' },
|
||||||
|
],
|
||||||
|
rowKey: 'id',
|
||||||
|
plugins: [filterPlugin(), sortPlugin(), paginationPlugin(), selectionPlugin()],
|
||||||
|
initialState: { pagination: { page: 1, pageSize: 2 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
/*** 断言工具:失败即退出 ***/
|
||||||
|
const assert = (cond, msg) => {
|
||||||
|
if (!cond) {
|
||||||
|
console.error('FAIL:', msg);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('ok -', msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
table.dispatch({ type: 'toggleSort', key: 'name' }); // 按名称升序
|
||||||
|
assert(
|
||||||
|
JSON.stringify(table.getVisibleRows().rows.map((r) => r.name)) ===
|
||||||
|
JSON.stringify(['Alice', 'Bob']),
|
||||||
|
'sort by name asc + page 1 of size 2',
|
||||||
|
);
|
||||||
|
|
||||||
|
table.dispatch({ type: 'setFilter', key: 'city', value: 'shang' }); // 筛选上海
|
||||||
|
const filtered = table.getVisibleRows();
|
||||||
|
assert(
|
||||||
|
JSON.stringify(filtered.rows.map((r) => r.name)) ===
|
||||||
|
JSON.stringify(['Bob', 'Eve']) &&
|
||||||
|
filtered.total === 2,
|
||||||
|
'filter city~sh keeps Shanghai rows, total=2',
|
||||||
|
);
|
||||||
|
assert(table.getPageInfo().totalPages === 1, 'totalPages computed from filtered total');
|
||||||
|
|
||||||
|
table.dispatch({ type: 'clearFilters' }); // 清筛选回到全量
|
||||||
|
table.dispatch({ type: 'setPage', page: 2 });
|
||||||
|
assert(
|
||||||
|
JSON.stringify(table.getVisibleRows().rows.map((r) => r.name)) ===
|
||||||
|
JSON.stringify(['Charlie', 'Dave']),
|
||||||
|
'page 2 after clearing filter',
|
||||||
|
);
|
||||||
|
assert(table.getPageInfo().page === 2 && table.getPageInfo().totalPages === 3, 'page info total=3 pages');
|
||||||
|
|
||||||
|
table.dispatch({ type: 'toggleSelect', key: 3 }); // 勾选行 3
|
||||||
|
assert(JSON.stringify(table.getState().selection) === JSON.stringify([3]), 'toggleSelect adds rowKey');
|
||||||
|
table.selectAllVisible(); // 全选当前页(替换语义)
|
||||||
|
assert(
|
||||||
|
JSON.stringify(table.getState().selection) === JSON.stringify([1, 4]),
|
||||||
|
'selectAllVisible replaces selection with visible rowKeys',
|
||||||
|
);
|
||||||
|
|
||||||
|
table.dispatch({ type: 'setPage', page: 99 }); // 越界页码
|
||||||
|
const clamped = table.getVisibleRows();
|
||||||
|
assert(
|
||||||
|
table.getPageInfo().page === 3 && clamped.rows.length === 1,
|
||||||
|
'page clamped to totalPages',
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = table.getVisibleRows(); // memo 基线
|
||||||
|
table.dispatch({ type: 'clearSelection' }); // 无关状态变更
|
||||||
|
assert(table.getVisibleRows() === before, 'derive stages memoized across unrelated state changes');
|
||||||
|
|
||||||
|
console.log('SMOKE OK');
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type {
|
||||||
|
Action,
|
||||||
|
DeriveContext,
|
||||||
|
RowKey,
|
||||||
|
RowModel,
|
||||||
|
TableInstance,
|
||||||
|
TableOptions,
|
||||||
|
TablePlugin,
|
||||||
|
TableState,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/*** 创建表格实例:状态机 + 派生管线 + 插件 API 合并,框架无关、零渲染 ***/
|
||||||
|
export function createTable<T>(
|
||||||
|
options: TableOptions<T>,
|
||||||
|
): TableInstance<T> & Record<string, unknown> {
|
||||||
|
const { data, columns, plugins = [], onStateChange } = options;
|
||||||
|
|
||||||
|
const rowKey = options.rowKey ?? ('id' as keyof T & string); // 默认行标识
|
||||||
|
const getKey: (row: T) => RowKey =
|
||||||
|
typeof rowKey === 'function'
|
||||||
|
? rowKey
|
||||||
|
: (row) => row[rowKey] as unknown as RowKey;
|
||||||
|
|
||||||
|
const defaultState: TableState = {
|
||||||
|
sorting: [],
|
||||||
|
filters: [],
|
||||||
|
pagination: { page: 1, pageSize: 20 },
|
||||||
|
selection: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx: DeriveContext<T> = { getCellValue, getKey }; // 派生上下文(getCellValue 有函数提升)
|
||||||
|
|
||||||
|
let state: TableState = { ...defaultState, ...options.initialState };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const corePatch = (s: TableState, a: Action): TableState =>
|
||||||
|
a.type === 'reset' ? { ...defaultState } : s; // 核心只认 reset,其余归插件
|
||||||
|
|
||||||
|
const patchers = [
|
||||||
|
corePatch,
|
||||||
|
...plugins
|
||||||
|
.map((p) => p.patchState)
|
||||||
|
.filter((p): p is NonNullable<typeof p> => typeof p === 'function'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/*** 派发动作:依序过各 patchState,状态有变才触发回调与订阅通知 ***/
|
||||||
|
function dispatch(action: Action): void {
|
||||||
|
let next = state;
|
||||||
|
for (const patch of patchers) next = patch(next, action);
|
||||||
|
if (next === state) return; // 无变化不通知
|
||||||
|
state = next;
|
||||||
|
onStateChange?.(state);
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 订阅状态变更,返回取消订阅函数 ***/
|
||||||
|
function subscribe(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 取单元格值:优先列 accessor,缺省取 row[key] ***/
|
||||||
|
function getCellValue(row: T, key: string): unknown {
|
||||||
|
const col = columns.find((c) => c.key === key);
|
||||||
|
if (col?.accessor) return col.accessor(row);
|
||||||
|
return (row as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础行模型按 data 引用 memo,避免重复建 keys
|
||||||
|
let lastData: T[] | null = null;
|
||||||
|
let cachedBase: RowModel<T> | null = null;
|
||||||
|
|
||||||
|
function baseModel(): RowModel<T> {
|
||||||
|
if (data !== lastData || !cachedBase) {
|
||||||
|
cachedBase = { rows: data, keys: data.map(getKey), total: data.length };
|
||||||
|
lastData = data;
|
||||||
|
}
|
||||||
|
return cachedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 可见行模型:依序过插件 derive(filter → sort → paginate) ***/
|
||||||
|
function getVisibleRows(): RowModel<T> {
|
||||||
|
let model = baseModel();
|
||||||
|
for (const p of plugins) {
|
||||||
|
if (p.derive) model = p.derive(model, state, ctx);
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance: TableInstance<T> = {
|
||||||
|
getState: () => state,
|
||||||
|
dispatch,
|
||||||
|
subscribe,
|
||||||
|
getVisibleRows,
|
||||||
|
getColumns: () => columns,
|
||||||
|
getCellValue,
|
||||||
|
isRowSelected: (key) => state.selection.includes(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插件 API 合并到实例(setPage / selectAllVisible 等)
|
||||||
|
const pluginApis: Record<string, unknown> = {};
|
||||||
|
for (const p of plugins) {
|
||||||
|
if (p.api) Object.assign(pluginApis, p.api(instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign(instance, pluginApis) as TableInstance<T> &
|
||||||
|
Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './types.js';
|
||||||
|
export { createTable } from './core.js';
|
||||||
|
export { sortPlugin } from './plugins/sort.js';
|
||||||
|
export type { SortPluginOptions } from './plugins/sort.js';
|
||||||
|
export { filterPlugin } from './plugins/filter.js';
|
||||||
|
export type { FilterPluginOptions } from './plugins/filter.js';
|
||||||
|
export { paginationPlugin } from './plugins/pagination.js';
|
||||||
|
export type { PageInfo } from './plugins/pagination.js';
|
||||||
|
export { selectionPlugin } from './plugins/selection.js';
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type {
|
||||||
|
Action,
|
||||||
|
DeriveContext,
|
||||||
|
FilterRule,
|
||||||
|
RowKey,
|
||||||
|
RowModel,
|
||||||
|
TablePlugin,
|
||||||
|
TableState,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
/*** 筛选插件入参 ***/
|
||||||
|
export interface FilterPluginOptions<T> {
|
||||||
|
predicate?: (cellValue: unknown, filterValue: unknown, row: T) => boolean; // 自定义匹配谓词;缺省大小写不敏感子串匹配
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 列筛选插件:'' / null 表示清除该列筛选;按(输入模型, filters 切片)memo ***/
|
||||||
|
export function filterPlugin<T = unknown>(
|
||||||
|
opts: FilterPluginOptions<T> = {},
|
||||||
|
): TablePlugin<T> {
|
||||||
|
const matches =
|
||||||
|
opts.predicate ??
|
||||||
|
((value: unknown, filterValue: unknown) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(String(filterValue ?? '').toLowerCase()));
|
||||||
|
|
||||||
|
let lastInput: RowModel<T> | null = null;
|
||||||
|
let lastFilters: FilterRule[] | null = null;
|
||||||
|
let cached: RowModel<T> | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'filter',
|
||||||
|
patchState(state: TableState, action: Action): TableState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'setFilter': {
|
||||||
|
const filters = state.filters.filter((f) => f.key !== action.key);
|
||||||
|
if (action.value !== '' && action.value != null) filters.push({ key: action.key, value: action.value });
|
||||||
|
return { ...state, filters };
|
||||||
|
}
|
||||||
|
case 'clearFilters':
|
||||||
|
return state.filters.length ? { ...state, filters: [] } : state;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
derive(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T> {
|
||||||
|
if (model === lastInput && state.filters === lastFilters && cached) return cached;
|
||||||
|
const { filters } = state;
|
||||||
|
let out = model;
|
||||||
|
if (filters.length > 0) {
|
||||||
|
const rows: T[] = [];
|
||||||
|
const keys: RowKey[] = [];
|
||||||
|
for (let i = 0; i < model.rows.length; i++) {
|
||||||
|
const row = model.rows[i];
|
||||||
|
if (!filters.every((f) => matches(ctx.getCellValue(row, f.key), f.value, row))) continue;
|
||||||
|
rows.push(row);
|
||||||
|
keys.push(model.keys[i]);
|
||||||
|
}
|
||||||
|
out = { rows, keys, total: rows.length }; // total = 筛选后行数,供分页算页数
|
||||||
|
}
|
||||||
|
cached = out;
|
||||||
|
lastInput = model;
|
||||||
|
lastFilters = state.filters;
|
||||||
|
return cached;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
Action,
|
||||||
|
RowModel,
|
||||||
|
TableInstance,
|
||||||
|
TablePlugin,
|
||||||
|
TableState,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
/*** 分页快照:由上游 total 推导的派生量,语义与 RowModel.total 一致 ***/
|
||||||
|
export interface PageInfo {
|
||||||
|
page: number; // 当前页(钳制后实际生效值)
|
||||||
|
pageSize: number; // 每页行数
|
||||||
|
totalPages: number; // 总页数 = ceil(total / pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 客户端分页插件:读入参 total 算页数,越界页在派生时钳制;按(输入模型, pagination 切片)memo ***/
|
||||||
|
export function paginationPlugin<T = unknown>(): TablePlugin<T> {
|
||||||
|
let lastInput: RowModel<T> | null = null;
|
||||||
|
let lastPage: { page: number; pageSize: number } | null = null;
|
||||||
|
let cached: RowModel<T> | null = null;
|
||||||
|
let pageInfo: PageInfo = { page: 1, pageSize: 20, totalPages: 1 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'pagination',
|
||||||
|
patchState(state: TableState, action: Action): TableState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'setPage':
|
||||||
|
return { ...state, pagination: { ...state.pagination, page: action.page } };
|
||||||
|
case 'setPageSize':
|
||||||
|
return { ...state, pagination: { page: 1, pageSize: action.pageSize } }; // 改页大小回到第 1 页
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
derive(model: RowModel<T>, state: TableState): RowModel<T> {
|
||||||
|
if (model === lastInput && state.pagination === lastPage && cached) return cached;
|
||||||
|
const { page, pageSize } = state.pagination;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(model.total / pageSize));
|
||||||
|
const safePage = Math.min(Math.max(1, page), totalPages); // 越界钳制,视图不空白
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
cached = {
|
||||||
|
rows: model.rows.slice(start, start + pageSize),
|
||||||
|
keys: model.keys.slice(start, start + pageSize),
|
||||||
|
total: model.total,
|
||||||
|
};
|
||||||
|
pageInfo = { page: safePage, pageSize, totalPages };
|
||||||
|
lastInput = model;
|
||||||
|
lastPage = state.pagination;
|
||||||
|
return cached;
|
||||||
|
},
|
||||||
|
api(table: TableInstance<T>) {
|
||||||
|
return {
|
||||||
|
setPage: (page: number) => table.dispatch({ type: 'setPage', page }),
|
||||||
|
nextPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page + 1 }),
|
||||||
|
prevPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page - 1 }),
|
||||||
|
setPageSize: (pageSize: number) => table.dispatch({ type: 'setPageSize', pageSize }),
|
||||||
|
getPageInfo: (): PageInfo => ({ ...pageInfo }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Action, TableInstance, TablePlugin, TableState } from '../types.js';
|
||||||
|
|
||||||
|
/*** 行选择插件:一律存 rowKey 而非索引,跨页、排序、异步刷新都不丢失 ***/
|
||||||
|
export function selectionPlugin<T = unknown>(): TablePlugin<T> {
|
||||||
|
return {
|
||||||
|
name: 'selection',
|
||||||
|
patchState(state: TableState, action: Action): TableState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'setSelection':
|
||||||
|
return { ...state, selection: [...action.keys] }; // 整体替换语义
|
||||||
|
case 'toggleSelect': {
|
||||||
|
const has = state.selection.includes(action.key);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selection: has
|
||||||
|
? state.selection.filter((k) => k !== action.key)
|
||||||
|
: [...state.selection, action.key],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'clearSelection':
|
||||||
|
return state.selection.length ? { ...state, selection: [] } : state;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
api(table: TableInstance<T>) {
|
||||||
|
return {
|
||||||
|
selectAllVisible: () => {
|
||||||
|
table.dispatch({ type: 'setSelection', keys: [...table.getVisibleRows().keys] });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type {
|
||||||
|
Action,
|
||||||
|
DeriveContext,
|
||||||
|
RowModel,
|
||||||
|
SortRule,
|
||||||
|
TablePlugin,
|
||||||
|
TableState,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
/*** 值比较:数值按大小、其余按本地化字符串序,null 恒排末尾 ***/
|
||||||
|
function compareValues(a: unknown, b: unknown): number {
|
||||||
|
if (a == null && b == null) return 0;
|
||||||
|
if (a == null) return 1;
|
||||||
|
if (b == null) return -1;
|
||||||
|
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||||
|
return String(a).localeCompare(String(b), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 排序插件入参 ***/
|
||||||
|
export interface SortPluginOptions {
|
||||||
|
initialDir?: 'asc' | 'desc'; // 首次点击列头时的排序方向,默认升序
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 多列排序插件:点击循环 asc → desc → 取消;按(输入模型, sorting 切片)memo,翻页不重排 ***/
|
||||||
|
export function sortPlugin<T = unknown>(
|
||||||
|
opts: SortPluginOptions = {},
|
||||||
|
): TablePlugin<T> {
|
||||||
|
const initialDir = opts.initialDir ?? 'asc';
|
||||||
|
|
||||||
|
let lastInput: RowModel<T> | null = null;
|
||||||
|
let lastRules: SortRule[] | null = null;
|
||||||
|
let cached: RowModel<T> | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'sort',
|
||||||
|
patchState(state: TableState, action: Action): TableState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'setSorting':
|
||||||
|
return { ...state, sorting: action.sorting };
|
||||||
|
case 'toggleSort': {
|
||||||
|
const rules = action.multi ? [...state.sorting] : []; // 非 multi 直接单列重置
|
||||||
|
const idx = rules.findIndex((r) => r.key === action.key);
|
||||||
|
if (idx === -1) rules.push({ key: action.key, dir: initialDir });
|
||||||
|
else if (rules[idx].dir === 'asc') rules[idx] = { key: action.key, dir: 'desc' };
|
||||||
|
else rules.splice(idx, 1); // desc 再点即取消
|
||||||
|
return { ...state, sorting: rules };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
derive(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T> {
|
||||||
|
if (model === lastInput && state.sorting === lastRules && cached) return cached;
|
||||||
|
const rules = state.sorting;
|
||||||
|
let rows = model.rows;
|
||||||
|
let keys = model.keys;
|
||||||
|
if (rules.length > 0) {
|
||||||
|
const order = rows.map((_, i) => i); // 只排索引,避免行对象来回搬
|
||||||
|
order.sort((a, b) => {
|
||||||
|
for (const rule of rules) {
|
||||||
|
const cmp = compareValues(
|
||||||
|
ctx.getCellValue(rows[a], rule.key),
|
||||||
|
ctx.getCellValue(rows[b], rule.key),
|
||||||
|
);
|
||||||
|
if (cmp !== 0) return rule.dir === 'asc' ? cmp : -cmp;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
rows = order.map((i) => model.rows[i]);
|
||||||
|
keys = order.map((i) => model.keys[i]);
|
||||||
|
}
|
||||||
|
cached = { rows, keys, total: model.total };
|
||||||
|
lastInput = model;
|
||||||
|
lastRules = state.sorting;
|
||||||
|
return cached;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*** 行唯一标识:跨页 / 异步刷新保持稳定 ***/
|
||||||
|
export type RowKey = string | number;
|
||||||
|
|
||||||
|
/*** 列定义:数据语义(accessor)与渲染语义(cell)分离 ***/
|
||||||
|
export interface ColumnDef<T = unknown> {
|
||||||
|
key: string; // 列标识,与数据字段对应
|
||||||
|
header?: string; // 表头文案
|
||||||
|
accessor?: (row: T) => unknown; // 取值器;缺省按 row[key] 直取
|
||||||
|
sortable?: boolean; // 是否允许排序
|
||||||
|
filterable?: boolean; // 是否允许筛选
|
||||||
|
width?: number | 'flex'; // 固定像素或弹性填充
|
||||||
|
pinned?: 'left' | 'right'; // 固定列方向
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 一条排序规则 ***/
|
||||||
|
export interface SortRule {
|
||||||
|
key: string; // 排序字段,对应 ColumnDef.key
|
||||||
|
dir: 'asc' | 'desc'; // 排序方向
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 一条筛选规则 ***/
|
||||||
|
export interface FilterRule {
|
||||||
|
key: string; // 筛选字段,对应 ColumnDef.key
|
||||||
|
value: unknown; // 筛选值;'' / null 语义上等于清除
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 分页状态 ***/
|
||||||
|
export interface PaginationState {
|
||||||
|
page: number; // 当前页,从 1 起
|
||||||
|
pageSize: number; // 每页行数
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表格状态:不可变,一切变更走 Action ***/
|
||||||
|
export interface TableState {
|
||||||
|
sorting: SortRule[]; // 多列排序,按数组序生效
|
||||||
|
filters: FilterRule[]; // 列筛选,逐条 AND
|
||||||
|
pagination: PaginationState; // 分页
|
||||||
|
selection: RowKey[]; // 已选行,存 rowKey 而非行索引
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 全部动作:状态变更的唯一入口 ***/
|
||||||
|
export type Action =
|
||||||
|
| { type: 'reset' } // 重置回默认状态
|
||||||
|
| { type: 'setSorting'; sorting: SortRule[] } // 整体替换排序规则
|
||||||
|
| { type: 'toggleSort'; key: string; multi?: boolean } // 点列头循环 asc → desc → 取消;multi 追加多列
|
||||||
|
| { type: 'setFilter'; key: string; value: unknown } // 设单列筛选;'' / null 清除该列
|
||||||
|
| { type: 'clearFilters' } // 清空全部筛选
|
||||||
|
| { type: 'setPage'; page: number } // 跳页,越界由派生层钳制
|
||||||
|
| { type: 'setPageSize'; pageSize: number } // 改页大小并回到第 1 页
|
||||||
|
| { type: 'setSelection'; keys: RowKey[] } // 整体替换选中集
|
||||||
|
| { type: 'toggleSelect'; key: RowKey } // 反选单行
|
||||||
|
| { type: 'clearSelection' } // 清空选择
|
||||||
|
;
|
||||||
|
|
||||||
|
/*** 流经派生管线的数据;total 语义全链路一致 = 筛选后、分页前的行数 ***/
|
||||||
|
export interface RowModel<T = unknown> {
|
||||||
|
rows: T[]; // 行数据,顺序即管线处理后的顺序
|
||||||
|
keys: RowKey[]; // 与 rows 一一对应的行标识
|
||||||
|
total: number; // 分页 UI 算页数的依据
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 派生上下文:核心注入给插件的取值能力 ***/
|
||||||
|
export interface DeriveContext<T = unknown> {
|
||||||
|
getCellValue(row: T, key: string): unknown; // 单元格取值(走列 accessor)
|
||||||
|
getKey(row: T): RowKey; // 行标识取值
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 插件三钩子:patchState 改状态、derive 插入数据管线、api 向实例注入方法 ***/
|
||||||
|
export interface TablePlugin<T = unknown> {
|
||||||
|
name: string; // 插件名,诊断用
|
||||||
|
patchState?(state: TableState, action: Action): TableState; // 处理自己关心的动作,无关动作原样返回
|
||||||
|
derive?(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T>; // 插入派生管线阶段
|
||||||
|
api?(table: TableInstance<T>): Record<string, unknown>; // 向实例注入便捷方法
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 建表入参 ***/
|
||||||
|
export interface TableOptions<T> {
|
||||||
|
data: T[]; // 全量行数据
|
||||||
|
columns: ColumnDef<T>[]; // 列定义
|
||||||
|
rowKey?: (keyof T & string) | ((row: T) => RowKey); // 行标识字段名或取值函数;缺省 'id'
|
||||||
|
initialState?: Partial<TableState>; // 初始状态,逐项浅合并
|
||||||
|
plugins?: TablePlugin<T>[]; // 插件按数组序组成派生管线
|
||||||
|
onStateChange?(state: TableState): void; // 状态变更回调(受控 / 持久化用)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表格实例:框架适配层只消费这里的面 ***/
|
||||||
|
export interface TableInstance<T = unknown> {
|
||||||
|
getState(): TableState; // 当前状态快照
|
||||||
|
dispatch(action: Action): void; // 派发动作,唯一变更入口
|
||||||
|
subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数
|
||||||
|
getVisibleRows(): RowModel<T>; // 可见行模型(过完整管线)
|
||||||
|
getColumns(): ColumnDef<T>[]; // 列定义
|
||||||
|
getCellValue(row: T, key: string): unknown; // 单元格取值
|
||||||
|
isRowSelected(key: RowKey): boolean; // 行是否已选
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/table-core",
|
||||||
|
"type": "headless-core",
|
||||||
|
"description": "Headless table state machine: immutable state, dispatch/reducer, composable derive pipeline (filter -> sort -> paginate) with per-stage memoization. No DOM, no framework dependency.",
|
||||||
|
"tags": [
|
||||||
|
"table",
|
||||||
|
"headless",
|
||||||
|
"state-machine",
|
||||||
|
"datagrid"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "src/index.ts",
|
||||||
|
"exports": [
|
||||||
|
"createTable(options) -> TableInstance: create headless table instance",
|
||||||
|
"sortPlugin(options?) -> multi-column sorting plugin (asc -> desc -> off cycle)",
|
||||||
|
"filterPlugin(options?) -> column filter plugin (default substring match, custom predicate supported)",
|
||||||
|
"paginationPlugin() -> client-side pagination with page clamping, exposes setPage/nextPage/prevPage/getPageInfo",
|
||||||
|
"selectionPlugin() -> rowKey-based selection, exposes selectAllVisible"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"ColumnDef",
|
||||||
|
"TableState",
|
||||||
|
"TablePlugin",
|
||||||
|
"RowModel",
|
||||||
|
"Action"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()], initialState: { pagination: { page: 1, pageSize: 20 } } }); table.dispatch({ type: 'toggleSort', key: 'name' }); table.getVisibleRows()",
|
||||||
|
"conventions": [
|
||||||
|
"selection/expanded must store rowKey, never row index",
|
||||||
|
"derive pipeline order is fixed: filter -> sort -> paginate",
|
||||||
|
"actions are the only way to mutate state; state is immutable"
|
||||||
|
],
|
||||||
|
"docs": {
|
||||||
|
"intro": "Headless 表格内核:不渲染任何 DOM,只管理状态与派生。任何渲染层通过 getVisibleRows() 拿行模型,配 subscribe() 刷新。",
|
||||||
|
"steps": [
|
||||||
|
"创建实例:const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()] })",
|
||||||
|
"取派生数据:table.getVisibleRows() 返回 { rows, total };total 语义固定 = 筛选后、分页前的行数",
|
||||||
|
"变更状态:只能 dispatch action,如 table.dispatch({ type: 'toggleSort', key: 'name' });状态不可变",
|
||||||
|
"订阅刷新:table.subscribe(fn),fn 里重新 getVisibleRows() 并交给渲染层",
|
||||||
|
"分页信息:api.getPageInfo() 返回 { page, pageSize, totalPages };越界页码自动钳制",
|
||||||
|
"扩展能力:实现 TablePlugin(derive / patchState / api 三钩子)传入 plugins 即插入管线"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/tokens",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Design tokens for the @ui-arch component library (CSS variables + JSON)",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./tokens.css": "./tokens.css",
|
||||||
|
"./tokens.json": "./tokens.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"tokens.css",
|
||||||
|
"tokens.json"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* @ui-arch design tokens - light theme defaults + dark theme overrides */
|
||||||
|
:root {
|
||||||
|
--ua-color-bg: #ffffff;
|
||||||
|
--ua-color-surface: #f6f7f9;
|
||||||
|
--ua-color-text: #1c1e21;
|
||||||
|
--ua-color-text-muted: #6b7280;
|
||||||
|
--ua-color-border: rgba(0, 0, 0, 0.15);
|
||||||
|
--ua-color-accent: #185fa5;
|
||||||
|
--ua-color-accent-contrast: #ffffff;
|
||||||
|
--ua-color-danger: #a32d2d;
|
||||||
|
--ua-color-success: #0f6e56;
|
||||||
|
--ua-color-warning: #9a6b1f;
|
||||||
|
|
||||||
|
--ua-radius-sm: 4px;
|
||||||
|
--ua-radius-md: 8px;
|
||||||
|
--ua-radius-lg: 12px;
|
||||||
|
|
||||||
|
--ua-space-1: 4px;
|
||||||
|
--ua-space-2: 8px;
|
||||||
|
--ua-space-3: 12px;
|
||||||
|
--ua-space-4: 16px;
|
||||||
|
--ua-space-table-cell: var(--ua-space-2) var(--ua-space-3);
|
||||||
|
|
||||||
|
--ua-font-size-sm: 12px;
|
||||||
|
--ua-font-size-md: 13px;
|
||||||
|
--ua-font-size-lg: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--ua-color-bg: #101216;
|
||||||
|
--ua-color-surface: #1a1c22;
|
||||||
|
--ua-color-text: #e7e9ec;
|
||||||
|
--ua-color-text-muted: #888780;
|
||||||
|
--ua-color-border: rgba(255, 255, 255, 0.15);
|
||||||
|
--ua-color-accent: #378add;
|
||||||
|
--ua-color-accent-contrast: #042c53;
|
||||||
|
--ua-color-danger: #d96a6a;
|
||||||
|
--ua-color-success: #4fc39e;
|
||||||
|
--ua-color-warning: #d9a94f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://design-tokens.github.io/community-group/format/",
|
||||||
|
"color": {
|
||||||
|
"bg": { "light": "#ffffff", "dark": "#101216" },
|
||||||
|
"surface": { "light": "#f6f7f9", "dark": "#1a1c22" },
|
||||||
|
"text": { "light": "#1c1e21", "dark": "#e7e9ec" },
|
||||||
|
"textMuted": { "light": "#6b7280", "dark": "#888780" },
|
||||||
|
"accent": { "light": "#185fa5", "dark": "#378add" },
|
||||||
|
"danger": "#a32d2d",
|
||||||
|
"success": "#0f6e56"
|
||||||
|
},
|
||||||
|
"radius": { "sm": "4px", "md": "8px", "lg": "12px" },
|
||||||
|
"space": { "1": "4px", "2": "8px", "3": "12px", "4": "16px" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/tokens",
|
||||||
|
"type": "design-tokens",
|
||||||
|
"description": "Single source of design tokens. Skin packages consume CSS variables only - hardcoded colors inside skins are forbidden. Dark theme overrides values under [data-theme='dark'], never names.",
|
||||||
|
"tags": [
|
||||||
|
"tokens",
|
||||||
|
"theme",
|
||||||
|
"css-variables"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "tokens.css",
|
||||||
|
"exports": [
|
||||||
|
"tokens.css: import '@ui-arch/tokens/tokens.css' once at app root",
|
||||||
|
"tokens.json: programmatic access for tools and agents"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"--ua-color-*",
|
||||||
|
"--ua-space-*",
|
||||||
|
"--ua-radius-*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "import '@ui-arch/tokens/tokens.css'; then use var(--ua-color-accent) etc. in any skin package",
|
||||||
|
"docs": {
|
||||||
|
"intro": "设计令牌唯一来源:CSS 变量 --ua-* + 同名 JSON。皮肤只允许消费变量,禁止硬编码颜色;暗色只换值不换名。",
|
||||||
|
"steps": [
|
||||||
|
"引入:在应用入口 import '@ui-arch/tokens/tokens.css'",
|
||||||
|
"消费:组件样式一律 var(--ua-color-accent) 这类变量,不写死色值",
|
||||||
|
"暗色:根元素设 data-theme=dark,变量值自动切换",
|
||||||
|
"扩展:新令牌先同时加进 tokens.css 与 tokens.json,再重新生成文档站"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "DOM component skin for @ui-arch: 15 components built on frame + table-core, token-driven styles",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./ui.css": "./ui.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"ui.css"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ui-arch/frame": "workspace:*",
|
||||||
|
"@ui-arch/table-core": "workspace:*",
|
||||||
|
"@ui-arch/tokens": "workspace:*"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"components",
|
||||||
|
"dom",
|
||||||
|
"skin",
|
||||||
|
"design-system"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { h } from '@ui-arch/frame';
|
||||||
|
import type { AlertProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 提示条:variant 决定语义色,纯展示不持有状态 ***/
|
||||||
|
export function alert(props: AlertProps): HTMLElement {
|
||||||
|
return h('div', { class: `ua-alert ua-alert-${props.variant ?? 'info'}` }, props.message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { h } from '@ui-arch/frame';
|
||||||
|
import type { BadgeProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 徽标:variant 决定语义色,纯展示不持有状态 ***/
|
||||||
|
export function badge(props: BadgeProps): HTMLElement {
|
||||||
|
return h('span', { class: `ua-badge ua-badge-${props.variant ?? 'default'}` }, props.label);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { h } from '@ui-arch/frame';
|
||||||
|
import type { ButtonProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 按钮:variant 控制视觉强度,onClick 为点击回调 ***/
|
||||||
|
export function button(props: ButtonProps): HTMLElement {
|
||||||
|
return h(
|
||||||
|
'button',
|
||||||
|
{ class: `ua-btn ua-btn-${props.variant ?? 'primary'}`, onClick: props.onClick },
|
||||||
|
props.label,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { h } from '@ui-arch/frame';
|
||||||
|
import type { CardProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 卡片容器:可选标题 + 任意子内容 ***/
|
||||||
|
export function card(props: CardProps, ...children: unknown[]): HTMLElement {
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ class: 'ua-card' },
|
||||||
|
props.title ? h('h3', { class: 'ua-card-title' }, props.title) : null,
|
||||||
|
...children,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { CheckboxProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 复选框:checked 信号双向绑定,onChange 为变更回调 ***/
|
||||||
|
export function checkbox(props: CheckboxProps): HTMLElement {
|
||||||
|
const el = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||||
|
if (props.checked) {
|
||||||
|
const checked = props.checked;
|
||||||
|
el.addEventListener('change', () => checked(el.checked)); // 用户勾选写回信号
|
||||||
|
effect(() => {
|
||||||
|
if (el.checked !== checked()) el.checked = checked(); // 外部变化写回勾选框
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (props.onChange) el.addEventListener('change', () => props.onChange!(el.checked));
|
||||||
|
if (!props.label) return el;
|
||||||
|
const wrap = h('label', { class: 'ua-check-row' }, el, props.label);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
createTable,
|
||||||
|
filterPlugin,
|
||||||
|
paginationPlugin,
|
||||||
|
selectionPlugin,
|
||||||
|
sortPlugin,
|
||||||
|
} from '@ui-arch/table-core';
|
||||||
|
import type { PageInfo, RowKey, TableInstance } from '@ui-arch/table-core';
|
||||||
|
import { computed, effect, h, renderList, signal } from '@ui-arch/frame';
|
||||||
|
import { button } from './button.js';
|
||||||
|
import { textField } from './textField.js';
|
||||||
|
import type { DataTableProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 数据表格:粘性表头 + 行选择 + 排序 + 筛选 + 分页 + 空状态;状态由 table-core 持有,信号只做桥接渲染 ***/
|
||||||
|
export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
|
||||||
|
// —— 状态集中声明 ——
|
||||||
|
const selectable = props.selectable ?? false; // 行选择开关
|
||||||
|
const table = createTable({
|
||||||
|
data: props.data,
|
||||||
|
columns: props.columns,
|
||||||
|
rowKey: 'id',
|
||||||
|
plugins: [filterPlugin<T>(), sortPlugin<T>(), paginationPlugin<T>(), selectionPlugin<T>()],
|
||||||
|
initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } },
|
||||||
|
});
|
||||||
|
const api = table as unknown as TableInstance<T> & {
|
||||||
|
getPageInfo(): PageInfo;
|
||||||
|
selectAllVisible(): void;
|
||||||
|
};
|
||||||
|
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增触发信号系统
|
||||||
|
table.subscribe(() => version(version() + 1));
|
||||||
|
|
||||||
|
// —— 派生:全部由 version 单一来源推导,语义与内核一致 ——
|
||||||
|
const visible = computed(() => {
|
||||||
|
version();
|
||||||
|
return table.getVisibleRows();
|
||||||
|
});
|
||||||
|
const state = computed(() => {
|
||||||
|
version();
|
||||||
|
return table.getState();
|
||||||
|
});
|
||||||
|
const info = computed(() => {
|
||||||
|
visible();
|
||||||
|
return api.getPageInfo();
|
||||||
|
});
|
||||||
|
const selectedCount = computed(() => state().selection.length);
|
||||||
|
|
||||||
|
const filterKey = props.filterKey ?? props.columns[0].key;
|
||||||
|
const colspan = props.columns.length + (selectable ? 1 : 0);
|
||||||
|
|
||||||
|
// —— 表头全选框:checked / indeterminate 三态跟随当页选中情况 ——
|
||||||
|
const headCheck = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||||
|
headCheck.addEventListener('change', () => {
|
||||||
|
if (headCheck.checked) api.selectAllVisible();
|
||||||
|
else table.dispatch({ type: 'clearSelection' });
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const keys = visible().keys;
|
||||||
|
const sel = state().selection;
|
||||||
|
const onPage = keys.filter((k) => sel.includes(k)).length;
|
||||||
|
headCheck.checked = keys.length > 0 && onPage === keys.length;
|
||||||
|
headCheck.indeterminate = onPage > 0 && onPage < keys.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
/*** 排序标记:升 ↑ / 降 ↓ / 未排序 ↕(可点击提示) ***/
|
||||||
|
const sortMark = (key: string) =>
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'ua-sort-mark' },
|
||||||
|
() => {
|
||||||
|
const rule = state().sorting.find((r) => r.key === key);
|
||||||
|
return rule ? (rule.dir === 'asc' ? '↑' : '↓') : '↕';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const th = (col: (typeof props.columns)[number]) =>
|
||||||
|
h(
|
||||||
|
'th',
|
||||||
|
{
|
||||||
|
class: () => (col.sortable ? 'ua-sortable' : ''),
|
||||||
|
onClick: () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }),
|
||||||
|
},
|
||||||
|
col.header ?? col.key,
|
||||||
|
col.sortable ? sortMark(col.key) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tbody = h('tbody');
|
||||||
|
renderList(tbody, () => {
|
||||||
|
const sel = state().selection; // 读入依赖:选择变化即整表重绘(行数 = pageSize,代价可控)
|
||||||
|
const rows = visible().rows;
|
||||||
|
if (!rows.length) {
|
||||||
|
return [h('tr', { class: 'ua-empty-row' }, h('td', { colspan }, '暂无匹配数据'))];
|
||||||
|
}
|
||||||
|
return rows.map((row, i) => {
|
||||||
|
const key = visible().keys[i];
|
||||||
|
const selected = sel.includes(key);
|
||||||
|
const cells = props.columns.map((col) => h('td', null, String(table.getCellValue(row, col.key))));
|
||||||
|
if (selectable) {
|
||||||
|
const check = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||||
|
check.checked = selected;
|
||||||
|
check.addEventListener('change', () => table.dispatch({ type: 'toggleSelect', key }));
|
||||||
|
cells.unshift(h('td', { class: 'ua-cell-check' }, check));
|
||||||
|
}
|
||||||
|
return h('tr', selected ? { class: 'ua-row-selected' } : null, ...cells);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterInput = textField({
|
||||||
|
placeholder: `筛选 ${filterKey}…`,
|
||||||
|
onInput: (value) => {
|
||||||
|
table.dispatch({ type: 'setFilter', key: filterKey, value });
|
||||||
|
table.dispatch({ type: 'setPage', page: 1 }); // 筛选变化回第 1 页
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'section',
|
||||||
|
{ class: 'ua-table' },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'ua-table-toolbar' },
|
||||||
|
filterInput,
|
||||||
|
selectable ? h('span', { class: 'ua-sel-count' }, () => (selectedCount() ? `已选 ${selectedCount()} 行` : '')) : null,
|
||||||
|
),
|
||||||
|
h('div', { class: 'ua-table-scroll' }, h('table', null, h('thead', null, h('tr', null, selectable ? headCheck : null, ...props.columns.map(th))), tbody)),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'ua-pager' },
|
||||||
|
button({ label: '上一页', variant: 'ghost', onClick: () => table.dispatch({ type: 'setPage', page: info().page - 1 }) }),
|
||||||
|
h('span', { class: 'ua-page-info' }, () => `第 ${info().page} / ${info().totalPages} 页 · 共 ${visible().total} 行`),
|
||||||
|
button({ label: '下一页', variant: 'ghost', onClick: () => table.dispatch({ type: 'setPage', page: info().page + 1 }) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { h } from '@ui-arch/frame';
|
||||||
|
import { button } from './button.js';
|
||||||
|
import { textField } from './textField.js';
|
||||||
|
import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js';
|
||||||
|
|
||||||
|
/*** 执行单条规则:null 通过,字符串为错误消息 ***/
|
||||||
|
function checkRule(value: string, rule: FormRule): string | null {
|
||||||
|
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
|
||||||
|
if (!value) return null; // 非必填且为空:其余规则跳过
|
||||||
|
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
|
||||||
|
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
|
||||||
|
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
|
||||||
|
if (rule.validator) {
|
||||||
|
const r = rule.validator(value);
|
||||||
|
if (r !== true) return typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄供 form 聚合校验 ***/
|
||||||
|
export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle {
|
||||||
|
// —— 状态集中声明 ——
|
||||||
|
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
|
||||||
|
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])];
|
||||||
|
const error = h('div', { class: 'ua-form-error' });
|
||||||
|
const label = h('label', { class: 'ua-form-label' }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null);
|
||||||
|
const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, error));
|
||||||
|
|
||||||
|
/*** 执行全部规则:返回首个错误消息,同时同步错误区与控件态 ***/
|
||||||
|
const validate = () => {
|
||||||
|
const value = props.value();
|
||||||
|
let msg: string | null = null;
|
||||||
|
for (const rule of rules) {
|
||||||
|
msg = checkRule(value, rule);
|
||||||
|
if (msg) break; // 首个失败即停止
|
||||||
|
}
|
||||||
|
error.textContent = msg ?? '';
|
||||||
|
control.classList.toggle('ua-invalid', !!msg);
|
||||||
|
return msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
control.addEventListener('input', () => {
|
||||||
|
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
|
||||||
|
});
|
||||||
|
control.addEventListener('blur', () => {
|
||||||
|
if (mode !== 'submit') validate();
|
||||||
|
});
|
||||||
|
return { el, validate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit) ***/
|
||||||
|
export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
|
||||||
|
// —— 状态集中声明 ——
|
||||||
|
/*** 整体提交:逐项校验,聚焦首个出错项并中止 ***/
|
||||||
|
const submit = () => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.validate()) {
|
||||||
|
(item.el.querySelector('input, textarea, select') as HTMLElement | null)?.focus();
|
||||||
|
return; // 卫语句:有错误不提交
|
||||||
|
}
|
||||||
|
}
|
||||||
|
props.onSubmit?.();
|
||||||
|
};
|
||||||
|
const submitBtn = button({ label: props.submitText ?? '提交', onClick: submit });
|
||||||
|
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', onClick: props.onReset }) : null;
|
||||||
|
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
|
||||||
|
const el = h(
|
||||||
|
'form',
|
||||||
|
{ class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' },
|
||||||
|
...items.map((i) => i.el),
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
el.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault(); // 拦截原生提交,走校验流程
|
||||||
|
submit();
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export * from './types.js';
|
||||||
|
export { button } from './button.js';
|
||||||
|
export { textField } from './textField.js';
|
||||||
|
export { textarea } from './textarea.js';
|
||||||
|
export { checkbox } from './checkbox.js';
|
||||||
|
export { switchButton } from './switch.js';
|
||||||
|
export { select } from './select.js';
|
||||||
|
export { radioGroup } from './radioGroup.js';
|
||||||
|
export { card } from './card.js';
|
||||||
|
export { badge } from './badge.js';
|
||||||
|
export { alert } from './alert.js';
|
||||||
|
export { spinner } from './spinner.js';
|
||||||
|
export { progress } from './progress.js';
|
||||||
|
export { tabs } from './tabs.js';
|
||||||
|
export { modal } from './modal.js';
|
||||||
|
export { form, formItem } from './form.js';
|
||||||
|
export { dataTable } from './dataTable.js';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { ModalProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose ***/
|
||||||
|
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
|
||||||
|
const close = () => {
|
||||||
|
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
|
||||||
|
props.open(false); // 写回信号触发隐藏
|
||||||
|
props.onClose?.();
|
||||||
|
};
|
||||||
|
const overlay = h('div', {
|
||||||
|
class: 'ua-modal-overlay',
|
||||||
|
onClick: (e: Event) => {
|
||||||
|
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const box = h(
|
||||||
|
'div',
|
||||||
|
{ class: 'ua-modal' },
|
||||||
|
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title' }, props.title), h('button', { class: 'ua-modal-close', onClick: close }, '×')),
|
||||||
|
h('div', { class: 'ua-modal-body' }, ...children),
|
||||||
|
);
|
||||||
|
overlay.append(box);
|
||||||
|
effect(() => {
|
||||||
|
const open = props.open();
|
||||||
|
overlay.classList.toggle('ua-open', open); // 显隐跟随信号
|
||||||
|
if (open) (box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.open()) close(); // Esc 关闭当前打开的弹窗
|
||||||
|
});
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { ProgressProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 进度条:轨道 + 渐变流动填充,value 信号驱动 0-100;满格切换 success 色并停止条纹 ***/
|
||||||
|
export function progress(props: ProgressProps): HTMLElement {
|
||||||
|
const fill = h('div', { class: 'ua-progress-fill' });
|
||||||
|
const track = h('div', { class: 'ua-progress-track' }, fill);
|
||||||
|
const text = h('span', { class: 'ua-progress-text' });
|
||||||
|
effect(() => {
|
||||||
|
const v = clamp(props.value()); // 依赖收集:信号变化自动写回
|
||||||
|
fill.style.width = `${v}%`;
|
||||||
|
fill.classList.toggle('ua-done', v >= 100); // 满格切语义色
|
||||||
|
text.textContent = `${Math.round(v)}%`;
|
||||||
|
});
|
||||||
|
return h('div', { class: 'ua-progress' }, track, props.showText === false ? null : text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 进度钳制到 0-100 ***/
|
||||||
|
function clamp(v: number): number {
|
||||||
|
if (Number.isNaN(v)) return 0; // 卫语句:非法值归零
|
||||||
|
return Math.min(100, Math.max(0, v));
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { RadioGroupProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 单选组:value 信号双向绑定,options 同组互斥,name 固定保证原生互斥行为 ***/
|
||||||
|
export function radioGroup(props: RadioGroupProps): HTMLElement {
|
||||||
|
const groupName = `ua-radio-${Math.random().toString(36).slice(2, 8)}`; // 组内唯一 name
|
||||||
|
const radios: HTMLInputElement[] = [];
|
||||||
|
for (const opt of props.options) {
|
||||||
|
const input = h('input', { type: 'radio', name: groupName, class: 'ua-radio' }) as HTMLInputElement;
|
||||||
|
input.value = opt.value;
|
||||||
|
radios.push(input);
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
if (props.value) props.value(input.value); // 用户选择写回信号
|
||||||
|
props.onChange?.(input.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (props.value) {
|
||||||
|
const value = props.value;
|
||||||
|
effect(() => {
|
||||||
|
for (const input of radios) input.checked = input.value === value(); // 外部变化同步勾选态
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = props.options.map((opt, i) => h('label', { class: 'ua-check-row' }, radios[i], opt.label));
|
||||||
|
return h('div', { class: 'ua-radio-group' }, ...rows);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { effect, h, signal } from '@ui-arch/frame';
|
||||||
|
import type { SelectProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;点选项 / 点外部 / Esc 关闭 ***/
|
||||||
|
export function select(props: SelectProps): HTMLElement {
|
||||||
|
// —— 状态集中声明 ——
|
||||||
|
const open = signal(false); // 弹层开合
|
||||||
|
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
|
||||||
|
|
||||||
|
const labelOf = (v: string) => props.options.find((o) => o.value === v)?.label ?? '';
|
||||||
|
const current = () => labelOf(value()); // 触发器显示文案(响应式)
|
||||||
|
|
||||||
|
const pick = (v: string) => {
|
||||||
|
value(v);
|
||||||
|
open(false);
|
||||||
|
props.onChange?.(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// —— 触发器 ——
|
||||||
|
const trigger = h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: 'ua-select-trigger',
|
||||||
|
'aria-haspopup': 'listbox',
|
||||||
|
'aria-expanded': () => String(open()),
|
||||||
|
onClick: () => open(!open()),
|
||||||
|
},
|
||||||
|
h('span', { class: () => `ua-select-value${value() ? '' : ' is-empty'}` }, () => current() || props.placeholder || ''),
|
||||||
|
h('span', { class: 'ua-select-arrow' }, '▾'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// —— 选项弹层 ——
|
||||||
|
const options = props.options.map((opt) =>
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: () => `ua-option${opt.value === value() ? ' ua-option-selected' : ''}`,
|
||||||
|
role: 'option',
|
||||||
|
'aria-selected': () => String(opt.value === value()),
|
||||||
|
onClick: () => pick(opt.value),
|
||||||
|
},
|
||||||
|
opt.label,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const pop = h('div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}`, role: 'listbox' }, ...options);
|
||||||
|
|
||||||
|
const root = h('div', { class: 'ua-select' }, trigger, pop);
|
||||||
|
|
||||||
|
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') open(false); // Esc 关闭弹层
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||||
|
open(false); // 点外部关闭
|
||||||
|
});
|
||||||
|
effect(() => open()); // 建立依赖,保证开合信号生效
|
||||||
|
return root;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { h } from '@ui-arch/frame';
|
||||||
|
import type { SpinnerProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 加载指示器:纯 CSS 旋转动画,size 控制直径 ***/
|
||||||
|
export function spinner(props: SpinnerProps = {}): HTMLElement {
|
||||||
|
const size = `${props.size ?? 18}px`;
|
||||||
|
const el = h('span', { class: 'ua-spinner' });
|
||||||
|
el.style.width = size;
|
||||||
|
el.style.height = size;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { SwitchProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 开关:checked 信号双向绑定,轨道 + 滑块的纯 CSS 实现 ***/
|
||||||
|
export function switchButton(props: SwitchProps): HTMLElement {
|
||||||
|
const input = h('input', { type: 'checkbox', class: 'ua-switch-input' }) as HTMLInputElement;
|
||||||
|
if (props.checked) {
|
||||||
|
const checked = props.checked;
|
||||||
|
input.addEventListener('change', () => checked(input.checked)); // 用户切换写回信号
|
||||||
|
effect(() => {
|
||||||
|
if (input.checked !== checked()) input.checked = checked(); // 外部变化写回开关
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (props.onChange) input.addEventListener('change', () => props.onChange!(input.checked));
|
||||||
|
const track = h('span', { class: 'ua-switch-track' }, h('span', { class: 'ua-switch-thumb' }));
|
||||||
|
const el = h('label', { class: 'ua-switch' }, input, track);
|
||||||
|
if (props.label) el.append(h('span', { class: 'ua-switch-label' }, props.label));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { TabsProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 标签页:active 信号双向绑定 + 滑动指示条 + 方向键环形导航;内容由调用方按 active 派生 ***/
|
||||||
|
export function tabs(props: TabsProps): HTMLElement {
|
||||||
|
// —— 状态集中声明 ——
|
||||||
|
const buttons: HTMLButtonElement[] = []; // 页签按钮,顺序与 tabs 一致
|
||||||
|
const indicator = h('span', { class: 'ua-tabs-indicator' });
|
||||||
|
const head = h('div', { class: 'ua-tabs-head', role: 'tablist' }, indicator);
|
||||||
|
|
||||||
|
/*** 同步激活态与指示条几何位置 ***/
|
||||||
|
const sync = () => {
|
||||||
|
const value = props.active?.();
|
||||||
|
for (const btn of buttons) {
|
||||||
|
const on = btn.dataset.value === value;
|
||||||
|
btn.classList.toggle('ua-tab-active', on);
|
||||||
|
btn.setAttribute('aria-selected', String(on));
|
||||||
|
btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅激活页签可 Tab 聚焦
|
||||||
|
if (on) {
|
||||||
|
indicator.style.left = `${btn.offsetLeft}px`;
|
||||||
|
indicator.style.width = `${btn.offsetWidth}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tab of props.tabs) {
|
||||||
|
const btn = h('button', { class: 'ua-tab', role: 'tab', type: 'button' }, tab.label) as HTMLButtonElement;
|
||||||
|
btn.dataset.value = tab.value;
|
||||||
|
btn.disabled = tab.disabled ?? false;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.disabled) return; // 卫语句:禁用页签不响应
|
||||||
|
if (props.active) props.active(tab.value); // 用户切换写回信号
|
||||||
|
props.onChange?.(tab.value);
|
||||||
|
});
|
||||||
|
buttons.push(btn);
|
||||||
|
head.append(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
head.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
if (!delta) return; // 卫语句:非方向键放行
|
||||||
|
const list = buttons.filter((b) => !b.disabled);
|
||||||
|
const i = list.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
if (i < 0) return; // 卫语句:焦点不在页签上
|
||||||
|
e.preventDefault();
|
||||||
|
const next = list[(i + delta + list.length) % list.length]; // 环形移动
|
||||||
|
next.focus();
|
||||||
|
next.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(sync);
|
||||||
|
requestAnimationFrame(sync); // 挂载后再量一次布局,否则指示条宽度为 0
|
||||||
|
return h('div', { class: 'ua-tabs' }, head);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { TextFieldProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 文本输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||||
|
export function textField(props: TextFieldProps): HTMLInputElement {
|
||||||
|
const el = h('input', { class: 'ua-input', placeholder: props.placeholder ?? '' }) as HTMLInputElement;
|
||||||
|
if (props.value) {
|
||||||
|
const signal = props.value;
|
||||||
|
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||||
|
effect(() => {
|
||||||
|
if (el.value !== signal()) el.value = signal(); // 外部变化写回输入框
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (props.onInput) el.addEventListener('input', () => props.onInput!(el.value));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { effect, h } from '@ui-arch/frame';
|
||||||
|
import type { TextareaProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 多行输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||||
|
export function textarea(props: TextareaProps): HTMLTextAreaElement {
|
||||||
|
const el = h('textarea', { class: 'ua-textarea', rows: props.rows ?? 3, placeholder: props.placeholder ?? '' }) as HTMLTextAreaElement;
|
||||||
|
if (props.value) {
|
||||||
|
const signal = props.value;
|
||||||
|
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||||
|
effect(() => {
|
||||||
|
if (el.value !== signal()) el.value = signal(); // 外部变化写回输入框
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (props.onInput) el.addEventListener('input', () => props.onInput!(el.value));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ColumnDef, RowKey } from '@ui-arch/table-core';
|
||||||
|
import type { Signal } from '@ui-arch/frame';
|
||||||
|
|
||||||
|
/*** 按钮入参 ***/
|
||||||
|
export interface ButtonProps {
|
||||||
|
label: string; // 按钮文案
|
||||||
|
variant?: 'primary' | 'ghost' | 'danger'; // 视觉强度;缺省 primary
|
||||||
|
onClick?: () => void; // 点击回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 文本输入入参 ***/
|
||||||
|
export interface TextFieldProps {
|
||||||
|
placeholder?: string; // 占位文案
|
||||||
|
value?: Signal<string>; // 双向绑定的信号
|
||||||
|
onInput?: (value: string) => void; // 输入回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 卡片入参 ***/
|
||||||
|
export interface CardProps {
|
||||||
|
title?: string; // 标题;缺省不渲染标题行
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 数据表格入参 ***/
|
||||||
|
export interface DataTableProps<T> {
|
||||||
|
data: T[]; // 全量行数据,行须含 id 字段作 rowKey
|
||||||
|
columns: ColumnDef<T>[]; // 列定义
|
||||||
|
filterKey?: string; // 工具栏筛选的列;缺省取第一列
|
||||||
|
pageSize?: number; // 每页行数;缺省 5
|
||||||
|
selectable?: boolean; // 是否启用行选择列(含全选三态与已选计数);缺省 false
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 选项条目:select / radioGroup / tabs 共用的选项形状 ***/
|
||||||
|
export interface OptionItem {
|
||||||
|
value: string; // 选项值(提交/匹配用)
|
||||||
|
label: string; // 选项文案(展示用)
|
||||||
|
disabled?: boolean; // 仅 tabs 消费:禁用该页签
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 复选框入参 ***/
|
||||||
|
export interface CheckboxProps {
|
||||||
|
label?: string; // 右侧文案;缺省不渲染
|
||||||
|
checked?: Signal<boolean>; // 勾选状态信号,双向绑定
|
||||||
|
onChange?: (checked: boolean) => void; // 变更回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 开关入参 ***/
|
||||||
|
export interface SwitchProps {
|
||||||
|
label?: string; // 左侧文案;缺省不渲染
|
||||||
|
checked?: Signal<boolean>; // 开关状态信号,双向绑定
|
||||||
|
onChange?: (checked: boolean) => void; // 变更回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 下拉选择入参 ***/
|
||||||
|
export interface SelectProps {
|
||||||
|
options: OptionItem[]; // 选项列表
|
||||||
|
value?: Signal<string>; // 选中值信号,双向绑定
|
||||||
|
placeholder?: string; // 未选中时的占位项文案
|
||||||
|
onChange?: (value: string) => void; // 变更回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 单选组入参 ***/
|
||||||
|
export interface RadioGroupProps {
|
||||||
|
options: OptionItem[]; // 选项列表
|
||||||
|
value?: Signal<string>; // 选中值信号,双向绑定
|
||||||
|
onChange?: (value: string) => void; // 变更回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 多行输入入参 ***/
|
||||||
|
export interface TextareaProps {
|
||||||
|
placeholder?: string; // 占位文案
|
||||||
|
rows?: number; // 行数;缺省 3
|
||||||
|
value?: Signal<string>; // 双向绑定的信号
|
||||||
|
onInput?: (value: string) => void; // 输入回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 徽标入参 ***/
|
||||||
|
export interface BadgeProps {
|
||||||
|
label: string; // 徽标文案
|
||||||
|
variant?: 'default' | 'success' | 'warning' | 'danger'; // 语义色;缺省 default
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 提示条入参 ***/
|
||||||
|
export interface AlertProps {
|
||||||
|
message: string; // 提示内容
|
||||||
|
variant?: 'info' | 'success' | 'warning' | 'danger'; // 语义色;缺省 info
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 加载指示器入参 ***/
|
||||||
|
export interface SpinnerProps {
|
||||||
|
size?: number; // 直径像素;缺省 18
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 进度条入参 ***/
|
||||||
|
export interface ProgressProps {
|
||||||
|
value: Signal<number>; // 当前进度 0-100 的信号
|
||||||
|
showText?: boolean; // 是否右侧显示百分比;缺省 true
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 标签页入参 ***/
|
||||||
|
export interface TabsProps {
|
||||||
|
tabs: OptionItem[]; // 页签列表
|
||||||
|
active?: Signal<string>; // 当前激活页签的 value 信号,双向绑定
|
||||||
|
onChange?: (value: string) => void; // 切换回调
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 弹窗入参 ***/
|
||||||
|
export interface ModalProps {
|
||||||
|
title: string; // 标题
|
||||||
|
open: Signal<boolean>; // 开关信号:true 显示 / false 隐藏
|
||||||
|
onClose?: () => void; // 关闭回调(点遮罩、× 或 Esc 触发)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表单校验规则:按序执行,首个失败即停止 ***/
|
||||||
|
export interface FormRule {
|
||||||
|
required?: boolean; // 非空校验(trim 后判定)
|
||||||
|
min?: number; // 最小长度
|
||||||
|
max?: number; // 最大长度
|
||||||
|
pattern?: RegExp; // 正则格式校验
|
||||||
|
validator?: (value: string) => boolean | string; // 自定义校验:true 通过 / 字符串为错误消息
|
||||||
|
message?: string; // 该规则的错误消息;缺省按规则类型给默认文案
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表单字段入参 ***/
|
||||||
|
export interface FormItemProps {
|
||||||
|
label: string; // 字段标签
|
||||||
|
value: Signal<string>; // 字段值信号(校验数据源)
|
||||||
|
required?: boolean; // 必填:显示红星并自动追加非空规则
|
||||||
|
rules?: FormRule[]; // 附加校验规则,按序执行
|
||||||
|
validateOn?: 'input' | 'blur' | 'submit'; // 校验时机;缺省 blur
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表单字段句柄:form 聚合校验用 ***/
|
||||||
|
export interface FormItemHandle {
|
||||||
|
el: HTMLElement; // 字段根元素(label + 控件 + 错误区)
|
||||||
|
validate(): string | null; // 执行校验:null 通过 / 字符串为错误消息
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 表单容器入参 ***/
|
||||||
|
export interface FormProps {
|
||||||
|
layout?: 'vertical' | 'horizontal'; // 布局方向;缺省 vertical
|
||||||
|
submitText?: string; // 提交按钮文案;缺省「提交」
|
||||||
|
resetText?: string; // 重置按钮文案;缺省不渲染重置按钮
|
||||||
|
onSubmit?: () => void; // 全部字段校验通过后的提交回调
|
||||||
|
onReset?: () => void; // 重置回调
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"paths": {
|
||||||
|
"@ui-arch/frame": ["../../core/frame/dist/index.d.ts"],
|
||||||
|
"@ui-arch/table-core": ["../../core/table-core/dist/index.d.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/ui",
|
||||||
|
"type": "skin-dom",
|
||||||
|
"description": "DOM 皮肤组件库:button / textField / textarea / checkbox / switch / select / radioGroup / card / badge / alert / spinner / progress / tabs / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
|
||||||
|
"tags": [
|
||||||
|
"components",
|
||||||
|
"dom",
|
||||||
|
"skin",
|
||||||
|
"table",
|
||||||
|
"form",
|
||||||
|
"feedback"
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"entry": "src/index.ts",
|
||||||
|
"exports": [
|
||||||
|
"button(props):按钮,variant = primary | ghost | danger",
|
||||||
|
"textField(props):文本输入,value 信号双向绑定",
|
||||||
|
"textarea(props):多行输入,value 信号双向绑定",
|
||||||
|
"checkbox(props):复选框,checked 信号双向绑定",
|
||||||
|
"switchButton(props):开关,checked 信号双向绑定",
|
||||||
|
"select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画",
|
||||||
|
"radioGroup(props):单选组,options + value 信号双向绑定",
|
||||||
|
"card(props, ...children):卡片容器",
|
||||||
|
"badge(props):徽标,variant = default | success | warning | danger",
|
||||||
|
"alert(props):提示条,variant = info | success | warning | danger",
|
||||||
|
"spinner(props):加载指示器,size 控制直径",
|
||||||
|
"progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色",
|
||||||
|
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签",
|
||||||
|
"modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭",
|
||||||
|
"form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit",
|
||||||
|
"formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄",
|
||||||
|
"dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core"
|
||||||
|
],
|
||||||
|
"keyTypes": [
|
||||||
|
"ButtonProps",
|
||||||
|
"TextFieldProps",
|
||||||
|
"TextareaProps",
|
||||||
|
"CheckboxProps",
|
||||||
|
"SwitchProps",
|
||||||
|
"SelectProps",
|
||||||
|
"RadioGroupProps",
|
||||||
|
"OptionItem",
|
||||||
|
"CardProps",
|
||||||
|
"BadgeProps",
|
||||||
|
"AlertProps",
|
||||||
|
"SpinnerProps",
|
||||||
|
"ProgressProps",
|
||||||
|
"TabsProps",
|
||||||
|
"ModalProps",
|
||||||
|
"FormRule",
|
||||||
|
"FormItemProps",
|
||||||
|
"FormItemHandle",
|
||||||
|
"FormProps",
|
||||||
|
"DataTableProps"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')",
|
||||||
|
"conventions": [
|
||||||
|
"皮肤不持有业务状态,一律委托 headless 内核或调用方信号",
|
||||||
|
"表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调",
|
||||||
|
"语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色"
|
||||||
|
],
|
||||||
|
"docs": {
|
||||||
|
"intro": "16 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
|
||||||
|
"steps": [
|
||||||
|
"安装依赖:pnpm add @ui-arch/ui @ui-arch/frame(workspace 内自动链接)",
|
||||||
|
"引入样式:在应用入口 import '@ui-arch/ui/ui.css',暗色主题在根元素设 data-theme='dark'",
|
||||||
|
"创建状态:import { signal } from '@ui-arch/frame',如 const name = signal('')",
|
||||||
|
"调用组件:const el = textField({ value: name }),把 el 挂到你的容器或配合 h() 组合",
|
||||||
|
"读写状态:name() 读、name('张三') 写,界面自动同步,无需手动操作 DOM"
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "button",
|
||||||
|
"title": "按钮 Button",
|
||||||
|
"demo": "sec-counter",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "按钮文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "variant",
|
||||||
|
"t": "'primary' | 'ghost' | 'danger'",
|
||||||
|
"d": "视觉强度;缺省 primary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onClick",
|
||||||
|
"t": "() => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "点击回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "button({ label: '提交', variant: 'primary', onClick: () => console.log('clicked') })",
|
||||||
|
"steps": [
|
||||||
|
"确定语义强度:主操作用 primary(默认),次要操作用 ghost,危险操作用 danger",
|
||||||
|
"绑定 onClick 回调处理点击逻辑",
|
||||||
|
"需要禁用态时自行在回调首行卫语句判断,或外层包条件渲染"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "textField",
|
||||||
|
"title": "文本输入 TextField",
|
||||||
|
"demo": "sec-bind",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "双向绑定信号:输入写回信号,信号变化写回输入框"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "占位文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onInput",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "输入回调,每次按键触发"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const name = signal(''); const el = textField({ value: name, placeholder: '输入名字…', onInput: (v) => console.log(v) })",
|
||||||
|
"steps": [
|
||||||
|
"先用 signal('') 创建字符串信号",
|
||||||
|
"传入 value 完成双向绑定:用户输入自动写回,外部赋值自动回显",
|
||||||
|
"在任意响应式位置用 name() 读取最新值,如 () => `你好,${name()}`"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "textarea",
|
||||||
|
"title": "多行输入 Textarea",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "双向绑定信号"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "rows",
|
||||||
|
"t": "number",
|
||||||
|
"d": "初始行数;缺省 3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "占位文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onInput",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "输入回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话介绍…' })",
|
||||||
|
"steps": [
|
||||||
|
"创建信号并传入 value",
|
||||||
|
"用 rows 控制初始高度,用户可纵向拉伸",
|
||||||
|
"字数统计等派生逻辑直接在响应式文本里读 bio().length"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "checkbox",
|
||||||
|
"title": "复选框 Checkbox",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "checked",
|
||||||
|
"t": "Signal<boolean>",
|
||||||
|
"d": "勾选状态信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"d": "右侧文案;缺省只渲染勾选框"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(checked: boolean) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "变更回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const agreed = signal(false); checkbox({ label: '同意条款', checked: agreed, onChange: (v) => console.log(v) })",
|
||||||
|
"steps": [
|
||||||
|
"创建布尔信号 agreed = signal(false)",
|
||||||
|
"传入 checked 与 label,勾选即写回信号",
|
||||||
|
"表单提交前用 agreed() 判断是否勾选"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "switchButton",
|
||||||
|
"title": "开关 Switch",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "checked",
|
||||||
|
"t": "Signal<boolean>",
|
||||||
|
"d": "开关状态信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"d": "左侧文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(checked: boolean) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "变更回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const night = signal(true); switchButton({ label: '夜间模式', checked: night })",
|
||||||
|
"steps": [
|
||||||
|
"创建布尔信号并传入 checked",
|
||||||
|
"轨道与滑块的动画由 CSS 完成,无需干预",
|
||||||
|
"状态变化在 onChange 里做副作用(如切换主题)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select",
|
||||||
|
"title": "下拉选择 Select(自绘弹层)",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "options",
|
||||||
|
"t": "OptionItem[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "选项列表,每项 { value, label }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "选中值信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "未选中时触发器显示的占位文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "选中回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], value: city, placeholder: '选择城市…' })",
|
||||||
|
"steps": [
|
||||||
|
"准备 options 数组,value 是提交用值、label 是展示文案",
|
||||||
|
"创建信号并传入 value;未传时组件内部自持状态",
|
||||||
|
"点击触发器展开弹层,点选项写回信号并收起;点外部或按 Esc 也会收起",
|
||||||
|
"选中项在弹层中用淡色弱化标记,不抢视觉焦点"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "radioGroup",
|
||||||
|
"title": "单选组 RadioGroup",
|
||||||
|
"demo": "sec-form",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "options",
|
||||||
|
"t": "OptionItem[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "选项列表"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "选中值信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "变更回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const size = signal('m'); radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size })",
|
||||||
|
"steps": [
|
||||||
|
"创建字符串信号,初值设为默认选项的 value",
|
||||||
|
"传入 options 与 value,组内自动互斥",
|
||||||
|
"读取 size() 获取当前选中值"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card",
|
||||||
|
"title": "卡片 Card",
|
||||||
|
"demo": "sec-card",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "title",
|
||||||
|
"t": "string",
|
||||||
|
"d": "标题;缺省不渲染标题行"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "...children",
|
||||||
|
"t": "unknown[]",
|
||||||
|
"d": "任意子节点:字符串 / DOM / 响应式函数"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "card({ title: '用户表' }, dataTable({ data, columns }))",
|
||||||
|
"steps": [
|
||||||
|
"card 是容器组件:第一个参数是 props,其余参数是任意子节点",
|
||||||
|
"传 title 显示标题行,否则只有内容区",
|
||||||
|
"子节点可以混合字符串、DOM 节点与响应式函数"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "badge",
|
||||||
|
"title": "徽标 Badge",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "label",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "徽标文案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "variant",
|
||||||
|
"t": "'default' | 'success' | 'warning' | 'danger'",
|
||||||
|
"d": "语义色;缺省 default"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "badge({ label: '成功', variant: 'success' })",
|
||||||
|
"steps": [
|
||||||
|
"选择语义色:default 中性、success 成功、warning 警告、danger 危险",
|
||||||
|
"传入 label 即可,纯展示组件无状态"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "alert",
|
||||||
|
"title": "提示条 Alert",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "message",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "提示内容"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "variant",
|
||||||
|
"t": "'info' | 'success' | 'warning' | 'danger'",
|
||||||
|
"d": "语义色;缺省 info"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "alert({ message: '操作可能存在风险', variant: 'warning' })",
|
||||||
|
"steps": [
|
||||||
|
"按信息级别选 variant:info 提示、success 成功、warning 警告、danger 错误",
|
||||||
|
"传入 message 即可,入场自带下滑动画"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spinner",
|
||||||
|
"title": "加载指示器 Spinner",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "size",
|
||||||
|
"t": "number",
|
||||||
|
"d": "直径像素;缺省 18"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "spinner(); spinner({ size: 26 })",
|
||||||
|
"steps": [
|
||||||
|
"数据加载时插入 spinner(),加载完成移除",
|
||||||
|
"用 size 匹配上下文尺寸(按钮内用小号,整页 loading 用大号)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "progress",
|
||||||
|
"title": "进度条 Progress",
|
||||||
|
"demo": "sec-feedback",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<number>",
|
||||||
|
"r": 1,
|
||||||
|
"d": "进度 0-100 信号,变化自动平滑过渡"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "showText",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "是否显示百分比文字;缺省 true"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() + 5) % 105), 400)",
|
||||||
|
"steps": [
|
||||||
|
"创建数值信号 pct = signal(0),范围 0-100(越界自动钳制)",
|
||||||
|
"传入 value,填充宽度与百分比文字随信号平滑过渡",
|
||||||
|
"进行中填充带流动条纹,到 100% 自动变 success 色并停止条纹",
|
||||||
|
"不需要百分比文字时传 showText: false"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tabs",
|
||||||
|
"title": "标签页 Tabs",
|
||||||
|
"demo": "sec-tabs-modal",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "tabs",
|
||||||
|
"t": "OptionItem[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "页签列表,每项 { value, label }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "active",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"d": "当前激活页签 value 信号,双向绑定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(value: string) => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "切换回调"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细', disabled: true }], active: tab })",
|
||||||
|
"steps": [
|
||||||
|
"创建信号 tab = signal('a'),初值为默认页签",
|
||||||
|
"tabs 只渲染页签头;页签内容用响应式函数按信号切换,如 () => tab() === 'a' ? panelA : panelB",
|
||||||
|
"点击或方向键(←→ 环形)切换,滑动指示条自动跟随;disabled: true 的页签置灰不可点",
|
||||||
|
"键盘可访问:Tab 聚焦激活页签,←→ 在启用页签间环形移动"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "modal",
|
||||||
|
"title": "弹窗 Modal",
|
||||||
|
"demo": "sec-tabs-modal",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "open",
|
||||||
|
"t": "Signal<boolean>",
|
||||||
|
"r": 1,
|
||||||
|
"d": "开关信号:true 显示 / false 隐藏"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "title",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "标题"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onClose",
|
||||||
|
"t": "() => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "关闭回调(点遮罩、× 或 Esc 触发)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "...children",
|
||||||
|
"t": "unknown[]",
|
||||||
|
"d": "弹窗内容子节点"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const open = signal(false); modal({ title: '确认操作', open, onClose: () => open(false) }, h('p', null, '内容'))",
|
||||||
|
"steps": [
|
||||||
|
"创建布尔信号 open = signal(false),弹窗初始隐藏",
|
||||||
|
"把 open 传给 modal,其余参数为弹窗内容子节点",
|
||||||
|
"打开:open(true);关闭:open(false),点遮罩或 × 会自动写回 false",
|
||||||
|
"入场自带遮罩淡入 + 弹窗上浮动画"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "form",
|
||||||
|
"title": "表单 Form / 表单字段 FormItem",
|
||||||
|
"demo": "sec-formlab",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "layout",
|
||||||
|
"t": "'vertical' | 'horizontal'",
|
||||||
|
"d": "布局方向;缺省 vertical(horizontal 为标签左置)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "submitText",
|
||||||
|
"t": "string",
|
||||||
|
"d": "提交按钮文案;缺省「提交」"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "resetText",
|
||||||
|
"t": "string",
|
||||||
|
"d": "重置按钮文案;缺省不渲染"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onSubmit",
|
||||||
|
"t": "() => void",
|
||||||
|
"k": 1,
|
||||||
|
"d": "全部字段校验通过后的提交回调"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)label",
|
||||||
|
"t": "string",
|
||||||
|
"r": 1,
|
||||||
|
"d": "字段标签"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)value",
|
||||||
|
"t": "Signal<string>",
|
||||||
|
"r": 1,
|
||||||
|
"d": "字段值信号,校验数据源"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)required",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "必填:显示红星并自动追加非空规则"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)rules",
|
||||||
|
"t": "FormRule[]",
|
||||||
|
"d": "校验规则:required / min / max / pattern / validator,按序执行取首个失败"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "(formItem)validateOn",
|
||||||
|
"t": "'input' | 'blur' | 'submit'",
|
||||||
|
"d": "校验时机;缺省 blur(输入时自动清已报错误)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])",
|
||||||
|
"steps": [
|
||||||
|
"为每个字段创建 signal 与控件,如 const name = signal(''); textField({ value: name })",
|
||||||
|
"用 formItem 包住控件:formItem({ label, required, value, rules }, control),返回 { el, validate } 句柄",
|
||||||
|
"把句柄数组交给 form:form({ onSubmit }, items),form 渲染全部字段 + 提交/重置按钮",
|
||||||
|
"提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项",
|
||||||
|
"规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)",
|
||||||
|
"非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dataTable",
|
||||||
|
"title": "数据表格 DataTable",
|
||||||
|
"demo": "sec-table",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "data",
|
||||||
|
"t": "T[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "全量行数据,每行须含 id 字段作 rowKey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "columns",
|
||||||
|
"t": "ColumnDef<T>[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "列定义:{ key, header?, sortable? }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "filterKey",
|
||||||
|
"t": "string",
|
||||||
|
"d": "工具栏筛选的列;缺省取第一列"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "pageSize",
|
||||||
|
"t": "number",
|
||||||
|
"d": "每页行数;缺省 5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "selectable",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "启用行选择列:全选三态(含半选)、行 checkbox、已选计数;缺省 false"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })",
|
||||||
|
"steps": [
|
||||||
|
"准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)",
|
||||||
|
"定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕",
|
||||||
|
"selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数",
|
||||||
|
"选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失",
|
||||||
|
"工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行",
|
||||||
|
"表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
/* @ui-arch 组件皮肤:只消费 @ui-arch/tokens 的 --ua-* 变量 */
|
||||||
|
.ua-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--ua-color-accent);
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
background: var(--ua-color-accent);
|
||||||
|
color: var(--ua-color-accent-contrast);
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.ua-btn:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
.ua-btn:active {
|
||||||
|
transform: translateY(0) scale(0.97);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.ua-btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ua-color-accent);
|
||||||
|
}
|
||||||
|
.ua-btn-ghost:hover {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
.ua-btn-danger {
|
||||||
|
background: var(--ua-color-danger);
|
||||||
|
border-color: var(--ua-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ua-input {
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
background: var(--ua-color-bg);
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.ua-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ua-color-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
.ua-card {
|
||||||
|
padding: var(--ua-space-4);
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-lg);
|
||||||
|
background: var(--ua-color-surface);
|
||||||
|
}
|
||||||
|
.ua-card-title {
|
||||||
|
margin: 0 0 var(--ua-space-3);
|
||||||
|
font-size: var(--ua-font-size-lg);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ua-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.ua-table th,
|
||||||
|
.ua-table td {
|
||||||
|
padding: var(--ua-space-table-cell);
|
||||||
|
border-bottom: 1px solid var(--ua-color-border);
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 粘性表头:滚动容器内固定,背景必须实色否则透视 */
|
||||||
|
.ua-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--ua-color-surface);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
box-shadow: inset 0 -1px 0 var(--ua-color-border);
|
||||||
|
}
|
||||||
|
.ua-table th.ua-sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.ua-table th.ua-sortable:hover {
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
}
|
||||||
|
.ua-sort-mark {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-accent);
|
||||||
|
}
|
||||||
|
/* 斑马纹 + 悬停高亮 + 选中行(低视觉比重:淡色底 + 弱化文字) */
|
||||||
|
.ua-table tbody tr {
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.ua-table tbody tr:nth-child(even) {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-border) 18%, transparent);
|
||||||
|
}
|
||||||
|
.ua-table tbody tr:hover {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
.ua-table tr.ua-row-selected,
|
||||||
|
.ua-table tr.ua-row-selected:hover {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.ua-table tr.ua-row-selected td {
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.ua-cell-check {
|
||||||
|
width: 36px;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
.ua-empty-row td {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
padding: 28px 12px;
|
||||||
|
}
|
||||||
|
.ua-table-scroll {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
background: var(--ua-color-bg);
|
||||||
|
}
|
||||||
|
.ua-table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--ua-space-3);
|
||||||
|
}
|
||||||
|
.ua-sel-count {
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-accent);
|
||||||
|
}
|
||||||
|
.ua-pager {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ua-space-3);
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--ua-space-3);
|
||||||
|
}
|
||||||
|
.ua-page-info {
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 复选框 / 单选 —— */
|
||||||
|
.ua-check-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ua-checkbox,
|
||||||
|
.ua-radio {
|
||||||
|
accent-color: var(--ua-color-accent);
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.ua-checkbox:active,
|
||||||
|
.ua-radio:active {
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
.ua-radio-group {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 开关 —— */
|
||||||
|
.ua-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ua-switch-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.ua-switch-track {
|
||||||
|
display: inline-block;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ua-color-border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.ua-switch-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: left 0.2s;
|
||||||
|
}
|
||||||
|
.ua-switch-input:checked + .ua-switch-track {
|
||||||
|
background: var(--ua-color-accent);
|
||||||
|
}
|
||||||
|
.ua-switch-input:checked + .ua-switch-track .ua-switch-thumb {
|
||||||
|
left: 18px;
|
||||||
|
}
|
||||||
|
.ua-switch-label {
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 下拉选择(自绘弹层) —— */
|
||||||
|
.ua-select {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
.ua-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
background: var(--ua-color-bg);
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.ua-select-trigger:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--ua-color-accent) 55%, var(--ua-color-border));
|
||||||
|
}
|
||||||
|
.ua-select-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ua-color-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
.ua-select-value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ua-select-value.is-empty {
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.ua-select-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.ua-select.ua-open .ua-select-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.ua-select-pop {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
background: var(--ua-color-surface);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||||
|
}
|
||||||
|
.ua-select-pop.ua-open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.ua-option {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--ua-radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.ua-option:hover {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
/* 选中态:低视觉比重——淡色底 + 弱化文字,不抢焦点 */
|
||||||
|
.ua-option-selected {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.ua-option-selected:hover {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 多行输入 —— */
|
||||||
|
.ua-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
background: var(--ua-color-bg);
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 徽标 —— */
|
||||||
|
.ua-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.ua-badge-default {
|
||||||
|
background: var(--ua-color-border);
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
}
|
||||||
|
.ua-badge-success {
|
||||||
|
background: var(--ua-color-success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ua-badge-warning {
|
||||||
|
background: var(--ua-color-warning);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ua-badge-danger {
|
||||||
|
background: var(--ua-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 提示条 —— */
|
||||||
|
.ua-alert {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
animation: ua-slide-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes ua-slide-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ua-alert-info {
|
||||||
|
border-color: var(--ua-color-accent);
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.ua-alert-success {
|
||||||
|
border-color: var(--ua-color-success);
|
||||||
|
background: color-mix(in srgb, var(--ua-color-success) 12%, transparent);
|
||||||
|
}
|
||||||
|
.ua-alert-warning {
|
||||||
|
border-color: var(--ua-color-warning);
|
||||||
|
background: color-mix(in srgb, var(--ua-color-warning) 12%, transparent);
|
||||||
|
}
|
||||||
|
.ua-alert-danger {
|
||||||
|
border-color: var(--ua-color-danger);
|
||||||
|
background: color-mix(in srgb, var(--ua-color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 加载指示器 —— */
|
||||||
|
.ua-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
border: 2px solid var(--ua-color-border);
|
||||||
|
border-top-color: var(--ua-color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ua-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ua-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 进度条:轨道 + 渐变流动填充 —— */
|
||||||
|
.ua-progress {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ua-progress-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--ua-color-border) 40%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ua-progress-fill {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--ua-color-accent) 65%, transparent), var(--ua-color-accent));
|
||||||
|
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.ua-progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.18) 0 6px, transparent 6px 12px);
|
||||||
|
background-size: 17px 100%;
|
||||||
|
animation: ua-stripe 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
.ua-progress-fill.ua-done {
|
||||||
|
background: var(--ua-color-success);
|
||||||
|
}
|
||||||
|
.ua-progress-fill.ua-done::after {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@keyframes ua-stripe {
|
||||||
|
to {
|
||||||
|
background-position: 17px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ua-progress-text {
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 标签页:滑动指示条 + 可滚动页签头 —— */
|
||||||
|
.ua-tabs {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ua-tabs-head {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 1px solid var(--ua-color-border);
|
||||||
|
}
|
||||||
|
.ua-tab {
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
font-size: var(--ua-font-size-md);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.ua-tab:hover {
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 5%, transparent);
|
||||||
|
}
|
||||||
|
.ua-tab:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--ua-color-accent);
|
||||||
|
}
|
||||||
|
.ua-tab:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ua-tab-active {
|
||||||
|
color: var(--ua-color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ua-tabs-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--ua-color-accent);
|
||||||
|
transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 表单 —— */
|
||||||
|
.ua-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ua-form-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
/* 水平布局:标签左置右对齐 */
|
||||||
|
.ua-form-h .ua-form-item {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ua-form-h .ua-form-label {
|
||||||
|
min-width: 80px;
|
||||||
|
padding-top: 6px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.ua-form-h .ua-form-control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ua-form-label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
}
|
||||||
|
.ua-form-req {
|
||||||
|
color: var(--ua-color-danger);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.ua-form-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ua-form-error {
|
||||||
|
color: var(--ua-color-danger);
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
animation: ua-slide-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.ua-form-error:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* 校验失败:控件描红 + 危险色光环 */
|
||||||
|
.ua-invalid {
|
||||||
|
border-color: var(--ua-color-danger) !important;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 15%, transparent) !important;
|
||||||
|
}
|
||||||
|
.ua-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 弹窗 —— */
|
||||||
|
.ua-modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ua-modal-overlay.ua-open {
|
||||||
|
display: flex;
|
||||||
|
animation: ua-fade 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.ua-modal {
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
border-radius: var(--ua-radius-lg);
|
||||||
|
background: var(--ua-color-surface);
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: ua-pop-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||||
|
}
|
||||||
|
@keyframes ua-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes ua-pop-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ua-modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--ua-color-border);
|
||||||
|
}
|
||||||
|
.ua-modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ua-font-size-lg);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ua-modal-close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ua-modal-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.ua-textarea:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ua-color-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 无障碍:用户偏好减少动效时关闭全部动画 —— */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ua-btn,
|
||||||
|
.ua-checkbox,
|
||||||
|
.ua-radio,
|
||||||
|
.ua-select-trigger,
|
||||||
|
.ua-select-arrow,
|
||||||
|
.ua-select-pop,
|
||||||
|
.ua-option,
|
||||||
|
.ua-switch-track,
|
||||||
|
.ua-switch-thumb,
|
||||||
|
.ua-tab,
|
||||||
|
.ua-tabs-indicator,
|
||||||
|
.ua-table tbody tr,
|
||||||
|
.ua-progress-fill,
|
||||||
|
.ua-progress-fill::after,
|
||||||
|
.ua-alert,
|
||||||
|
.ua-form-error,
|
||||||
|
.ua-modal,
|
||||||
|
.ua-modal-overlay {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*** @ui-arch CLI:组件库注册表命令行 + 内置 MCP 服务(零依赖,Node >= 18) ***/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/*** 自底向上查找仓库根(以 pnpm-workspace.yaml 为界) ***/
|
||||||
|
function findRepoRoot(start) {
|
||||||
|
let dir = start;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir;
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) return process.cwd();
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
return process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOT = findRepoRoot(__dirname);
|
||||||
|
const PKG_DIR = path.join(ROOT, 'packages');
|
||||||
|
const INDEX_DIR = path.join(ROOT, '.ua');
|
||||||
|
const INDEX_FILE = path.join(INDEX_DIR, 'index.json');
|
||||||
|
const LLMS_FILE = path.join(ROOT, 'llms.txt');
|
||||||
|
const LLMS_FULL = path.join(ROOT, 'llms-full.txt');
|
||||||
|
|
||||||
|
/*** 汇总各包 ua.manifest.json 与 package.json 版本(扫描 packages/ 两层目录) ***/
|
||||||
|
function collectManifests() {
|
||||||
|
const out = [];
|
||||||
|
if (!fs.existsSync(PKG_DIR)) return out;
|
||||||
|
const readManifest = (dir) => {
|
||||||
|
const manifestPath = path.join(dir, 'ua.manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) return; // 卫语句:无清单不入索引
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
const pkgPath = path.join(dir, 'package.json');
|
||||||
|
let version = '0.0.0';
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? version;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
...manifest,
|
||||||
|
version,
|
||||||
|
dir: path.relative(ROOT, dir).split(path.sep).join('/'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (const layer of fs.readdirSync(PKG_DIR)) {
|
||||||
|
const layerDir = path.join(PKG_DIR, layer);
|
||||||
|
if (!fs.statSync(layerDir).isDirectory()) continue;
|
||||||
|
readManifest(layerDir); // 兼容单层直放
|
||||||
|
for (const name of fs.readdirSync(layerDir)) {
|
||||||
|
const dir = path.join(layerDir, name);
|
||||||
|
if (fs.statSync(dir).isDirectory()) readManifest(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 聚合清单,生成 .ua/index.json 与 llms.txt ***/
|
||||||
|
function cmdIndex() {
|
||||||
|
const packages = collectManifests();
|
||||||
|
fs.mkdirSync(INDEX_DIR, { recursive: true });
|
||||||
|
const index = {
|
||||||
|
$schema: 'ui-arch-registry-index/0.1',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
repoRoot: '.',
|
||||||
|
packages,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'# @ui-arch 私人组件库索引',
|
||||||
|
'',
|
||||||
|
'> 本文件由 `ua index` 自动生成,请勿手改。机器可读版:`.ua/index.json`。',
|
||||||
|
'> Agent 可通过 `ua mcp`(stdio MCP server)直接查询注册表。',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
for (const p of packages) {
|
||||||
|
lines.push(`## ${p.name} (v${p.version})`, '');
|
||||||
|
lines.push(`- 类型: ${p.type} · 目录: ${p.dir}`);
|
||||||
|
lines.push(`- 描述: ${p.description}`);
|
||||||
|
if (p.api?.exports?.length) {
|
||||||
|
lines.push('- API:');
|
||||||
|
for (const e of p.api.exports) lines.push(` - ${e}`);
|
||||||
|
}
|
||||||
|
if (p.usage) lines.push(`- 用法: \`${p.usage}\``);
|
||||||
|
for (const c of p.conventions ?? []) lines.push(`- 约定: ${c}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(LLMS_FILE, lines.join('\n'));
|
||||||
|
console.log(`indexed ${packages.length} package(s) -> .ua/index.json, llms.txt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 读取注册表索引;缺失则报错退出 ***/
|
||||||
|
function loadIndex() {
|
||||||
|
if (!fs.existsSync(INDEX_FILE)) {
|
||||||
|
console.error('index not found, run: ua index');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 列出全部已注册包 ***/
|
||||||
|
function cmdList() {
|
||||||
|
const { packages } = loadIndex();
|
||||||
|
if (!packages.length) return console.log('(no packages indexed)');
|
||||||
|
for (const p of packages) {
|
||||||
|
console.log(`${p.name} v${p.version} [${p.type}] ${p.description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 输出单包完整元数据 ***/
|
||||||
|
function cmdInfo(name) {
|
||||||
|
const { packages } = loadIndex();
|
||||||
|
const p = packages.find((x) => x.name === name || x.name === `@ui-arch/${name}`);
|
||||||
|
if (!p) {
|
||||||
|
console.error(`not found: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(p, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 静态文档站生成 ----
|
||||||
|
|
||||||
|
/*** HTML 转义:& < > ***/
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 渲染人类文档站:注册表 + 逐组件 props/步骤 + 现场演示 iframe + 令牌,数据内嵌支持搜索 ***/
|
||||||
|
function renderDocs(index, tokens) {
|
||||||
|
const data = JSON.stringify(index.packages).replace(/</g, '\\u003c');
|
||||||
|
const tokData = JSON.stringify(tokens).replace(/</g, '\\u003c');
|
||||||
|
const generatedAt = esc(index.generatedAt);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>@ui-arch 组件库文档</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#ffffff;--surface:#f6f7f9;--text:#1c1e21;--muted:#6b7280;--border:rgba(0,0,0,.14);--accent:#185fa5;--code-bg:#101216;--code-text:#e7e9ec}
|
||||||
|
@media (prefers-color-scheme:dark){:root{--bg:#101216;--surface:#1a1c22;--text:#e7e9ec;--muted:#888780;--border:rgba(255,255,255,.15);--accent:#378add}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.65 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
||||||
|
header{position:sticky;top:0;z-index:2;display:flex;gap:16px;align-items:center;flex-wrap:wrap;padding:14px 24px;background:var(--bg);border-bottom:1px solid var(--border)}
|
||||||
|
header h1{margin:0;font-size:16px;font-weight:500}
|
||||||
|
header h1 span{color:var(--muted);font-weight:400;font-size:14px;margin-left:8px}
|
||||||
|
#q{flex:1;min-width:220px;max-width:420px;margin-left:auto;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;outline:none}
|
||||||
|
#q:focus{border-color:var(--accent)}
|
||||||
|
nav.pkgnav{max-width:960px;margin:18px auto 0;padding:0 20px;display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
nav.pkgnav a{font-size:12px;color:var(--accent);text-decoration:none;border:1px solid var(--border);border-radius:999px;padding:3px 12px}
|
||||||
|
main{max-width:960px;margin:0 auto;padding:20px 20px 60px}
|
||||||
|
.card{border:1px solid var(--border);border-radius:12px;background:var(--surface);padding:22px 24px;margin-bottom:22px}
|
||||||
|
.card h2{margin:0;font-size:16px;font-weight:500;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||||
|
.ver{font-size:12px;color:var(--muted)}
|
||||||
|
.badge{font-size:11px;padding:2px 8px;border-radius:999px;background:var(--accent);color:#fff}
|
||||||
|
.desc{color:var(--muted);margin:10px 0 0}
|
||||||
|
.tag{display:inline-block;font-size:11px;padding:2px 9px;margin:10px 6px 0 0;border:1px solid var(--border);border-radius:999px;color:var(--muted)}
|
||||||
|
.card h3{font-size:13px;font-weight:500;margin:18px 0 8px}
|
||||||
|
.card ul,.card ol{margin:0;padding-left:18px}
|
||||||
|
.card li{margin:4px 0}
|
||||||
|
.comp{border:1px dashed var(--border);border-radius:10px;padding:14px 16px;margin:14px 0 0;background:var(--bg)}
|
||||||
|
.comp h4{margin:0 0 8px;font-size:14px;font-weight:500;font-family:Consolas,Menlo,monospace}
|
||||||
|
.comp h5{margin:12px 0 6px;font-size:12px;color:var(--muted);font-weight:500}
|
||||||
|
table.props{width:100%;border-collapse:collapse;font-size:12.5px;margin:8px 0}
|
||||||
|
table.props th,table.props td{border:1px solid var(--border);padding:5px 9px;text-align:left;vertical-align:top}
|
||||||
|
table.props th{background:var(--surface);font-weight:500;color:var(--muted)}
|
||||||
|
pre{background:var(--code-bg);color:var(--code-text);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12.5px/1.6 Consolas,Menlo,monospace;margin:8px 0}
|
||||||
|
code{font-family:Consolas,Menlo,monospace;font-size:12.5px}
|
||||||
|
li code,td code{background:var(--code-bg);color:var(--code-text);padding:1px 6px;border-radius:4px}
|
||||||
|
iframe.demo{width:100%;height:430px;border:1px dashed var(--border);border-radius:10px;margin-top:10px;background:var(--surface)}
|
||||||
|
.sw{display:flex;align-items:center;gap:10px;margin:6px 0}
|
||||||
|
.sw i{width:26px;height:26px;border-radius:6px;border:1px solid var(--border);flex:none}
|
||||||
|
.sw code{color:var(--muted)}
|
||||||
|
.sw span{font-size:12px;color:var(--muted)}
|
||||||
|
.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:4px 20px}
|
||||||
|
.empty{color:var(--muted);text-align:center;padding:40px 0}
|
||||||
|
footer{max-width:960px;margin:0 auto;padding:0 20px 40px;color:var(--muted);font-size:12px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><h1>@ui-arch<span>私人组件库</span></h1><input id="q" placeholder="搜索组件、参数、API…" autocomplete="off"></header>
|
||||||
|
<nav class="pkgnav" id="nav"></nav>
|
||||||
|
<main id="app"></main>
|
||||||
|
<footer>由 <code>ua docs</code> 生成于 ${generatedAt} · AI 版全文:<code>llms-full.txt</code> · 重新生成:npm run docs</footer>
|
||||||
|
<script>
|
||||||
|
var DATA=${data};
|
||||||
|
var TOKENS=${tokData};
|
||||||
|
var ORDER={skin-dom:0,'headless-core':1,'design-tokens':2};
|
||||||
|
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||||
|
function propsTable(c){
|
||||||
|
var rows=(c.props||[]).map(function(p){
|
||||||
|
return '<tr><td><code>'+esc(p.n)+'</code></td><td><code>'+esc(p.t)+'</code></td><td>'+(p.r?'必填':'否')+'</td><td>'+esc(p.d||'')+(p.k?'(回调)':'')+'</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
return '<table class="props"><tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>'+rows+'</table>';
|
||||||
|
}
|
||||||
|
function compBlock(c){
|
||||||
|
var steps=(c.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||||||
|
return '<div class="comp" id="c-'+esc(c.id)+'"><h4>'+esc(c.id)+' · '+esc(c.title||'')+'</h4>'
|
||||||
|
+(c.props?propsTable(c):'')
|
||||||
|
+(c.example?'<pre><code>'+esc(c.example)+'</code></pre>':'')
|
||||||
|
+(steps?'<h5>手把手</h5><ol>'+steps+'</ol>':'')
|
||||||
|
+(c.demo?'<iframe class="demo" src="demo.html#'+esc(c.demo)+'" loading="lazy" title="'+esc(c.id)+' 现场演示"></iframe>':'')
|
||||||
|
+'</div>';
|
||||||
|
}
|
||||||
|
function pkgCard(p){
|
||||||
|
var steps=(p.docs&&p.docs.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||||||
|
var comps=(p.docs&&p.docs.components||[]).map(compBlock).join('');
|
||||||
|
var ex=(p.api&&p.api.exports||[]).map(function(e){return '<li><code>'+esc(e)+'</code></li>'}).join('');
|
||||||
|
var conv=(p.conventions||[]).map(function(c){return '<li>'+esc(c)+'</li>'}).join('');
|
||||||
|
var tags=(p.tags||[]).map(function(t){return '<span class="tag">'+esc(t)+'</span>'}).join('');
|
||||||
|
return '<section class="card" id="p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'
|
||||||
|
+'<h2>'+esc(p.name)+' <span class="ver">v'+esc(p.version)+'</span> <span class="badge">'+esc(p.type)+'</span></h2>'
|
||||||
|
+'<p class="desc">'+esc((p.docs&&p.docs.intro)||p.description)+'</p><div>'+tags+'</div>'
|
||||||
|
+(steps?'<h3>手把手接入</h3><ol>'+steps+'</ol>':'')
|
||||||
|
+(comps?'<h3>组件明细</h3>'+comps:'')
|
||||||
|
+(ex?'<h3>API 速览</h3><ul>'+ex+'</ul>':'')
|
||||||
|
+(p.usage?'<h3>一行用法</h3><pre><code>'+esc(p.usage)+'</code></pre>':'')
|
||||||
|
+(conv?'<h3>约定</h3><ul>'+conv+'</ul>':'')
|
||||||
|
+'</section>';
|
||||||
|
}
|
||||||
|
function quickstart(){
|
||||||
|
var ui=DATA.filter(function(p){return p.docs&&p.docs.steps}).sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
|
||||||
|
if(!ui.length)return '';
|
||||||
|
var blocks=ui.map(function(p){
|
||||||
|
var steps=p.docs.steps.map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||||||
|
return '<p class="desc" style="margin:10px 0 4px"><b>'+esc(p.name)+'</b></p><ol>'+steps+'</ol>';
|
||||||
|
}).join('');
|
||||||
|
return '<section class="card"><h2>快速上手 <span class="badge">guide</span></h2>'+blocks+'</section>';
|
||||||
|
}
|
||||||
|
function tokensCard(){
|
||||||
|
if(!TOKENS||!TOKENS.color)return '';
|
||||||
|
var sw=Object.keys(TOKENS.color).map(function(k){
|
||||||
|
var v=TOKENS.color[k];
|
||||||
|
var light=typeof v==='string'?v:v.light;
|
||||||
|
var dark=typeof v==='string'?v:v.dark;
|
||||||
|
var extra=dark&&dark!==light?' / '+dark:'';
|
||||||
|
return '<div class="sw"><i style="background:'+light+'"></i><code>--ua-'+esc(k)+'</code><span>'+light+extra+'</span></div>';
|
||||||
|
}).join('');
|
||||||
|
return '<section class="card"><h2>@ui-arch/tokens <span class="badge">design-tokens</span></h2><p class="desc">皮肤包只允许消费 --ua-* CSS 变量;暗色主题覆盖值不换名(light / dark)。</p><h3>颜色令牌</h3><div class="swatches">'+sw+'</div></section>';
|
||||||
|
}
|
||||||
|
function render(list){
|
||||||
|
var app=document.getElementById('app');
|
||||||
|
if(!list.length){app.innerHTML='<p class="empty">无匹配结果</p>';return}
|
||||||
|
app.innerHTML=quickstart()+list.map(pkgCard).join('')+(list.some(function(p){return p.name.indexOf('tokens')>=0})?'':tokensCard());
|
||||||
|
}
|
||||||
|
function norm(p){
|
||||||
|
var parts=[p.name,p.description,p.type,(p.tags||[]).join(' '),(p.api&&p.api.exports||[]).join(' ')];
|
||||||
|
(p.docs&&p.docs.components||[]).forEach(function(c){parts.push(c.id,c.title||'',(c.props||[]).map(function(x){return x.n}).join(' '))});
|
||||||
|
return parts.join(' ').toLowerCase();
|
||||||
|
}
|
||||||
|
var ordered=DATA.slice().sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
|
||||||
|
document.getElementById('nav').innerHTML=ordered.map(function(p){return '<a href="#p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'+esc(p.name)+'</a>'}).join('')+'<a href="#top-tokens">令牌</a>';
|
||||||
|
render(ordered);
|
||||||
|
document.getElementById('q').addEventListener('input',function(e){
|
||||||
|
var q=e.target.value.trim().toLowerCase();
|
||||||
|
render(q?ordered.filter(function(p){return norm(p).indexOf(q)>=0}):ordered);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 渲染 Agent 文档(llms-full.txt):无图形纯文本,逐组件参数 / 回调 / 步骤 / 约定全量收录 ***/
|
||||||
|
function renderAgentDocs(index, tokens) {
|
||||||
|
const lines = [
|
||||||
|
'# @ui-arch 组件库 Agent 文档(无图形版)',
|
||||||
|
'',
|
||||||
|
`> 自动生成于 ${index.generatedAt},数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。`,
|
||||||
|
'> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。',
|
||||||
|
'',
|
||||||
|
'## 快速上手',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
for (const p of index.packages) {
|
||||||
|
if (!p.docs?.steps?.length) continue;
|
||||||
|
lines.push(`### ${p.name}`, '');
|
||||||
|
p.docs.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
const order = { 'skin-dom': 0, 'headless-core': 1, 'design-tokens': 2 };
|
||||||
|
for (const p of [...index.packages].sort((a, b) => (order[a.type] ?? 9) - (order[b.type] ?? 9))) {
|
||||||
|
lines.push(`## ${p.name}(${p.type})v${p.version}`, '');
|
||||||
|
lines.push((p.docs?.intro ?? p.description) + '', '');
|
||||||
|
for (const c of p.docs?.components ?? []) {
|
||||||
|
lines.push(`### ${c.id} — ${c.title ?? ''}`, '');
|
||||||
|
if (c.props?.length) {
|
||||||
|
lines.push('参数:', '');
|
||||||
|
for (const prop of c.props) {
|
||||||
|
const marks = [prop.r ? '必填' : '', prop.k ? '回调' : ''].filter(Boolean).join(',');
|
||||||
|
lines.push(`- ${prop.n}${marks ? `(${marks})` : ''}:${prop.t}${prop.d ? ` —— ${prop.d}` : ''}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (c.example) lines.push('示例:', '', '```', c.example, '```', '');
|
||||||
|
if (c.steps?.length) {
|
||||||
|
lines.push('手把手:', '');
|
||||||
|
c.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (c.demo) lines.push(`现场演示:apps/docs/demo.html#${c.demo}`, '');
|
||||||
|
}
|
||||||
|
if (p.api?.exports?.length) {
|
||||||
|
lines.push('API 速览:', '');
|
||||||
|
for (const e of p.api.exports) lines.push(`- ${e}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (p.usage) lines.push('一行用法:', '', '```', p.usage, '```', '');
|
||||||
|
if (p.conventions?.length) {
|
||||||
|
lines.push('约定:', '');
|
||||||
|
for (const cv of p.conventions) lines.push(`- ${cv}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('---', '');
|
||||||
|
}
|
||||||
|
if (tokens?.color) {
|
||||||
|
lines.push('## 设计令牌(--ua-*)', '');
|
||||||
|
for (const [k, v] of Object.entries(tokens.color)) {
|
||||||
|
const light = typeof v === 'string' ? v : v.light;
|
||||||
|
const dark = typeof v === 'string' ? v : v.dark;
|
||||||
|
lines.push(`- --ua-color-${k}: ${light}${dark !== light ? `(暗色 ${dark})` : ''}`);
|
||||||
|
}
|
||||||
|
for (const [g, vals] of Object.entries(tokens)) {
|
||||||
|
if (g === 'color' || g === '$schema') continue;
|
||||||
|
lines.push('', `### ${g}`, '');
|
||||||
|
for (const [k, v] of Object.entries(vals)) lines.push(`- --ua-${g}-${k}: ${v}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 生成双轨文档:人类版 apps/docs/index.html(含演示 iframe)+ Agent 版 llms-full.txt ***/
|
||||||
|
function cmdDocs() {
|
||||||
|
if (!fs.existsSync(INDEX_FILE)) cmdIndex();
|
||||||
|
const index = loadIndex();
|
||||||
|
const tokensPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json');
|
||||||
|
const tokens = fs.existsSync(tokensPath)
|
||||||
|
? JSON.parse(fs.readFileSync(tokensPath, 'utf8'))
|
||||||
|
: null;
|
||||||
|
const outDir = path.join(ROOT, 'apps', 'docs');
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outDir, 'index.html'), renderDocs(index, tokens));
|
||||||
|
const demoSrc = path.join(ROOT, 'apps', 'demo', 'dist', 'demo.html'); // 演示页拷入文档站供 iframe
|
||||||
|
if (fs.existsSync(demoSrc)) fs.copyFileSync(demoSrc, path.join(outDir, 'demo.html'));
|
||||||
|
fs.writeFileSync(LLMS_FULL, renderAgentDocs(index, tokens));
|
||||||
|
console.log(
|
||||||
|
`docs generated -> apps/docs/index.html + demo.html, llms-full.txt (${index.packages.length} packages)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内置 stdio MCP server(JSON-RPC 2.0,按行分帧) ----
|
||||||
|
|
||||||
|
/*** 启动 MCP server:向 Agent 暴露 list / get / search 三个查询工具 ***/
|
||||||
|
function cmdMcp() {
|
||||||
|
if (!fs.existsSync(INDEX_FILE)) cmdIndex();
|
||||||
|
const { packages } = loadIndex();
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: 'list_components',
|
||||||
|
description: 'List all packages/components in the @ui-arch private library',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_component',
|
||||||
|
description: 'Get full metadata (API, usage, conventions) of one component',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { name: { type: 'string', description: 'package name, e.g. @ui-arch/table-core or table-core' } },
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'search_components',
|
||||||
|
description: 'Search packages by keyword across name/description/tags/api',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { query: { type: 'string' } },
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
||||||
|
|
||||||
|
/*** 执行 MCP 工具调用 ***/
|
||||||
|
function callTool(name, args) {
|
||||||
|
if (name === 'list_components') {
|
||||||
|
return packages.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description }));
|
||||||
|
}
|
||||||
|
if (name === 'get_component') {
|
||||||
|
const p = packages.find((x) => x.name === args.name || x.name === `@ui-arch/${args.name}`);
|
||||||
|
if (!p) throw new Error(`not found: ${args.name}`);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (name === 'search_components') {
|
||||||
|
const q = String(args.query ?? '').toLowerCase();
|
||||||
|
return packages
|
||||||
|
.filter((p) =>
|
||||||
|
[p.name, p.description, p.type, ...(p.tags ?? []), ...(p.api?.exports ?? [])]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q),
|
||||||
|
)
|
||||||
|
.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description }));
|
||||||
|
}
|
||||||
|
throw new Error(`unknown tool: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 处理单条 JSON-RPC 消息;通知类消息不回包 ***/
|
||||||
|
function handle(msg) {
|
||||||
|
const { id, method, params } = msg;
|
||||||
|
if (method === 'initialize') {
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: 'ui-arch-registry', version: '0.1.0' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'tools/list') {
|
||||||
|
send({ jsonrpc: '2.0', id, result: { tools } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'tools/call') {
|
||||||
|
try {
|
||||||
|
const result = callTool(params?.name, params?.arguments ?? {});
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: { isError: true, content: [{ type: 'text', text: String(err.message) }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id != null) {
|
||||||
|
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
buf += chunk;
|
||||||
|
let idx;
|
||||||
|
while ((idx = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, idx).trim();
|
||||||
|
buf = buf.slice(idx + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
handle(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 风格检查(CODING.md 的机器执行版) ----
|
||||||
|
|
||||||
|
/*** 递归收集仓库源码文件(packages + apps),跳过产物与工具目录 ***/
|
||||||
|
function collectSourceFiles() {
|
||||||
|
const exts = new Set(['.ts', '.mts', '.cts', '.mjs', '.js']);
|
||||||
|
const skip = new Set(['node_modules', 'dist', '.ua', '.git', '.workbuddy']);
|
||||||
|
const out = [];
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
if (skip.has(name)) continue;
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
const st = fs.statSync(full);
|
||||||
|
if (st.isDirectory()) walk(full);
|
||||||
|
else if (exts.has(path.extname(name))) out.push(full);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(ROOT);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 检查单文件:导出缺注释块、接口字段缺行尾注释、行注释非中文 ***/
|
||||||
|
function styleCheckFile(file, src) {
|
||||||
|
const issues = [];
|
||||||
|
const lines = src.split(/\r?\n/);
|
||||||
|
let iface = null; // 当前追踪中的接口名
|
||||||
|
let depth = 0; // 接口体花括号深度
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const raw = lines[i];
|
||||||
|
const line = raw.trim();
|
||||||
|
|
||||||
|
if (iface) {
|
||||||
|
for (const ch of raw) { // 花括号计数进出接口体
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') depth--;
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z_$][\w$]*\??:/.test(line) && !line.includes('//')) {
|
||||||
|
issues.push(`${file}:${i + 1} 接口 ${iface} 字段缺少行尾注释`);
|
||||||
|
}
|
||||||
|
if (depth <= 0) iface = null;
|
||||||
|
} else {
|
||||||
|
const im = raw.match(/\bexport\s+interface\s+(\w+)/);
|
||||||
|
if (im) {
|
||||||
|
depth = (raw.match(/\{/g) || []).length - (raw.match(/\}/g) || []).length;
|
||||||
|
iface = im[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decl = line.match(/^export\s+(?:async\s+)?(?:function|interface|type|class)\s+([A-Za-z_$][\w$]*)/);
|
||||||
|
if (decl) {
|
||||||
|
let j = i - 1;
|
||||||
|
while (j >= 0 && lines[j].trim() === '') j--; // 找上一个非空行
|
||||||
|
if (j < 0 || !lines[j].includes('/***')) {
|
||||||
|
issues.push(`${file}:${i + 1} 导出 ${decl[1]} 缺少 /*** 作用 ***/ 注释`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = raw.indexOf('//');
|
||||||
|
if (hash >= 0) {
|
||||||
|
const text = raw.slice(hash + 2);
|
||||||
|
const isPragma = /^[\s*/]*(eslint|@ts-|prettier-ignore|istanbul|c8|node:)/i.test(text);
|
||||||
|
if (!isPragma && /[A-Za-z]{2,}/.test(text) && !/[\u4e00-\u9fff]/.test(text)) {
|
||||||
|
issues.push(`${file}:${i + 1} 行注释需使用中文`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 全仓风格检查:有违规退出码 1,全绿输出 OK ***/
|
||||||
|
function cmdStyle() {
|
||||||
|
const files = collectSourceFiles();
|
||||||
|
const issues = [];
|
||||||
|
for (const f of files) {
|
||||||
|
issues.push(...styleCheckFile(f, fs.readFileSync(f, 'utf8')));
|
||||||
|
}
|
||||||
|
if (issues.length) {
|
||||||
|
for (const it of issues) console.error('style: ' + it);
|
||||||
|
console.error(`${issues.length} violation(s)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`style OK (${files.length} files)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 入口 ----
|
||||||
|
|
||||||
|
const [cmd, ...args] = process.argv.slice(2);
|
||||||
|
switch (cmd) {
|
||||||
|
case 'index':
|
||||||
|
cmdIndex();
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
cmdList();
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
if (!args[0]) {
|
||||||
|
console.error('usage: ua info <package-name>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
cmdInfo(args[0]);
|
||||||
|
break;
|
||||||
|
case 'docs':
|
||||||
|
cmdDocs();
|
||||||
|
break;
|
||||||
|
case 'style':
|
||||||
|
cmdStyle();
|
||||||
|
break;
|
||||||
|
case 'mcp':
|
||||||
|
cmdMcp();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('usage: ua <index|list|info <name>|docs|style|mcp>');
|
||||||
|
process.exit(cmd ? 1 : 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@ui-arch/cli",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Agent-friendly CLI + MCP registry server for the @ui-arch component library",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"ua": "./bin/ua.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/*/*"
|
||||||
|
- "apps/*"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user