Compare commits
60 Commits
main
..
3fb3cd2395
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fb3cd2395 | |||
| f2fc286eeb | |||
| b9b05f53fb | |||
| c19f83b413 | |||
| 67374c78c3 | |||
| cbf1d4a248 | |||
| c457e0cc33 | |||
| 84d008b5fd | |||
| fd4ee018e6 | |||
| 62d4ce648d | |||
| ee20322e3f | |||
| dc4bac4d17 | |||
| 7072f77f88 | |||
| 4f7d1e86b3 | |||
| fc7a1e8299 | |||
| 47bea0b69c | |||
| 20d7b9440f | |||
| d7226f452d | |||
| b89d1cd801 | |||
| f8934ba84a | |||
| c8154b4099 | |||
| 8e168293ec | |||
| fac74a31e8 | |||
| 734017a627 | |||
| 82f48aaca1 | |||
| 6a5e6623fe | |||
| 6efe3ab57b | |||
| f361a1933d | |||
| f86da9866c | |||
| 00a6104f84 | |||
| 9cfdb43a4f | |||
| 4555542811 | |||
| 4012923ff1 | |||
| bdc1fec3f9 | |||
| fa168b9a1c | |||
| 1c03c8113a | |||
| d3fc754b33 | |||
| 730ee1abde | |||
| 9eefdcbd75 | |||
| d1a2864470 | |||
| a08f157e5e | |||
| a11157b80a | |||
| 1a4b2c1fed | |||
| fb9ab88feb | |||
| 582318adf1 | |||
| 60c052439f | |||
| ccbdcb9271 | |||
| df2fb34e63 | |||
| 6b9fa95d1e | |||
| 84277a744e | |||
| f9324ddc6a | |||
| 2a5892a88c | |||
| 83bc1e241c | |||
| 8c9a63b5d2 | |||
| 28820574e8 | |||
| ece38bcac9 | |||
| aaf6fdc5ac | |||
| a57577a995 | |||
| e9165ead18 | |||
| 17a4fb171b |
@@ -5,3 +5,5 @@ dist/
|
||||
apps/docs/*.html
|
||||
.workbuddy/
|
||||
.gitea-token
|
||||
.ua/loop.lock
|
||||
.ua/heartbeat*
|
||||
|
||||
+333
-9
@@ -6,40 +6,364 @@
|
||||
"title": "新增 tooltip 组件",
|
||||
"motivation": "皮肤包 16 组件缺少悬浮提示,表单与工具栏场景常用;可复用自绘弹层与微动画既有模式",
|
||||
"acceptance": "packages/skin/ui 新增 tooltip.ts:content/message 简单 API,hover/focus 触发,延迟约 150ms 出场,带出入场微动画与 prefers-reduced-motion 兜底,只消费 --ua-* 令牌;导出并注册进 ua.manifest.json,文档站 props 表与 llms-full.txt 同步更新;npm run check 全绿",
|
||||
"status": "open",
|
||||
"notes": ""
|
||||
"status": "done",
|
||||
"notes": "API 定为 tooltip(props, trigger) 包裹式;delay 默认 150ms,Esc 立即收起,气泡 pointer-events:none 防抖,aria-describedby + role=tooltip 保证读屏可读;demo 新增 sec-tooltip 区块。顺带修复 ua style 对 https:// 的误判(先剥离字面量再定位 //)"
|
||||
},
|
||||
{
|
||||
"id": "b02",
|
||||
"title": "新增 toast 消息提示组件",
|
||||
"motivation": "alert 是常驻块级元素,缺少轻量转瞬即逝的全局反馈;操作成功/失败场景刚需",
|
||||
"acceptance": "新增 toast.ts:toast.success/error/warning/info 四个工厂函数,入参 {message, duration?},默认 3s 自动消失,右上角堆叠,语义色复用四档 variant,出入场微动画 + prefers-reduced-motion 兜底;导出、manifest、文档、llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "open",
|
||||
"notes": ""
|
||||
"status": "done",
|
||||
"notes": "对外暴露 toast.info/success/warning/error(message, duration?) 四工厂 + showToast(props) 完整入参版;全局堆叠容器懒创建,role=status 无障碍;退场用 transitionend 移除并带 320ms 兜底(覆盖 reduced-motion 下 transition 不触发的情况);样式复用 alert 语义色 color-mix 模式,reduced-motion 列表已补 .ua-toast;demo 新增 sec-toast 区块"
|
||||
},
|
||||
{
|
||||
"id": "b03",
|
||||
"title": "新增 skeleton 骨架屏组件",
|
||||
"motivation": "异步加载占位是数据表格与卡片的标配,配合 dataTable 空状态形成完整加载体验",
|
||||
"acceptance": "新增 skeleton.ts:skeleton({variant: 'text'|'rect'|'circle', rows?}),呼吸/扫光微动画 + prefers-reduced-motion 静态化,只消费 --ua-* 令牌;导出、manifest、文档、llms-full.txt 同步;demo 页加一节演示;npm run check 全绿",
|
||||
"status": "open",
|
||||
"notes": ""
|
||||
"status": "done",
|
||||
"notes": "skeleton({variant, rows}):text(多行,末行缩短)/ rect / circle 三形状,扫光动画(::after 位移 + 渐变),reduced-motion 下静态灰块(动画列表已补 .ua-skeleton 与 ::after);demo 新增 sec-skeleton 卡片组合演示"
|
||||
},
|
||||
{
|
||||
"id": "b04",
|
||||
"title": "新增独立 pagination 分页器组件并与 dataTable 演示联动",
|
||||
"motivation": "table-core 已有 pagination 插件,但皮肤包没有独立分页器;dataTable 演示也未展示分页联动",
|
||||
"acceptance": "新增 pagination.ts:受控 API {page, pageSize, total, onChange},页码过多时折叠省略号,键盘可达,禁用态完整;demo 页 dataTable 一节增加分页联动演示;导出、manifest、文档、llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "open",
|
||||
"notes": ""
|
||||
"status": "done",
|
||||
"notes": "pagination(props):page 信号双向绑定(受控),≤7 页全展开、更多时首尾 + 当前页 ±1 折叠省略号(renderList 重建),原生 button 键盘可达,aria-current / aria-label 完整,首末页禁用 ‹ ›;dataTable 新增可选 page 信号桥接(双向同步 + 越界钳制,防无效派发);demo sec-table 新增 pageSize 3 × 24 行的联动卡片"
|
||||
},
|
||||
{
|
||||
"id": "b05",
|
||||
"title": "文档站演示区由 manifest 驱动自动生成",
|
||||
"motivation": "目前 apps/docs/index.html 手写 props 表与演示,每加组件都要人肉同步;由 manifest 单一来源驱动可消除重复维护",
|
||||
"acceptance": "ua CLI 新增 docs-gen 逻辑:读取各组件 ua.manifest.json 中的 components 参数表,自动生成 props 表 HTML 片段注入文档站模板;生成结果与现有手写版信息量对齐(props 名/类型/默认值/说明);npm run check 全绿且文档站可正常打开",
|
||||
"status": "done",
|
||||
"notes": "核查确认 props 表/示例/步骤/演示 iframe 早已由 ua docs 从 manifest docs.components 单一来源驱动;真正的缺口是信息量不含默认值——本轮 CLI propsTable 新增「默认值」列(props 条目可选 df 字段,缺省渲染 —),llms-full.txt 参数行同步追加「默认 x」标注;16 处可机读默认值从描述文案提取进 df;check 全绿 + 文档站结构完整性校验通过"
|
||||
},
|
||||
{
|
||||
"id": "c01",
|
||||
"title": "button / checkbox / switch 补 disabled 禁用态",
|
||||
"motivation": "三个基础交互组件缺禁用能力,表单只读/权限控制场景刚需",
|
||||
"acceptance": "ButtonProps / CheckboxProps / SwitchProps 新增可选 disabled(boolean | Signal<boolean>):静态布尔与信号绑定都支持,禁用时事件不触发、置灰 + not-allowed 光标;demo 表单区加静态禁用与信号联动示例;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "零新增辅助代码:复用 frame h() 对 false/undefined 移除属性、Signal 函数走响应式绑定的既有语义,disabled 透传即可;CSS 补通用禁用态(opacity 0.45 + not-allowed,开关轨道同级置灰);demo 用同一 locked 信号绑定 switch 的 checked 与 checkbox 的 disabled 展示联动"
|
||||
},
|
||||
{
|
||||
"id": "c02",
|
||||
"title": "textField / textarea 补 disabled 禁用态",
|
||||
"motivation": "输入类组件与 c01 的交互组件对齐,表单只读场景闭环",
|
||||
"acceptance": "TextFieldProps / TextareaProps 新增可选 disabled(boolean | Signal<boolean>),禁用时不响应输入、置灰 + not-allowed 光标;demo 禁用示例区补一个信号绑定输入框;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "与 c01 同模式:disabled 透传 h() 即得布尔/信号双支持;禁用态 CSS 追加 .ua-input / .ua-textarea;demo 复用 locked 信号联动开关与输入框"
|
||||
},
|
||||
{
|
||||
"id": "d01",
|
||||
"title": "新增下拉菜单 menu 组件",
|
||||
"motivation": "工具栏/行操作场景缺省可折叠的动作集合,select 只能选值不能派发动作;可复用自绘弹层与微动画既有模式",
|
||||
"acceptance": "packages/skin/ui 新增 menu.ts:触发器 + 自绘弹层,items 支持 disabled / danger / divider,点条目 / 点外部 / Esc 关闭,↑↓ 环形导航(禁用项跳过),Enter/Space 选中,Esc 收起后焦点回触发器;只消费 --ua-* 令牌并带 prefers-reduced-motion 兜底;导出 + manifest + 文档 + llms-full.txt + demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "API menu({ label, items, variant, align, onSelect });MenuItem = { value, label, disabled?, danger?, divider? };弹层 ua-open 同时挂在根与弹层(根管箭头旋转、弹层管出入场),危险条目取 danger 色;键盘 ↑↓ 环形导航 + Esc 回焦触发器;demo 新增 sec-menu 双示例(含禁用/危险/分隔线与右对齐)"
|
||||
},
|
||||
{
|
||||
"id": "d02",
|
||||
"title": "select / radioGroup 补 disabled,修复 select 箭头旋转失效",
|
||||
"motivation": "c01/c02 已覆盖 button/checkbox/switch/输入类,选择类仍缺禁用;同时发现 select 的 .ua-select.ua-open 选择器永不命中(ua-open 只挂在弹层),展开时箭头不旋转",
|
||||
"acceptance": "SelectProps / RadioGroupProps 新增可选 disabled(boolean | Signal<boolean>);禁用时 select 触发器置灰且不展开弹层,radioGroup 整组置灰不可选;select 箭头在展开时旋转 180°;demo 加信号禁用与静态禁用示例;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "disabled 仍走 h() 透传(原生 button/input 拦截交互)零辅助代码;select 根节点补 class 函数加 ua-open,箭头旋转选择器命中;CSS 通用禁用态列表并入 .ua-select-trigger 与 .ua-radio,另加 :has() 让单选行整行弱化、禁用触发器 hover 不改边框色"
|
||||
},
|
||||
{
|
||||
"id": "d03",
|
||||
"title": "新增折叠面板 accordion 组件",
|
||||
"motivation": "长表单/设置页需要分区收起,皮肤包无折叠容器;grid 行高过渡可零测量实现展开动画",
|
||||
"acceptance": "新增 accordion.ts:items(value/title/content/disabled)+ 单开(手风琴)与 multiple 多开,active 信号可选受控,标题为原生 button(aria-expanded/controls + region 关联),内容区 0fr→1fr 过渡且 reduced-motion 静态化;导出 / manifest / 文档 / llms-full.txt / demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "contents 走 appendChild 既有语义,字符串/DOM/响应式函数通吃;未传 active 时内部 signal<string[]> 持有展开集合,受控模式只认 active 单开;CSS 用 grid-template-rows 0fr→1fr 过渡免测量,reduced-motion 列表补 ua-acc-*;demo 新增 sec-accordion(多开 + 禁用面板)"
|
||||
},
|
||||
{
|
||||
"id": "d04",
|
||||
"title": "dataTable 接加载态(骨架行 + 交互禁用)",
|
||||
"motivation": "异步取数时表格直接闪空状态,缺少 loading 反馈;skeleton 已就位但未与表格打通",
|
||||
"acceptance": "DataTableProps 新增 loading?: boolean | Signal<boolean>;为真时表体渲染骨架行(行数随 pageSize,列数含选择列)、筛选输入与上下页按钮禁用、容器 aria-busy=true;demo 加开关演示;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "busy() 读取器兼顾布尔与信号(信号在 renderList 内调用可被依赖收集);骨架行数量 Math.min(pageSize,6) 防过长;加载态优先于数据判断,避免闪出旧行;disabled 仍走 props 透传,零新增辅助组件"
|
||||
},
|
||||
{
|
||||
"id": "d05",
|
||||
"title": "新增面包屑 breadcrumb 组件",
|
||||
"motivation": "多层级页面的位置指示缺失;顺带补齐导航类组件的语义结构(nav/ol/li + aria-current)",
|
||||
"acceptance": "新增 breadcrumb.ts:nav + ol 语义结构,末项自动为当前页(不可点、aria-current=page),条目有 href 渲染 <a>、无 href 渲染 button 走 onNavigate,分隔符可配;只消费 --ua-* 令牌;导出 / manifest / 文档 / llms-full.txt / demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "API breadcrumb({ items: {label, href?}[], separator?, onNavigate? });分隔符 aria-hidden;reduced-motion 列表补 .ua-crumb-link;demo 新增 sec-breadcrumb('›' 分隔 + toast 反馈跳转);首轮 style 门禁拦下多行 /*** 注释(检查器只认紧邻单行块),已改单行"
|
||||
},
|
||||
{
|
||||
"id": "d06",
|
||||
"title": "门禁新增 ua verify:清单 ↔ 文档 ↔ 演示锚点一致性校验",
|
||||
"motivation": "新增组件靠人工同步 manifest/docs/demo,易漏(本轮已出现 badge/alert/spinner 手把手只有 2 步);把不变量机器化,防回归",
|
||||
"acceptance": "CLI 新增 verify 子命令:docs.components 的 id 唯一且 api.exports 有 `${id}(` 或以 `${id}.` 开头条目、props 条目必备 n/t/d、steps ≥3、example 必填、demo 锚点必须存在于 apps/demo/main.ts;纳入 npm run check 链条;AGENTS.md 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "锚点校验改看演示源码而非打包产物(demo.html 是 esbuild 产物,锚点在运行时生成);顺带补齐 badge/alert/spinner 的第 3 条手把手(说明与 button/skeleton 的分工);check 链变为 build→style→typecheck→smoke→index→verify"
|
||||
},
|
||||
{
|
||||
"id": "d07",
|
||||
"title": "新增可交互气泡卡片 popover 组件",
|
||||
"motivation": "tooltip 只做纯展示(pointer-events:none),筛选面板/快捷操作需要可点击的浮层;与 tooltip 形成展示/交互分工",
|
||||
"acceptance": "新增 popover.ts:popover(props, trigger) 包裹式,默认点击开合,trigger:'hover' 走延迟出场,点外部 / Esc 收起(Esc 回焦触发器),content 支持任意 DOM 且内部点击不误关,onOpenChange 回调;aria-haspopup/aria-expanded 正确;只消费 --ua-* 令牌 + reduced-motion 兜底;导出 / manifest / 文档 / llms-full.txt / demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "触发器 click 内 stopPropagation,避免刚开就被 document 的外部点击关掉;aria-expanded 用 effect 写回(setAttribute 不吃函数值);弹层点击已阻止冒泡;demo 新增 sec-popover(含 checkbox+button 的筛选面板与 hover 触发示例)"
|
||||
},
|
||||
{
|
||||
"id": "d08",
|
||||
"title": "演示页加侧边目录与滚动高亮",
|
||||
"motivation": "演示页已 14 个区块,长页滚动找区块靠手滑;目录同时给文档站 iframe 外的直接访问提供导航",
|
||||
"acceptance": "demo 页改为 shell 双栏布局(左 sticky 目录 + 右内容),目录项挂载后从 .sec 自动反推(不手写清单),IntersectionObserver 高亮当前区块,窄屏(≤820px)隐藏目录,reduced-motion 下关平滑滚动;npm run check 全绿、demo 可正常构建",
|
||||
"status": "done",
|
||||
"notes": "目录由 buildToc 在 mount 后扫描 section.sec 生成(id + h2 文案),新增区块自动入目录无需维护;rootMargin -10%/-80% 让高亮更跟手;.page 去掉 max-width 由 .shell 统一控宽 1160"
|
||||
},
|
||||
{
|
||||
"id": "d09",
|
||||
"title": "新增滑块 slider 组件",
|
||||
"motivation": "设置类场景(音量/阈值/预算)需要范围输入,皮肤包只有文本类输入;原生 range 自带键盘与触屏,接入成本低",
|
||||
"acceptance": "新增 slider.ts:value 信号双向绑定(拖动写回 + 外部赋值同步),min/max/step 可配,disabled 支持布尔或信号,showValue 可关,onChange 回调;样式只消费 --ua-* 令牌(accent-color)且带聚焦光环与禁用态;导出 / manifest / 文档 / llms-full.txt / demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "组件不持有数值,一律委托调用方信号;effect 内等值不重写 input.value,避免拖动过程被打断;轨道配色用 accent-color 令牌化,无需自绘 thumb;demo 新增 sec-slider(音量 step 5 + 与 locked 信号联动的禁用滑块)"
|
||||
},
|
||||
{
|
||||
"id": "d10",
|
||||
"title": "toast 补手动关闭与同屏堆叠上限",
|
||||
"motivation": "自动消失前用户无法提前关掉(如已看清错误),且连续触发会无限堆叠淹没页面",
|
||||
"acceptance": "ToastProps 新增 closable(显示 × 立即退场);同屏最多 4 条,超出挤掉最早一条;退场逻辑抽为内部 dismiss() 供自动/手动共用;is-closable 才接收指针事件,其余保持 pointer-events:none;manifest/文档/llms-full.txt/demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "dismiss() 统一 transitionend + 320ms 双保险;超限用 firstElementChild.remove() 立即腾位(不走动画,保证硬上限);demo sec-toast 增加「可关闭」按钮演示 showToast({closable:true})"
|
||||
},
|
||||
{
|
||||
"id": "d11",
|
||||
"title": "modal 补 size 档位与 footer 操作区",
|
||||
"motivation": "弹窗内容宽窄差异大(确认框 vs 表单),底部操作按钮只能塞进 body 导致分割线语义缺失",
|
||||
"acceptance": "ModalProps 新增 size?: 'sm'|'md'|'lg'(340/420/640)与 footer?: unknown;footer 渲染右对齐操作区带上分割线;缺省行为与旧版一致;manifest/文档/llms-full.txt/demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "宽度档位用 .ua-modal-sm/lg 覆盖基准 md,基础宽 min(420px,90vw) 不动;footer 仅在传入时渲染(null 分支不进 DOM);demo 的弹窗改为 size:'lg' + 取消/确定 footer 按钮"
|
||||
},
|
||||
{
|
||||
"id": "d12",
|
||||
"title": "新增可关闭标签 tag 组件",
|
||||
"motivation": "badge 纯展示无法移除,筛选条件/已选关键字需要带 × 的标签;补齐 badge 的交互版",
|
||||
"acceptance": "新增 tag.ts:label + variant 五档 + closable/onClose;closable 时渲染带 aria-label 的 × 按钮;只消费 --ua-* 令牌;导出 / manifest / 文档 / llms-full.txt / demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "组件无状态:onClose 只通知,节点由调用方按信号重渲染移除(与 form/formItem 返回句柄、dataTable 委托内核的哲学一致);demo 用 signal<string[]> + renderList 演示真实删除链路,另有四档语义色静态展示"
|
||||
},
|
||||
{
|
||||
"id": "d13",
|
||||
"title": "dataTable 列定义支持对齐与单元格格式化",
|
||||
"motivation": "皮肤层只能 String() 原始值,数字列无法右对齐、年龄/金额无法加单位;内核不关心展示,字段应留在皮肤层",
|
||||
"acceptance": "新增皮肤层 TableColumn<T> extends ColumnDef<T>,追加 align?: left|center|right 与 format?: (value,row)=>string;DataTableProps.columns 改用它;表头与单元格共用对齐类;format 走 textContent 天然免注入;demo 表格演示年龄右对齐+单位、城市居中;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "严格分层:内核 ColumnDef 不动,展示字段只在 skin 的 TableColumn 上扩展(extends 而非改核心);对齐类同时作用于 th(取代原 class 函数,合并 ua-sortable);demo 首表展示 align:right + format(v=>`${v} 岁`)、城市居中"
|
||||
},
|
||||
{
|
||||
"id": "d14",
|
||||
"title": "新增抽屉 drawer 组件",
|
||||
"motivation": "详情查看/编辑需要不打断主视图的侧滑容器,modal 是强打断确认;移动端尤其常用",
|
||||
"acceptance": "新增 drawer.ts:open 信号驱动,placement 左右、width 可调(窄屏 92vw 上限)、footer 操作区、遮罩 / × / Esc 关闭、打开聚焦首个可交互元素;只消费 --ua-* 令牌 + reduced-motion 兜底;导出 / manifest / 文档 / llms-full.txt / demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "遮罩开合用 opacity+visibility(与 tooltip/popover 同模式),面板靠 transform:translateX 滑入;width 走 inline style(调用方数据驱动的表现值不进 CSS);demo 新增 sec-drawer(订单详情 + footer 关闭 + tag/badge 组合)"
|
||||
},
|
||||
{
|
||||
"id": "d15",
|
||||
"title": "CLI 包补 ua.manifest.json 并修正文档里的过期组件数",
|
||||
"motivation": "工具链自身不在注册表里,Agent 无法通过 MCP/llms 发现 ua 的 6 个子命令;AGENTS.md 与 ARCHITECTURE.md 仍写 17 个组件(实际 27)",
|
||||
"acceptance": "packages/tools/cli/ua.manifest.json(type: tooling)描述 index/list/info/docs/style/verify/mcp 七个子命令与约定,索引包数变 5;AGENTS.md 与 ARCHITECTURE.md 的组件数改成 27;llms.txt / llms-full.txt 重新生成且 CLI 段落可读;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "tooling 类型在 ORDER 表外,排序自然落到末尾(??9 兜底),无需改 CLI 排序逻辑;CLI 清单 docs.components 留空,故不参与 verify 的组件级校验;顺带确认 docs 生成对无组件包不报错"
|
||||
},
|
||||
{
|
||||
"id": "d16",
|
||||
"title": "dataTable 落地内核已定义的列宽 width",
|
||||
"motivation": "table-core 的 ColumnDef 声明了 width?: number | 'flex',但皮肤层从未消费,列宽完全靠浏览器猜;契约与实现脱节",
|
||||
"acceptance": "dataTable 渲染 colgroup:数字 width 落成固定像素 <col>,'flex' 或不设交给自适应,选择列固定 44px;demo 表格演示 name 160 / age 100 / city flex;manifest 列定义说明与手把手同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "用 colgroup 而非逐 th 设宽,保证表头与表体一致且不入侵单元格渲染;未开 table-layout:fixed(nowrap 内容会被裁),宽高仍由内容兜底;pinned 固定列仍未消费(多列 sticky 偏移需测量),留作后续条目"
|
||||
},
|
||||
{
|
||||
"id": "d17",
|
||||
"title": "表单错误态覆盖包裹式控件并修正校验时机事件",
|
||||
"motivation": "① 现有 blur 不冒泡,checkbox/select/radioGroup 这类包裹式控件失焦永远不校验;② ua-invalid 只加在控件根,自绘 select 的边框在内部触发器上,视觉上无错误提示;③ 缺 aria-invalid,读屏感知不到",
|
||||
"acceptance": "formItem 用 focusout 取代 blur、补 change(select/checkbox 变更即校验);错误态同时落到控件根与内部原生控件,并同步 aria-invalid;CSS 覆盖 .ua-invalid 后代 .ua-input/.ua-textarea/.ua-select-trigger/.ua-radio/.ua-slider-input;demo 表单加必填 select 演示;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "marked() 统一收集控件根 + 内部 input/textarea/select/.ua-select-trigger 两处落点;.ua-invalid 后代选择器让包裹式控件描红,无需组件各自改签名(低侵入);demo formlab 新增城市必填 select,提交结果显示城市"
|
||||
},
|
||||
{
|
||||
"id": "d18",
|
||||
"title": "把「皮肤包禁止硬编码颜色」做成 ua verify 门禁",
|
||||
"motivation": "AGENTS.md 写着皮肤禁硬编码颜色,但一直靠自觉;ui.css 实际残留 5 处 #fff(按钮/徽标/开关滑块),暗色下对比也不理想",
|
||||
"acceptance": "checkSkinCss():skin-dom 类型包里所有 .css 出现 #hex 字面量即报错并指出文件行号;5 处 #fff 改为 var(--ua-color-bg)(信号色底上的反色,暗色自动翻转);负向测试(临时加 .ua-tmp{color:#fff})能拦下,移除后回归全绿;AGENTS.md 门禁说明同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "rgba(...) 未禁:仅用于阴影与遮罩透明度叠加,不承载品牌色,禁掉会逼出更难读的写法(已在函数注释写明边界);实现上先随 python 写文件把正则转义搞坏两次,改用编辑工具直写并做负向验证确认门禁真实生效"
|
||||
},
|
||||
{
|
||||
"id": "d19",
|
||||
"title": "dataTable 选中态受控(selected 信号)",
|
||||
"motivation": "选中集只在内核里,外部拿不到也无法预设,批量操作/清空选择只能靠内部工具栏;与 page 受控桥接形成对称能力",
|
||||
"acceptance": "DataTableProps 新增 selected?: Signal<RowKey[]>,与内核选择双向同步(内容比较避免来回派发,外部数组拷贝保护内核);demo 首表传 picked 信号,展示已选计数与「清空选择」按钮;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "复用 page 桥接的同一套思路:两个 effect 各管一向,sameKeys 内容比较收敛;内部写回拷贝 [...internal],防外部改动内核数组;demo 需 import type { RowKey } 才能对齐 Signal 的逆变"
|
||||
},
|
||||
{
|
||||
"id": "d20",
|
||||
"title": "progress 支持不确定态(未知时长加载)",
|
||||
"motivation": "请求时长未知时只能硬编造一个假百分比,或用 spinner 但失去条状形态;不确定态是进度条的标准形态",
|
||||
"acceptance": "ProgressProps.value 改为可选;缺省时渲染不确定态(35% 填充块 1.2s 往复)、不显示百分比、role=progressbar + aria-label=进行中;定量态补 aria-valuemin/max/now;reduced-motion 静态化;demo 加不确定态示例;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "以「不传 value」作为不确定态的开关,不新增布尔参数(少一个可矛盾的状态);定量态的 aria-valuenow 用函数属性随信号更新;reduced-motion 下靠既有 .ua-progress-fill 规则把动画归零,退化为静态 35% 条"
|
||||
},
|
||||
{
|
||||
"id": "d21",
|
||||
"title": "无障碍收尾:× 按钮 aria-label、spinner/skeleton 语义、tablist 标签",
|
||||
"motivation": "审计发现 modal 的 × 按钮无 aria-label(读屏只念「×」)、spinner 无 role=status(不播报加载)、skeleton 未 aria-hidden(占位文本被读出来)、tablist 缺 aria-label 与 aria-disabled",
|
||||
"acceptance": "modal 关闭按钮补 type/aria-label、spinner 补 role=status + aria-label、skeleton 全形态 aria-hidden、tabs 的 tablist 补 aria-label 且禁用页签标 aria-disabled;manifest conventions 记录无障碍基线;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "纯属性级修复,不动结构与样式;tabIndex 漫游(仅激活页签可 Tab)此前已实现,本次只补语义;分页器审计后无需改动(已有 nav aria-label、页码 aria-current、‹› aria-label)"
|
||||
},
|
||||
{
|
||||
"id": "d22",
|
||||
"title": "新增头像 avatar 组件",
|
||||
"motivation": "用户列表/评论区需要头像位,缺 component 只能手拼 img + 圆角;首字占位是缺图时的标准兜底",
|
||||
"acceptance": "新增 avatar.ts:name 必填(alt / title / 首字来源),src 可选(有则图片、无则首字占位),size 可调,variant 四档语义底色;只消费 --ua-* 令牌;导出 / manifest / 文档 / llms-full.txt / demo 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "首字占位对中文取首字、英文大写,空名兜底 ?;flex:none 保证不被挤压;首次跑 verify 被自身新门禁拦下(声明 sec-avatar 但 demo 缺区块),补独立区块后通过——门禁有效性再次得到验证"
|
||||
},
|
||||
{
|
||||
"id": "d23",
|
||||
"title": "alert 补标题行与可关闭,并补 role=alert",
|
||||
"motivation": "常驻提示无法消除(错误已处理完仍占版面),且只有单层正文放不下「结论 + 详情」;role=alert 缺失导致读屏不播报",
|
||||
"acceptance": "AlertProps 新增 title? / closable? / onClose?;关闭时先退场(opacity+上移,200ms)再从 DOM 移除;容器 role=alert;只消费 --ua-* 令牌;demo 新增 sec-alert 展示无标题与可关闭带标题两种;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "与 toast 的分工保持:alert 常驻在页面流里、toast 全局浮动;关闭后由调用方自行决定是否重新渲染;body/title/message 三层结构让 flex 布局能把 × 对齐到首行;对现有 alert({message}) 调用完全兼容"
|
||||
},
|
||||
{
|
||||
"id": "d24",
|
||||
"title": "重写 README 并把组件清单纳入 verify 防漂移",
|
||||
"motivation": "README 只有标题一行,新人/Agent 从仓库首页拿不到任何信息;而文档里的组件数此前已经漂移过(17 vs 27)",
|
||||
"acceptance": "README 覆盖定位 / 快速开始 / 28 组件清单 / 目录表 / 三道门禁 / Agent 接入 / 分支流水线;新增 <!-- ua:components --> 标记区块,ua verify 校验区块 id 集合与全部清单 docs.components 完全一致(缺/多都报);负向测试(删 avatar)能拦下;AGENTS.md 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "不做自动写入(避免工具偷偷改文档),只做一致性校验:新增组件时 verify 报「README 组件区块缺少 xxx」,由人补一行;标记区块用 · 分隔便于人读与机器切分"
|
||||
},
|
||||
{
|
||||
"id": "d25",
|
||||
"title": "menu 条目支持快捷键提示",
|
||||
"motivation": "菜单里展示快捷键是桌面级体验的标配;缺字段时调用方只能把快捷键拼进 label,文案与对齐都失控",
|
||||
"acceptance": "MenuItem 新增 shortcut?: string;条目改为 flex 两端对齐,label 单行省略,右侧 kbd 灰字提示(不绑定按键);demo 文件操作菜单加 F2 / ⌘D;manifest/文档/llms-full.txt 同步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "明确边界:快捷键只做展示,实际按键仍需调用方全局监听(组件不做全局键盘陷阱);两端对齐让长文案截断而快捷键始终可见;kbd 用 inherit 字体避免等宽字跳脱整体排版"
|
||||
},
|
||||
{
|
||||
"id": "d26",
|
||||
"title": "select 键盘导航:↑↓ 环形移动、Home/End 跳转、Esc 回焦触发器",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"id": "d27",
|
||||
"title": "modal 焦点陷阱:Tab/Shift+Tab 回绕、role=dialog/aria-modal、关闭归还焦点",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"id": "d28",
|
||||
"title": "drawer 焦点陷阱:Tab/Shift+Tab 回绕、关闭归还焦点(对齐 modal)",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"id": "d29",
|
||||
"title": "menu 补 Home/End 跳转,与 select 键盘导航对称",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"id": "e01",
|
||||
"title": "新增 inputNumber 数字输入组件",
|
||||
"motivation": "数量、份数等离散数值输入是表单常见场景,纯 textField 缺少步进语义;加减按钮 + 键盘调节可复用既有 button 与信号双向绑定模式",
|
||||
"acceptance": "packages/skin/ui 新增 inputNumber.ts:−/+ 按钮 + 输入框三段式,value 信号双向绑定,min/max/step 超界自动夹取,键盘 ↑↓ 调节,非法输入回滚,disabled 支持布尔或信号;只消费 --ua-* 令牌;导出、manifest、文档、llms-full.txt 同步;demo 加一节演示;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "inputNumber(props):−/+ 复用 ghost button(自带微动画与 disabled 透传),输入框 inputmode=decimal,change 时 parseFloat 解析、非法回滚当前值;↑↓ 步进并 preventDefault;clamp 统一夹取 min/max(缺省 ±Infinity);aria-label 三件套 + role=group;CSS 步进按钮收窄、tabular-nums;demo 新增 sec-inputnumber(与禁用态实例共用 qty 信号);manifest/docs/llms-full/README 同步为 29 组件"
|
||||
},
|
||||
{
|
||||
"id": "e02",
|
||||
"title": "新增 stepper 步骤条组件",
|
||||
"motivation": "下单、注册等多步流程需要进度指示与回跳,现有导航类组件(tabs/breadcrumb/pagination)都不覆盖分步语义;纯展示 + 可选点击回跳,改动范围可控",
|
||||
"acceptance": "packages/skin/ui 新增 stepper.ts:stepper({steps, active, clickable?, onChange?}),done/current/upcoming 三态视觉,clickable 时已完成步骤可点回跳并写回 active 信号,aria-current=step;只消费 --ua-* 令牌;导出、manifest、文档、llms-full.txt、README 清单同步;demo 加一节演示;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "stepper(props):active 信号变化整条重渲染(effect 首跑赋值 + replaceWith,条目少开销可忽略);done 显示 ✓、current 光环聚焦、upcoming 弱化,aria-current=step;clickable 时已完成步骤渲染为 button 回跳写回信号;连接线用 .ua-step + .ua-step::before 横向短线;demo 新增 sec-stepper(上一步/下一步按钮 computed 禁用联动);README 同步 30 组件"
|
||||
},
|
||||
{
|
||||
"id": "e03",
|
||||
"title": "新增 rate 评分组件",
|
||||
"motivation": "评价、打分场景常用,现有组件无覆盖;星标交互可完整复用信号双向绑定与微动画审美约定,单文件可控",
|
||||
"acceptance": "packages/skin/ui 新增 rate.ts:rate({value, max?, disabled?, onChange?}),max 缺省 5,hover 预览移出还原,键盘 ←→/↑↓ 调节,aria 星标语义;只消费 --ua-* 令牌;导出、manifest、文档、llms-full.txt、README 清单同步;demo 加一节演示;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "rate(props):hover 预览用组件内局部 hover 信号(容器 mouseleave 归零),shown = hover || value 决定点亮数;value/hover 变化整条重渲染(与 stepper 同款 effect + replaceWith 模式);键盘 →/↑ 加分 ←/↓ 减分并 clamp [1,max];role=slider + aria-valuenow;选中色取 --ua-color-warning 金黄;demo 新增 sec-rate(可交互 + 只读两实例共用 score 信号);README 同步 31 组件"
|
||||
},
|
||||
{
|
||||
"id": "e04",
|
||||
"title": "新增 empty 空状态组件",
|
||||
"motivation": "列表、表格、搜索无结果时需要统一的占位提示,dataTable 内置空态样式但独立场景(卡片、面板、筛选结果)没有可复用组件",
|
||||
"acceptance": "packages/skin/ui 新增 empty.ts:empty({title?, description?, action?}),居中排版,action 为可选 DOM 操作区(常见放按钮);只消费 --ua-* 令牌;导出、manifest、文档、llms-full.txt、README 清单同步;demo 加一节演示;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "empty(props):纯静态组件(无信号),role=status 由读屏播报;装饰符号 ⌀ 弱化为边框色且 aria-hidden;min-height 120px 撑留白(令牌只到 space-4);demo 新增 sec-empty(信号切换有数据/无结果对比,内容用响应式函数渲染);README 同步 32 组件"
|
||||
},
|
||||
{
|
||||
"id": "e05",
|
||||
"title": "新增 divider 分隔线组件",
|
||||
"motivation": "表单分组、卡片内区块、工具栏之间需要统一分隔语义,目前只有 menu 内部私有分隔线,独立场景无组件可用",
|
||||
"acceptance": "packages/skin/ui 新增 divider.ts:divider({label?, vertical?, dashed?}),水平/垂直两种方向,可带居中文案标签;只消费 --ua-* 令牌;导出、manifest、文档、llms-full.txt、README 清单同步;demo 加一节演示;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "divider(props):水平线用 ::before/::after 双线夹 label 居中文案;vertical 单线(伪元素清空,dashed 走 border-left);dashed 水平线 border-top 替代背景色;role=separator + aria-label;demo 新增 sec-divider(文案线/虚线/工具栏竖线三种用法);README 同步 33 组件"
|
||||
},
|
||||
{
|
||||
"id": "f01",
|
||||
"title": "组件数漂移治理:AGENTS/ARCHITECTURE 数字纳入 verify 门禁",
|
||||
"motivation": "组件数第三次漂移(AGENTS.md 与 ARCHITECTURE.md 仍写 27,实际 33),ua verify 只锁 README 区块锁不住散文数字",
|
||||
"acceptance": "ua verify 新增规则:扫描 AGENTS.md 与 ARCHITECTURE.md 中的「NN 个组件」模式数字,与 manifest docs.components 长度比对不一致即报错;当前两处 27 改为 33;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "来源:2026-10-23 第二次冷启动审计应修 #1"
|
||||
},
|
||||
{
|
||||
"id": "f02",
|
||||
"title": "check 链描述同步:AGENTS.md 与 ITERATION.md 补 verify 第六步",
|
||||
"motivation": "AGENTS.md:10 与 ITERATION.md:31 写 5 步,package.json 实为 6 步(含 verify),文档与实际门禁不一致",
|
||||
"acceptance": "两处描述改为 build → style → typecheck → smoke → index → verify 六步;npm run check 全绿",
|
||||
"status": "done",
|
||||
"notes": "来源:审计应修 #2"
|
||||
},
|
||||
{
|
||||
"id": "f03",
|
||||
"title": "新增 ua lint-iterations 门禁:留痕格式机器校验",
|
||||
"motivation": "iterations.log 41 行中 36 行违规(time 字段混用、UTC 时间戳、缺 commit),靠人工反复修补,规范缺机器校验",
|
||||
"acceptance": "ua 新增 lint-iterations 子命令:校验每行 JSON 字段恰为 {ts,id,result,commit,summary}、ts 为本地 +08:00 ISO 且可用 Date 解析、commit 为 7-40 位哈希或 aborted 时允许为空、result 枚举 done/aborted;并入 npm run check;历史 36 条违规行按规范修复(ts 以对应 git 提交的 author date 本地时区回填,commit 补哈希);npm run check 全绿",
|
||||
"status": "open",
|
||||
"notes": ""
|
||||
"notes": "来源:审计应修 #3;历史行修复用 git log --format=%cI 按条目 id 关联提交时间回填"
|
||||
},
|
||||
{
|
||||
"id": "f04",
|
||||
"title": "commit-lint 收严:chore/docs 等强制 scope,杜绝无 scope 放行",
|
||||
"motivation": "COMMITTING.md 规定 <type>(<scope>) 格式,但 chore:、docs: 无 scope 被 lint 放行,历史还有 17 条 iter: 缺 scope",
|
||||
"acceptance": "commit-lint 规则收严为:除 Merge 外所有 type 必须带 scope(scope 允许 cli/skin/core/backlog/留痕等枚举或条目 id);反向用例 chore: xxx / docs: xxx / iter: xxx 必须被拒;正例全部通过;npm run check 全绿",
|
||||
"status": "open",
|
||||
"notes": "来源:审计应修 #4;历史 iter 缺 scope 的提交不回改(已发布历史不动),只管增量"
|
||||
},
|
||||
{
|
||||
"id": "f05",
|
||||
"title": "ua index 幂等化:generatedAt 仅内容变化时更新",
|
||||
"motivation": "ua index 每次重写 .ua/index.json 的 generatedAt,npm run check 必然弄脏工作区,与「提交前 check」工作流冲突",
|
||||
"acceptance": "index 命令改为:若除 generatedAt 外内容与现有文件一致则不写入(或 generatedAt 改为内容哈希派生);连续两次 npm run check 后 git status 必须干净",
|
||||
"status": "open",
|
||||
"notes": "来源:审计应修 #5"
|
||||
},
|
||||
{
|
||||
"id": "f06",
|
||||
"title": "pnpm-lock.yaml 入库 + 目录树补全",
|
||||
"motivation": "lockfile 缺失导致依赖树不可复现;ARCHITECTURE.md 目录树缺 scripts/、COMMITTING.md、ITERATION.md、.ua/ 清单等条目,新代理接手有盲区",
|
||||
"acceptance": "pnpm-lock.yaml 提交入库;ARCHITECTURE.md 目录树补 scripts/(gitea.mjs/gitea-api.mjs 一句话说明)、COMMITTING.md、ITERATION.md、.ua/backlog.json、.ua/iterations.log、pnpm-workspace.yaml;npm run check 全绿",
|
||||
"status": "open",
|
||||
"notes": "来源:审计应修 #6 + 建议 #7"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+898
-37
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{"time":"2026-10-23T15:47:00+08:00","id":"b01","result":"done","summary":"新增 tooltip 悬浮提示组件(包裹式 API / 150ms 延迟 / Esc 收起 / reduced-motion 兜底),同步 manifest + llms-full.txt + demo 区块,并修复 ua style 对 URL 中 // 的误判;npm run check 全绿"}
|
||||
{"time":"2026-10-23T15:50:03+08:00","id":"b02","result":"done","summary":"新增 toast 消息提示组件:四语义工厂 + showToast,右上角堆叠,默认 3s 自动消失,微动画 + reduced-motion 兜底;manifest/docs/llms 同步,check 全绿"}
|
||||
{"time":"2026-10-23T15:53:33+08:00","id":"b03","result":"done","summary":"新增 skeleton 骨架屏组件:text/rect/circle 三形状 + 扫光微动画 + reduced-motion 静态化;manifest/docs/llms/demo 同步,check 全绿"}
|
||||
{"time":"2026-10-23T16:00:11+08:00","id":"b04","result":"done","summary":"新增独立 pagination 分页器(page 信号双向绑定 + 省略号折叠 + 键盘可达),dataTable 新增 page 受控桥接实现联动;demo 联动卡片,check 全绿"}
|
||||
{"time":"2026-10-23T16:03:46+08:00","id":"b05","result":"done","summary":"文档站核查确认已 manifest 驱动;补齐默认值信息量缺口:CLI propsTable 新增默认值列(df 字段)+ llms-full 默认标注,16 处默认值提取;check 全绿"}
|
||||
{"time":"2026-10-23T16:08:17+08:00","id":"c01","result":"done","summary":"button/checkbox/switchButton 补 disabled(boolean|Signal),复用 h() 布尔/信号既有语义零辅助代码;CSS 通用禁用态 + demo 信号联动示例;check 全绿"}
|
||||
{"time":"2026-10-23T16:10:31+08:00","id":"c02","result":"done","summary":"textField/textarea 补 disabled(boolean|Signal),与 c01 同模式;禁用态 CSS 覆盖输入类;demo locked 信号联动;check 全绿"}
|
||||
{"time": "2026-10-23T16:55:00+08:00", "id": "d01", "result": "done", "summary": "新增下拉菜单 menu:触发器 + 自绘弹层,disabled/danger/divider 条目,↑↓ 环形导航 + Esc 回焦,reduced-motion 兜底;manifest/llms-full/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T17:02:00+08:00", "id": "d02", "result": "done", "summary": "select/radioGroup 补 disabled(布尔|信号),并修复 select 根节点缺 ua-open 导致箭头不旋转的既有缺陷;CSS :has() 行级弱化;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:47:56+08:00", "id": "d03", "result": "done", "summary": "新增折叠面板 accordion:单开/多开 + active 受控 + 禁用面板,grid 行高 0fr→1fr 过渡免测量,aria 关联完整;manifest/llms/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:48:36+08:00", "id": "d04", "result": "done", "summary": "dataTable 新增 loading(布尔|信号):表体换骨架行、筛选与翻页禁用、aria-busy;demo 加开关,manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:49:45+08:00", "id": "d05", "result": "done", "summary": "新增面包屑 breadcrumb:nav/ol 语义结构 + 末项 aria-current,href 走 <a>、无 href 走 onNavigate,分隔符可配;manifest/llms/demo 同步,check 全绿(中途被 style 门禁拦下多行注释,已改单行)"}
|
||||
{"time": "2026-10-23T16:50:49+08:00", "id": "d06", "result": "done", "summary": "新增 ua verify 门禁:清单 id/exports/props/steps/example 与 demo 锚点一致性,纳入 npm run check;顺带补齐 badge/alert/spinner 手把手第三条,check 全绿"}
|
||||
{"time": "2026-10-23T16:51:48+08:00", "id": "d07", "result": "done", "summary": "新增可交互气泡卡片 popover:点击/hover 触发、点外部与 Esc 收起、内容可交互且内部点击不误关;manifest/llms/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:52:28+08:00", "id": "d08", "result": "done", "summary": "演示页改双栏 shell:sticky 侧边目录由 .sec 自动反推 + IntersectionObserver 滚动高亮,窄屏隐藏、reduced-motion 关平滑滚动;check 全绿"}
|
||||
{"time": "2026-10-23T16:53:06+08:00", "id": "d09", "result": "done", "summary": "新增滑块 slider:value 信号双向绑定 + min/max/step + 布尔|信号禁用 + showValue,原生 range 承载键盘与触屏,accent-color 令牌化;manifest/llms/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:53:44+08:00", "id": "d10", "result": "done", "summary": "toast 新增 closable(× 手动关闭)与同屏上限 4 条,退场统一走 dismiss() 双兜底;is-closable 才接收指针;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:54:36+08:00", "id": "d11", "result": "done", "summary": "modal 新增 size 三档(sm/md/lg)与 footer 操作区(右对齐 + 上分割线),缺省行为不变;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:55:10+08:00", "id": "d12", "result": "done", "summary": "新增可关闭标签 tag(五档语义色 + closable/onClose,无状态委托调用方重渲染);demo 用 renderList 演示真实删除;manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:55:55+08:00", "id": "d13", "result": "done", "summary": "dataTable 列定义新增皮肤层 TableColumn(align + format),内核 ColumnDef 不变;表头/单元格共用对齐类,format 走 textContent;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:56:36+08:00", "id": "d14", "result": "done", "summary": "新增抽屉 drawer:open 信号驱动侧滑、placement/width 可配、footer 操作区、遮罩/×/Esc 关闭并聚焦首元素;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:57:22+08:00", "id": "d15", "result": "done", "summary": "CLI 包补 ua.manifest.json(type: tooling,七个子命令 + 约定),注册表 5 包;修正 AGENTS/ARCHITECTURE 过期组件数 17→27;check 全绿"}
|
||||
{"time": "2026-10-23T16:58:51+08:00", "id": "d16", "result": "done", "summary": "dataTable 消费内核列宽 width:colgroup 固定像素 / flex 自适应,选择列 44px;demo 三列演示宽档;manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:59:46+08:00", "id": "d17", "result": "done", "summary": "表单错误态打通包裹式控件:focusout/change 取代 blur + aria-invalid + .ua-invalid 后代选择器覆盖 select/radio/slider;demo 加必填 select;check 全绿"}
|
||||
{"time": "2026-10-23T17:00:55+08:00", "id": "d18", "result": "done", "summary": "ua verify 新增皮肤硬编码颜色门禁(#hex 禁止,rgba 仅限阴影/遮罩),清掉 ui.css 5 处 #fff 改令牌;负向测试确认能拦;check 全绿"}
|
||||
{"time": "2026-10-23T17:01:46+08:00", "id": "d19", "result": "done", "summary": "dataTable 新增 selected 受控信号:与内核选择双向同步(内容比较 + 拷贝保护),demo 展示已选计数与清空;check 全绿"}
|
||||
{"time": "2026-10-23T17:02:24+08:00", "id": "d20", "result": "done", "summary": "progress 支持不确定态:value 可选,缺省时 35% 填充块往复 + aria-label 进行中;定量态补 aria-valuenow;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T17:03:02+08:00", "id": "d21", "result": "done", "summary": "无障碍收尾:modal × 补 aria-label/type、spinner role=status、skeleton aria-hidden、tablist aria-label + 禁用页签 aria-disabled;conventions 记录基线;check 全绿"}
|
||||
{"time": "2026-10-23T17:03:49+08:00", "id": "d22", "result": "done", "summary": "新增头像 avatar(src 图片 / 首字占位 + size + 四档语义底色),demo 独立 sec-avatar;中途被 verify 的 demo 锚点检查拦下已补;check 全绿"}
|
||||
{"time": "2026-10-23T17:04:27+08:00", "id": "d23", "result": "done", "summary": "alert 新增 title / closable / onClose 与 role=alert,关闭走 200ms 退场后摘除;demo 独立 sec-alert;manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T17:05:46+08:00", "id": "d24", "result": "done", "summary": "重写 README(定位/快速开始/28 组件/目录/门禁/Agent 接入/流水线),组件清单做成标记区块并由 ua verify 校验防漂移;负向测试通过,check 全绿"}
|
||||
{"time": "2026-10-23T17:06:17+08:00", "id": "d25", "result": "done", "summary": "menu 条目新增 shortcut 快捷键提示(右侧 kbd 灰字,两端对齐 + 单行省略,不绑按键);demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T09:09:16+00:00", "round": 26, "id": "d26", "result": "done", "commit": "82f48aa", "summary": "select 键盘导航:↑↓ 环形移动、Home/End 跳转、Esc 回焦触发器"}
|
||||
{"ts": "2026-10-23T09:11:42+00:00", "round": 27, "id": "d27", "result": "done", "commit": "fac74a3", "summary": "modal 焦点陷阱 + dialog 语义"}
|
||||
{"ts": "2026-10-23T09:11:42+00:00", "round": 28, "id": "d28", "result": "done", "commit": "", "summary": "drawer 焦点陷阱对齐 modal"}
|
||||
{"ts": "2026-10-23T09:12:49+00:00", "round": 29, "id": "d29", "result": "done", "commit": "", "summary": "menu Home/End 跳转对齐 select"}
|
||||
{"ts":"2026-10-23T17:50:14+08:00","id":"e01","result":"done","commit":"20d7b94","summary":"新增 inputNumber 数字输入:−/+ 按钮 + 键盘上下步进,信号双向绑定,min/max/step 夹取,门禁全绿"}
|
||||
{"ts":"2026-10-23T17:57:31+08:00","id":"e02","result":"done","commit":"fc7a1e8","summary":"新增 stepper 步骤条:三态流转 + active 信号双向绑定 + clickable 回跳,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:03:34+08:00","id":"e03","result":"done","commit":"ee20322","summary":"新增 rate 评分组件:星标点选 + hover 预览 + 键盘方向键调节,信号双向绑定,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:11:46+08:00","id":"e04","result":"done","commit":"fd4ee01","summary":"新增 empty 空状态组件:title/description/action 居中占位,静态零依赖,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:14:46+08:00","id":"e05","result":"done","commit":"c457e0c","summary":"新增 divider 分隔线组件:水平/垂直 + 居中文案 + 虚线,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:42:15+08:00","id":"f01","result":"done","commit":"c19f83b","summary":"组件数漂移纳入 ua verify 门禁:AGENTS/ARCHITECTURE 的「N 个组件」与 skin-dom 清单实际数比对,修正 27→33"}
|
||||
{"ts":"2026-10-23T18:44:08+08:00","id":"f02","result":"done","commit":"f2fc286","summary":"AGENTS.md 与 ITERATION.md 的 npm run check 描述补上 verify,与实际六步链一致"}
|
||||
@@ -7,7 +7,7 @@
|
||||
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)确认基线全绿,再开始改代码。
|
||||
4. **验证自检**:装好后先跑 `npm run check`(build → style → typecheck → smoke → index → verify)确认基线全绿,再开始改代码。
|
||||
|
||||
## 编码规范(强制)
|
||||
|
||||
@@ -21,13 +21,15 @@
|
||||
|
||||
- `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/skin/ui`(@ui-arch/ui):33 个 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,提交前必过。
|
||||
**门禁一条龙**:`npm run check` = build + style + typecheck + smoke + index + verify,提交前必过。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、demo 锚点必须在 `apps/demo/main.ts` 中存在),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌),并核对 README 的 `<!-- ua:components -->` 区块与清单组件集合完全一致。
|
||||
|
||||
**提交信息规范**:`COMMITTING.md`(`<type>(<scope>): <中文祈使句>`),`ua commit-lint` + commit-msg 钩子自动校验,迭代提交用 `iter(<条目id>): <subject>`。
|
||||
|
||||
## Agent 查询组件库的三种方式
|
||||
|
||||
@@ -46,7 +48,7 @@
|
||||
|
||||
可用工具:`list_components` / `get_component` / `search_components`。
|
||||
|
||||
2. **CLI**:`node packages/tools/cli/bin/ua.js list | info <name> | index`
|
||||
2. **CLI**:`node packages/tools/cli/bin/ua.js list | info <name> | index | docs | style | verify`
|
||||
|
||||
3. **静态索引**:`.ua/index.json`(机器可读索引)、`llms.txt`(速览)或 **`llms-full.txt`(全量 Agent 文档:逐组件参数 / 回调 / 手把手步骤 / 令牌,无图形纯文本,喂给 Agent 首选)**。
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ ui-arch-mvp/
|
||||
│ │ ├── table-core/ # 表格状态内核
|
||||
│ │ └── frame/ # 信号 + hyperscript 微框架
|
||||
│ ├── skin/ # 皮肤层(DOM 组件,可发布)
|
||||
│ │ └── ui/ # 15 个组件 + ui.css
|
||||
│ │ └── ui/ # 33 个组件 + ui.css
|
||||
│ ├── design/ # 资产层(可发布)
|
||||
│ │ └── tokens/ # --ua-* 设计令牌
|
||||
│ └── tools/ # 工程层(private,不发布)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Git 提交规范(COMMITTING.md)
|
||||
|
||||
> 适用于所有人工提交与自动化迭代提交。`ua commit-lint` 门禁与 `.git/hooks/commit-msg` 钩子按本文校验,违规提交被拒绝。
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
[body:动机与要点,每行 ≤ 80 字符]
|
||||
|
||||
[footer:Refs: <编号> / BREAKING CHANGE: <说明>]
|
||||
```
|
||||
|
||||
## type 枚举
|
||||
|
||||
| type | 用途 |
|
||||
|---|---|
|
||||
| `feat` | 新功能(组件、命令、插件、门禁) |
|
||||
| `fix` | 缺陷修复 |
|
||||
| `docs` | 纯文档变更 |
|
||||
| `style` | 格式调整(不影响语义:空白、注释格式) |
|
||||
| `refactor` | 重构(既非新增也非修复) |
|
||||
| `test` | 测试相关 |
|
||||
| `chore` | 构建、工具、元数据、依赖 |
|
||||
| `iter` | 迭代流水线产物(自动化专用,scope 必须为 backlog 条目 id) |
|
||||
|
||||
## scope 枚举
|
||||
|
||||
- 包名:`table-core` / `frame` / `ui` / `tokens` / `cli`(跨包可省略)
|
||||
- 应用:`demo` / `docs`
|
||||
- 元数据:`backlog` / `repo` / `protocol`
|
||||
- `iter` 提交的 scope 固定为条目 id:`b02`、`c01`、`d29` …
|
||||
|
||||
## subject 规则
|
||||
|
||||
- 中文祈使句,动词开头(「新增…」「修复…」「补充…」);
|
||||
- ≤ 50 字符;
|
||||
- 结尾不加句号;
|
||||
- 条目 id 不写入 subject(scope 已承载,方便 `git log --grep='iter(b02)'` 检索)。
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
iter(b02): 新增 toast 消息提示组件
|
||||
feat(ui): dataTable 新增 selected 受控信号
|
||||
fix(cli): 修正 style 检查对 URL 中 // 的误判
|
||||
chore(backlog): 记录 d29 完成
|
||||
docs: 重写 README 组件清单
|
||||
```
|
||||
|
||||
## 反例(会被门禁拒绝)
|
||||
|
||||
```
|
||||
iter: b02 新增 toast 消息提示组件 ← iter 缺 scope(id)
|
||||
iter(b02): 文档站 toast 组件的新增。 ← 句号结尾
|
||||
feat(ui): 增加 dataTable 的受控选中信号并且支持与内核选择双向同步以及已选计数展示 ← 超 50 字符
|
||||
update stuff ← 非 type 枚举、非中文祈使句
|
||||
```
|
||||
|
||||
## 与流水线的关系
|
||||
|
||||
- 自动化每轮迭代的首个提交:`iter(<id>): <subject>`;
|
||||
- 同轮的元数据变更(backlog 置 done):`chore(backlog): 记录 <id> 完成`;
|
||||
- 合并提交(qa/stable 的 ff-only)不产生 message,无需遵守;
|
||||
- 门禁来源:`ua commit-lint <文件|message>`,commit-msg 钩子自动调用;git 自动的 Merge/Revert/fixup 信息跳过校验。
|
||||
+13
-6
@@ -1,7 +1,16 @@
|
||||
# 无人值守自我迭代协议(ITERATION.md)
|
||||
## 无人值守自我迭代协议(ITERATION.md)
|
||||
|
||||
> 本协议由自动化 Agent 执行,也可供人工参考。任何迭代必须遵守 `CODING.md`、`AGENTS.md`、`ARCHITECTURE.md` 三份规范,门禁 `npm run check` 不通过的一切产出作废。
|
||||
|
||||
## 运行模式:连续迭代 + 心跳监护
|
||||
|
||||
调度器(每小时唤起)**不是**迭代器,只做两件事:健康检查 + 中断重启。真正的迭代是单次唤起内的**连续循环**——反复执行《每轮迭代流程》,直到 backlog 清空或时间预算耗尽,中间不停歇。
|
||||
|
||||
- **并发锁**:`.ua/loop.lock`(含时间戳,不入库)。唤起时若锁存在且心跳在 15 分钟内 → 已有实例在跑,直接退出;否则写锁开始循环。每轮开始前刷新锁内时间戳作心跳。
|
||||
- **时间预算**:单次唤起最多连续迭代约 50 分钟,到点在完成当前轮后停止(防止与下次唤起重叠)。**预算判断必须基于客观时钟**:点火时用 `date +%s` 记录起点并写入 `.ua/loop.lock`,每轮开始时重新取 `date +%s` 与起点求差,差值 ≥ 2700 秒(45 分钟)即到点;**禁止凭感觉估算时长**——LLM 无内部时钟,自估时长系统性偏高(实测自报 40+ 分钟而实际约 30 分钟)。
|
||||
- **熔断**:同一 backlog 条目连续 2 轮失败 → 停止循环,锁照常释放,等待下次唤起。
|
||||
- **重启语义**:锁心跳超过 15 分钟视为循环中断(崩溃/超时),调度器删旧锁、重新点火。
|
||||
|
||||
## 分支流水线
|
||||
|
||||
```
|
||||
@@ -16,15 +25,13 @@ qa/probe ──门禁通过──▶ qa/stable ──人工审阅──▶ main
|
||||
|
||||
## 每轮迭代流程(自动化 Agent 执行)
|
||||
|
||||
> 所有远程推送一律使用认证包装脚本:`node scripts/gitea.mjs push <remote> <分支>`(令牌读自本地 `.gitea-token`,不入库;凭据管理器不可用)。
|
||||
|
||||
1. **同步**:`git fetch --all --prune`,切到 `qa/stable` 并 `git pull`;从其最新提交强制重建 `qa/probe` 并切换过去。
|
||||
2. **选题**:读 `.ua/backlog.json`,取第一条 `status === "open"` 的条目;若全部关闭,按《选题规则》自行补充一条新点子后继续。
|
||||
3. **实现**:按 `CODING.md` 编码规范实现,保持低侵入,单文件 < 200 行,涉及组件需同步 manifest/docs/llms-full.txt。
|
||||
4. **门禁**:跑 `npm run check`(build → style → typecheck → smoke → index)。
|
||||
- **通过**:提交到 `qa/probe`(commit message 以 `iter:` 开头,注明 backlog 条目 id),推送 `qa/probe`,fast-forward 合并到 `qa/stable` 并推送;将 backlog 条目 `status` 置为 `done` 并随迭代提交。
|
||||
4. **门禁**:跑 `npm run check`(build → style → typecheck → smoke → index → verify)。
|
||||
- **通过**:提交到 `qa/probe`,提交信息按 `COMMITTING.md` 规范(`iter(<条目id>): <中文祈使句>`,元数据变更用 `chore(backlog): 记录 <id> 完成`;commit-msg 钩子自动校验),推送 `qa/probe`,fast-forward 合并到 `qa/stable` 并推送;将 backlog 条目 `status` 置为 `done` 并随迭代提交。
|
||||
- **失败**:尝试修复一次;仍失败则放弃本轮——`qa/probe` 直接重置回 `qa/stable`,backlog 条目在 `notes` 里追加失败原因、`status` 保持 `open`,提交并推送该元数据变更(仅改 `.ua/backlog.json`,不碰代码)。
|
||||
5. **留痕**:在 `.ua/iterations.log` 追加一行 JSON:`{时间, 条目id, 结果, 摘要}`,随本轮提交。
|
||||
5. **留痕**:在 `.ua/iterations.log` 追加一行 JSON,**字段统一**:`{"ts":"本地ISO时间","id":"条目id","result":"done|aborted","commit":"短哈希","summary":"一句话"}`。硬性规范:`ts` 必须来自 `date` 命令实测(本地时间,带 +08:00 偏移,**禁止手写或用 UTC 换算**);时长结论必须来自两次时间查询之差,禁止使用「大约/接近」等估算词;并发实例格式不得自创变体。随本轮提交。
|
||||
6. **边界**:不改 `main` 分支保护、不删分支、不动 `.workbuddy/`、不重写历史、不 force push 到 `qa/stable` 与 `main`。
|
||||
|
||||
## 选题规则(自主补点子时)
|
||||
|
||||
@@ -1,2 +1,54 @@
|
||||
# UI
|
||||
# @ui-arch · 私人组件库
|
||||
|
||||
自研、零依赖、Agent 友好的 DOM 组件库单体仓:**signal 微框架 + headless 表格内核 + 令牌化皮肤组件**,所有文档与 MCP 注册表都从一个清单派生。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
pnpm install # 包间依赖用 workspace:*,npm 不支持
|
||||
npm run check # 门禁一条龙:build → style → typecheck → smoke → index → verify
|
||||
npm run demo # 打开演示:apps/demo/dist/demo.html
|
||||
npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt
|
||||
```
|
||||
|
||||
## 组件(共 33 个)
|
||||
|
||||
<!-- ua:components -->
|
||||
accordion · alert · avatar · badge · breadcrumb · button · card · checkbox · dataTable · divider · drawer · empty · form · inputNumber · menu · modal · pagination · popover · progress · radioGroup · rate · select · skeleton · slider · spinner · stepper · switchButton · tabs · tag · textField · textarea · toast · tooltip
|
||||
<!-- /ua:components -->
|
||||
|
||||
分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / form / formItem)、反馈(alert / toast / spinner / progress / skeleton / badge / empty)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper)、数据(dataTable / tag / avatar / card)、其他(divider)。
|
||||
|
||||
## 目录
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) |
|
||||
| `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) |
|
||||
| `packages/skin/ui` | @ui-arch/ui:33 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
|
||||
| `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) |
|
||||
| `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server |
|
||||
| `apps/demo` | 交互演示(esbuild 打包成单文件) |
|
||||
| `apps/docs` | 文档站纯产物(`ua docs` 生成) |
|
||||
|
||||
## 三道门禁
|
||||
|
||||
1. `ua style`:注释规范——导出必须有 `/*** 作用 ***/`、接口字段行尾注释、行注释用中文。
|
||||
2. `ua verify`:清单 ↔ 文档 ↔ 演示一致(id 唯一、exports 对应、props 带 n/t/d、steps ≥ 3、demo 锚点存在),皮肤样式表禁止 `#hex` 硬编码颜色。
|
||||
3. `ua index`:刷新 `.ua/index.json` / `llms.txt`,供 MCP 与 Agent 查询。
|
||||
|
||||
## Agent 接入
|
||||
|
||||
- 静态:`llms.txt`(速览)/ **`llms-full.txt`(全量:逐组件参数 / 回调 / 手把手 / 令牌)**
|
||||
- 命令行:`node packages/tools/cli/bin/ua.js list | info <name> | docs | verify | mcp`
|
||||
- MCP:在 `~/.workbuddy/mcp.json` 注册 `ua mcp`,可用 `list_components` / `get_component` / `search_components`
|
||||
|
||||
规范见 `AGENTS.md`(接入手册)、`ARCHITECTURE.md`(分层与依赖方向)、`CODING.md`(编码规范)、`ITERATION.md`(自动迭代协议)。
|
||||
|
||||
## 分支流水线
|
||||
|
||||
```
|
||||
qa/probe ──门禁通过──▶ qa/stable ──人工审阅──▶ main(受保护)
|
||||
```
|
||||
|
||||
自动化只在 `qa/stable` 做 fast-forward 合并,`main` 仅由人审后合入。
|
||||
|
||||
+10
-1
@@ -45,7 +45,16 @@ ${css}
|
||||
@media (prefers-color-scheme:dark){:root{--page-bg:#101216;--page-text:#e7e9ec;--page-muted:#888780;--page-border:rgba(255,255,255,.15)}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--page-bg);color:var(--page-text);font:14px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
.page{max-width:880px;margin:0 auto;padding:36px 20px 80px}
|
||||
.shell{max-width:1160px;margin:0 auto;padding:36px 20px 80px;display:grid;grid-template-columns:190px minmax(0,1fr);gap:36px}
|
||||
.page{max-width:900px;margin:0;padding:0}
|
||||
.toc{position:sticky;top:24px;align-self:start;max-height:calc(100vh - 48px);overflow:auto;font-size:13px}
|
||||
.toc-title{color:var(--page-muted);font-size:11px;letter-spacing:.08em;margin-bottom:8px}
|
||||
.toc-link{display:block;padding:3px 8px;border-left:2px solid transparent;border-radius:0 4px 4px 0;color:var(--page-muted);text-decoration:none;transition:color .15s,background .15s,border-color .15s}
|
||||
.toc-link:hover{color:var(--page-text);background:rgba(127,127,127,.08)}
|
||||
.toc-link.is-active{color:var(--ua-color-accent);border-left-color:var(--ua-color-accent);background:color-mix(in srgb,var(--ua-color-accent) 10%,transparent)}
|
||||
@media (max-width:820px){.shell{grid-template-columns:1fr;gap:0}.toc{display:none}}
|
||||
html{scroll-behavior:smooth}
|
||||
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
||||
header{margin-bottom:30px}
|
||||
h1{font-size:20px;font-weight:500;margin:0}
|
||||
.sub{color:var(--page-muted);font-size:13px;margin:8px 0 0}
|
||||
|
||||
+390
-10
@@ -1,23 +1,63 @@
|
||||
import { h, mount, signal } from '@ui-arch/frame';
|
||||
import { computed, h, mount, renderList, signal } from '@ui-arch/frame';
|
||||
import {
|
||||
accordion,
|
||||
alert,
|
||||
avatar,
|
||||
badge,
|
||||
breadcrumb,
|
||||
button,
|
||||
card,
|
||||
checkbox,
|
||||
dataTable,
|
||||
divider,
|
||||
drawer,
|
||||
empty,
|
||||
form,
|
||||
formItem,
|
||||
inputNumber,
|
||||
menu,
|
||||
modal,
|
||||
pagination,
|
||||
popover,
|
||||
progress,
|
||||
radioGroup,
|
||||
rate,
|
||||
select,
|
||||
showToast,
|
||||
skeleton,
|
||||
slider,
|
||||
spinner,
|
||||
stepper,
|
||||
switchButton,
|
||||
tabs,
|
||||
tag,
|
||||
textarea,
|
||||
textField,
|
||||
toast,
|
||||
tooltip,
|
||||
} from '@ui-arch/ui';
|
||||
import type { RowKey } from '@ui-arch/ui';
|
||||
|
||||
/*** 侧边目录:挂载后从 .sec 反推锚点,IntersectionObserver 高亮视口内区块(清单改动自动跟随) ***/
|
||||
function buildToc(toc: HTMLElement, content: HTMLElement): void {
|
||||
const secs = Array.from(content.querySelectorAll<HTMLElement>('section.sec'));
|
||||
if (!secs.length) return; // 卫语句:无区块不建目录
|
||||
const links = secs.map((sec) =>
|
||||
h('a', { class: 'toc-link', href: `#${sec.id}`, 'data-for': sec.id }, sec.querySelector('h2')?.textContent ?? sec.id),
|
||||
);
|
||||
toc.append(...links);
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const en of entries) {
|
||||
if (!en.isIntersecting) continue; // 只处理刚进入视口的区块
|
||||
const id = (en.target as HTMLElement).id;
|
||||
for (const link of links) link.classList.toggle('is-active', link.getAttribute('data-for') === id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-10% 0px -80% 0px' }, // 顶部窄条判定,滚动时高亮更跟手
|
||||
);
|
||||
for (const sec of secs) io.observe(sec);
|
||||
}
|
||||
|
||||
/*** 生成演示区块;id 供文档站 iframe 锚点定位 ***/
|
||||
function section(id: string, title: string, desc: string, ...body: unknown[]): HTMLElement {
|
||||
@@ -64,7 +104,43 @@ const username = signal('');
|
||||
const email = signal('');
|
||||
const formResult = signal('');
|
||||
|
||||
const app = h(
|
||||
// —— 分页联动状态:dataTable 与独立 pagination 共用同一页码信号 ——
|
||||
const tablePage = signal(1);
|
||||
|
||||
// —— 抽屉开关 ——
|
||||
const drawerOpen = signal(false);
|
||||
|
||||
// —— 标签列表:关闭即写回信号,容器按信号重渲染 ——
|
||||
const tags = signal(['设计', '前端', '组件库']);
|
||||
|
||||
// —— 滑块:音量与禁用态演示 ——
|
||||
const volume = signal(40);
|
||||
|
||||
// —— 数字输入:购买数量演示,与禁用态实例共用同一信号 ——
|
||||
const qty = signal(1);
|
||||
|
||||
// —— 步骤条:下单流程演示,按钮推进、点已完成步骤回跳 ——
|
||||
const stepIdx = signal(0);
|
||||
|
||||
// —— 评分:满意度演示,与只读实例共用同一信号 ——
|
||||
const score = signal(0);
|
||||
|
||||
// —— 空状态:搜索无结果演示 ——
|
||||
const noResult = signal(true);
|
||||
|
||||
// —— 受控选中:dataTable 内部勾选实时写回,外部可清空 ——
|
||||
const picked = signal<RowKey[]>([]);
|
||||
|
||||
// —— 表格加载态:切换后数据行变骨架行,筛选与翻页临时禁用 ——
|
||||
const tableLoading = signal(false);
|
||||
|
||||
// —— 菜单选中结果:演示 onSelect 回调 ——
|
||||
const menuPick = signal('');
|
||||
|
||||
// —— 禁用态演示:开关信号同时绑定 switch 的 checked 与 checkbox 的 disabled ——
|
||||
const locked = signal(true);
|
||||
|
||||
const content = h(
|
||||
'div',
|
||||
{ class: 'page' },
|
||||
h(
|
||||
@@ -98,6 +174,91 @@ const app = h(
|
||||
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} 字`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
button({ label: '禁用按钮', variant: 'ghost', disabled: true }),
|
||||
checkbox({ label: '信号禁用的勾选', disabled: locked }),
|
||||
switchButton({ label: '锁定', checked: locked }), // 关掉开关即解锁上面的勾选框与输入框
|
||||
textField({ placeholder: '锁定后不可输入…', disabled: locked }),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '信号禁用的下拉…', disabled: locked }),
|
||||
radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size, disabled: locked }),
|
||||
radioGroup({ options: [{ value: 'x', label: '静态禁用' }], value: size, disabled: true }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-drawer',
|
||||
'抽屉',
|
||||
'drawer:open 信号驱动侧滑面板,保留主视图上下文;遮罩 / × / Esc 关闭。',
|
||||
button({ label: '打开抽屉', onClick: () => drawerOpen(true) }),
|
||||
drawer(
|
||||
{
|
||||
title: '订单详情',
|
||||
open: drawerOpen,
|
||||
width: 360,
|
||||
onClose: () => drawerOpen(false),
|
||||
footer: h('div', { class: 'row' }, button({ label: '关闭', variant: 'ghost', onClick: () => drawerOpen(false) })),
|
||||
},
|
||||
h('p', null, '这里是抽屉内容,可放任意 DOM。'),
|
||||
h('div', { class: 'row' }, tag({ label: '#A1024', variant: 'info' }), badge({ label: '已发货', variant: 'success' })),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-tag',
|
||||
'标签',
|
||||
'tag:可关闭标签(badge 的交互版),关闭回调里改信号即从列表移除。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
tag({ label: '纯展示', variant: 'default' }),
|
||||
tag({ label: '通过', variant: 'success' }),
|
||||
tag({ label: '待处理', variant: 'warning' }),
|
||||
tag({ label: '已失效', variant: 'danger' }),
|
||||
),
|
||||
(() => {
|
||||
const box = h('div', { class: 'row' });
|
||||
renderList(box, () => tags().map((t) => tag({ label: t, variant: 'info', closable: true, onClose: () => tags(tags().filter((x) => x !== t)) })));
|
||||
return box;
|
||||
})(),
|
||||
),
|
||||
section(
|
||||
'sec-avatar',
|
||||
'头像',
|
||||
'avatar:有 src 走图片(alt 用名称),无 src 用首字占位;size 与语义底色都可调。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
avatar({ name: '张三' }),
|
||||
avatar({ name: 'Alice', size: 40, variant: 'success' }),
|
||||
avatar({ name: 'Bob', size: 24, variant: 'warning' }),
|
||||
avatar({ name: '李四', size: 36, variant: 'danger' }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-slider',
|
||||
'滑块',
|
||||
'slider:原生 range 承载交互(含键盘 ←→),value 信号双向绑定,右侧可显示当前值。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
slider({ value: volume, min: 0, max: 100, step: 5, onChange: (v) => toast.info(`音量 ${v}`) }),
|
||||
slider({ value: volume, disabled: locked }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-inputnumber',
|
||||
'数字输入',
|
||||
'inputNumber:−/+ 按钮与键盘 ↑↓ 调节,value 信号双向绑定,min/max/step 超界自动夹取。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
inputNumber({ value: qty, min: 1, max: 99, onChange: (v) => toast.info(`数量 ${v}`) }),
|
||||
inputNumber({ value: qty, disabled: locked }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-feedback',
|
||||
@@ -111,16 +272,61 @@ const app = h(
|
||||
alert({ message: '警告提示:操作可能存在风险。', variant: 'warning' }),
|
||||
h('div', { class: 'row' }, spinner(), spinner({ size: 26 })),
|
||||
progress({ value: percent }),
|
||||
h('div', { class: 'row' }, h('span', { class: 'out' }, '不确定态:'), progress()),
|
||||
),
|
||||
section(
|
||||
'sec-alert',
|
||||
'提示条',
|
||||
'alert:四档语义色 + 可选标题与关闭按钮,role=alert 由读屏播报。',
|
||||
alert({ message: '这是一条中性提示(无标题)', variant: 'info' }),
|
||||
alert({ title: '保存失败', message: '网络波动,请稍后重试;本次修改已保留在本地草稿。', variant: 'danger', closable: true, onClose: () => toast.warning('提示已关闭') }),
|
||||
),
|
||||
section(
|
||||
'sec-tooltip',
|
||||
'悬浮提示',
|
||||
'tooltip 包裹触发器:hover / 聚焦延迟 150ms 出场,移出、失焦或 Esc 收起,气泡不抢指针。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
tooltip({ content: '保存后立即生效' }, button({ label: '保存' })),
|
||||
tooltip({ content: '该操作不可撤销', placement: 'bottom' }, button({ label: '删除', variant: 'danger' })),
|
||||
tooltip({ content: '聚焦输入框同样会显示' }, textField({ placeholder: '聚焦我…' })),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-toast',
|
||||
'消息提示',
|
||||
'toast 右上角堆叠弹出,默认 3s 自动淡出移除;语义色四档,出入场微动画。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
button({ label: '成功', onClick: () => toast.success('保存成功') }),
|
||||
button({ label: '错误', variant: 'danger', onClick: () => toast.error('网络请求失败') }),
|
||||
button({ label: '警告', onClick: () => toast.warning('磁盘空间不足') }),
|
||||
button({ label: '信息(1.5s)', onClick: () => toast.info('已同步云端', 1500) }),
|
||||
button({ label: '可关闭', variant: 'ghost', onClick: () => showToast({ message: '这条可以手动关掉', variant: 'warning', closable: true }) }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-skeleton',
|
||||
'骨架屏',
|
||||
'skeleton 占位:text 多行段落 / rect 块 / circle 圆,扫光微动画,reduced-motion 自动静态化。',
|
||||
card(
|
||||
{ title: '加载中的用户资料' },
|
||||
h('div', { class: 'row' }, skeleton({ variant: 'circle' }), skeleton({ variant: 'text', rows: 2 })),
|
||||
skeleton({ variant: 'rect' }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-formlab',
|
||||
'表单校验',
|
||||
'form + formItem:必填 / 长度 / 正则校验,全部通过才触发提交;失焦校验、输入清错。',
|
||||
form(
|
||||
{ submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()}`) },
|
||||
{ submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) },
|
||||
[
|
||||
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 })),
|
||||
formItem({ label: '城市', required: true, value: city }, select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '请选择城市…' })),
|
||||
],
|
||||
),
|
||||
h('span', { class: 'out' }, () => formResult() || '等待提交…'),
|
||||
@@ -133,10 +339,158 @@ const app = h(
|
||||
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' })),
|
||||
{
|
||||
title: '确认操作',
|
||||
open: modalOpen,
|
||||
size: 'lg',
|
||||
onClose: () => modalOpen(false),
|
||||
footer: h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
button({ label: '取消', variant: 'ghost', onClick: () => modalOpen(false) }),
|
||||
button({ label: '确定', onClick: () => { modalOpen(false); toast.success('已确认'); } }),
|
||||
),
|
||||
},
|
||||
h('p', null, '这是一个由信号驱动的弹窗组件(size: lg + footer 操作区)。'),
|
||||
h('div', { class: 'row' }, badge({ label: 'modal', variant: 'success' }), badge({ label: 'footer', variant: 'default' })),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-stepper',
|
||||
'步骤条',
|
||||
'stepper:done/current/upcoming 三态随 active 信号流转,clickable 时点已完成步骤回跳。',
|
||||
stepper({
|
||||
steps: [{ label: '确认订单', description: '收货信息与商品清单' }, { label: '支付', description: '选择支付方式' }, { label: '完成', description: '等待发货' }],
|
||||
active: stepIdx,
|
||||
clickable: true,
|
||||
onChange: (i) => toast.info(`回到第 ${i + 1} 步`),
|
||||
}),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
button({ label: '上一步', variant: 'ghost', disabled: computed(() => stepIdx() === 0), onClick: () => stepIdx(Math.max(0, stepIdx() - 1)) }),
|
||||
button({ label: '下一步', disabled: computed(() => stepIdx() === 2), onClick: () => stepIdx(Math.min(2, stepIdx() + 1)) }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-rate',
|
||||
'评分',
|
||||
'rate:点选星标打分,悬停预览移出还原,键盘 →/↑ 加分 ←/↓ 减分,disabled 只读展示。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
rate({ value: score, onChange: (v) => toast.info(`评分 ${v} 星`) }),
|
||||
h('span', { class: 'out' }, () => `当前:${score() || '未评分'}`),
|
||||
),
|
||||
h('div', { class: 'row' }, rate({ value: score, disabled: true })),
|
||||
),
|
||||
section(
|
||||
'sec-empty',
|
||||
'空状态',
|
||||
'empty:无数据 / 无结果的统一占位,action 放引导按钮,点击按钮切回有数据态可对比。',
|
||||
h('div', { class: 'row' }, h('span', { class: 'out' }, () => (noResult() ? '当前:无结果' : '当前:有数据'))),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
button({ label: '切换数据态', onClick: () => noResult(!noResult()) }),
|
||||
),
|
||||
() => (noResult()
|
||||
? empty({ title: '暂无匹配结果', description: '试试放宽筛选条件,或清空关键词重新搜索。', action: button({ label: '重置筛选', variant: 'ghost', onClick: () => noResult(false) }) })
|
||||
: card({ title: '搜索结果' }, h('p', null, '这里展示命中的数据列表。'))),
|
||||
),
|
||||
section(
|
||||
'sec-divider',
|
||||
'分隔线',
|
||||
'divider:水平线可带居中文案,dashed 切虚线;vertical 行内竖线用于工具栏。',
|
||||
divider({ label: '更多信息' }),
|
||||
h('p', null, '分割线上下的内容各自成组。'),
|
||||
divider({ dashed: true }),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
button({ label: '保存', variant: 'ghost' }),
|
||||
divider({ vertical: true }),
|
||||
button({ label: '另存为', variant: 'ghost' }),
|
||||
divider({ vertical: true, dashed: true }),
|
||||
button({ label: '导出', variant: 'ghost' }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-menu',
|
||||
'下拉菜单',
|
||||
'menu 触发器 + 自绘弹层:条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部或 Esc 收起。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
menu({
|
||||
label: '文件操作',
|
||||
items: [
|
||||
{ value: 'rename', label: '重命名', shortcut: 'F2' },
|
||||
{ value: 'copy', label: '创建副本', shortcut: '⌘D' },
|
||||
{ value: 'archive', label: '归档', disabled: true },
|
||||
{ divider: true, value: '', label: '' },
|
||||
{ value: 'delete', label: '删除', danger: true },
|
||||
],
|
||||
onSelect: (v) => menuPick(v),
|
||||
}),
|
||||
menu({
|
||||
label: '右对齐',
|
||||
align: 'end',
|
||||
variant: 'primary',
|
||||
items: [
|
||||
{ value: 'profile', label: '个人资料' },
|
||||
{ value: 'settings', label: '偏好设置' },
|
||||
],
|
||||
onSelect: (v) => menuPick(v),
|
||||
}),
|
||||
h('span', { class: 'out' }, () => (menuPick() ? `已选:${menuPick()}` : '未选择')),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-popover',
|
||||
'气泡卡片',
|
||||
'popover:可交互弹层(tooltip 只展示,popover 里能点),点外部 / Esc 收起。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
popover(
|
||||
{
|
||||
title: '筛选条件',
|
||||
content: h(
|
||||
'div',
|
||||
null,
|
||||
h('div', { class: 'row' }, checkbox({ label: '只看我的', checked: agreed })),
|
||||
h('div', { class: 'row' }, button({ label: '应用', onClick: () => toast.success('已应用筛选') })),
|
||||
),
|
||||
},
|
||||
button({ label: '打开气泡卡片' }),
|
||||
),
|
||||
popover({ placement: 'top', trigger: 'hover', content: '悬停出场,移出收起(可放链接)' }, badge({ label: 'hover 触发' })),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-breadcrumb',
|
||||
'面包屑',
|
||||
'breadcrumb:nav + ol 语义结构,末项自动标为当前页,无 href 的条目走 onNavigate。',
|
||||
breadcrumb({
|
||||
items: [{ label: '首页', href: '#sec-counter' }, { label: '组件库' }, { label: '导航类' }, { label: '面包屑' }],
|
||||
separator: '›',
|
||||
onNavigate: (item) => toast.info(`跳转到 ${item.label}`),
|
||||
}),
|
||||
),
|
||||
section(
|
||||
'sec-accordion',
|
||||
'折叠面板',
|
||||
'accordion:标题按钮 + grid 行高过渡,默认手风琴单开,multiple 可多开,支持禁用面板。',
|
||||
accordion({
|
||||
multiple: true,
|
||||
items: [
|
||||
{ value: 'a', title: '账户设置', content: '修改昵称、头像与绑定邮箱。' },
|
||||
{ value: 'b', title: '通知偏好', content: alert({ message: '通知偏好:每日摘要已开启', variant: 'info' }) },
|
||||
{ value: 'c', title: '危险操作', content: '删除账号后数据不可恢复。' },
|
||||
{ value: 'd', title: '企业版专属(禁用)', content: '升级后可配置 SSO。', disabled: true },
|
||||
],
|
||||
}),
|
||||
),
|
||||
section(
|
||||
'sec-table',
|
||||
@@ -145,13 +499,35 @@ const app = h(
|
||||
card({ title: '用户表' }, dataTable({
|
||||
data: users,
|
||||
selectable: true,
|
||||
loading: tableLoading,
|
||||
columns: [
|
||||
{ key: 'name', header: '姓名', sortable: true },
|
||||
{ key: 'age', header: '年龄', sortable: true },
|
||||
{ key: 'city', header: '城市' },
|
||||
{ key: 'name', header: '姓名', sortable: true, width: 160 },
|
||||
{ key: 'age', header: '年龄', sortable: true, align: 'right', width: 100, format: (v) => `${v} 岁` },
|
||||
{ key: 'city', header: '城市', align: 'center', width: 'flex' },
|
||||
],
|
||||
pageSize: 5,
|
||||
})),
|
||||
selected: picked,
|
||||
}), h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
switchButton({ label: '加载态', checked: tableLoading }),
|
||||
badge({ label: '受控选中', variant: 'default' }),
|
||||
h('span', { class: 'out' }, () => `已选 ${picked().length} 行`),
|
||||
button({ label: '清空选择', variant: 'ghost', onClick: () => picked([]) }),
|
||||
)),
|
||||
card(
|
||||
{ title: '独立分页器联动(pageSize 3,共用页码信号)' },
|
||||
dataTable({
|
||||
data: users,
|
||||
columns: [
|
||||
{ key: 'name', header: '姓名' },
|
||||
{ key: 'city', header: '城市' },
|
||||
],
|
||||
pageSize: 3,
|
||||
page: tablePage,
|
||||
}),
|
||||
pagination({ page: tablePage, pageSize: 3, total: users.length, onChange: (p) => tablePage(p) }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-card',
|
||||
@@ -161,4 +537,8 @@ const app = h(
|
||||
),
|
||||
);
|
||||
|
||||
const toc = h('aside', { class: 'toc' }, h('div', { class: 'toc-title' }, '目录')); // 目录项由 buildToc 填充
|
||||
const app = h('div', { class: 'shell' }, toc, content);
|
||||
|
||||
mount('#app', () => app);
|
||||
buildToc(toc, content);
|
||||
|
||||
+540
-33
@@ -1,6 +1,6 @@
|
||||
# @ui-arch 组件库 Agent 文档(无图形版)
|
||||
|
||||
> 自动生成于 2026-10-23T06:57:51.085Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||
> 自动生成于 2026-10-23T10:14:36.972Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
||||
|
||||
## 快速上手
|
||||
@@ -38,16 +38,25 @@
|
||||
4. 调用组件:const el = textField({ value: name }),把 el 挂到你的容器或配合 h() 组合
|
||||
5. 读写状态:name() 读、name('张三') 写,界面自动同步,无需手动操作 DOM
|
||||
|
||||
### @ui-arch/cli
|
||||
|
||||
1. 装好依赖后先跑 npm run check 确认基线全绿(build → style → typecheck → smoke → index → verify)
|
||||
2. 查组件:ua list 看全量,ua info <包名> 看单包 api 与约定
|
||||
3. 改完清单跑 npm run docs:同时刷新人类文档站与 llms-full.txt(Agent 首选喂料)
|
||||
4. 提交前 npm run check:style 查注释规范,verify 查清单 ↔ 文档 ↔ 演示锚点是否一致
|
||||
5. 在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components
|
||||
|
||||
## @ui-arch/ui(skin-dom)v0.1.0
|
||||
|
||||
16 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。
|
||||
28 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。
|
||||
|
||||
### button — 按钮 Button
|
||||
|
||||
参数:
|
||||
|
||||
- label(必填):string —— 按钮文案
|
||||
- variant:'primary' | 'ghost' | 'danger' —— 视觉强度;缺省 primary
|
||||
- variant(默认 primary):'primary' | 'ghost' | 'danger' —— 视觉强度;缺省 primary
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- onClick(回调):() => void —— 点击回调
|
||||
|
||||
示例:
|
||||
@@ -70,6 +79,7 @@ button({ label: '提交', variant: 'primary', onClick: () => console.log('clicke
|
||||
|
||||
- value:Signal<string> —— 双向绑定信号:输入写回信号,信号变化写回输入框
|
||||
- placeholder:string —— 占位文案
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- onInput(回调):(value: string) => void —— 输入回调,每次按键触发
|
||||
|
||||
示例:
|
||||
@@ -86,13 +96,39 @@ const name = signal(''); const el = textField({ value: name, placeholder: '输
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-bind
|
||||
|
||||
### inputNumber — 数字输入 InputNumber
|
||||
|
||||
参数:
|
||||
|
||||
- value(必填):Signal<number> —— 当前值信号,双向绑定(按钮/键盘写回、外部赋值同步)
|
||||
- min:number —— 最小值;缺省无下限
|
||||
- max:number —— 最大值;缺省无上限
|
||||
- step(默认 1):number —— 步长;缺省 1
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- onChange(回调):(value: number) => void —— 值变化回调(已夹取到合法区间)
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
const qty = signal(1); const el = inputNumber({ value: qty, min: 1, max: 99, onChange: (v) => console.log(v) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 用 signal(1) 创建数字信号并传入 value 完成双向绑定
|
||||
2. 点击 −/+ 按钮或在输入框聚焦时按 ↑↓ 以 step 调节;直接输入在失焦或回车时解析
|
||||
3. 非法输入自动回滚为当前值;min/max 超界自动夹取,disabled 支持布尔或信号禁用
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-inputnumber
|
||||
|
||||
### textarea — 多行输入 Textarea
|
||||
|
||||
参数:
|
||||
|
||||
- value:Signal<string> —— 双向绑定信号
|
||||
- rows:number —— 初始行数;缺省 3
|
||||
- rows(默认 3):number —— 初始行数;缺省 3
|
||||
- placeholder:string —— 占位文案
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- onInput(回调):(value: string) => void —— 输入回调
|
||||
|
||||
示例:
|
||||
@@ -109,12 +145,41 @@ const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-form
|
||||
|
||||
### slider — 滑块 Slider
|
||||
|
||||
参数:
|
||||
|
||||
- value(必填):Signal<number> —— 当前值信号,双向绑定:拖动写回,外部赋值同步位置
|
||||
- min(默认 0):number —— 最小值;缺省 0
|
||||
- max(默认 100):number —— 最大值;缺省 100
|
||||
- step(默认 1):number —— 步长;缺省 1
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- showValue(默认 true):boolean —— 是否右侧显示当前值;缺省 true
|
||||
- onChange(回调):(value: number) => void —— 拖动回调
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
const vol = signal(40); slider({ value: vol, min: 0, max: 100, step: 5, onChange: (v) => console.log(v) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 创建数值信号(初值在 min/max 区间内),传入 value 完成双向绑定
|
||||
2. 用 min / max / step 限定范围与粒度,如 step: 5 只取 5 的倍数
|
||||
3. 拖动或按 ←→ 键改变值:原生 range 自带键盘与触屏交互,无需额外处理
|
||||
4. 只想看拖动条不显示数字时传 showValue: false;需要自定义展示可自行读 signal
|
||||
5. 禁用场景传 disabled(布尔或信号),置灰且不可拖
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-slider
|
||||
|
||||
### checkbox — 复选框 Checkbox
|
||||
|
||||
参数:
|
||||
|
||||
- checked:Signal<boolean> —— 勾选状态信号,双向绑定
|
||||
- label:string —— 右侧文案;缺省只渲染勾选框
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- onChange(回调):(checked: boolean) => void —— 变更回调
|
||||
|
||||
示例:
|
||||
@@ -137,6 +202,7 @@ const agreed = signal(false); checkbox({ label: '同意条款', checked: agreed,
|
||||
|
||||
- checked:Signal<boolean> —— 开关状态信号,双向绑定
|
||||
- label:string —— 左侧文案
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- onChange(回调):(checked: boolean) => void —— 变更回调
|
||||
|
||||
示例:
|
||||
@@ -160,6 +226,7 @@ const night = signal(true); switchButton({ label: '夜间模式', checked: night
|
||||
- options(必填):OptionItem[] —— 选项列表,每项 { value, label }
|
||||
- value:Signal<string> —— 选中值信号,双向绑定
|
||||
- placeholder:string —— 未选中时触发器显示的占位文案
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步;禁用时触发器置灰且不展开弹层
|
||||
- onChange(回调):(value: string) => void —— 选中回调
|
||||
|
||||
示例:
|
||||
@@ -177,12 +244,40 @@ const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], v
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-form
|
||||
|
||||
### menu — 下拉菜单 Menu
|
||||
|
||||
参数:
|
||||
|
||||
- label(必填):string —— 触发器文案
|
||||
- items(必填):MenuItem[] —— 菜单条目:{ value, label, disabled?, danger?, divider?, shortcut? }
|
||||
- variant(默认 ghost):'primary' | 'ghost' —— 触发器视觉强度;缺省 ghost
|
||||
- align(默认 start):'start' | 'end' —— 弹层对齐方向;缺省 start(与触发器左对齐)
|
||||
- onSelect(回调):(value: string) => void —— 选中回调,禁用项不触发
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
menu({ label: '操作', items: [{ value: 'edit', label: '编辑' }, { value: 'del', label: '删除', danger: true }], onSelect: (v) => console.log(v) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 准备 items 数组:value 是回调标识、label 是展示文案,插一条 { divider: true } 即可分出条目组
|
||||
2. 传 label 决定触发器文案;默认 ghost 轻量样式,主操作用 variant: 'primary'
|
||||
3. 点触发器展开弹层,点条目收起并触发 onSelect;点外部或按 Esc 也会收起(Esc 后焦点回到触发器)
|
||||
4. 条目要给快捷键提示时传 shortcut(如 '⌘K'):右侧灰字展示,组件不绑定按键,实际快捷键由调用方在全局监听
|
||||
5. 键盘:Tab 聚焦触发器,↑↓ 展开并在可用条目间环形移动,Enter / Space 选中;disabled 项自动跳过
|
||||
6. 靠右对齐避免溢出时传 align: 'end';危险操作给条目加 danger: true
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-menu
|
||||
|
||||
### radioGroup — 单选组 RadioGroup
|
||||
|
||||
参数:
|
||||
|
||||
- options(必填):OptionItem[] —— 选项列表
|
||||
- value:Signal<string> —— 选中值信号,双向绑定
|
||||
- disabled:boolean | Signal<boolean> —— 整组禁用:全部选项与文案置灰且不可选
|
||||
- onChange(回调):(value: string) => void —— 变更回调
|
||||
|
||||
示例:
|
||||
@@ -196,9 +291,58 @@ const size = signal('m'); radioGroup({ options: [{ value: 's', label: '小' }, {
|
||||
1. 创建字符串信号,初值设为默认选项的 value
|
||||
2. 传入 options 与 value,组内自动互斥
|
||||
3. 读取 size() 获取当前选中值
|
||||
4. 需要整组禁用时传 disabled:布尔值静态禁用,也可传信号(如 locked)实现动态锁定
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-form
|
||||
|
||||
### accordion — 折叠面板 Accordion
|
||||
|
||||
参数:
|
||||
|
||||
- items(必填):AccordionItem[] —— 面板列表:{ value, title, content, disabled? }
|
||||
- active:Signal<string> —— 受控展开面板信号(单开模式);缺省由组件内部持有状态
|
||||
- multiple(默认 false):boolean —— 允许多个面板同时展开;缺省 false(手风琴,单开)
|
||||
- onChange(回调):(active: string) => void —— 展开变化回调:收起时传空串
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
accordion({ items: [{ value: 'a', title: '账户设置', content: '内容…' }, { value: 'b', title: '通知', content: h('p', null, '…') }] })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 准备 items:value 作标识、title 作标题、content 可为字符串 / DOM 节点 / 响应式函数
|
||||
2. 默认手风琴模式:展开一个自动收起其他;需要同时展开多个时传 multiple: true
|
||||
3. 需要外部控制或读取展开项时传 active 信号(受控单开),否则组件内部自持状态
|
||||
4. 标题是原生 button:Tab 聚焦、Enter / Space 切换,展开态带 aria-expanded + region 关联
|
||||
5. 内容区用 grid 行高 0fr → 1fr 过渡,无需测量高度;reduced-motion 用户自动静态切换
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-accordion
|
||||
|
||||
### breadcrumb — 面包屑 Breadcrumb
|
||||
|
||||
参数:
|
||||
|
||||
- items(必填):BreadcrumbItem[] —— 路径条目:{ label, href? },末项自动视为当前页
|
||||
- separator(默认 /):string —— 分隔符;缺省 '/'
|
||||
- onNavigate(回调):(item, index) => void —— 无 href 条目的点击回调
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
breadcrumb({ items: [{ label: '首页', href: '/' }, { label: '设置' }, { label: '账户' }], onNavigate: (it) => console.log(it.label) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 按层级顺序准备 items,最后一项即当前页,自动渲染为不可点的灰色文字并带 aria-current='page'
|
||||
2. 需要真实跳转就给条目加 href(渲染 <a>);单页应用想自行接管时不给 href,改用 onNavigate 回调
|
||||
3. 改分隔符传 separator,如 '›';分隔符对读屏隐藏
|
||||
4. 语义结构为 nav + ol + li,可直接放进页头或卡片标题栏
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-breadcrumb
|
||||
|
||||
### card — 卡片 Card
|
||||
|
||||
参数:
|
||||
@@ -225,7 +369,7 @@ card({ title: '用户表' }, dataTable({ data, columns }))
|
||||
参数:
|
||||
|
||||
- label(必填):string —— 徽标文案
|
||||
- variant:'default' | 'success' | 'warning' | 'danger' —— 语义色;缺省 default
|
||||
- variant(默认 default):'default' | 'success' | 'warning' | 'danger' —— 语义色;缺省 default
|
||||
|
||||
示例:
|
||||
|
||||
@@ -237,15 +381,68 @@ badge({ label: '成功', variant: 'success' })
|
||||
|
||||
1. 选择语义色:default 中性、success 成功、warning 警告、danger 危险
|
||||
2. 传入 label 即可,纯展示组件无状态
|
||||
3. 状态语义靠 variant 承载,不要自造颜色;纯展示不可交互,需要操作请配 button
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-feedback
|
||||
|
||||
### tag — 标签 Tag
|
||||
|
||||
参数:
|
||||
|
||||
- label(必填):string —— 标签文案
|
||||
- variant(默认 default):'default' | 'success' | 'warning' | 'danger' | 'info' —— 语义色;缺省 default
|
||||
- closable(默认 false):boolean —— 显示 × 关闭按钮;缺省 false(纯展示)
|
||||
- onClose(回调):() => void —— 关闭回调:调用方从数据源移除该项后重新渲染
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
tag({ label: '前端', variant: 'info', closable: true, onClose: () => remove('前端') })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 纯展示用 badge,需要可移除的标签(筛选项 / 已选条件)用 tag
|
||||
2. 按语义选 variant:info 中性标记、success 通过、warning 待处理、danger 已失效
|
||||
3. closable: true 时必须配 onClose:把数据源里的该项删掉再重渲染(组件无状态,不自行摘除节点)
|
||||
4. 组合用法:容器里用 renderList 按信号数组渲染 tag 列表,删除即写回信号
|
||||
5. 标签是内联元素,可直接排进 .row 等容器,自动换行由容器决定
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-tag
|
||||
|
||||
### avatar — 头像 Avatar
|
||||
|
||||
参数:
|
||||
|
||||
- name(必填):string —— 名称:生成占位首字,并作为图片 alt 与悬停标题
|
||||
- src:string —— 图片地址;缺省用首字占位
|
||||
- size(默认 32):number —— 直径像素;缺省 32
|
||||
- variant(默认 default):'default' | 'success' | 'warning' | 'danger' —— 占位底色语义档;缺省 default
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
avatar({ name: '张三' }); avatar({ name: 'Alice', size: 40, variant: 'success' })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 只传 name:用名称首字做占位(中文取首字、英文大写),底色走 accent 淡化,暗色自动适配
|
||||
2. 有图片就传 src:渲染 img 并用 name 作 alt,读屏语义完整;图片缺失时由浏览器 alt 兜底
|
||||
3. 尺寸用 size(像素)控制,头像不参与布局拉伸(flex:none),可安全排进 .row 与表格单元格
|
||||
4. 需要区分类别(在线/告警)时用 variant 换底色档,颜色一律来自令牌
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-avatar
|
||||
|
||||
### alert — 提示条 Alert
|
||||
|
||||
参数:
|
||||
|
||||
- message(必填):string —— 提示内容
|
||||
- variant:'info' | 'success' | 'warning' | 'danger' —— 语义色;缺省 info
|
||||
- title:string —— 加粗标题行;缺省只渲染正文
|
||||
- variant(默认 info):'info' | 'success' | 'warning' | 'danger' —— 语义色;缺省 info
|
||||
- closable(默认 false):boolean —— 显示 × 关闭按钮,退场后从 DOM 移除;缺省 false
|
||||
- onClose(回调):() => void —— 关闭回调,供调用方同步业务状态
|
||||
|
||||
示例:
|
||||
|
||||
@@ -257,14 +454,118 @@ alert({ message: '操作可能存在风险', variant: 'warning' })
|
||||
|
||||
1. 按信息级别选 variant:info 提示、success 成功、warning 警告、danger 错误
|
||||
2. 传入 message 即可,入场自带下滑动画
|
||||
3. 需要可关闭或带操作按钮时,外层用 h() 组合 button 自行拼装,alert 只负责语义底色
|
||||
4. 需要用户可自行消除的提示传 closable: true:点 × 淡出并从 DOM 移除,配合 onClose 同步业务状态
|
||||
5. 有标题与正文两层信息时传 title(加粗首行),正文仍走 message
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-feedback
|
||||
|
||||
### skeleton — 骨架屏 Skeleton
|
||||
|
||||
参数:
|
||||
|
||||
- variant(默认 rect):'text' | 'rect' | 'circle' —— 占位形状;缺省 rect(整块矩形)
|
||||
- rows(默认 3):number —— text 形状的行数;缺省 3,末行自动缩短模拟段落
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
skeleton({ variant: 'text', rows: 3 }); skeleton({ variant: 'circle' }); skeleton({ variant: 'rect' })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 数据加载期间用 skeleton 顶替真实内容,加载完成后整体替换
|
||||
2. 文本段落用 variant: 'text' + rows 控制行数,头像用 'circle',卡片/图片区域用 'rect'
|
||||
3. 占位块自带扫光动画,reduced-motion 用户自动静态化为纯灰块
|
||||
4. 组合布局:skeleton 是普通 DOM,可放进 card / row 等任意容器
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-skeleton
|
||||
|
||||
### toast — 消息提示 Toast
|
||||
|
||||
参数:
|
||||
|
||||
- message(必填):string —— 提示文案
|
||||
- duration(默认 3000):number —— 自动消失毫秒;缺省 3000
|
||||
- closable(默认 false):boolean —— 显示关闭按钮,点 × 立即收起;缺省 false(仅可关闭的 toast 接收指针事件,其余保持穿透)
|
||||
- variant(默认 info):'info' | 'success' | 'warning' | 'danger' —— 语义色;缺省 info(toast.error 对应 danger)
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
toast.success('保存成功'); toast.error('网络请求失败', 5000)
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 直接调用语义工厂:toast.info / success / warning / error(message, duration?),无需先建容器
|
||||
2. 不传 duration 时默认停留 3 秒后自动淡出并移除节点
|
||||
3. 多条消息在右上角自动纵向堆叠,互不遮挡
|
||||
4. 需要完整入参(含 variant)时用 showToast(props);组件挂载容器由内部懒创建,样式随 ui.css 一起引入
|
||||
5. 同屏最多 4 条,超出自动挤掉最早一条;需要用户主动确认的消息用 showToast({ closable: true }) 给个 ×
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-toast
|
||||
|
||||
### tooltip — 悬浮提示 Tooltip
|
||||
|
||||
参数:
|
||||
|
||||
- content(必填):string —— 提示文案(纯文本)
|
||||
- placement(默认 top):'top' | 'bottom' —— 气泡方位;缺省 top(触发器上方)
|
||||
- delay(默认 150):number —— 出场延迟毫秒;缺省 150,传 0 立即显示
|
||||
- trigger(必填):HTMLElement —— 第二个参数:被包裹的触发器元素(按钮 / 输入框等任意 DOM)
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
tooltip({ content: '保存后立即生效' }, button({ label: '保存', onClick: save }))
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 先创建触发器元素,如 const btn = button({ label: '保存' })
|
||||
2. 用 tooltip(props, trigger) 包一层:返回带气泡的包裹元素,原触发器仍是独立节点
|
||||
3. 鼠标移入或键盘聚焦约 150ms 后出场,移出 / 失焦立即收起;按 Esc 可立刻关闭
|
||||
4. 气泡默认在上方,空间不足或需要避让时传 placement: 'bottom'
|
||||
5. 气泡 pointer-events: none 不抢指针;判读依赖 aria-describedby + role='tooltip',读屏可读
|
||||
6. 只做展示:不要在气泡里放可点击元素,需要交互请用 modal 或自绘弹层
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-tooltip
|
||||
|
||||
### popover — 气泡卡片 Popover
|
||||
|
||||
参数:
|
||||
|
||||
- content(必填):unknown —— 弹层内容:字符串 / DOM 节点 / 响应式函数,可放按钮等交互元素
|
||||
- title:string —— 弹层标题;缺省不渲染标题行
|
||||
- placement(默认 bottom):'top' | 'bottom' —— 弹层方位;缺省 bottom(触发器下方)
|
||||
- trigger(默认 click):'click' | 'hover' —— 触发方式;缺省 click
|
||||
- delay(默认 150):number —— hover 模式出场延迟毫秒;缺省 150
|
||||
- onOpenChange(回调):(open: boolean) => void —— 开合回调
|
||||
- triggerEl(第二个参数)(必填):HTMLElement —— 第二个参数:被包裹的触发器元素
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
popover({ title: '筛选条件', content: h('div', null, '任意可交互内容') }, button({ label: '筛选' }))
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 创建触发器元素,再用 popover(props, trigger) 包一层:返回带弹层的包裹元素
|
||||
2. 点击触发器开合;点弹层外部或按 Esc 收起(Esc 后焦点回触发器)
|
||||
3. content 里可以放 button / checkbox 等交互元素(tooltip 只能纯文本),点击弹层内部不会误关
|
||||
4. hover 场景传 trigger: 'hover' + delay 控制多快出场;placement: 'top' 让弹层往上翻
|
||||
5. 需要感知开合做副作用(如懒加载数据)时用 onOpenChange
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-popover
|
||||
|
||||
### spinner — 加载指示器 Spinner
|
||||
|
||||
参数:
|
||||
|
||||
- size:number —— 直径像素;缺省 18
|
||||
- size(默认 18):number —— 直径像素;缺省 18
|
||||
|
||||
示例:
|
||||
|
||||
@@ -276,6 +577,7 @@ spinner(); spinner({ size: 26 })
|
||||
|
||||
1. 数据加载时插入 spinner(),加载完成移除
|
||||
2. 用 size 匹配上下文尺寸(按钮内用小号,整页 loading 用大号)
|
||||
3. 与 skeleton 分工:已知内容结构用 skeleton 占位,只有『在跑』的短等待用 spinner
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-feedback
|
||||
|
||||
@@ -283,8 +585,8 @@ spinner(); spinner({ size: 26 })
|
||||
|
||||
参数:
|
||||
|
||||
- value(必填):Signal<number> —— 进度 0-100 信号,变化自动平滑过渡
|
||||
- showText:boolean —— 是否显示百分比文字;缺省 true
|
||||
- value:Signal<number> —— 当前进度 0-100 信号,变化自动平滑过渡(越界钳制)
|
||||
- showText(默认 true):boolean —— 是否显示百分比文字;缺省 true
|
||||
|
||||
示例:
|
||||
|
||||
@@ -294,10 +596,12 @@ const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() +
|
||||
|
||||
手把手:
|
||||
|
||||
1. 创建数值信号 pct = signal(0),范围 0-100(越界自动钳制)
|
||||
2. 传入 value,填充宽度与百分比文字随信号平滑过渡
|
||||
3. 进行中填充带流动条纹,到 100% 自动变 success 色并停止条纹
|
||||
4. 不需要百分比文字时传 showText: false
|
||||
1. 时长未知的加载直接 progress() 不传 value:填充块匀速往复,不显示百分比,读屏读作进行中
|
||||
2. 创建数值信号 pct = signal(0),范围 0-100(越界自动钳制)
|
||||
3. 传入 value,填充宽度与百分比文字随信号平滑过渡
|
||||
4. 进行中填充带流动条纹,到 100% 自动变 success 色并停止条纹
|
||||
5. 不需要百分比文字时传 showText: false
|
||||
6. 定量进度用 value 信号:满格自动切 success 色并停止条纹,role=progressbar + aria-valuenow 同步
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-feedback
|
||||
|
||||
@@ -324,12 +628,129 @@ const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览' }, { value:
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-tabs-modal
|
||||
|
||||
### stepper — 步骤条 Stepper
|
||||
|
||||
参数:
|
||||
|
||||
- steps(必填):StepperItem[] —— 步骤列表,每项 { label, description? }
|
||||
- active(必填):Signal<number> —— 当前步索引(0 基)信号,双向绑定:回跳写回、外部赋值同步
|
||||
- clickable:boolean —— 已完成步骤是否可点击回跳;缺省 false
|
||||
- onChange(回调):(index: number) => void —— 步骤切换回调(点击回跳触发)
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
const step = signal(1); stepper({ steps: [{ label: '下单' }, { label: '支付' }, { label: '完成' }], active: step, clickable: true })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 用 signal(0) 创建当前步索引,传入 active;索引之前的步骤显示 ✓ 已完成态
|
||||
2. 流程推进时外部赋值即可:step(step() + 1),组件三态自动跟随
|
||||
3. clickable: true 时已完成步骤可点击回跳并写回信号,配合 onChange 同步业务状态
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-stepper
|
||||
|
||||
### rate — 评分 Rate
|
||||
|
||||
参数:
|
||||
|
||||
- value(必填):Signal<number> —— 当前评分(1 基整数值)信号,双向绑定:点选/键盘写回、外部赋值同步
|
||||
- max(默认 5):number —— 星标总数;缺省 5
|
||||
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
|
||||
- onChange(回调):(value: number) => void —— 评分变化回调
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
const score = signal(0); rate({ value: score, max: 5, onChange: (v) => console.log(v) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 用 signal(0) 创建评分信号并传入 value;0 表示未评分
|
||||
2. 点击星标或聚焦后按 →/↑ 加一分、←/↓ 减一分;悬停高亮预览、移出还原
|
||||
3. max 可调整星标总数;disabled 支持布尔或信号禁用
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-rate
|
||||
|
||||
### empty — 空状态 Empty
|
||||
|
||||
参数:
|
||||
|
||||
- title:string —— 主标题;缺省不渲染
|
||||
- description:string —— 补充说明;缺省不渲染
|
||||
- action:unknown —— 操作区内容(常见为按钮);缺省不渲染
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
empty({ title: '暂无数据', description: '调整筛选条件后再试', action: button({ label: '重置筛选', variant: 'ghost' }) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 在列表 / 表格 / 筛选结果为空时渲染 empty 占位
|
||||
2. title 一句话说明状态,description 补充原因或建议
|
||||
3. action 传入 DOM(如 ghost 按钮)引导用户下一步操作
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-empty
|
||||
|
||||
### divider — 分隔线 Divider
|
||||
|
||||
参数:
|
||||
|
||||
- label:string —— 居中文案标签;缺省纯线条
|
||||
- vertical:boolean —— 是否垂直分隔线(用于行内工具栏);缺省水平
|
||||
- dashed:boolean —— 是否虚线;缺省实线
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
divider({ label: '更多信息' }) / divider({ vertical: true })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 在区块之间插入 divider 划分内容层次
|
||||
2. 传 label 在水平线上显示居中文案,如「更多信息」
|
||||
3. 工具栏内用 vertical: true 的行内竖线,dashed: true 切虚线弱化分隔感
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-divider
|
||||
|
||||
### pagination — 分页器 Pagination
|
||||
|
||||
参数:
|
||||
|
||||
- page(必填):Signal<number> —— 当前页码信号,双向绑定:外部赋值自动翻页,点击页码写回信号
|
||||
- pageSize(必填):number —— 每页条数
|
||||
- total(必填):number —— 总条数
|
||||
- onChange(回调):(page: number) => void —— 翻页回调
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
const page = signal(1); pagination({ page, pageSize: 3, total: 24, onChange: (p) => console.log(p) })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 创建页码信号 page = signal(1),与 pageSize / total 一起传入
|
||||
2. 页码 ≤7 全部展开;更多时只保留首尾与当前页 ±1,缺口折叠为省略号
|
||||
3. 首末页自动禁用 ‹ › 翻页按钮,当前页禁点并标记 aria-current
|
||||
4. 与 dataTable 联动:把同一个 page 信号传给 dataTable 的 page 参数,两处翻页自动同步
|
||||
5. 键盘可达:全部是原生 button,Tab 聚焦后 Enter / Space 触发
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-table
|
||||
|
||||
### modal — 弹窗 Modal
|
||||
|
||||
参数:
|
||||
|
||||
- open(必填):Signal<boolean> —— 开关信号:true 显示 / false 隐藏
|
||||
- title(必填):string —— 标题
|
||||
- size(默认 md):'sm' | 'md' | 'lg' —— 宽度档位:sm 340px / md 420px / lg 640px;缺省 md
|
||||
- footer:unknown —— 底部操作区内容(常见为按钮组,右对齐);缺省不渲染
|
||||
- onClose(回调):() => void —— 关闭回调(点遮罩、× 或 Esc 触发)
|
||||
- ...children:unknown[] —— 弹窗内容子节点
|
||||
|
||||
@@ -345,14 +766,43 @@ const open = signal(false); modal({ title: '确认操作', open, onClose: () =>
|
||||
2. 把 open 传给 modal,其余参数为弹窗内容子节点
|
||||
3. 打开:open(true);关闭:open(false),点遮罩或 × 会自动写回 false
|
||||
4. 入场自带遮罩淡入 + 弹窗上浮动画
|
||||
5. 底部操作按钮放进 footer(如取消/确定),需要更宽内容用 size: 'lg',窄确认框用 'sm'
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-tabs-modal
|
||||
|
||||
### drawer — 抽屉 Drawer
|
||||
|
||||
参数:
|
||||
|
||||
- title(必填):string —— 标题
|
||||
- open(必填):Signal<boolean> —— 开关信号:true 滑出 / false 收起
|
||||
- placement(默认 right):'left' | 'right' —— 滑出方向;缺省 right
|
||||
- width(默认 320):number —— 面板宽度像素(窄屏最多 92vw);缺省 320
|
||||
- footer:unknown —— 底部操作区内容;缺省不渲染
|
||||
- onClose(回调):() => void —— 关闭回调(点遮罩 / × / Esc 触发)
|
||||
- ...children:unknown[] —— 面板内容子节点
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
const open = signal(false); drawer({ title: '详情', open, width: 360, onClose: () => open(false) }, h('p', null, '内容'))
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 创建布尔信号 open = signal(false),传给 drawer;其余参数为面板内容子节点
|
||||
2. 打开后从侧边滑出:placement: 'right'(默认)或 'left',width 控制宽度(窄屏自动收到 92vw)
|
||||
3. 点遮罩、× 或按 Esc 关闭并写回 false;打开时焦点落在面板首个可交互元素
|
||||
4. 底部操作按钮放进 footer;与 modal 的分工:modal 是强打断的确认,drawer 保留主视图上下文做详情/编辑
|
||||
5. 面板内容与 modal 一样接受任意 DOM 与响应式函数
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-drawer
|
||||
|
||||
### form — 表单 Form / 表单字段 FormItem
|
||||
|
||||
参数:
|
||||
|
||||
- layout:'vertical' | 'horizontal' —— 布局方向;缺省 vertical(horizontal 为标签左置)
|
||||
- layout(默认 vertical):'vertical' | 'horizontal' —— 布局方向;缺省 vertical(horizontal 为标签左置)
|
||||
- submitText:string —— 提交按钮文案;缺省「提交」
|
||||
- resetText:string —— 重置按钮文案;缺省不渲染
|
||||
- onSubmit(回调):() => void —— 全部字段校验通过后的提交回调
|
||||
@@ -360,7 +810,7 @@ const open = signal(false); modal({ title: '确认操作', open, onClose: () =>
|
||||
- (formItem)value(必填):Signal<string> —— 字段值信号,校验数据源
|
||||
- (formItem)required:boolean —— 必填:显示红星并自动追加非空规则
|
||||
- (formItem)rules:FormRule[] —— 校验规则:required / min / max / pattern / validator,按序执行取首个失败
|
||||
- (formItem)validateOn:'input' | 'blur' | 'submit' —— 校验时机;缺省 blur(输入时自动清已报错误)
|
||||
- (formItem)validateOn(默认 blur):'input' | 'blur' | 'submit' —— 校验时机;缺省 blur(输入时自动清已报错误)
|
||||
|
||||
示例:
|
||||
|
||||
@@ -376,6 +826,8 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label:
|
||||
4. 提交时逐项校验:全过触发 onSubmit;有错则标红控件、显示错误文案并聚焦首个出错项
|
||||
5. 规则语义:required 非空(trim)、min/max 长度、pattern 正则、validator 自定义(true 通过 / 字符串为错误)
|
||||
6. 非必填字段为空时跳过其余规则;Enter 键触发表单原生 submit(已拦截走校验流程)
|
||||
7. 错误态覆盖包裹式控件:标红作用于控件根与内部原生控件(含自绘 select 的触发器),并同步 aria-invalid,读屏与视觉一致
|
||||
8. 触发时机用 input / change / focusout(均会冒泡),因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-formlab
|
||||
|
||||
@@ -384,10 +836,13 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label:
|
||||
参数:
|
||||
|
||||
- data(必填):T[] —— 全量行数据,每行须含 id 字段作 rowKey
|
||||
- columns(必填):ColumnDef<T>[] —— 列定义:{ key, header?, sortable? }
|
||||
- columns(必填):ColumnDef<T>[] —— 列定义:内核字段 { key, header?, sortable?, width? } + 皮肤字段 { align?, format? };width 数字为固定像素,'flex' 自适应
|
||||
- filterKey:string —— 工具栏筛选的列;缺省取第一列
|
||||
- pageSize:number —— 每页行数;缺省 5
|
||||
- selectable:boolean —— 启用行选择列:全选三态(含半选)、行 checkbox、已选计数;缺省 false
|
||||
- pageSize(默认 5):number —— 每页行数;缺省 5
|
||||
- selectable(默认 false):boolean —— 启用行选择列:全选三态(含半选)、行 checkbox、已选计数;缺省 false
|
||||
- page:Signal<number> —— 受控页码信号:与内核分页双向同步,传同一个信号给独立 pagination 组件即可联动
|
||||
- loading:boolean | Signal<boolean> —— 加载态:为真时数据行换成骨架行(行数随 pageSize),筛选输入与翻页按钮临时禁用,容器带 aria-busy
|
||||
- selected:Signal<RowKey[]> —— 受控选中行信号:与内核选择双向同步(内容比较,不来回派发),供批量操作读取/改写
|
||||
|
||||
示例:
|
||||
|
||||
@@ -399,32 +854,54 @@ dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓
|
||||
|
||||
1. 准备行数据,确保每行有唯一 id(跨页选择与刷新不丢行靠它)
|
||||
2. 定义 columns:sortable: true 的列表头可点击排序(升 → 降 → 取消循环),标记箭头 ↑↓↕
|
||||
3. selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数
|
||||
4. 选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失
|
||||
5. 工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行
|
||||
6. 表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供
|
||||
3. 列展示细节用皮肤字段:align 控制表头与单元格对齐,format(value, row) 返回纯文本(走 textContent,不需要转义)
|
||||
4. 列宽用内核字段 width:数字(像素)固定该列,'flex' 或不设则按内容自适应
|
||||
5. selectable: true 开启行选择:表头全选框带 indeterminate 半选态,工具栏显示已选计数
|
||||
6. 选中行用低视觉比重样式(淡色底 + 弱化文字),选择状态存 rowKey,跨页不丢失
|
||||
7. 工具栏输入框即筛选,输入即时过滤并回第 1 页;无匹配时显示空状态行
|
||||
8. 表头粘性固定(滚动容器 max-height 420px),斑马纹 + 悬停高亮由 CSS 提供
|
||||
9. 数据在途时传 loading(布尔或信号):表格体换成骨架行、筛选与翻页禁用,返回后置回 false 即恢复
|
||||
10. 需要独立分页器联动时传 page 信号:const page = signal(1),dataTable({ page }) 与 pagination({ page }) 共用
|
||||
11. 需要批量操作时传 selected 信号:内部勾选实时写回,外部置空或赋值即同步整表(跨页保留,按 rowKey 存)
|
||||
|
||||
现场演示: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 信号双向绑定
|
||||
- button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用
|
||||
- textField(props):文本输入,value 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- inputNumber(props):数字输入,−/+ 按钮 + 键盘 ↑↓ 调节,value 信号双向绑定,min/max/step 超界自动夹取
|
||||
- textarea(props):多行输入,value 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- slider(props):滑块,value 信号双向绑定,min/max/step 可配,原生 range 承载键盘交互,右侧可显示当前值
|
||||
- checkbox(props):复选框,checked 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- switchButton(props):开关,checked 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画,disabled 支持布尔或信号禁用
|
||||
- menu(props):下拉菜单,触发器 + 自绘弹层,条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部 / Esc 关闭
|
||||
- radioGroup(props):单选组,options + value 信号双向绑定,disabled 整组禁用
|
||||
- accordion(props):折叠面板,标题按钮 + grid 行高过渡,单开(手风琴)或多开,支持禁用面板与 active 信号受控
|
||||
- breadcrumb(props):面包屑,nav + ol 语义结构,末项为当前页(aria-current),有 href 走原生链接、无 href 交给 onNavigate
|
||||
- card(props, ...children):卡片容器
|
||||
- badge(props):徽标,variant = default | success | warning | danger
|
||||
- alert(props):提示条,variant = info | success | warning | danger
|
||||
- tag(props):可关闭标签,variant 五档语义色,closable + onClose 支持从数据源移除(纯展示请用 badge)
|
||||
- avatar(props):头像,有 src 渲染图片(alt 用名称)、无 src 用首字占位,size 可调 + 四档语义底色
|
||||
- alert(props):提示条,variant 四档语义色 + 可选 title 与 closable 关闭按钮,role=alert 读屏可播报
|
||||
- toast.info/success/warning/error(message, duration?):消息提示工厂,右上角堆叠,默认 3s 自动消失,出入场微动画;showToast(props) 为完整入参版本(closable 显示关闭按钮,同屏最多 4 条)
|
||||
- tooltip(props, trigger):悬浮提示,包裹触发器,hover / focus 延迟 150ms 出场,移出 / 失焦 / Esc 收起
|
||||
- popover(props, trigger):可交互气泡卡片,包裹触发器,默认点击开合 + 点外部 / Esc 收起,content 可放按钮等交互元素
|
||||
- skeleton(props):骨架屏占位,variant = text | rect | circle,text 支持 rows 多行,扫光微动画
|
||||
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,键盘可达
|
||||
- spinner(props):加载指示器,size 控制直径
|
||||
- progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色
|
||||
- progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
|
||||
- modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭
|
||||
- stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳
|
||||
- rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配
|
||||
- empty(props):空状态占位,title/description 可选,action 放操作区
|
||||
- divider(props):分隔线,水平/垂直两种方向,可带居中文案标签,dashed 切虚线
|
||||
- modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭,size 三档宽度,footer 提供底部操作区
|
||||
- drawer(props, ...children):抽屉,open 信号驱动侧滑面板,placement 左右可配、width 可调、footer 操作区,遮罩 / × / Esc 关闭
|
||||
- form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit
|
||||
- formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄
|
||||
- dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core
|
||||
- dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态;loading 为真时数据行换骨架行且筛选/翻页禁用,内核为 table-core
|
||||
|
||||
一行用法:
|
||||
|
||||
@@ -437,6 +914,7 @@ const checked = signal(false); checkbox({ label: '同意', checked }); const ope
|
||||
- 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
|
||||
- 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
|
||||
- 语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色
|
||||
- 无障碍基线:装饰性占位(skeleton)aria-hidden、加载指示(spinner)role=status、关闭按钮必须 aria-label、弹层弹窗 aria-haspopup/expanded、读屏可见的状态一律同步 aria-*
|
||||
|
||||
---
|
||||
|
||||
@@ -509,6 +987,35 @@ import '@ui-arch/tokens/tokens.css'; then use var(--ua-color-accent) etc. in any
|
||||
|
||||
---
|
||||
|
||||
## @ui-arch/cli(tooling)v0.1.0
|
||||
|
||||
工程层工具包(private,不发布)。所有 Agent 与人都通过它读取组件库:清单驱动索引、文档、MCP 三件套,外加 style / verify 两道门禁。
|
||||
|
||||
API 速览:
|
||||
|
||||
- ua index:扫描 packages/ 两层目录的 ua.manifest.json,生成 .ua/index.json 与 llms.txt
|
||||
- ua list:列出注册表内所有包与组件数
|
||||
- ua info <name>:查看单个包的清单详情(api / 约定 / 文档步骤)
|
||||
- ua docs:生成人类文档站 apps/docs/index.html + demo.html 与 Agent 全量文档 llms-full.txt
|
||||
- ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1
|
||||
- ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在)
|
||||
- ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1
|
||||
- ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components
|
||||
|
||||
一行用法:
|
||||
|
||||
```
|
||||
node packages/tools/cli/bin/ua.js <index|list|info <name>|docs|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- 零依赖:只用 node 标准库,Node >= 18
|
||||
- 清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生
|
||||
- 门禁只增不改:新增校验必须能被人手复现,报错要指出文件与字段
|
||||
|
||||
---
|
||||
|
||||
## 设计令牌(--ua-*)
|
||||
|
||||
- --ua-color-bg: #ffffff(暗色 #101216)
|
||||
|
||||
@@ -45,26 +45,62 @@
|
||||
## @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-* 令牌。
|
||||
- 描述: DOM 皮肤组件库:button / textField / textarea / slider / checkbox / switch / select / menu / radioGroup / accordion / breadcrumb / card / badge / tag / avatar / alert / toast / tooltip / popover / skeleton / spinner / progress / tabs / pagination / modal / drawer / 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 信号双向绑定
|
||||
- button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用
|
||||
- textField(props):文本输入,value 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- inputNumber(props):数字输入,−/+ 按钮 + 键盘 ↑↓ 调节,value 信号双向绑定,min/max/step 超界自动夹取
|
||||
- textarea(props):多行输入,value 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- slider(props):滑块,value 信号双向绑定,min/max/step 可配,原生 range 承载键盘交互,右侧可显示当前值
|
||||
- checkbox(props):复选框,checked 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- switchButton(props):开关,checked 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画,disabled 支持布尔或信号禁用
|
||||
- menu(props):下拉菜单,触发器 + 自绘弹层,条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部 / Esc 关闭
|
||||
- radioGroup(props):单选组,options + value 信号双向绑定,disabled 整组禁用
|
||||
- accordion(props):折叠面板,标题按钮 + grid 行高过渡,单开(手风琴)或多开,支持禁用面板与 active 信号受控
|
||||
- breadcrumb(props):面包屑,nav + ol 语义结构,末项为当前页(aria-current),有 href 走原生链接、无 href 交给 onNavigate
|
||||
- card(props, ...children):卡片容器
|
||||
- badge(props):徽标,variant = default | success | warning | danger
|
||||
- alert(props):提示条,variant = info | success | warning | danger
|
||||
- tag(props):可关闭标签,variant 五档语义色,closable + onClose 支持从数据源移除(纯展示请用 badge)
|
||||
- avatar(props):头像,有 src 渲染图片(alt 用名称)、无 src 用首字占位,size 可调 + 四档语义底色
|
||||
- alert(props):提示条,variant 四档语义色 + 可选 title 与 closable 关闭按钮,role=alert 读屏可播报
|
||||
- toast.info/success/warning/error(message, duration?):消息提示工厂,右上角堆叠,默认 3s 自动消失,出入场微动画;showToast(props) 为完整入参版本(closable 显示关闭按钮,同屏最多 4 条)
|
||||
- tooltip(props, trigger):悬浮提示,包裹触发器,hover / focus 延迟 150ms 出场,移出 / 失焦 / Esc 收起
|
||||
- popover(props, trigger):可交互气泡卡片,包裹触发器,默认点击开合 + 点外部 / Esc 收起,content 可放按钮等交互元素
|
||||
- skeleton(props):骨架屏占位,variant = text | rect | circle,text 支持 rows 多行,扫光微动画
|
||||
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,键盘可达
|
||||
- spinner(props):加载指示器,size 控制直径
|
||||
- progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色
|
||||
- progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
|
||||
- modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭
|
||||
- stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳
|
||||
- rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配
|
||||
- empty(props):空状态占位,title/description 可选,action 放操作区
|
||||
- divider(props):分隔线,水平/垂直两种方向,可带居中文案标签,dashed 切虚线
|
||||
- modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭,size 三档宽度,footer 提供底部操作区
|
||||
- drawer(props, ...children):抽屉,open 信号驱动侧滑面板,placement 左右可配、width 可调、footer 操作区,遮罩 / × / Esc 关闭
|
||||
- form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit
|
||||
- formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄
|
||||
- dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态,内核为 table-core
|
||||
- dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态;loading 为真时数据行换骨架行且筛选/翻页禁用,内核为 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,一律来自令牌,禁止硬编码颜色
|
||||
- 约定: 无障碍基线:装饰性占位(skeleton)aria-hidden、加载指示(spinner)role=status、关闭按钮必须 aria-label、弹层弹窗 aria-haspopup/expanded、读屏可见的状态一律同步 aria-*
|
||||
|
||||
## @ui-arch/cli (v0.1.0)
|
||||
|
||||
- 类型: tooling · 目录: packages/tools/cli
|
||||
- 描述: ua 命令行与 MCP 注册表服务:把各包 ua.manifest.json 汇总成机器可读索引(.ua/index.json)、Agent 全量文档(llms-full.txt)与人类文档站,并提供清单一致性门禁。
|
||||
- API:
|
||||
- ua index:扫描 packages/ 两层目录的 ua.manifest.json,生成 .ua/index.json 与 llms.txt
|
||||
- ua list:列出注册表内所有包与组件数
|
||||
- ua info <name>:查看单个包的清单详情(api / 约定 / 文档步骤)
|
||||
- ua docs:生成人类文档站 apps/docs/index.html + demo.html 与 Agent 全量文档 llms-full.txt
|
||||
- ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1
|
||||
- ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在)
|
||||
- ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1
|
||||
- ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components
|
||||
- 用法: `node packages/tools/cli/bin/ua.js <index|list|info <name>|docs|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify`
|
||||
- 约定: 零依赖:只用 node 标准库,Node >= 18
|
||||
- 约定: 清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生
|
||||
- 约定: 门禁只增不改:新增校验必须能被人手复现,报错要指出文件与字段
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@
|
||||
"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"
|
||||
"check": "npm run build && npm run style && npm run typecheck && npm run smoke && npm run index && npm run verify",
|
||||
"ua": "node packages/tools/cli/bin/ua.js",
|
||||
"verify": "node packages/tools/cli/bin/ua.js verify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { h, signal } from '@ui-arch/frame';
|
||||
import type { AccordionItem, AccordionProps } from './types.js';
|
||||
|
||||
/*** 折叠面板:标题按钮 + grid 行高过渡;单开(手风琴)或多开,展开态由 active 信号驱动或内部持有 ***/
|
||||
export function accordion(props: AccordionProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const internal = signal<string[]>([]); // 内部展开集合(未传 active 时使用)
|
||||
const opened = (v: string): boolean => (props.active ? props.active() === v : internal().includes(v));
|
||||
|
||||
const toggle = (item: AccordionItem): void => {
|
||||
if (props.active) {
|
||||
const next = props.active() === item.value ? '' : item.value; // 单开:再点收起
|
||||
props.active(next);
|
||||
props.onChange?.(next);
|
||||
return;
|
||||
}
|
||||
const cur = internal();
|
||||
const next = cur.includes(item.value)
|
||||
? cur.filter((v) => v !== item.value)
|
||||
: props.multiple
|
||||
? [...cur, item.value]
|
||||
: [item.value];
|
||||
internal(next);
|
||||
props.onChange?.(next[0] ?? '');
|
||||
};
|
||||
|
||||
// —— 面板:标题为原生 button(键盘可达),内容包一层 overflow 容器供过渡裁剪 ——
|
||||
const rows = props.items.map((item, i) =>
|
||||
h(
|
||||
'div',
|
||||
{ class: () => `ua-acc-item${opened(item.value) ? ' ua-open' : ''}` },
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-acc-head',
|
||||
id: `ua-acc-h-${i}`,
|
||||
'aria-expanded': () => String(opened(item.value)),
|
||||
'aria-controls': `ua-acc-b-${i}`,
|
||||
disabled: item.disabled ?? false, // 布尔透传即得禁用态
|
||||
onClick: () => toggle(item),
|
||||
},
|
||||
h('span', { class: 'ua-acc-title' }, item.title),
|
||||
h('span', { class: 'ua-acc-caret' }, '▸'),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-acc-body', id: `ua-acc-b-${i}`, role: 'region', 'aria-labelledby': `ua-acc-h-${i}` },
|
||||
h('div', { class: 'ua-acc-inner' }, item.content),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return h('div', { class: 'ua-accordion' }, ...rows);
|
||||
}
|
||||
@@ -1,7 +1,32 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AlertProps } from './types.js';
|
||||
|
||||
/*** 提示条:variant 决定语义色,纯展示不持有状态 ***/
|
||||
/*** 提示条:variant 决定语义色,可选标题与关闭按钮;关闭即退场并从 DOM 移除(内容由调用方持有) ***/
|
||||
export function alert(props: AlertProps): HTMLElement {
|
||||
return h('div', { class: `ua-alert ua-alert-${props.variant ?? 'info'}` }, props.message);
|
||||
const body = h(
|
||||
'div',
|
||||
{ class: 'ua-alert-body' },
|
||||
props.title ? h('div', { class: 'ua-alert-title' }, props.title) : null,
|
||||
h('div', { class: 'ua-alert-message' }, props.message),
|
||||
);
|
||||
const el = h('div', { class: `ua-alert ua-alert-${props.variant ?? 'info'}`, role: 'alert' }, body);
|
||||
|
||||
if (!props.closable) return el; // 纯展示形态
|
||||
el.append(
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-alert-close',
|
||||
'aria-label': '关闭提示',
|
||||
onClick: () => {
|
||||
el.classList.add('ua-alert-out'); // 先退场
|
||||
window.setTimeout(() => el.isConnected && el.remove(), 200); // 过渡结束再摘除
|
||||
props.onClose?.();
|
||||
},
|
||||
},
|
||||
'×',
|
||||
),
|
||||
);
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AvatarProps } from './types.js';
|
||||
|
||||
/*** 头像:有 src 渲染图片(alt 用名称),否则用名称首字做占位;尺寸与语义底色由 props 决定 ***/
|
||||
export function avatar(props: AvatarProps): HTMLElement {
|
||||
const size = `${props.size ?? 32}px`;
|
||||
return h(
|
||||
'span',
|
||||
{ class: `ua-avatar ua-avatar-${props.variant ?? 'default'}`, style: `width:${size};height:${size}`, title: props.name },
|
||||
props.src
|
||||
? h('img', { class: 'ua-avatar-img', src: props.src, alt: props.name })
|
||||
: h('span', { class: 'ua-avatar-text', 'aria-label': props.name }, firstChar(props.name)),
|
||||
);
|
||||
}
|
||||
|
||||
/*** 取首个可见字符(中文取首字、英文取首字母大写) ***/
|
||||
function firstChar(name: string): string {
|
||||
const c = name.trim().charAt(0);
|
||||
return c ? c.toUpperCase() : '?'; // 空名兜底问号,避免空白圆
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { BreadcrumbProps } from './types.js';
|
||||
|
||||
/*** 面包屑:nav + ol 语义结构,末项为当前页(不可点、aria-current);有 href 渲染 <a>,无 href 渲染 button 交 onNavigate ***/
|
||||
export function breadcrumb(props: BreadcrumbProps): HTMLElement {
|
||||
const items = props.items;
|
||||
const sep = props.separator ?? '/';
|
||||
|
||||
const entries = items.map((item, i) => {
|
||||
const last = i === items.length - 1; // 末项即当前页
|
||||
const node = last
|
||||
? h('span', { class: 'ua-crumb-current', 'aria-current': 'page' }, item.label)
|
||||
: item.href
|
||||
? h('a', { class: 'ua-crumb-link', href: item.href }, item.label)
|
||||
: h('button', { type: 'button', class: 'ua-crumb-link', onClick: () => props.onNavigate?.(item, i) }, item.label);
|
||||
return h('li', { class: 'ua-crumb-item' }, node, last ? null : h('span', { class: 'ua-crumb-sep', 'aria-hidden': 'true' }, sep));
|
||||
});
|
||||
|
||||
return h('nav', { class: 'ua-breadcrumb', 'aria-label': '面包屑导航' }, h('ol', { class: 'ua-crumb-list' }, ...entries));
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { ButtonProps } from './types.js';
|
||||
|
||||
/*** 按钮:variant 控制视觉强度,onClick 为点击回调 ***/
|
||||
/*** 按钮:variant 控制视觉强度,disabled 支持布尔或信号禁用,onClick 为点击回调 ***/
|
||||
export function button(props: ButtonProps): HTMLElement {
|
||||
return h(
|
||||
'button',
|
||||
{ class: `ua-btn ua-btn-${props.variant ?? 'primary'}`, onClick: props.onClick },
|
||||
{ class: `ua-btn ua-btn-${props.variant ?? 'primary'}`, disabled: props.disabled, onClick: props.onClick }, // false/undefined 移除属性,Signal 走响应式
|
||||
props.label,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { CheckboxProps } from './types.js';
|
||||
|
||||
/*** 复选框:checked 信号双向绑定,onChange 为变更回调 ***/
|
||||
/*** 复选框:checked 信号双向绑定,disabled 支持布尔或信号禁用,onChange 为变更回调 ***/
|
||||
export function checkbox(props: CheckboxProps): HTMLElement {
|
||||
const el = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
const el = h('input', { type: 'checkbox', class: 'ua-checkbox', disabled: props.disabled }) as HTMLInputElement; // false/undefined 移除属性,Signal 走响应式
|
||||
if (props.checked) {
|
||||
const checked = props.checked;
|
||||
el.addEventListener('change', () => checked(el.checked)); // 用户勾选写回信号
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
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 { skeleton } from './skeleton.js';
|
||||
import { textField } from './textField.js';
|
||||
import type { DataTableProps } from './types.js';
|
||||
|
||||
@@ -43,6 +44,35 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
return api.getPageInfo();
|
||||
});
|
||||
const selectedCount = computed(() => state().selection.length);
|
||||
// 加载态读取器:布尔直接读,信号则保持响应性(供 renderList 依赖收集)
|
||||
const busy = (): boolean => (typeof props.loading === 'function' ? props.loading() : props.loading === true);
|
||||
|
||||
// —— 受控页码桥接:外部信号 ↔ 内核分页;外部越界先钳制,signal 等值写回不触发,双向同步收敛安全 ——
|
||||
if (props.page) {
|
||||
const pageSig = props.page;
|
||||
effect(() => {
|
||||
const external = Math.min(api.getPageInfo().totalPages, Math.max(1, pageSig())); // 越界钳制,防无效派发
|
||||
if (external !== info().page) table.dispatch({ type: 'setPage', page: external }); // 外部翻页 → 内核
|
||||
});
|
||||
effect(() => {
|
||||
const internal = info().page;
|
||||
if (internal !== pageSig()) pageSig(internal); // 内部翻页(筛选/内置翻页器)→ 写回外部
|
||||
});
|
||||
}
|
||||
|
||||
// —— 受控选中桥接:外部信号 ↔ 内核选择集;比较内容而非引用,避免来回派发 ——
|
||||
if (props.selected) {
|
||||
const selSig = props.selected;
|
||||
const sameKeys = (a: RowKey[], b: RowKey[]): boolean => a.length === b.length && a.every((k, i) => k === b[i]);
|
||||
effect(() => {
|
||||
const external = selSig();
|
||||
if (!sameKeys(external, state().selection)) table.dispatch({ type: 'setSelection', keys: external }); // 外部改写 → 内核
|
||||
});
|
||||
effect(() => {
|
||||
const internal = state().selection;
|
||||
if (!sameKeys(internal, selSig())) selSig([...internal]); // 内部勾选 → 写回外部(拷贝避免外部改到内核数组)
|
||||
});
|
||||
}
|
||||
|
||||
const filterKey = props.filterKey ?? props.columns[0].key;
|
||||
const colspan = props.columns.length + (selectable ? 1 : 0);
|
||||
@@ -76,15 +106,23 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
h(
|
||||
'th',
|
||||
{
|
||||
class: () => (col.sortable ? 'ua-sortable' : ''),
|
||||
class: `${col.sortable ? 'ua-sortable' : ''}${col.align ? ` ua-align-${col.align}` : ''}`.trim(),
|
||||
onClick: () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }),
|
||||
},
|
||||
col.header ?? col.key,
|
||||
col.sortable ? sortMark(col.key) : null,
|
||||
);
|
||||
|
||||
/*** 骨架行:列宽与表头对齐,行数取当前每页条数(上限 6 行防过长) ***/
|
||||
const skeletonRows = (): Node[] => {
|
||||
const count = Math.min(props.pageSize ?? 5, 6);
|
||||
const cell = () => h('td', null, skeleton({ variant: 'text', rows: 1 }));
|
||||
return Array.from({ length: count }, () => h('tr', { class: 'ua-loading-row' }, ...Array.from({ length: colspan }, cell)));
|
||||
};
|
||||
|
||||
const tbody = h('tbody');
|
||||
renderList(tbody, () => {
|
||||
if (busy()) return skeletonRows(); // 加载态优先:不看数据,避免闪出旧行
|
||||
const sel = state().selection; // 读入依赖:选择变化即整表重绘(行数 = pageSize,代价可控)
|
||||
const rows = visible().rows;
|
||||
if (!rows.length) {
|
||||
@@ -93,7 +131,11 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
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))));
|
||||
const cells = props.columns.map((col) => {
|
||||
const raw = table.getCellValue(row, col.key);
|
||||
const text = col.format ? col.format(raw, row) : String(raw); // format 只产出纯文本,天然免注入
|
||||
return h('td', col.align ? { class: `ua-align-${col.align}` } : null, text);
|
||||
});
|
||||
if (selectable) {
|
||||
const check = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
check.checked = selected;
|
||||
@@ -104,8 +146,17 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
});
|
||||
});
|
||||
|
||||
// —— 列宽:数字落成 <col> 固定宽度,'flex' 或未设置交给浏览器自适应 ——
|
||||
const colgroup = h(
|
||||
'colgroup',
|
||||
null,
|
||||
selectable ? h('col', { style: 'width:44px' }) : null,
|
||||
...props.columns.map((col) => (typeof col.width === 'number' ? h('col', { style: `width:${col.width}px` }) : h('col'))),
|
||||
);
|
||||
|
||||
const filterInput = textField({
|
||||
placeholder: `筛选 ${filterKey}…`,
|
||||
disabled: props.loading, // 加载中禁止筛选,避免与在途结果竞争
|
||||
onInput: (value) => {
|
||||
table.dispatch({ type: 'setFilter', key: filterKey, value });
|
||||
table.dispatch({ type: 'setPage', page: 1 }); // 筛选变化回第 1 页
|
||||
@@ -114,20 +165,20 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
|
||||
return h(
|
||||
'section',
|
||||
{ class: 'ua-table' },
|
||||
{ class: 'ua-table', 'aria-busy': () => String(busy()) },
|
||||
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-table-scroll' }, h('table', null, colgroup, 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 }) }),
|
||||
button({ label: '上一页', variant: 'ghost', disabled: props.loading, 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 }) }),
|
||||
button({ label: '下一页', variant: 'ghost', disabled: props.loading, onClick: () => table.dispatch({ type: 'setPage', page: info().page + 1 }) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { DividerProps } from './types.js';
|
||||
|
||||
/*** 分隔线:水平/垂直两种方向,可带居中文案标签,dashed 切虚线 ***/
|
||||
export function divider(props: DividerProps): HTMLDivElement {
|
||||
const cls = ['ua-divider', props.vertical ? 'ua-divider-vertical' : '', props.dashed ? 'ua-divider-dashed' : ''].filter(Boolean).join(' ');
|
||||
const line = props.label && !props.vertical
|
||||
? h('div', { class: cls, role: 'separator', 'aria-label': props.label }, h('span', { class: 'ua-divider-label' }, props.label))
|
||||
: h('div', { class: cls, role: 'separator', 'aria-label': props.label }); // 垂直或无标签:纯线条
|
||||
return line as HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { DrawerProps } from './types.js';
|
||||
|
||||
/*** 抽屉:open 信号驱动侧滑面板,点遮罩 / × / Esc 关闭;Tab 焦点陷阱 + 关闭归还焦点,与 modal 的分工是不打断主视图、可从侧边保留上下文 ***/
|
||||
export function drawer(props: DrawerProps, ...children: unknown[]): HTMLElement {
|
||||
const close = (): void => {
|
||||
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
|
||||
props.open(false);
|
||||
props.onClose?.();
|
||||
};
|
||||
const overlay = h('div', {
|
||||
class: () => `ua-drawer-overlay${props.open() ? ' ua-open' : ''}`,
|
||||
onClick: (e: Event) => {
|
||||
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
|
||||
},
|
||||
});
|
||||
const panel = h(
|
||||
'aside',
|
||||
{
|
||||
class: `ua-drawer ua-drawer-${props.placement ?? 'right'}`,
|
||||
style: `width:${props.width ?? 320}px`,
|
||||
role: 'dialog',
|
||||
'aria-hidden': () => String(!props.open()),
|
||||
},
|
||||
h('div', { class: 'ua-drawer-head' }, h('h3', { class: 'ua-drawer-title' }, props.title), h('button', { class: 'ua-drawer-close', 'aria-label': '关闭抽屉', onClick: close }, '×')),
|
||||
h('div', { class: 'ua-drawer-body' }, ...children),
|
||||
props.footer ? h('div', { class: 'ua-drawer-foot' }, props.footer) : null,
|
||||
);
|
||||
overlay.append(panel);
|
||||
|
||||
let lastFocus: HTMLElement | null = null; // 打开前焦点宿主,关闭时归还
|
||||
const focusables = (): HTMLElement[] =>
|
||||
Array.from(panel.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter((el) => !el.hasAttribute('disabled')); // 面板内可聚焦元素(排除禁用)
|
||||
effect(() => {
|
||||
if (props.open()) {
|
||||
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
|
||||
(panel.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||
} else if (lastFocus) {
|
||||
lastFocus.focus(); // 关闭时归还焦点
|
||||
lastFocus = null;
|
||||
}
|
||||
});
|
||||
overlay.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !props.open()) return; // 仅处理打开中的 Tab
|
||||
const els = focusables();
|
||||
if (els.length === 0) { e.preventDefault(); return; } // 无可聚焦元素:拦截 Tab 防焦点逃逸
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || !panel.contains(document.activeElement))) {
|
||||
e.preventDefault(); last.focus(); // Shift+Tab 到首位时回绕到末尾
|
||||
} else if (!e.shiftKey && (document.activeElement === last || !panel.contains(document.activeElement))) {
|
||||
e.preventDefault(); first.focus(); // Tab 到末位时回绕到开头
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.open()) close();
|
||||
});
|
||||
return overlay;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { EmptyProps } from './types.js';
|
||||
|
||||
/*** 空状态:居中占位提示,title/description 可选,action 放操作区(常见为按钮) ***/
|
||||
export function empty(props: EmptyProps): HTMLDivElement {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'ua-empty', role: 'status' }, // role=status 由读屏播报
|
||||
h('div', { class: 'ua-empty-icon', 'aria-hidden': 'true' }, '⌀'), // 装饰符号,读屏跳过
|
||||
props.title ? h('div', { class: 'ua-empty-title' }, props.title) : null,
|
||||
props.description ? h('div', { class: 'ua-empty-desc' }, props.description) : null,
|
||||
props.action ? h('div', { class: 'ua-empty-action' }, props.action) : null,
|
||||
) as HTMLDivElement;
|
||||
}
|
||||
@@ -26,7 +26,13 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
|
||||
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));
|
||||
|
||||
/*** 执行全部规则:返回首个错误消息,同时同步错误区与控件态 ***/
|
||||
/*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/
|
||||
const marked = (): HTMLElement[] => [
|
||||
control,
|
||||
...Array.from(control.querySelectorAll<HTMLElement>('input, textarea, select, .ua-select-trigger')),
|
||||
];
|
||||
|
||||
/*** 执行全部规则:返回首个错误消息,同时同步错误区、控件态与 aria-invalid ***/
|
||||
const validate = () => {
|
||||
const value = props.value();
|
||||
let msg: string | null = null;
|
||||
@@ -36,14 +42,18 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
|
||||
}
|
||||
error.textContent = msg ?? '';
|
||||
control.classList.toggle('ua-invalid', !!msg);
|
||||
for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); // 读屏同步错误态
|
||||
return msg;
|
||||
};
|
||||
|
||||
control.addEventListener('input', () => {
|
||||
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
|
||||
});
|
||||
control.addEventListener('blur', () => {
|
||||
if (mode !== 'submit') validate();
|
||||
control.addEventListener('change', () => {
|
||||
if (mode === 'input') validate(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
|
||||
});
|
||||
control.addEventListener('focusout', () => {
|
||||
if (mode !== 'submit') validate(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
|
||||
});
|
||||
return { el, validate };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
export * from './types.js';
|
||||
export { button } from './button.js';
|
||||
export { textField } from './textField.js';
|
||||
export { inputNumber } from './inputNumber.js';
|
||||
export { textarea } from './textarea.js';
|
||||
export { slider } from './slider.js';
|
||||
export { checkbox } from './checkbox.js';
|
||||
export { switchButton } from './switch.js';
|
||||
export { select } from './select.js';
|
||||
export { menu } from './menu.js';
|
||||
export { radioGroup } from './radioGroup.js';
|
||||
export { accordion } from './accordion.js';
|
||||
export { breadcrumb } from './breadcrumb.js';
|
||||
export { card } from './card.js';
|
||||
export { badge } from './badge.js';
|
||||
export { tag } from './tag.js';
|
||||
export { avatar } from './avatar.js';
|
||||
export { alert } from './alert.js';
|
||||
export { toast, showToast } from './toast.js';
|
||||
export { tooltip } from './tooltip.js';
|
||||
export { popover } from './popover.js';
|
||||
export { skeleton } from './skeleton.js';
|
||||
export { pagination } from './pagination.js';
|
||||
export { spinner } from './spinner.js';
|
||||
export { progress } from './progress.js';
|
||||
export { tabs } from './tabs.js';
|
||||
export { stepper } from './stepper.js';
|
||||
export { rate } from './rate.js';
|
||||
export { empty } from './empty.js';
|
||||
export { divider } from './divider.js';
|
||||
export { modal } from './modal.js';
|
||||
export { drawer } from './drawer.js';
|
||||
export { form, formItem } from './form.js';
|
||||
export { dataTable } from './dataTable.js';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import type { InputNumberProps } from './types.js';
|
||||
|
||||
/*** 数字输入:−/+ 按钮 + 输入框三段式,value 信号双向绑定,min/max/step 超界自动夹取,键盘 ↑↓ 调节 ***/
|
||||
export function inputNumber(props: InputNumberProps): HTMLDivElement {
|
||||
const min = props.min ?? -Infinity; // 无下限缺省
|
||||
const max = props.max ?? Infinity; // 无上限缺省
|
||||
const step = props.step ?? 1; // 步长缺省 1
|
||||
const clamp = (v: number): number => Math.min(max, Math.max(min, v)); // 任何来源的值都夹回合法区间
|
||||
const emit = (v: number): void => { const n = clamp(v); props.value(n); if (props.onChange) props.onChange(n); }; // 写回信号并回调
|
||||
const input = h('input', { class: 'ua-input ua-input-number-field', inputmode: 'decimal', disabled: props.disabled, 'aria-label': '数字输入' }) as HTMLInputElement;
|
||||
const sync = (): void => { const text = String(props.value()); if (input.value !== text) input.value = text; }; // 外部变化写回输入框
|
||||
effect(sync);
|
||||
input.addEventListener('change', () => { const n = parseFloat(input.value); if (Number.isNaN(n)) sync(); else emit(n); }); // 非法输入回滚当前值
|
||||
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; // 卫语句:只拦截上下方向键
|
||||
e.preventDefault();
|
||||
emit(props.value() + (e.key === 'ArrowUp' ? step : -step)); // ↑ 加步长 / ↓ 减步长
|
||||
});
|
||||
const dec = button({ label: '−', variant: 'ghost', disabled: props.disabled, onClick: () => emit(props.value() - step) });
|
||||
const inc = button({ label: '+', variant: 'ghost', disabled: props.disabled, onClick: () => emit(props.value() + step) });
|
||||
dec.setAttribute('aria-label', '减少');
|
||||
inc.setAttribute('aria-label', '增加');
|
||||
return h('div', { class: 'ua-input-number', role: 'group' }, dec, input, inc) as HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { MenuItem, MenuProps } from './types.js';
|
||||
|
||||
/*** 下拉菜单:触发器 + 自绘弹层,项支持禁用 / 危险 / 分隔线;点项 / 点外部 / Esc 关闭,↑↓ 环形导航 + Home/End 跳转 ***/
|
||||
export function menu(props: MenuProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层开合
|
||||
const focusables: HTMLButtonElement[] = []; // 可聚焦项(跳过分隔线与禁用项),供键盘导航
|
||||
|
||||
const pick = (item: MenuItem): void => {
|
||||
open(false); // 选中即收起
|
||||
props.onSelect?.(item.value);
|
||||
};
|
||||
|
||||
// —— 菜单项:分隔线走独立分支,其余为原生 button 保证键盘可达 ——
|
||||
const rows = props.items.map((item) => {
|
||||
if (item.divider) return h('div', { class: 'ua-menu-divider', role: 'separator' });
|
||||
const btn = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: `ua-menu-item${item.danger ? ' is-danger' : ''}`,
|
||||
role: 'menuitem',
|
||||
disabled: item.disabled ? true : undefined,
|
||||
onClick: () => pick(item),
|
||||
},
|
||||
h('span', { class: 'ua-menu-label' }, item.label),
|
||||
item.shortcut ? h('kbd', { class: 'ua-menu-key' }, item.shortcut) : null, // 快捷键只做提示,不拦截按键
|
||||
) as HTMLButtonElement;
|
||||
if (!item.disabled) focusables.push(btn); // 禁用项不参与导航
|
||||
return btn;
|
||||
});
|
||||
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: `ua-btn ua-btn-${props.variant ?? 'ghost'} ua-menu-trigger`,
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': () => String(open()),
|
||||
onClick: () => open(!open()),
|
||||
},
|
||||
props.label,
|
||||
h('span', { class: 'ua-menu-caret' }, '▾'),
|
||||
);
|
||||
|
||||
const pop = h(
|
||||
'div',
|
||||
{
|
||||
class: () => `ua-menu-pop${open() ? ' ua-open' : ''}${props.align === 'end' ? ' is-end' : ''}`,
|
||||
role: 'menu',
|
||||
},
|
||||
...rows,
|
||||
);
|
||||
|
||||
const root = h('div', { class: () => `ua-menu${open() ? ' ua-open' : ''}` }, trigger, pop);
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
open(false);
|
||||
trigger.focus(); // 收起后焦点回到触发器,键盘流不断
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Home' && open()) { e.preventDefault(); focusables[0]?.focus(); return; } // Home 跳首项
|
||||
if (e.key === 'End' && open()) { e.preventDefault(); focusables[focusables.length - 1]?.focus(); return; } // End 跳末项
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; // 卫语句:只接管上下键
|
||||
e.preventDefault();
|
||||
if (!open()) {
|
||||
open(true);
|
||||
focusables[0]?.focus(); // 首次按下即展开并进首项
|
||||
return;
|
||||
}
|
||||
const at = focusables.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const step = e.key === 'ArrowDown' ? 1 : -1;
|
||||
focusables[(at + step + focusables.length) % focusables.length]?.focus(); // 环形移动
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false); // 点外部收起
|
||||
});
|
||||
|
||||
effect(() => open()); // 建立依赖,保证开合信号生效
|
||||
return root;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ModalProps } from './types.js';
|
||||
|
||||
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose ***/
|
||||
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose;Tab 焦点陷阱 + 关闭归还焦点 ***/
|
||||
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
|
||||
const close = () => {
|
||||
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
|
||||
@@ -16,15 +16,38 @@ export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
|
||||
});
|
||||
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 }, '×')),
|
||||
{ class: `ua-modal ua-modal-${props.size ?? 'md'}`, role: 'dialog', 'aria-modal': 'true' }, // 对话框语义,供读屏识别
|
||||
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title' }, props.title), h('button', { type: 'button', class: 'ua-modal-close', 'aria-label': '关闭弹窗', onClick: close }, '×')),
|
||||
h('div', { class: 'ua-modal-body' }, ...children),
|
||||
props.footer ? h('div', { class: 'ua-modal-foot' }, props.footer) : null, // 底部操作区:右对齐,仅传了才渲染
|
||||
);
|
||||
overlay.append(box);
|
||||
let lastFocus: HTMLElement | null = null; // 打开前焦点宿主,关闭时归还
|
||||
const focusables = (): HTMLElement[] =>
|
||||
Array.from(box.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter((el) => !el.hasAttribute('disabled')); // 弹窗内可聚焦元素(排除禁用)
|
||||
effect(() => {
|
||||
const open = props.open();
|
||||
overlay.classList.toggle('ua-open', open); // 显隐跟随信号
|
||||
if (open) (box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||
if (open) {
|
||||
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
|
||||
(box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||
} else if (lastFocus) {
|
||||
lastFocus.focus(); // 关闭时归还焦点
|
||||
lastFocus = null;
|
||||
}
|
||||
});
|
||||
overlay.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !props.open()) return; // 仅处理打开中的 Tab
|
||||
const els = focusables();
|
||||
if (els.length === 0) { e.preventDefault(); return; } // 无可聚焦元素:拦截 Tab 防焦点逃逸
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) {
|
||||
e.preventDefault(); last.focus(); // Shift+Tab 到首位时回绕到末尾
|
||||
} else if (!e.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) {
|
||||
e.preventDefault(); first.focus(); // Tab 到末位时回绕到开头
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.open()) close(); // Esc 关闭当前打开的弹窗
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { h, renderList } from '@ui-arch/frame';
|
||||
import type { PaginationProps } from './types.js';
|
||||
|
||||
/*** 页码序列:≤7 页全展开;否则保留首尾与当前页 ±1 窗口,缺口折叠为省略号(null 占位) ***/
|
||||
function pageSequence(page: number, totalPages: number): (number | null)[] {
|
||||
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const marks = [1, 2, page - 1, page, page + 1, totalPages - 1, totalPages].filter((p) => p >= 1 && p <= totalPages);
|
||||
const seq: (number | null)[] = [];
|
||||
let prev = 0;
|
||||
for (const p of [...new Set(marks)].sort((a, b) => a - b)) {
|
||||
if (p - prev > 1) seq.push(null); // 缺口 → 省略号
|
||||
seq.push(p);
|
||||
prev = p;
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
/*** 独立分页器:受控组件,page 为双向绑定信号;页码过多折叠省略号,首末页禁用翻页,原生 button 保证键盘可达 ***/
|
||||
export function pagination(props: PaginationProps): HTMLElement {
|
||||
const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize)); // 总页数
|
||||
const go = (p: number) => {
|
||||
const next = Math.min(totalPages, Math.max(1, p)); // 钳制到有效页码范围
|
||||
props.page(next);
|
||||
props.onChange?.(next);
|
||||
};
|
||||
|
||||
const nav = h('nav', { class: 'ua-pagination', 'aria-label': '分页' });
|
||||
renderList(nav, () => {
|
||||
const page = Math.min(totalPages, Math.max(1, props.page()));
|
||||
const items: HTMLElement[] = [h('button', { class: 'ua-page-btn', disabled: page <= 1, 'aria-label': '上一页', onClick: () => go(page - 1) }, '‹')];
|
||||
for (const it of pageSequence(page, totalPages)) {
|
||||
items.push(
|
||||
it == null
|
||||
? h('span', { class: 'ua-page-gap', 'aria-hidden': 'true' }, '…')
|
||||
: h('button', { class: `ua-page-btn${it === page ? ' ua-active' : ''}`, disabled: it === page, 'aria-current': it === page ? 'page' : null, 'aria-label': `第 ${it} 页`, onClick: () => go(it) }, String(it)),
|
||||
);
|
||||
}
|
||||
items.push(h('button', { class: 'ua-page-btn', disabled: page >= totalPages, 'aria-label': '下一页', onClick: () => go(page + 1) }, '›'));
|
||||
return items;
|
||||
});
|
||||
return nav;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { PopoverProps } from './types.js';
|
||||
|
||||
let seq = 0; // 全局自增序号:保证每个弹层 id 唯一,供 aria-controls 关联
|
||||
|
||||
/*** 气泡卡片:包裹触发器,默认点击开合;内容可交互,点外部 / Esc / 再次点触发器收起 ***/
|
||||
export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层显隐
|
||||
const id = `ua-pop-${++seq}`; // 弹层唯一 id
|
||||
let timer: number | null = null; // hover 模式下的出入场计时器
|
||||
|
||||
const setOpen = (next: boolean): void => {
|
||||
if (open() === next) return; // 幂等:状态未变不派发回调
|
||||
open(next);
|
||||
props.onOpenChange?.(next);
|
||||
};
|
||||
const toggle = (): void => setOpen(!open());
|
||||
|
||||
// —— 触发器与弹层 ——
|
||||
trigger.setAttribute('aria-haspopup', 'dialog');
|
||||
effect(() => trigger.setAttribute('aria-expanded', String(open()))); // 展开态跟随信号写回
|
||||
const panel = h(
|
||||
'div',
|
||||
{
|
||||
id,
|
||||
class: () => `ua-pop ua-pop-${props.placement ?? 'bottom'}${open() ? ' ua-open' : ''}`,
|
||||
role: 'dialog',
|
||||
'aria-hidden': () => String(!open()),
|
||||
onClick: (e: Event) => e.stopPropagation(), // 弹层内点击不触发外部关闭
|
||||
},
|
||||
props.title ? h('div', { class: 'ua-pop-title' }, props.title) : null,
|
||||
h('div', { class: 'ua-pop-body' }, props.content),
|
||||
);
|
||||
const root = h('span', { class: 'ua-pop-wrap' }, trigger, panel);
|
||||
|
||||
if (props.trigger === 'hover') {
|
||||
root.addEventListener('pointerenter', () => {
|
||||
if (timer != null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => setOpen(true), props.delay ?? 150);
|
||||
});
|
||||
root.addEventListener('pointerleave', () => {
|
||||
if (timer != null) window.clearTimeout(timer); // 未出场就离开:取消计时
|
||||
setOpen(false);
|
||||
});
|
||||
} else {
|
||||
trigger.addEventListener('click', (e: Event) => {
|
||||
e.stopPropagation(); // 阻止冒泡到 document,否则刚打开就被外部点击关掉
|
||||
toggle();
|
||||
});
|
||||
}
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || !open()) return; // 卫语句:未展开时 Esc 不处理
|
||||
setOpen(false);
|
||||
trigger.focus(); // 收起后焦点回触发器
|
||||
});
|
||||
document.addEventListener('click', () => setOpen(false)); // 点外部收起(弹层内已阻止冒泡)
|
||||
|
||||
return root;
|
||||
}
|
||||
@@ -1,18 +1,37 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ProgressProps } from './types.js';
|
||||
|
||||
/*** 进度条:轨道 + 渐变流动填充,value 信号驱动 0-100;满格切换 success 色并停止条纹 ***/
|
||||
export function progress(props: ProgressProps): HTMLElement {
|
||||
/*** 进度条:value 信号驱动 0-100 定量态,缺省 value 则退化为不确定态(无限往复);满格切 success 色并停条纹 ***/
|
||||
export function progress(props: ProgressProps = {}): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value = props.value; // 可选:不传即不确定态
|
||||
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' });
|
||||
const withText = props.showText !== false && !!value; // 不确定态没有百分比可显示
|
||||
|
||||
if (!value) {
|
||||
fill.classList.add('ua-indeterminate'); // 宽度与往复动画交给 CSS
|
||||
return h('div', { class: 'ua-progress', role: 'progressbar', 'aria-label': '进行中' }, track);
|
||||
}
|
||||
effect(() => {
|
||||
const v = clamp(props.value()); // 依赖收集:信号变化自动写回
|
||||
const v = clamp(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);
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'ua-progress',
|
||||
role: 'progressbar',
|
||||
'aria-valuemin': '0',
|
||||
'aria-valuemax': '100',
|
||||
'aria-valuenow': () => String(Math.round(clamp(value()))),
|
||||
},
|
||||
track,
|
||||
withText ? text : null,
|
||||
);
|
||||
}
|
||||
|
||||
/*** 进度钳制到 0-100 ***/
|
||||
|
||||
@@ -6,7 +6,12 @@ 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;
|
||||
const input = h('input', {
|
||||
type: 'radio',
|
||||
name: groupName,
|
||||
class: 'ua-radio',
|
||||
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传
|
||||
}) as HTMLInputElement;
|
||||
input.value = opt.value;
|
||||
radios.push(input);
|
||||
input.addEventListener('change', () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { RateProps } from './types.js';
|
||||
|
||||
/*** 评分:星标点选,hover 预览移出还原,键盘 ←→/↑↓ 调节,value 信号双向绑定 ***/
|
||||
export function rate(props: RateProps): HTMLDivElement {
|
||||
const value = props.value;
|
||||
const max = props.max ?? 5; // 星标总数缺省 5
|
||||
const hover = signal(0); // 预览索引(1 基,0 表示无预览)
|
||||
const emit = (v: number): void => { const n = Math.min(max, Math.max(1, v)); value(n); if (props.onChange) props.onChange(n); }; // 写回信号并回调
|
||||
const render = (): HTMLElement => {
|
||||
const shown = hover() || value(); // 预览优先,无预览显示当前值
|
||||
const stars = [];
|
||||
for (let i = 1; i <= max; i++) {
|
||||
stars.push(h('button', {
|
||||
class: `ua-rate-star${i <= shown ? ' is-on' : ''}`,
|
||||
type: 'button',
|
||||
disabled: props.disabled,
|
||||
'aria-label': `${i} 星`,
|
||||
onClick: () => emit(i),
|
||||
onMouseEnter: () => hover(i),
|
||||
}, '★'));
|
||||
}
|
||||
return h('div', { class: 'ua-rate', role: 'slider', 'aria-label': '评分', 'aria-valuemin': 1, 'aria-valuemax': max, 'aria-valuenow': value(), onMouseLeave: () => hover(0) }, ...stars);
|
||||
};
|
||||
let el!: HTMLElement; // 当前渲染产物(effect 首跑赋值)
|
||||
effect(() => { const next = render(); if (el) el.replaceWith(next); el = next; }); // value/hover 变化整条重渲染(星标少,开销可忽略)
|
||||
const keyNav = (e: KeyboardEvent): void => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; // 卫语句:只拦截方向键
|
||||
e.preventDefault();
|
||||
emit(value() + (e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : -1)); // →/↑ 加一分,←/↓ 减一分
|
||||
};
|
||||
el.addEventListener('keydown', keyNav);
|
||||
return el as HTMLDivElement;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { SelectProps } from './types.js';
|
||||
|
||||
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;点选项 / 点外部 / Esc 关闭 ***/
|
||||
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;支持 ↑↓/Home/End 键盘导航,点选项 / 点外部 / Esc 关闭 ***/
|
||||
export function select(props: SelectProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层开合
|
||||
@@ -24,6 +24,7 @@ export function select(props: SelectProps): HTMLElement {
|
||||
class: 'ua-select-trigger',
|
||||
'aria-haspopup': 'listbox',
|
||||
'aria-expanded': () => String(open()),
|
||||
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传(原生 button 拦截点击)
|
||||
onClick: () => open(!open()),
|
||||
},
|
||||
h('span', { class: () => `ua-select-value${value() ? '' : ' is-empty'}` }, () => current() || props.placeholder || ''),
|
||||
@@ -46,10 +47,21 @@ export function select(props: SelectProps): HTMLElement {
|
||||
);
|
||||
const pop = h('div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}`, role: 'listbox' }, ...options);
|
||||
|
||||
const root = h('div', { class: 'ua-select' }, trigger, pop);
|
||||
const root = h('div', { class: () => `ua-select${open() ? ' ua-open' : ''}` }, trigger, pop); // 根带开合类,供箭头旋转
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') open(false); // Esc 关闭弹层
|
||||
if (e.key === 'Escape') { open(false); trigger.focus(); return; } // Esc 关闭并回焦触发器
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault(); // 阻止页面滚动
|
||||
if (!open()) { open(true); options[0]?.focus(); return; } // 未展开:展开并聚焦首个选项
|
||||
const idx = options.indexOf(document.activeElement as HTMLElement); // 展开中:在选项间环形移动
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const next = (idx + dir + options.length) % options.length;
|
||||
options[next]?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Home' && open()) { e.preventDefault(); options[0]?.focus(); return; } // Home 跳首项
|
||||
if (e.key === 'End' && open()) { e.preventDefault(); options[options.length - 1]?.focus(); } // End 跳末项
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { SkeletonProps } from './types.js';
|
||||
|
||||
/*** 骨架屏:variant 决定占位形状(text 多行 / rect 块 / circle 圆),纯展示无状态 ***/
|
||||
export function skeleton(props: SkeletonProps): HTMLElement {
|
||||
const variant = props.variant ?? 'rect'; // 缺省整块矩形
|
||||
if (variant !== 'text') return h('div', { class: `ua-skeleton ua-skeleton-${variant}`, 'aria-hidden': 'true' }); // 纯装饰,读屏跳过
|
||||
const rows = props.rows ?? 3; // text 行数,末行缩短模拟段落收尾
|
||||
const lines: HTMLElement[] = [];
|
||||
for (let i = 0; i < rows; i++) lines.push(h('div', { class: `ua-skeleton ua-skeleton-text${i === rows - 1 ? ' ua-last' : ''}`, 'aria-hidden': 'true' }));
|
||||
return h('div', { class: 'ua-skeleton-group', 'aria-hidden': 'true' }, ...lines); // 整组装饰性占位
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { SliderProps } from './types.js';
|
||||
|
||||
/*** 滑块:原生 range 承载交互与键盘,value 信号双向绑定;轨道配色走 accent-color 令牌 ***/
|
||||
export function slider(props: SliderProps): HTMLElement {
|
||||
const value = props.value; // 必需信号:组件不持有数值,一律委托调用方
|
||||
const input = h('input', {
|
||||
type: 'range',
|
||||
class: 'ua-slider-input',
|
||||
min: props.min ?? 0,
|
||||
max: props.max ?? 100,
|
||||
step: props.step ?? 1,
|
||||
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传
|
||||
}) as HTMLInputElement;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const next = Number(input.value);
|
||||
value(next); // 用户拖动写回信号
|
||||
props.onChange?.(next);
|
||||
});
|
||||
effect(() => {
|
||||
const next = String(value());
|
||||
if (input.value !== next) input.value = next; // 外部赋值同步滑块位置(等值不重写,避免打断拖动)
|
||||
});
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'ua-slider' },
|
||||
input,
|
||||
props.showValue === false ? null : h('span', { class: 'ua-slider-value' }, () => String(value())),
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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' });
|
||||
const el = h('span', { class: 'ua-spinner', role: 'status', 'aria-label': '加载中' }); // 读屏播报加载状态
|
||||
el.style.width = size;
|
||||
el.style.height = size;
|
||||
return el;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { StepperProps } from './types.js';
|
||||
|
||||
/*** 步骤条:done/current/upcoming 三态横向流程指示,clickable 时已完成步骤可点回跳并写回 active 信号 ***/
|
||||
export function stepper(props: StepperProps): HTMLElement {
|
||||
const active = props.active;
|
||||
const clickStep = (i: number): void => { if (!props.clickable || i >= active()) return; // 卫语句:不可点或未完成不回跳
|
||||
active(i); if (props.onChange) props.onChange(i); }; // 写回信号并回调
|
||||
const render = (): HTMLElement => {
|
||||
const cur = active();
|
||||
const items = props.steps.map((s, i) => {
|
||||
const state = i < cur ? 'done' : i === cur ? 'current' : 'upcoming'; // 三态:已完成 / 当前 / 未到
|
||||
const mark = state === 'done' ? '✓' : String(i + 1); // 完成打勾,其余显示序号
|
||||
const clickable = !!props.clickable && i < cur; // 仅已完成步骤可回跳
|
||||
const head = clickable
|
||||
? h('button', { class: 'ua-step-dot', type: 'button', onClick: () => clickStep(i) }, mark)
|
||||
: h('span', { class: 'ua-step-dot' }, mark);
|
||||
const body = h('div', { class: 'ua-step-text' }, h('span', { class: 'ua-step-label' }, s.label),
|
||||
s.description ? h('span', { class: 'ua-step-desc' }, s.description) : null);
|
||||
const li = h('li', { class: `ua-step ua-step-${state}`, 'aria-current': state === 'current' ? 'step' : null }, head, body);
|
||||
return li;
|
||||
});
|
||||
return h('ol', { class: 'ua-stepper' }, ...items);
|
||||
};
|
||||
let el!: HTMLElement; // 当前渲染产物(effect 首跑赋值)
|
||||
effect(() => { const next = render(); if (el) el.replaceWith(next); el = next; }); // active 变化整条重渲染(条目少,开销可忽略)
|
||||
return el;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { SwitchProps } from './types.js';
|
||||
|
||||
/*** 开关:checked 信号双向绑定,轨道 + 滑块的纯 CSS 实现 ***/
|
||||
/*** 开关:checked 信号双向绑定,disabled 支持布尔或信号禁用,轨道 + 滑块的纯 CSS 实现 ***/
|
||||
export function switchButton(props: SwitchProps): HTMLElement {
|
||||
const input = h('input', { type: 'checkbox', class: 'ua-switch-input' }) as HTMLInputElement;
|
||||
const input = h('input', { type: 'checkbox', class: 'ua-switch-input', disabled: props.disabled }) as HTMLInputElement; // false/undefined 移除属性,Signal 走响应式
|
||||
if (props.checked) {
|
||||
const checked = props.checked;
|
||||
input.addEventListener('change', () => checked(input.checked)); // 用户切换写回信号
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 head = h('div', { class: 'ua-tabs-head', role: 'tablist', 'aria-label': '标签页' }, indicator);
|
||||
|
||||
/*** 同步激活态与指示条几何位置 ***/
|
||||
const sync = () => {
|
||||
@@ -27,6 +27,7 @@ export function tabs(props: TabsProps): HTMLElement {
|
||||
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;
|
||||
if (tab.disabled) btn.setAttribute('aria-disabled', 'true'); // 读屏提示不可选
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.disabled) return; // 卫语句:禁用页签不响应
|
||||
if (props.active) props.active(tab.value); // 用户切换写回信号
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { TagProps } from './types.js';
|
||||
|
||||
/*** 标签:语义色小胶囊,可选 × 关闭;与 badge 的分工是 tag 可交互(带关闭回调) ***/
|
||||
export function tag(props: TagProps): HTMLElement {
|
||||
const label = h('span', { class: 'ua-tag-label' }, props.label);
|
||||
if (!props.closable) {
|
||||
return h('span', { class: `ua-tag ua-tag-${props.variant ?? 'default'}` }, label); // 纯展示形态
|
||||
}
|
||||
return h(
|
||||
'span',
|
||||
{ class: `ua-tag is-closable ua-tag-${props.variant ?? 'default'}` },
|
||||
label,
|
||||
h('button', { type: 'button', class: 'ua-tag-close', 'aria-label': `移除 ${props.label}`, onClick: () => props.onClose?.() }, '×'),
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TextFieldProps } from './types.js';
|
||||
|
||||
/*** 文本输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||
/*** 文本输入:value 信号双向绑定,disabled 支持布尔或信号禁用,onInput 为输入回调 ***/
|
||||
export function textField(props: TextFieldProps): HTMLInputElement {
|
||||
const el = h('input', { class: 'ua-input', placeholder: props.placeholder ?? '' }) as HTMLInputElement;
|
||||
const el = h('input', { class: 'ua-input', placeholder: props.placeholder ?? '', disabled: props.disabled }) as HTMLInputElement; // false/undefined 移除属性,Signal 走响应式
|
||||
if (props.value) {
|
||||
const signal = props.value;
|
||||
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { TextareaProps } from './types.js';
|
||||
|
||||
/*** 多行输入:value 信号双向绑定,onInput 为输入回调 ***/
|
||||
/*** 多行输入:value 信号双向绑定,disabled 支持布尔或信号禁用,onInput 为输入回调 ***/
|
||||
export function textarea(props: TextareaProps): HTMLTextAreaElement {
|
||||
const el = h('textarea', { class: 'ua-textarea', rows: props.rows ?? 3, placeholder: props.placeholder ?? '' }) as HTMLTextAreaElement;
|
||||
const el = h('textarea', { class: 'ua-textarea', rows: props.rows ?? 3, placeholder: props.placeholder ?? '', disabled: props.disabled }) as HTMLTextAreaElement; // false/undefined 移除属性,Signal 走响应式
|
||||
if (props.value) {
|
||||
const signal = props.value;
|
||||
el.addEventListener('input', () => signal(el.value)); // 用户输入写回信号
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { ToastProps } from './types.js';
|
||||
|
||||
let host: HTMLElement | null = null; // 全局右上角堆叠容器:首条消息出现时懒创建
|
||||
let seq = 0; // 自增序号:为每条 toast 生成唯一 id,辅助无障碍定位
|
||||
const MAX_STACK = 4; // 同屏最多条数:超出即挤掉最早一条,避免刷屏淹页面
|
||||
|
||||
/*** 确保堆叠容器存在:fixed 挂在 body 末尾,后续消息依次向上堆叠 ***/
|
||||
function ensureHost(): HTMLElement {
|
||||
if (host) return host;
|
||||
host = h('div', { class: 'ua-toast-host' });
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
/*** 退场并移除:先摘 ua-open 触发过渡,transitionend 与 320ms 超时兜底二选一 ***/
|
||||
function dismiss(item: HTMLElement): void {
|
||||
item.classList.remove('ua-open');
|
||||
item.addEventListener('transitionend', () => item.remove(), { once: true });
|
||||
window.setTimeout(() => item.isConnected && item.remove(), 320); // reduced-motion 下 transition 不触发
|
||||
}
|
||||
|
||||
/*** 弹出一条 toast:语义色由 variant 决定,duration 毫秒后自动淡出并移除节点 ***/
|
||||
export function showToast(props: ToastProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const duration = props.duration ?? 3000; // 停留时长毫秒
|
||||
const item = h(
|
||||
'div',
|
||||
{ class: `ua-toast ua-toast-${props.variant ?? 'info'}${props.closable ? ' is-closable' : ''}`, role: 'status', id: `ua-toast-${++seq}` },
|
||||
h('span', { class: 'ua-toast-msg' }, props.message),
|
||||
props.closable ? h('button', { type: 'button', class: 'ua-toast-close', 'aria-label': '关闭提示', onClick: () => dismiss(item) }, '×') : null,
|
||||
);
|
||||
const stack = ensureHost();
|
||||
if (stack.childElementCount >= MAX_STACK) stack.firstElementChild?.remove(); // 超过上限直接挤掉最早一条
|
||||
|
||||
stack.appendChild(item);
|
||||
requestAnimationFrame(() => item.classList.add('ua-open')); // 下一帧入场,保证过渡生效
|
||||
window.setTimeout(() => dismiss(item), duration);
|
||||
return item;
|
||||
}
|
||||
|
||||
/*** toast 语义快捷工厂:toast.info / success / warning / error(message, duration?) ***/
|
||||
export const toast = {
|
||||
info: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'info' }), // 中性信息
|
||||
success: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'success' }), // 成功反馈
|
||||
warning: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'warning' }), // 警告
|
||||
error: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'danger' }), // 错误
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { h, signal } from '@ui-arch/frame';
|
||||
import type { TooltipProps } from './types.js';
|
||||
|
||||
let seq = 0; // 全局自增序号:保证每个气泡 id 唯一,供 aria-describedby 关联
|
||||
|
||||
/*** 悬浮提示:包裹触发器,hover / focus 延迟出场,移出 / 失焦 / Esc 收起,纯展示不拦截指针 ***/
|
||||
export function tooltip(props: TooltipProps, trigger: HTMLElement): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 气泡显隐
|
||||
const id = `ua-tip-${++seq}`; // 气泡唯一 id
|
||||
const delay = props.delay ?? 150; // 出场延迟毫秒;<=0 视为立即显示
|
||||
let timer: number | null = null; // 出场延迟计时器句柄
|
||||
|
||||
const show = () => {
|
||||
if (timer != null) window.clearTimeout(timer); // 重复进入先清旧计时
|
||||
if (delay <= 0) return open(true);
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
open(true);
|
||||
}, delay);
|
||||
};
|
||||
const hide = () => {
|
||||
if (timer != null) { // 还没出场就离开:直接取消计时,避免闪一下
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
open(false);
|
||||
};
|
||||
|
||||
// —— 触发器与气泡 ——
|
||||
trigger.setAttribute('aria-describedby', id); // 无障碍:读屏把提示文案关联到触发器
|
||||
const bubble = h(
|
||||
'span',
|
||||
{
|
||||
id,
|
||||
class: () => `ua-tip ua-tip-${props.placement ?? 'top'}${open() ? ' ua-open' : ''}`,
|
||||
role: 'tooltip',
|
||||
'aria-hidden': () => String(!open()),
|
||||
},
|
||||
props.content,
|
||||
);
|
||||
const root = h('span', { class: 'ua-tip-wrap' }, trigger, bubble);
|
||||
|
||||
root.addEventListener('pointerenter', show); // 鼠标进入
|
||||
root.addEventListener('pointerleave', hide); // 鼠标离开
|
||||
root.addEventListener('focusin', show); // 键盘聚焦(focusin 会冒泡)
|
||||
root.addEventListener('focusout', hide); // 失焦
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') hide(); // Esc 立即收起,不必等延迟
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
import type { ColumnDef, RowKey } from '@ui-arch/table-core';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
|
||||
/*** 悬浮提示入参 ***/
|
||||
export interface TooltipProps {
|
||||
content: string; // 提示文案(纯文本)
|
||||
placement?: 'top' | 'bottom'; // 气泡方位;缺省 top(触发器上方)
|
||||
delay?: number; // 出场延迟毫秒;缺省 150,传 0 立即显示
|
||||
}
|
||||
|
||||
/*** 气泡卡片入参 ***/
|
||||
export interface PopoverProps {
|
||||
content: unknown; // 弹层内容:字符串 / DOM 节点 / 响应式函数(可放交互元素)
|
||||
title?: string; // 弹层标题;缺省不渲染标题行
|
||||
placement?: 'top' | 'bottom'; // 弹层方位;缺省 bottom(触发器下方)
|
||||
trigger?: 'click' | 'hover'; // 触发方式;缺省 click(hover 模式带 delay 延迟)
|
||||
delay?: number; // hover 模式出场延迟毫秒;缺省 150
|
||||
onOpenChange?: (open: boolean) => void; // 开合回调
|
||||
}
|
||||
|
||||
/*** 按钮入参 ***/
|
||||
export interface ButtonProps {
|
||||
label: string; // 按钮文案
|
||||
variant?: 'primary' | 'ghost' | 'danger'; // 视觉强度;缺省 primary
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onClick?: () => void; // 点击回调
|
||||
}
|
||||
|
||||
@@ -12,6 +30,7 @@ export interface ButtonProps {
|
||||
export interface TextFieldProps {
|
||||
placeholder?: string; // 占位文案
|
||||
value?: Signal<string>; // 双向绑定的信号
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onInput?: (value: string) => void; // 输入回调
|
||||
}
|
||||
|
||||
@@ -20,13 +39,22 @@ export interface CardProps {
|
||||
title?: string; // 标题;缺省不渲染标题行
|
||||
}
|
||||
|
||||
/*** 皮肤层列定义:内核列定义 + 纯渲染字段(对齐 / 文案格式化),不参与排序筛选语义 ***/
|
||||
export interface TableColumn<T = unknown> extends ColumnDef<T> {
|
||||
align?: 'left' | 'center' | 'right'; // 表头与单元格对齐;缺省 left
|
||||
format?: (value: unknown, row: T) => string; // 单元格文案格式化(返回纯文本,走 textContent 渲染)
|
||||
}
|
||||
|
||||
/*** 数据表格入参 ***/
|
||||
export interface DataTableProps<T> {
|
||||
data: T[]; // 全量行数据,行须含 id 字段作 rowKey
|
||||
columns: ColumnDef<T>[]; // 列定义
|
||||
columns: TableColumn<T>[]; // 列定义:内核字段 + align / format
|
||||
filterKey?: string; // 工具栏筛选的列;缺省取第一列
|
||||
pageSize?: number; // 每页行数;缺省 5
|
||||
selectable?: boolean; // 是否启用行选择列(含全选三态与已选计数);缺省 false
|
||||
page?: Signal<number>; // 受控页码信号:与内核分页双向同步,可接独立 pagination 组件联动
|
||||
loading?: boolean | Signal<boolean>; // 加载态:为真时数据行换成骨架行,筛选与翻页禁用
|
||||
selected?: Signal<RowKey[]>; // 受控选中行信号:与内核选择双向同步,供批量操作读取
|
||||
}
|
||||
|
||||
/*** 选项条目:select / radioGroup / tabs 共用的选项形状 ***/
|
||||
@@ -40,6 +68,7 @@ export interface OptionItem {
|
||||
export interface CheckboxProps {
|
||||
label?: string; // 右侧文案;缺省不渲染
|
||||
checked?: Signal<boolean>; // 勾选状态信号,双向绑定
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onChange?: (checked: boolean) => void; // 变更回调
|
||||
}
|
||||
|
||||
@@ -47,6 +76,7 @@ export interface CheckboxProps {
|
||||
export interface SwitchProps {
|
||||
label?: string; // 左侧文案;缺省不渲染
|
||||
checked?: Signal<boolean>; // 开关状态信号,双向绑定
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onChange?: (checked: boolean) => void; // 变更回调
|
||||
}
|
||||
|
||||
@@ -55,6 +85,7 @@ export interface SelectProps {
|
||||
options: OptionItem[]; // 选项列表
|
||||
value?: Signal<string>; // 选中值信号,双向绑定
|
||||
placeholder?: string; // 未选中时的占位项文案
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onChange?: (value: string) => void; // 变更回调
|
||||
}
|
||||
|
||||
@@ -62,26 +93,174 @@ export interface SelectProps {
|
||||
export interface RadioGroupProps {
|
||||
options: OptionItem[]; // 选项列表
|
||||
value?: Signal<string>; // 选中值信号,双向绑定
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:整组置灰且不可选
|
||||
onChange?: (value: string) => void; // 变更回调
|
||||
}
|
||||
|
||||
/*** 菜单项:divider 渲染成分隔线,其余为可选条目 ***/
|
||||
export interface MenuItem {
|
||||
value: string; // 条目标识(经 onSelect 透传)
|
||||
label: string; // 条目文案
|
||||
disabled?: boolean; // 禁用该条目:置灰且不参与键盘导航
|
||||
danger?: boolean; // 危险操作条目:文案取 danger 语义色
|
||||
shortcut?: string; // 右侧快捷键提示(仅展示,不绑定按键),如 '⌘K'
|
||||
divider?: boolean; // 该条目渲染为分隔线(忽略 value / label)
|
||||
}
|
||||
|
||||
/*** 下拉菜单入参 ***/
|
||||
export interface MenuProps {
|
||||
label: string; // 触发器文案
|
||||
items: MenuItem[]; // 菜单条目列表
|
||||
variant?: 'primary' | 'ghost'; // 触发器视觉强度;缺省 ghost
|
||||
align?: 'start' | 'end'; // 弹层对齐方向;缺省 start(与触发器左对齐)
|
||||
onSelect?: (value: string) => void; // 选中回调(禁用项不触发)
|
||||
}
|
||||
|
||||
/*** 面包屑条目 ***/
|
||||
export interface BreadcrumbItem {
|
||||
label: string; // 条目文案
|
||||
href?: string; // 跳转地址;缺省时渲染 button 交给 onNavigate
|
||||
}
|
||||
|
||||
/*** 面包屑入参 ***/
|
||||
export interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[]; // 路径条目,末项视为当前页(不可点)
|
||||
separator?: string; // 分隔符;缺省 '/'
|
||||
onNavigate?: (item: BreadcrumbItem, index: number) => void; // 无 href 条目的点击回调
|
||||
}
|
||||
|
||||
/*** 折叠面板条目 ***/
|
||||
export interface AccordionItem {
|
||||
value: string; // 面板标识(回调与展开集合用)
|
||||
title: string; // 标题文案(同时作为触发器)
|
||||
content: unknown; // 面板内容:字符串 / DOM 节点 / 响应式函数
|
||||
disabled?: boolean; // 禁用该面板:标题置灰且不可展开
|
||||
}
|
||||
|
||||
/*** 折叠面板入参 ***/
|
||||
export interface AccordionProps {
|
||||
items: AccordionItem[]; // 面板列表
|
||||
active?: Signal<string>; // 展开面板信号(单开模式);缺省由组件内部持有状态
|
||||
multiple?: boolean; // 是否允许多个面板同时展开;缺省 false(手风琴)
|
||||
onChange?: (active: string) => void; // 展开变化回调:收起时传空串
|
||||
}
|
||||
|
||||
/*** 滑块入参 ***/
|
||||
export interface SliderProps {
|
||||
value: Signal<number>; // 当前值信号,双向绑定(拖动写回、外部赋值同步)
|
||||
min?: number; // 最小值;缺省 0
|
||||
max?: number; // 最大值;缺省 100
|
||||
step?: number; // 步长;缺省 1
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
showValue?: boolean; // 是否右侧显示当前值;缺省 true
|
||||
onChange?: (value: number) => void; // 拖动回调
|
||||
}
|
||||
|
||||
/*** 多行输入入参 ***/
|
||||
export interface TextareaProps {
|
||||
placeholder?: string; // 占位文案
|
||||
rows?: number; // 行数;缺省 3
|
||||
value?: Signal<string>; // 双向绑定的信号
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onInput?: (value: string) => void; // 输入回调
|
||||
}
|
||||
|
||||
/*** 数字输入入参 ***/
|
||||
export interface InputNumberProps {
|
||||
value: Signal<number>; // 当前值信号,双向绑定(按钮/键盘写回、外部赋值同步)
|
||||
min?: number; // 最小值;缺省无下限
|
||||
max?: number; // 最大值;缺省无上限
|
||||
step?: number; // 步长;缺省 1
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onChange?: (value: number) => void; // 值变化回调(已夹取到合法区间)
|
||||
}
|
||||
|
||||
/*** 步骤条条目 ***/
|
||||
export interface StepperItem {
|
||||
label: string; // 步骤标题
|
||||
description?: string; // 步骤补充说明;缺省不渲染
|
||||
}
|
||||
|
||||
/*** 步骤条入参 ***/
|
||||
export interface StepperProps {
|
||||
steps: StepperItem[]; // 步骤列表(按顺序渲染)
|
||||
active: Signal<number>; // 当前步索引(0 基),双向绑定(回跳写回、外部赋值同步)
|
||||
clickable?: boolean; // 已完成步骤是否可点击回跳;缺省 false
|
||||
onChange?: (index: number) => void; // 步骤切换回调(点击回跳触发)
|
||||
}
|
||||
|
||||
/*** 评分入参 ***/
|
||||
export interface RateProps {
|
||||
value: Signal<number>; // 当前评分(1 基整数值),双向绑定(点击/键盘写回、外部赋值同步)
|
||||
max?: number; // 星标总数;缺省 5
|
||||
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
|
||||
onChange?: (value: number) => void; // 评分变化回调
|
||||
}
|
||||
|
||||
/*** 空状态入参 ***/
|
||||
export interface EmptyProps {
|
||||
title?: string; // 主标题;缺省不渲染
|
||||
description?: string; // 补充说明;缺省不渲染
|
||||
action?: unknown; // 操作区内容(常见为按钮);缺省不渲染
|
||||
}
|
||||
|
||||
/*** 分隔线入参 ***/
|
||||
export interface DividerProps {
|
||||
label?: string; // 居中文案标签;缺省纯线条
|
||||
vertical?: boolean; // 是否垂直分隔线(用于行内工具栏);缺省水平
|
||||
dashed?: boolean; // 是否虚线;缺省实线
|
||||
}
|
||||
|
||||
/*** 徽标入参 ***/
|
||||
export interface BadgeProps {
|
||||
label: string; // 徽标文案
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger'; // 语义色;缺省 default
|
||||
}
|
||||
|
||||
/*** 头像入参 ***/
|
||||
export interface AvatarProps {
|
||||
name: string; // 名称:生成占位首字,并作为图片 alt 与悬停标题
|
||||
src?: string; // 图片地址;缺省用首字占位
|
||||
size?: number; // 直径像素;缺省 32
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger'; // 占位底色语义档;缺省 default
|
||||
}
|
||||
|
||||
/*** 标签入参(可关闭,与纯展示的 badge 区分) ***/
|
||||
export interface TagProps {
|
||||
label: string; // 标签文案
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'; // 语义色;缺省 default
|
||||
closable?: boolean; // 显示 × 关闭按钮;缺省 false
|
||||
onClose?: () => void; // 关闭回调(调用方自行从数据源移除该项)
|
||||
}
|
||||
|
||||
/*** 提示条入参 ***/
|
||||
export interface AlertProps {
|
||||
message: string; // 提示内容
|
||||
title?: string; // 可选的加粗标题行;缺省只渲染正文
|
||||
variant?: 'info' | 'success' | 'warning' | 'danger'; // 语义色;缺省 info
|
||||
closable?: boolean; // 显示 × 关闭按钮(退场后从 DOM 移除);缺省 false
|
||||
onClose?: () => void; // 关闭回调,供调用方同步业务状态
|
||||
}
|
||||
|
||||
/*** 骨架屏入参 ***/
|
||||
export interface SkeletonProps {
|
||||
variant?: 'text' | 'rect' | 'circle'; // 占位形状;缺省 rect
|
||||
rows?: number; // text 形状的行数;缺省 3
|
||||
}
|
||||
|
||||
/*** 分页器入参 ***/
|
||||
export interface PaginationProps {
|
||||
page: Signal<number>; // 当前页码信号,双向绑定(外部赋值自动翻页)
|
||||
pageSize: number; // 每页条数
|
||||
total: number; // 总条数
|
||||
onChange?: (page: number) => void; // 翻页回调
|
||||
}
|
||||
|
||||
/*** toast 消息提示入参 ***/
|
||||
export interface ToastProps {
|
||||
message: string; // 提示文案
|
||||
duration?: number; // 自动消失毫秒;缺省 3000
|
||||
closable?: boolean; // 是否显示关闭按钮(手动立即收起);缺省 false
|
||||
variant?: 'info' | 'success' | 'warning' | 'danger'; // 语义色;缺省 info
|
||||
}
|
||||
|
||||
@@ -92,8 +271,8 @@ export interface SpinnerProps {
|
||||
|
||||
/*** 进度条入参 ***/
|
||||
export interface ProgressProps {
|
||||
value: Signal<number>; // 当前进度 0-100 的信号
|
||||
showText?: boolean; // 是否右侧显示百分比;缺省 true
|
||||
value?: Signal<number>; // 当前进度 0-100 的信号;不传即不确定态(未知时长的加载)
|
||||
showText?: boolean; // 是否右侧显示百分比;缺省 true(不确定态忽略)
|
||||
}
|
||||
|
||||
/*** 标签页入参 ***/
|
||||
@@ -107,9 +286,21 @@ export interface TabsProps {
|
||||
export interface ModalProps {
|
||||
title: string; // 标题
|
||||
open: Signal<boolean>; // 开关信号:true 显示 / false 隐藏
|
||||
size?: 'sm' | 'md' | 'lg'; // 宽度档位;缺省 md(420px),sm 340px / lg 640px
|
||||
footer?: unknown; // 底部操作区内容(常见为按钮组);缺省不渲染
|
||||
onClose?: () => void; // 关闭回调(点遮罩、× 或 Esc 触发)
|
||||
}
|
||||
|
||||
/*** 抽屉入参 ***/
|
||||
export interface DrawerProps {
|
||||
title: string; // 标题
|
||||
open: Signal<boolean>; // 开关信号:true 滑出 / false 收起
|
||||
placement?: 'left' | 'right'; // 滑出方向;缺省 right
|
||||
width?: number; // 面板宽度像素;缺省 320
|
||||
footer?: unknown; // 底部操作区内容;缺省不渲染
|
||||
onClose?: () => void; // 关闭回调(遮罩 / × / Esc 触发)
|
||||
}
|
||||
|
||||
/*** 表单校验规则:按序执行,首个失败即停止 ***/
|
||||
export interface FormRule {
|
||||
required?: boolean; // 非空校验(trim 后判定)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+936
-8
File diff suppressed because it is too large
Load Diff
@@ -191,9 +191,9 @@ var ORDER={skin-dom:0,'headless-core':1,'design-tokens':2};
|
||||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
function propsTable(c){
|
||||
var rows=(c.props||[]).map(function(p){
|
||||
return '<tr><td><code>'+esc(p.n)+'</code></td><td><code>'+esc(p.t)+'</code></td><td>'+(p.r?'必填':'否')+'</td><td>'+esc(p.d||'')+(p.k?'(回调)':'')+'</td></tr>';
|
||||
return '<tr><td><code>'+esc(p.n)+'</code></td><td><code>'+esc(p.t)+'</code></td><td>'+(p.r?'必填':'否')+'</td><td>'+(p.df?'<code>'+esc(p.df)+'</code>':'—')+'</td><td>'+esc(p.d||'')+(p.k?'(回调)':'')+'</td></tr>';
|
||||
}).join('');
|
||||
return '<table class="props"><tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>'+rows+'</table>';
|
||||
return '<table class="props"><tr><th>参数</th><th>类型</th><th>必填</th><th>默认值</th><th>说明</th></tr>'+rows+'</table>';
|
||||
}
|
||||
function compBlock(c){
|
||||
var steps=(c.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||||
@@ -289,7 +289,7 @@ function renderAgentDocs(index, tokens) {
|
||||
if (c.props?.length) {
|
||||
lines.push('参数:', '');
|
||||
for (const prop of c.props) {
|
||||
const marks = [prop.r ? '必填' : '', prop.k ? '回调' : ''].filter(Boolean).join(',');
|
||||
const marks = [prop.r ? '必填' : '', prop.k ? '回调' : '', prop.df ? `默认 ${prop.df}` : ''].filter(Boolean).join(',');
|
||||
lines.push(`- ${prop.n}${marks ? `(${marks})` : ''}:${prop.t}${prop.d ? ` —— ${prop.d}` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
@@ -491,6 +491,11 @@ function collectSourceFiles() {
|
||||
return out;
|
||||
}
|
||||
|
||||
/*** 把字符串 / 模板字面量整体替换为等长空格,便于按位置定位真实注释(避开 URL、正则里的 //) ***/
|
||||
function blankStrings(line) {
|
||||
return line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, (m) => ' '.repeat(m.length));
|
||||
}
|
||||
|
||||
/*** 检查单文件:导出缺注释块、接口字段缺行尾注释、行注释非中文 ***/
|
||||
function styleCheckFile(file, src) {
|
||||
const issues = [];
|
||||
@@ -528,7 +533,7 @@ function styleCheckFile(file, src) {
|
||||
}
|
||||
}
|
||||
|
||||
const hash = raw.indexOf('//');
|
||||
const hash = blankStrings(raw).indexOf('//'); // 注释定位:先剥离字面量,'https://' 不算注释
|
||||
if (hash >= 0) {
|
||||
const text = raw.slice(hash + 2);
|
||||
const isPragma = /^[\s*/]*(eslint|@ts-|prettier-ignore|istanbul|c8|node:)/i.test(text);
|
||||
@@ -555,6 +560,139 @@ function cmdStyle() {
|
||||
console.log(`style OK (${files.length} files)`);
|
||||
}
|
||||
|
||||
/*** 皮肤包硬编码颜色检查:#hex 字面量一律禁止,颜色必须走 --ua-* 令牌(rgba 仅限阴影/遮罩的透明度叠加) ***/
|
||||
function checkSkinCss(pkg) {
|
||||
const issues = [];
|
||||
const dir = path.join(ROOT, pkg.dir);
|
||||
if (!fs.existsSync(dir)) return issues;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (!name.endsWith('.css')) continue; // 只查样式表
|
||||
const file = path.join(dir, name);
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, i) => {
|
||||
if (/#[0-9a-fA-F]{3,8}\b/.test(line)) issues.push(`${pkg.dir}/${name}:${i + 1} 皮肤包禁止硬编码颜色,改用 --ua-* 令牌`);
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/*** README 组件区块校验:marker 之间的 id 集合必须与全部清单的 docs.components 完全一致(防文档漂移) ***/
|
||||
function checkReadme(pkgs) {
|
||||
const issues = [];
|
||||
const file = path.join(ROOT, 'README.md');
|
||||
if (!fs.existsSync(file)) return issues; // 卫语句:没有 README 就不校验
|
||||
const block = fs.readFileSync(file, 'utf8').match(/<!-- ua:components -->([\s\S]*?)<!-- \/ua:components -->/);
|
||||
if (!block) {
|
||||
issues.push('README.md 缺少组件区块标记(<!-- ua:components --> … <!-- /ua:components -->)');
|
||||
return issues;
|
||||
}
|
||||
const listed = new Set(block[1].split('·').map((s) => s.trim()).filter(Boolean));
|
||||
const actual = new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id)));
|
||||
for (const id of actual) if (!listed.has(id)) issues.push(`README.md 组件区块缺少 ${id}`);
|
||||
for (const id of listed) if (!actual.has(id)) issues.push(`README.md 组件区块多出 ${id}(清单里没有)`);
|
||||
return issues;
|
||||
}
|
||||
|
||||
/*** 组件数漂移校验:AGENTS.md / ARCHITECTURE.md 中的「N 个组件」必须与 skin-dom 清单实际总数一致 ***/
|
||||
function checkCountDocs(actual) {
|
||||
const issues = [];
|
||||
const re = /(\d+)\s*个(?:\s*DOM\s*)?组件/;
|
||||
for (const name of ['AGENTS.md', 'ARCHITECTURE.md']) {
|
||||
const file = path.join(ROOT, name);
|
||||
if (!fs.existsSync(file)) continue; // 卫语句:文档缺失不校验
|
||||
fs.readFileSync(file, 'utf8').split(/\r?\n/).forEach((line, i) => {
|
||||
const m = line.match(re);
|
||||
if (m && Number(m[1]) !== actual) {
|
||||
issues.push(`${name}:${i + 1} 组件数「${m[1]}」与清单实际 ${actual} 不一致,请同步`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/*** 清单一致性校验:组件文档块的 id / props 说明 / demo 锚点必须与代码和演示产物对齐 ***/
|
||||
function cmdVerify() {
|
||||
const issues = [];
|
||||
const pkgs = collectManifests(); // 一次收集,供组件校验与 README 区块校验共用
|
||||
const demoPath = path.join(ROOT, 'apps', 'demo', 'main.ts'); // 演示区块定义在源码里(产物是打包后的 JS)
|
||||
const demoSource = fs.existsSync(demoPath) ? fs.readFileSync(demoPath, 'utf8') : '';
|
||||
const anchors = new Set(demoSource.match(/sec-[a-z0-9-]+/g) ?? []); // 已定义的区块锚点
|
||||
if (!demoSource) console.log('verify: 未找到 apps/demo/main.ts,跳过 demo 锚点校验');
|
||||
|
||||
for (const pkg of pkgs) {
|
||||
const comps = pkg.docs?.components ?? []; // 清单已被 collectManifests 摊平
|
||||
const exports = pkg.api?.exports ?? [];
|
||||
if (pkg.type === 'skin-dom') issues.push(...checkSkinCss(pkg));
|
||||
if (comps.length && !exports.length) issues.push(`${pkg.name}: docs.components 有 ${comps.length} 项但 api.exports 为空`);
|
||||
|
||||
const seen = new Set(); // 组件 id 去重(同一清单内不允许重复)
|
||||
for (const c of comps) {
|
||||
const tag = `${pkg.name}#${c.id ?? '?'}`;
|
||||
if (!c.id || !c.title) issues.push(`${tag}: 缺少 id 或 title`);
|
||||
if (seen.has(c.id)) issues.push(`${tag}: id 重复`);
|
||||
seen.add(c.id);
|
||||
if (!c.example) issues.push(`${tag}: 缺少 example`);
|
||||
if ((c.steps ?? []).length < 3) issues.push(`${tag}: steps 少于 3 条`);
|
||||
if (!exports.some((e) => e.startsWith(`${c.id}(`) || e.startsWith(`${c.id}.`))) {
|
||||
issues.push(`${tag}: api.exports 中没有以「${c.id}(」或「${c.id}.」开头的条目`);
|
||||
}
|
||||
for (const prop of c.props ?? []) {
|
||||
if (!prop.n || !prop.t || !prop.d) issues.push(`${tag}: props 条目 ${prop.n ?? '?'} 缺 n/t/d`);
|
||||
}
|
||||
if (c.demo && demoSource && !anchors.has(c.demo)) {
|
||||
issues.push(`${tag}: demo 锚点 ${c.demo} 在 apps/demo/main.ts 中不存在(缺对应 section)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skinTotal = pkgs // skin-dom 清单组件总数,供文档数字漂移校验
|
||||
.filter((p) => p.type === 'skin-dom')
|
||||
.flatMap((p) => p.docs?.components ?? []).length;
|
||||
issues.push(...checkCountDocs(skinTotal));
|
||||
issues.push(...checkReadme(pkgs));
|
||||
|
||||
if (issues.length) {
|
||||
for (const it of issues) console.error('verify: ' + it);
|
||||
console.error(`${issues.length} violation(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('verify OK (manifest ↔ docs ↔ demo 一致,皮肤无硬编码颜色,README 组件清单同步)');
|
||||
}
|
||||
|
||||
/*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/
|
||||
function cmdCommitLint(args) {
|
||||
// 输入来源:文件路径(commit-msg 钩子传参)或参数拼接
|
||||
let msg = '';
|
||||
if (args[0] && fs.existsSync(args[0])) msg = fs.readFileSync(args[0], 'utf8');
|
||||
else msg = args.join(' ');
|
||||
if (!msg.trim()) { console.error('commit-lint: 提交信息为空'); process.exit(1); }
|
||||
|
||||
const head = msg.split('\n')[0].trimEnd();
|
||||
// git 自动生成的合并/回滚信息直接放行
|
||||
if (/^(Merge |Revert |fixup!|squash!)/.test(head)) return console.log('commit-lint OK (git 自动信息,跳过校验)');
|
||||
|
||||
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'iter'];
|
||||
const m = head.match(/^(\w+)(?:\(([^)]*)\))?: (.+)$/);
|
||||
if (!m) { console.error('commit-lint: 首行须为 <type>(<scope>): <subject>,规范见 COMMITTING.md'); process.exit(1); }
|
||||
const [, type, scope, subject] = m;
|
||||
const problems = [];
|
||||
// type 枚举
|
||||
if (!TYPES.includes(type)) problems.push(`type「${type}」不在枚举:${TYPES.join('/')}`);
|
||||
// iter 提交 scope 必须是 backlog 条目 id
|
||||
if (type === 'iter' && !/^[a-z]\d{2,}$/.test(scope ?? '')) problems.push('iter 提交的 scope 必须是 backlog 条目 id(如 b02/e03)');
|
||||
// 非 iter 提交 scope 仅允许小写字母与连字符
|
||||
if (type !== 'iter' && scope && !/^[a-z][a-z-]*$/.test(scope)) problems.push(`scope「${scope}」仅允许小写字母与连字符`);
|
||||
// subject 长度与标点
|
||||
if ([...subject].length > 50) problems.push(`subject 超过 50 字符(当前 ${[...subject].length})`);
|
||||
if (/[。..!!??]$/.test(subject.trim())) problems.push('subject 结尾不加句号');
|
||||
// body 行宽
|
||||
for (const line of msg.split('\n').slice(1)) {
|
||||
if (line.length > 80) problems.push(`body 行超过 80 字符:${line.slice(0, 20)}…`);
|
||||
}
|
||||
if (problems.length) { for (const p of problems) console.error('commit-lint: ' + p); process.exit(1); }
|
||||
console.log(`commit-lint OK (${type}${scope ? `(${scope})` : ''})`);
|
||||
}
|
||||
|
||||
// ---- 入口 ----
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
@@ -578,10 +716,16 @@ switch (cmd) {
|
||||
case 'style':
|
||||
cmdStyle();
|
||||
break;
|
||||
case 'verify':
|
||||
cmdVerify();
|
||||
break;
|
||||
case 'commit-lint':
|
||||
cmdCommitLint(args);
|
||||
break;
|
||||
case 'mcp':
|
||||
cmdMcp();
|
||||
break;
|
||||
default:
|
||||
console.log('usage: ua <index|list|info <name>|docs|style|mcp>');
|
||||
console.log('usage: ua <index|list|info <name>|docs|style|verify|commit-lint|mcp>');
|
||||
process.exit(cmd ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@ui-arch/cli",
|
||||
"type": "tooling",
|
||||
"description": "ua 命令行与 MCP 注册表服务:把各包 ua.manifest.json 汇总成机器可读索引(.ua/index.json)、Agent 全量文档(llms-full.txt)与人类文档站,并提供清单一致性门禁。",
|
||||
"tags": ["cli", "tooling", "mcp", "docs", "gate"],
|
||||
"api": {
|
||||
"entry": "bin/ua.js",
|
||||
"exports": [
|
||||
"ua index:扫描 packages/ 两层目录的 ua.manifest.json,生成 .ua/index.json 与 llms.txt",
|
||||
"ua list:列出注册表内所有包与组件数",
|
||||
"ua info <name>:查看单个包的清单详情(api / 约定 / 文档步骤)",
|
||||
"ua docs:生成人类文档站 apps/docs/index.html + demo.html 与 Agent 全量文档 llms-full.txt",
|
||||
"ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1",
|
||||
"ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在)",
|
||||
"ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1",
|
||||
"ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components"
|
||||
],
|
||||
"keyTypes": ["ua.manifest.json 清单结构", "MCP tool 入参 schema"]
|
||||
},
|
||||
"usage": "node packages/tools/cli/bin/ua.js <index|list|info <name>|docs|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify",
|
||||
"conventions": [
|
||||
"零依赖:只用 node 标准库,Node >= 18",
|
||||
"清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生",
|
||||
"门禁只增不改:新增校验必须能被人手复现,报错要指出文件与字段"
|
||||
],
|
||||
"docs": {
|
||||
"intro": "工程层工具包(private,不发布)。所有 Agent 与人都通过它读取组件库:清单驱动索引、文档、MCP 三件套,外加 style / verify 两道门禁。",
|
||||
"steps": [
|
||||
"装好依赖后先跑 npm run check 确认基线全绿(build → style → typecheck → smoke → index → verify)",
|
||||
"查组件:ua list 看全量,ua info <包名> 看单包 api 与约定",
|
||||
"改完清单跑 npm run docs:同时刷新人类文档站与 llms-full.txt(Agent 首选喂料)",
|
||||
"提交前 npm run check:style 查注释规范,verify 查清单 ↔ 文档 ↔ 演示锚点是否一致",
|
||||
"在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components"
|
||||
],
|
||||
"components": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*** 作用:Gitea REST API 调用包装器,用法 node scripts/gitea-api.mjs <METHOD> <path不带前导斜杠> [jsonBody],令牌读自 .gitea-token ***/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// 仓库与令牌
|
||||
const base = 'https://gitea.yinyuanjingling.icu/api/v1';
|
||||
const token = readFileSync(new URL('../.gitea-token', import.meta.url), 'utf8').trim();
|
||||
const [method = 'GET', path = '', body = ''] = process.argv.slice(2);
|
||||
// 卫语句:缺参数退出
|
||||
if (!method || !path) { console.error('用法: node scripts/gitea-api.mjs <METHOD> <path> [jsonBody]'); process.exit(2); }
|
||||
|
||||
// 发起请求并输出结果(path 不带前导斜杠,规避 Git Bash MSYS 路径转换篡改)
|
||||
const res = await fetch(`${base}/${path.replace(/^\//, '')}`, {
|
||||
method: method.toUpperCase(),
|
||||
headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' },
|
||||
body: body || undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
console.log(`HTTP ${res.status}\n${text}`);
|
||||
process.exit(res.ok ? 0 : 1);
|
||||
Reference in New Issue
Block a user