From 0de9b6e66e0b7f1a301bff8f79179ac9449a0b5e Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 23 Oct 2026 14:58:10 +0800 Subject: [PATCH] =?UTF-8?q?init:=20@ui-arch=20=E7=A7=81=E4=BA=BA=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=BA=93=20monorepo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 6 + .ua/index.json | 711 ++++++++++++++++++ AGENTS.md | 60 ++ ARCHITECTURE.md | 60 ++ CODING.md | 69 ++ apps/demo/build.mjs | 75 ++ apps/demo/main.ts | 164 ++++ apps/demo/package.json | 16 + apps/demo/tsconfig.json | 14 + llms-full.txt | 533 +++++++++++++ llms.txt | 70 ++ package.json | 22 + packages/core/frame/package.json | 28 + packages/core/frame/src/dom.ts | 70 ++ packages/core/frame/src/index.ts | 2 + packages/core/frame/src/signal.ts | 49 ++ packages/core/frame/tsconfig.json | 10 + packages/core/frame/ua.manifest.json | 41 + packages/core/table-core/package.json | 29 + packages/core/table-core/scripts/smoke.mjs | 83 ++ packages/core/table-core/src/core.ts | 110 +++ packages/core/table-core/src/index.ts | 9 + .../core/table-core/src/plugins/filter.ts | 67 ++ .../core/table-core/src/plugins/pagination.ts | 61 ++ .../core/table-core/src/plugins/selection.ts | 34 + packages/core/table-core/src/plugins/sort.ts | 81 ++ packages/core/table-core/src/types.ts | 95 +++ packages/core/table-core/tsconfig.json | 8 + packages/core/table-core/ua.manifest.json | 45 ++ packages/design/tokens/package.json | 15 + packages/design/tokens/tokens.css | 40 + packages/design/tokens/tokens.json | 14 + packages/design/tokens/ua.manifest.json | 32 + packages/skin/ui/package.json | 35 + packages/skin/ui/src/alert.ts | 7 + packages/skin/ui/src/badge.ts | 7 + packages/skin/ui/src/button.ts | 11 + packages/skin/ui/src/card.ts | 12 + packages/skin/ui/src/checkbox.ts | 18 + packages/skin/ui/src/dataTable.ts | 133 ++++ packages/skin/ui/src/form.ts | 78 ++ packages/skin/ui/src/index.ts | 17 + packages/skin/ui/src/modal.ts | 33 + packages/skin/ui/src/progress.ts | 22 + packages/skin/ui/src/radioGroup.ts | 25 + packages/skin/ui/src/select.ts | 60 ++ packages/skin/ui/src/spinner.ts | 11 + packages/skin/ui/src/switch.ts | 19 + packages/skin/ui/src/tabs.ts | 54 ++ packages/skin/ui/src/textField.ts | 16 + packages/skin/ui/src/textarea.ts | 16 + packages/skin/ui/src/types.ts | 147 ++++ packages/skin/ui/tsconfig.json | 13 + packages/skin/ui/ua.manifest.json | 578 ++++++++++++++ packages/skin/ui/ui.css | 639 ++++++++++++++++ packages/tools/cli/bin/ua.js | 587 +++++++++++++++ packages/tools/cli/package.json | 14 + pnpm-workspace.yaml | 3 + tsconfig.base.json | 15 + 59 files changed, 5293 insertions(+) create mode 100644 .gitignore create mode 100644 .ua/index.json create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE.md create mode 100644 CODING.md create mode 100644 apps/demo/build.mjs create mode 100644 apps/demo/main.ts create mode 100644 apps/demo/package.json create mode 100644 apps/demo/tsconfig.json create mode 100644 llms-full.txt create mode 100644 llms.txt create mode 100644 package.json create mode 100644 packages/core/frame/package.json create mode 100644 packages/core/frame/src/dom.ts create mode 100644 packages/core/frame/src/index.ts create mode 100644 packages/core/frame/src/signal.ts create mode 100644 packages/core/frame/tsconfig.json create mode 100644 packages/core/frame/ua.manifest.json create mode 100644 packages/core/table-core/package.json create mode 100644 packages/core/table-core/scripts/smoke.mjs create mode 100644 packages/core/table-core/src/core.ts create mode 100644 packages/core/table-core/src/index.ts create mode 100644 packages/core/table-core/src/plugins/filter.ts create mode 100644 packages/core/table-core/src/plugins/pagination.ts create mode 100644 packages/core/table-core/src/plugins/selection.ts create mode 100644 packages/core/table-core/src/plugins/sort.ts create mode 100644 packages/core/table-core/src/types.ts create mode 100644 packages/core/table-core/tsconfig.json create mode 100644 packages/core/table-core/ua.manifest.json create mode 100644 packages/design/tokens/package.json create mode 100644 packages/design/tokens/tokens.css create mode 100644 packages/design/tokens/tokens.json create mode 100644 packages/design/tokens/ua.manifest.json create mode 100644 packages/skin/ui/package.json create mode 100644 packages/skin/ui/src/alert.ts create mode 100644 packages/skin/ui/src/badge.ts create mode 100644 packages/skin/ui/src/button.ts create mode 100644 packages/skin/ui/src/card.ts create mode 100644 packages/skin/ui/src/checkbox.ts create mode 100644 packages/skin/ui/src/dataTable.ts create mode 100644 packages/skin/ui/src/form.ts create mode 100644 packages/skin/ui/src/index.ts create mode 100644 packages/skin/ui/src/modal.ts create mode 100644 packages/skin/ui/src/progress.ts create mode 100644 packages/skin/ui/src/radioGroup.ts create mode 100644 packages/skin/ui/src/select.ts create mode 100644 packages/skin/ui/src/spinner.ts create mode 100644 packages/skin/ui/src/switch.ts create mode 100644 packages/skin/ui/src/tabs.ts create mode 100644 packages/skin/ui/src/textField.ts create mode 100644 packages/skin/ui/src/textarea.ts create mode 100644 packages/skin/ui/src/types.ts create mode 100644 packages/skin/ui/tsconfig.json create mode 100644 packages/skin/ui/ua.manifest.json create mode 100644 packages/skin/ui/ui.css create mode 100644 packages/tools/cli/bin/ua.js create mode 100644 packages/tools/cli/package.json create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3a9076 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.turbo/ +.ua/cache/ +apps/docs/*.html +.workbuddy/ diff --git a/.ua/index.json b/.ua/index.json new file mode 100644 index 0000000..af488b3 --- /dev/null +++ b/.ua/index.json @@ -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:无参读值并收集依赖,传参写值并触发订阅", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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[]", + "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" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4aaf65d --- /dev/null +++ b/AGENTS.md @@ -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 | 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e069309 --- /dev/null +++ b/ARCHITECTURE.md @@ -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` 过全套门禁 diff --git a/CODING.md b/CODING.md new file mode 100644 index 0000000..1afb7e5 --- /dev/null +++ b/CODING.md @@ -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` 检查项:导出缺注释、接口字段缺注释、类型散落实现文件、闭包状态未集中顶部。 diff --git a/apps/demo/build.mjs b/apps/demo/build.mjs new file mode 100644 index 0000000..7d93abc --- /dev/null +++ b/apps/demo/build.mjs @@ -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 = ` + + + + +@ui-arch 组件库 · 现场演示 + + + +
+
由 @ui-arch/frame + @ui-arch/ui 驱动 · 构建产物单文件,无运行时依赖
+ + + +`; + +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)`); diff --git a/apps/demo/main.ts b/apps/demo/main.ts new file mode 100644 index 0000000..90c5969 --- /dev/null +++ b/apps/demo/main.ts @@ -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); diff --git a/apps/demo/package.json b/apps/demo/package.json new file mode 100644 index 0000000..95d2c01 --- /dev/null +++ b/apps/demo/package.json @@ -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" +} diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json new file mode 100644 index 0000000..0bc54de --- /dev/null +++ b/apps/demo/tsconfig.json @@ -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": ["."] +} diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..49750fa --- /dev/null +++ b/llms-full.txt @@ -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 —— 双向绑定信号:输入写回信号,信号变化写回输入框 +- 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 —— 双向绑定信号 +- 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 —— 勾选状态信号,双向绑定 +- 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 —— 开关状态信号,双向绑定 +- 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 —— 选中值信号,双向绑定 +- 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 —— 选中值信号,双向绑定 +- 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 —— 进度 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 —— 当前激活页签 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 —— 开关信号: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 —— 字段值信号,校验数据源 +- (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[] —— 列定义:{ 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:无参读值并收集依赖,传参写值并触发订阅 +- 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 diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..4024713 --- /dev/null +++ b/llms.txt @@ -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:无参读值并收集依赖,传参写值并触发订阅 + - 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,一律来自令牌,禁止硬编码颜色 diff --git a/package.json b/package.json new file mode 100644 index 0000000..aaad274 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/packages/core/frame/package.json b/packages/core/frame/package.json new file mode 100644 index 0000000..73ec65f --- /dev/null +++ b/packages/core/frame/package.json @@ -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" +} diff --git a/packages/core/frame/src/dom.ts b/packages/core/frame/src/dom.ts new file mode 100644 index 0000000..1569ded --- /dev/null +++ b/packages/core/frame/src/dom.ts @@ -0,0 +1,70 @@ +import { effect } from './signal.js'; + +type Child = Node | string | number | boolean | null | undefined | (() => unknown); +type Props = Record; + +/*** 响应式属性绑定:函数值属性在依赖变化时自动写回 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()); +} diff --git a/packages/core/frame/src/index.ts b/packages/core/frame/src/index.ts new file mode 100644 index 0000000..658042c --- /dev/null +++ b/packages/core/frame/src/index.ts @@ -0,0 +1,2 @@ +export * from './signal.js'; +export * from './dom.js'; diff --git a/packages/core/frame/src/signal.ts b/packages/core/frame/src/signal.ts new file mode 100644 index 0000000..b69eecc --- /dev/null +++ b/packages/core/frame/src/signal.ts @@ -0,0 +1,49 @@ +// 当前正在收集依赖的 effect;null 表示非追踪态 +let active: (() => void) | null = null; + +/*** 响应式信号:无参调用读值并收集依赖,传参写值并触发订阅 ***/ +export interface Signal { + (): T; + (value: T): void; +} + +/*** 创建信号:框架状态的最小单元 ***/ +export function signal(initial: T): Signal { + 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; +} + +/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑 ***/ +export function effect(fn: () => void): () => void { + const run = () => { + const prev = active; + active = run; + try { + fn(); + } finally { + active = prev; + } + }; + run(); + return () => { + /* 演示级实现:不做依赖清理 */ + }; +} + +/*** 计算值:由信号派生的只读视图,同样参与上层依赖收集 ***/ +export function computed(fn: () => T): () => T { + const out = signal(undefined as T); + effect(() => out(fn())); + return () => out(); +} diff --git a/packages/core/frame/tsconfig.json b/packages/core/frame/tsconfig.json new file mode 100644 index 0000000..87f3d42 --- /dev/null +++ b/packages/core/frame/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/core/frame/ua.manifest.json b/packages/core/frame/ua.manifest.json new file mode 100644 index 0000000..ead6c92 --- /dev/null +++ b/packages/core/frame/ua.manifest.json @@ -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:无参读值并收集依赖,传参写值并触发订阅", + "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))),信号变化整体替换子节点" + ] + } +} diff --git a/packages/core/table-core/package.json b/packages/core/table-core/package.json new file mode 100644 index 0000000..b5b956d --- /dev/null +++ b/packages/core/table-core/package.json @@ -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" +} diff --git a/packages/core/table-core/scripts/smoke.mjs b/packages/core/table-core/scripts/smoke.mjs new file mode 100644 index 0000000..be1a766 --- /dev/null +++ b/packages/core/table-core/scripts/smoke.mjs @@ -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'); diff --git a/packages/core/table-core/src/core.ts b/packages/core/table-core/src/core.ts new file mode 100644 index 0000000..fdbc412 --- /dev/null +++ b/packages/core/table-core/src/core.ts @@ -0,0 +1,110 @@ +import type { + Action, + DeriveContext, + RowKey, + RowModel, + TableInstance, + TableOptions, + TablePlugin, + TableState, +} from './types.js'; + +/*** 创建表格实例:状态机 + 派生管线 + 插件 API 合并,框架无关、零渲染 ***/ +export function createTable( + options: TableOptions, +): TableInstance & Record { + 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 = { 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 === '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)[key]; + } + + // 基础行模型按 data 引用 memo,避免重复建 keys + let lastData: T[] | null = null; + let cachedBase: RowModel | null = null; + + function baseModel(): RowModel { + 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 { + let model = baseModel(); + for (const p of plugins) { + if (p.derive) model = p.derive(model, state, ctx); + } + return model; + } + + const instance: TableInstance = { + getState: () => state, + dispatch, + subscribe, + getVisibleRows, + getColumns: () => columns, + getCellValue, + isRowSelected: (key) => state.selection.includes(key), + }; + + // 插件 API 合并到实例(setPage / selectAllVisible 等) + const pluginApis: Record = {}; + for (const p of plugins) { + if (p.api) Object.assign(pluginApis, p.api(instance)); + } + + return Object.assign(instance, pluginApis) as TableInstance & + Record; +} diff --git a/packages/core/table-core/src/index.ts b/packages/core/table-core/src/index.ts new file mode 100644 index 0000000..4fff8d8 --- /dev/null +++ b/packages/core/table-core/src/index.ts @@ -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'; diff --git a/packages/core/table-core/src/plugins/filter.ts b/packages/core/table-core/src/plugins/filter.ts new file mode 100644 index 0000000..3f5b280 --- /dev/null +++ b/packages/core/table-core/src/plugins/filter.ts @@ -0,0 +1,67 @@ +import type { + Action, + DeriveContext, + FilterRule, + RowKey, + RowModel, + TablePlugin, + TableState, +} from '../types.js'; + +/*** 筛选插件入参 ***/ +export interface FilterPluginOptions { + predicate?: (cellValue: unknown, filterValue: unknown, row: T) => boolean; // 自定义匹配谓词;缺省大小写不敏感子串匹配 +} + +/*** 列筛选插件:'' / null 表示清除该列筛选;按(输入模型, filters 切片)memo ***/ +export function filterPlugin( + opts: FilterPluginOptions = {}, +): TablePlugin { + const matches = + opts.predicate ?? + ((value: unknown, filterValue: unknown) => + String(value ?? '') + .toLowerCase() + .includes(String(filterValue ?? '').toLowerCase())); + + let lastInput: RowModel | null = null; + let lastFilters: FilterRule[] | null = null; + let cached: RowModel | 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, state: TableState, ctx: DeriveContext): RowModel { + 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; + }, + }; +} diff --git a/packages/core/table-core/src/plugins/pagination.ts b/packages/core/table-core/src/plugins/pagination.ts new file mode 100644 index 0000000..bf37a0a --- /dev/null +++ b/packages/core/table-core/src/plugins/pagination.ts @@ -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(): TablePlugin { + let lastInput: RowModel | null = null; + let lastPage: { page: number; pageSize: number } | null = null; + let cached: RowModel | 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, state: TableState): RowModel { + 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) { + 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 }), + }; + }, + }; +} diff --git a/packages/core/table-core/src/plugins/selection.ts b/packages/core/table-core/src/plugins/selection.ts new file mode 100644 index 0000000..ad39147 --- /dev/null +++ b/packages/core/table-core/src/plugins/selection.ts @@ -0,0 +1,34 @@ +import type { Action, TableInstance, TablePlugin, TableState } from '../types.js'; + +/*** 行选择插件:一律存 rowKey 而非索引,跨页、排序、异步刷新都不丢失 ***/ +export function selectionPlugin(): TablePlugin { + 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) { + return { + selectAllVisible: () => { + table.dispatch({ type: 'setSelection', keys: [...table.getVisibleRows().keys] }); + }, + }; + }, + }; +} diff --git a/packages/core/table-core/src/plugins/sort.ts b/packages/core/table-core/src/plugins/sort.ts new file mode 100644 index 0000000..8adc923 --- /dev/null +++ b/packages/core/table-core/src/plugins/sort.ts @@ -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( + opts: SortPluginOptions = {}, +): TablePlugin { + const initialDir = opts.initialDir ?? 'asc'; + + let lastInput: RowModel | null = null; + let lastRules: SortRule[] | null = null; + let cached: RowModel | 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, state: TableState, ctx: DeriveContext): RowModel { + 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; + }, + }; +} diff --git a/packages/core/table-core/src/types.ts b/packages/core/table-core/src/types.ts new file mode 100644 index 0000000..eb947be --- /dev/null +++ b/packages/core/table-core/src/types.ts @@ -0,0 +1,95 @@ +/*** 行唯一标识:跨页 / 异步刷新保持稳定 ***/ +export type RowKey = string | number; + +/*** 列定义:数据语义(accessor)与渲染语义(cell)分离 ***/ +export interface ColumnDef { + 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 { + rows: T[]; // 行数据,顺序即管线处理后的顺序 + keys: RowKey[]; // 与 rows 一一对应的行标识 + total: number; // 分页 UI 算页数的依据 +} + +/*** 派生上下文:核心注入给插件的取值能力 ***/ +export interface DeriveContext { + getCellValue(row: T, key: string): unknown; // 单元格取值(走列 accessor) + getKey(row: T): RowKey; // 行标识取值 +} + +/*** 插件三钩子:patchState 改状态、derive 插入数据管线、api 向实例注入方法 ***/ +export interface TablePlugin { + name: string; // 插件名,诊断用 + patchState?(state: TableState, action: Action): TableState; // 处理自己关心的动作,无关动作原样返回 + derive?(model: RowModel, state: TableState, ctx: DeriveContext): RowModel; // 插入派生管线阶段 + api?(table: TableInstance): Record; // 向实例注入便捷方法 +} + +/*** 建表入参 ***/ +export interface TableOptions { + data: T[]; // 全量行数据 + columns: ColumnDef[]; // 列定义 + rowKey?: (keyof T & string) | ((row: T) => RowKey); // 行标识字段名或取值函数;缺省 'id' + initialState?: Partial; // 初始状态,逐项浅合并 + plugins?: TablePlugin[]; // 插件按数组序组成派生管线 + onStateChange?(state: TableState): void; // 状态变更回调(受控 / 持久化用) +} + +/*** 表格实例:框架适配层只消费这里的面 ***/ +export interface TableInstance { + getState(): TableState; // 当前状态快照 + dispatch(action: Action): void; // 派发动作,唯一变更入口 + subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数 + getVisibleRows(): RowModel; // 可见行模型(过完整管线) + getColumns(): ColumnDef[]; // 列定义 + getCellValue(row: T, key: string): unknown; // 单元格取值 + isRowSelected(key: RowKey): boolean; // 行是否已选 +} diff --git a/packages/core/table-core/tsconfig.json b/packages/core/table-core/tsconfig.json new file mode 100644 index 0000000..e1b7131 --- /dev/null +++ b/packages/core/table-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/core/table-core/ua.manifest.json b/packages/core/table-core/ua.manifest.json new file mode 100644 index 0000000..b108a8f --- /dev/null +++ b/packages/core/table-core/ua.manifest.json @@ -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 即插入管线" + ] + } +} diff --git a/packages/design/tokens/package.json b/packages/design/tokens/package.json new file mode 100644 index 0000000..3d00fba --- /dev/null +++ b/packages/design/tokens/package.json @@ -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" +} diff --git a/packages/design/tokens/tokens.css b/packages/design/tokens/tokens.css new file mode 100644 index 0000000..39a8387 --- /dev/null +++ b/packages/design/tokens/tokens.css @@ -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; +} diff --git a/packages/design/tokens/tokens.json b/packages/design/tokens/tokens.json new file mode 100644 index 0000000..39d9b6e --- /dev/null +++ b/packages/design/tokens/tokens.json @@ -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" } +} diff --git a/packages/design/tokens/ua.manifest.json b/packages/design/tokens/ua.manifest.json new file mode 100644 index 0000000..e69fc2b --- /dev/null +++ b/packages/design/tokens/ua.manifest.json @@ -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,再重新生成文档站" + ] + } +} diff --git a/packages/skin/ui/package.json b/packages/skin/ui/package.json new file mode 100644 index 0000000..460121b --- /dev/null +++ b/packages/skin/ui/package.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" +} diff --git a/packages/skin/ui/src/alert.ts b/packages/skin/ui/src/alert.ts new file mode 100644 index 0000000..3ac64c6 --- /dev/null +++ b/packages/skin/ui/src/alert.ts @@ -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); +} diff --git a/packages/skin/ui/src/badge.ts b/packages/skin/ui/src/badge.ts new file mode 100644 index 0000000..6dbd6dd --- /dev/null +++ b/packages/skin/ui/src/badge.ts @@ -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); +} diff --git a/packages/skin/ui/src/button.ts b/packages/skin/ui/src/button.ts new file mode 100644 index 0000000..1e81c74 --- /dev/null +++ b/packages/skin/ui/src/button.ts @@ -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, + ); +} diff --git a/packages/skin/ui/src/card.ts b/packages/skin/ui/src/card.ts new file mode 100644 index 0000000..8b38cda --- /dev/null +++ b/packages/skin/ui/src/card.ts @@ -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, + ); +} diff --git a/packages/skin/ui/src/checkbox.ts b/packages/skin/ui/src/checkbox.ts new file mode 100644 index 0000000..8ad7c74 --- /dev/null +++ b/packages/skin/ui/src/checkbox.ts @@ -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; +} diff --git a/packages/skin/ui/src/dataTable.ts b/packages/skin/ui/src/dataTable.ts new file mode 100644 index 0000000..a435c4d --- /dev/null +++ b/packages/skin/ui/src/dataTable.ts @@ -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(props: DataTableProps): HTMLElement { + // —— 状态集中声明 —— + const selectable = props.selectable ?? false; // 行选择开关 + const table = createTable({ + data: props.data, + columns: props.columns, + rowKey: 'id', + plugins: [filterPlugin(), sortPlugin(), paginationPlugin(), selectionPlugin()], + initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } }, + }); + const api = table as unknown as TableInstance & { + 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 }) }), + ), + ); +} diff --git a/packages/skin/ui/src/form.ts b/packages/skin/ui/src/form.ts new file mode 100644 index 0000000..489029c --- /dev/null +++ b/packages/skin/ui/src/form.ts @@ -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; +} diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts new file mode 100644 index 0000000..ab840ac --- /dev/null +++ b/packages/skin/ui/src/index.ts @@ -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'; diff --git a/packages/skin/ui/src/modal.ts b/packages/skin/ui/src/modal.ts new file mode 100644 index 0000000..f5e0a7c --- /dev/null +++ b/packages/skin/ui/src/modal.ts @@ -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; +} diff --git a/packages/skin/ui/src/progress.ts b/packages/skin/ui/src/progress.ts new file mode 100644 index 0000000..3cdad3b --- /dev/null +++ b/packages/skin/ui/src/progress.ts @@ -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)); +} diff --git a/packages/skin/ui/src/radioGroup.ts b/packages/skin/ui/src/radioGroup.ts new file mode 100644 index 0000000..4e25adf --- /dev/null +++ b/packages/skin/ui/src/radioGroup.ts @@ -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); +} diff --git a/packages/skin/ui/src/select.ts b/packages/skin/ui/src/select.ts new file mode 100644 index 0000000..0a41f2b --- /dev/null +++ b/packages/skin/ui/src/select.ts @@ -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; +} diff --git a/packages/skin/ui/src/spinner.ts b/packages/skin/ui/src/spinner.ts new file mode 100644 index 0000000..ffb12b7 --- /dev/null +++ b/packages/skin/ui/src/spinner.ts @@ -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; +} diff --git a/packages/skin/ui/src/switch.ts b/packages/skin/ui/src/switch.ts new file mode 100644 index 0000000..a4b1283 --- /dev/null +++ b/packages/skin/ui/src/switch.ts @@ -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; +} diff --git a/packages/skin/ui/src/tabs.ts b/packages/skin/ui/src/tabs.ts new file mode 100644 index 0000000..82ab914 --- /dev/null +++ b/packages/skin/ui/src/tabs.ts @@ -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); +} diff --git a/packages/skin/ui/src/textField.ts b/packages/skin/ui/src/textField.ts new file mode 100644 index 0000000..55d6d13 --- /dev/null +++ b/packages/skin/ui/src/textField.ts @@ -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; +} diff --git a/packages/skin/ui/src/textarea.ts b/packages/skin/ui/src/textarea.ts new file mode 100644 index 0000000..6d83756 --- /dev/null +++ b/packages/skin/ui/src/textarea.ts @@ -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; +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts new file mode 100644 index 0000000..bcd0dd8 --- /dev/null +++ b/packages/skin/ui/src/types.ts @@ -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; // 双向绑定的信号 + onInput?: (value: string) => void; // 输入回调 +} + +/*** 卡片入参 ***/ +export interface CardProps { + title?: string; // 标题;缺省不渲染标题行 +} + +/*** 数据表格入参 ***/ +export interface DataTableProps { + data: T[]; // 全量行数据,行须含 id 字段作 rowKey + columns: ColumnDef[]; // 列定义 + 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; // 勾选状态信号,双向绑定 + onChange?: (checked: boolean) => void; // 变更回调 +} + +/*** 开关入参 ***/ +export interface SwitchProps { + label?: string; // 左侧文案;缺省不渲染 + checked?: Signal; // 开关状态信号,双向绑定 + onChange?: (checked: boolean) => void; // 变更回调 +} + +/*** 下拉选择入参 ***/ +export interface SelectProps { + options: OptionItem[]; // 选项列表 + value?: Signal; // 选中值信号,双向绑定 + placeholder?: string; // 未选中时的占位项文案 + onChange?: (value: string) => void; // 变更回调 +} + +/*** 单选组入参 ***/ +export interface RadioGroupProps { + options: OptionItem[]; // 选项列表 + value?: Signal; // 选中值信号,双向绑定 + onChange?: (value: string) => void; // 变更回调 +} + +/*** 多行输入入参 ***/ +export interface TextareaProps { + placeholder?: string; // 占位文案 + rows?: number; // 行数;缺省 3 + value?: Signal; // 双向绑定的信号 + 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; // 当前进度 0-100 的信号 + showText?: boolean; // 是否右侧显示百分比;缺省 true +} + +/*** 标签页入参 ***/ +export interface TabsProps { + tabs: OptionItem[]; // 页签列表 + active?: Signal; // 当前激活页签的 value 信号,双向绑定 + onChange?: (value: string) => void; // 切换回调 +} + +/*** 弹窗入参 ***/ +export interface ModalProps { + title: string; // 标题 + open: Signal; // 开关信号: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; // 字段值信号(校验数据源) + 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 }; // 再导出常用内核类型,方便调用方 diff --git a/packages/skin/ui/tsconfig.json b/packages/skin/ui/tsconfig.json new file mode 100644 index 0000000..a9fc4b9 --- /dev/null +++ b/packages/skin/ui/tsconfig.json @@ -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"] +} diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json new file mode 100644 index 0000000..857b38d --- /dev/null +++ b/packages/skin/ui/ua.manifest.json @@ -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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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[]", + "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 提供" + ] + } + ] + } +} diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css new file mode 100644 index 0000000..e92ed2c --- /dev/null +++ b/packages/skin/ui/ui.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; + } +} diff --git a/packages/tools/cli/bin/ua.js b/packages/tools/cli/bin/ua.js new file mode 100644 index 0000000..cc6233f --- /dev/null +++ b/packages/tools/cli/bin/ua.js @@ -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, '>'); +} + +/*** 渲染人类文档站:注册表 + 逐组件 props/步骤 + 现场演示 iframe + 令牌,数据内嵌支持搜索 ***/ +function renderDocs(index, tokens) { + const data = JSON.stringify(index.packages).replace(/ + + + + +@ui-arch 组件库文档 + + + +

@ui-arch私人组件库

+ +
+
由 ua docs 生成于 ${generatedAt} · AI 版全文:llms-full.txt · 重新生成:npm run docs
+ + + +`; +} + +/*** 渲染 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 '); + process.exit(1); + } + cmdInfo(args[0]); + break; + case 'docs': + cmdDocs(); + break; + case 'style': + cmdStyle(); + break; + case 'mcp': + cmdMcp(); + break; + default: + console.log('usage: ua |docs|style|mcp>'); + process.exit(cmd ? 1 : 0); +} diff --git a/packages/tools/cli/package.json b/packages/tools/cli/package.json new file mode 100644 index 0000000..efa268d --- /dev/null +++ b/packages/tools/cli/package.json @@ -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" +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3905987 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*/*" + - "apps/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..914706e --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +}