Compare commits

...

606 Commits

Author SHA1 Message Date
HIsheR d0e408fae6 chore(backlog): 记录 g282 完成 2026-10-26 23:08:29 +08:00
HIsheR c98f98e3f4 iter(g282): 代码块支持长行折行 2026-10-26 23:08:29 +08:00
HIsheR cdf68b457e chore(backlog): 记录 g281 完成 2026-10-26 23:07:50 +08:00
HIsheR 82e69df6df iter(g281): 抽出差异纯逻辑并加皮肤冒烟 2026-10-26 23:07:49 +08:00
HIsheR 65c8faaa66 chore(backlog): 记录 g280 完成 2026-10-26 23:06:22 +08:00
HIsheR 865477c37f iter(g280): 表单项支持条件显示 2026-10-26 23:06:21 +08:00
HIsheR 802dbabe05 chore(backlog): 记录 g279 完成 2026-10-26 23:05:23 +08:00
HIsheR ef58a415e7 iter(g279): diffView 增加忽略行内空白模式 2026-10-26 23:05:23 +08:00
HIsheR 242624c33a chore(backlog): 记录 g278 完成 2026-10-26 23:04:20 +08:00
HIsheR c36b5078af iter(g278): 热力图图例与网格尺寸同源 2026-10-26 23:04:19 +08:00
HIsheR 414c61a002 chore(backlog): 记录 g277 完成 2026-10-26 23:03:29 +08:00
HIsheR b5576c7082 iter(g277): 新增日历热力图组件 2026-10-26 23:03:28 +08:00
HIsheR b37b23c813 chore(backlog): 修正 g276 备注被命令替换吞掉的一句 2026-10-26 23:02:32 +08:00
HIsheR 8db2e8ef72 chore(backlog): 记录 g276 完成 2026-10-26 23:02:16 +08:00
HIsheR eac7d4b337 iter(g276): 新增迷你趋势线组件 2026-10-26 23:02:15 +08:00
HIsheR 573d3aa8a9 docs(changelog): 收录 g265-g275 迭代记录 2026-10-26 23:01:16 +08:00
HIsheR 72432e8d31 chore(backlog): 记录 g275 完成 2026-10-26 23:00:45 +08:00
HIsheR b454d5f7b0 iter(g275): 新增相对时间组件 2026-10-26 23:00:45 +08:00
HIsheR fa84a392fb chore(backlog): 记录 g274 完成 2026-10-26 22:59:58 +08:00
HIsheR b93e8b01a5 iter(g274): 补三条新组件的组合配方 2026-10-26 22:59:57 +08:00
HIsheR 2b8deb5099 chore(backlog): 记录 g273 完成 2026-10-26 22:59:07 +08:00
HIsheR d6e4319151 iter(g273): 新增区域加载遮罩组件 2026-10-26 22:59:06 +08:00
HIsheR 1cb9d7a25a chore(backlog): 记录 g272 完成 2026-10-26 22:58:04 +08:00
HIsheR b6c2afbd97 iter(g272): 视觉抽查清单扩到十一个组件 2026-10-26 22:58:03 +08:00
HIsheR 2c08c11645 chore(backlog): 记录 g271 完成 2026-10-26 22:56:38 +08:00
HIsheR 1680c4ef8d iter(g271): 新增手写签名板组件 2026-10-26 22:56:38 +08:00
HIsheR 6e048c1cd3 chore(backlog): 记录 g270 完成 2026-10-26 22:54:56 +08:00
HIsheR b8f90b8e0d iter(g270): 新增代码块组件 2026-10-26 22:54:55 +08:00
HIsheR 02d7eb3c4d chore(backlog): 记录 g269 完成 2026-10-26 22:53:50 +08:00
HIsheR 368d47cbca iter(g269): 新增文本差异对比组件 2026-10-26 22:53:49 +08:00
HIsheR dccf79bb01 chore(backlog): 记录 g268 完成 2026-10-26 22:52:37 +08:00
HIsheR 9380304e80 iter(g268): verify 增加孤儿演示区块反向校验 2026-10-26 22:52:36 +08:00
HIsheR 8fc4ec67e8 chore(backlog): 记录 g267 完成 2026-10-26 22:51:24 +08:00
HIsheR cf44880df7 iter(g267): 补 Windows 强制色高对比兜底 2026-10-26 22:51:23 +08:00
HIsheR a7b34894ed chore(backlog): 记录 g266 完成 2026-10-26 22:50:12 +08:00
HIsheR 90bd60e0cf iter(g266): 新增 JSON 树查看组件 2026-10-26 22:50:05 +08:00
HIsheR e2fb02f40e chore(iterations): 修正 g265 留痕时区格式 2026-10-26 22:50:05 +08:00
HIsheR 6238012198 chore(backlog): 记录 g265 完成 2026-10-26 22:48:15 +08:00
HIsheR 71cff84932 iter(g265): 新增布局骨架组件 layout 2026-10-26 22:48:04 +08:00
HIsheR 617269166a chore(backlog): 记录 g264 完成 2026-10-26 22:26:53 +08:00
HIsheR fe9f1fb9ec iter(g264): 数据表格可自定义空状态文案 2026-10-26 22:26:53 +08:00
HIsheR 80671f846f chore(backlog): 记录 g263 完成 2026-10-26 22:25:45 +08:00
HIsheR f8bf00d2f8 iter(g263): 标签输入支持多值粘贴 2026-10-26 22:25:44 +08:00
HIsheR 854f425401 chore(changelog): 收录 g254-g262 迭代记录 2026-10-26 22:24:41 +08:00
HIsheR 2ab472c9a5 chore(backlog): 记录 g262 完成 2026-10-26 22:24:07 +08:00
HIsheR 62d8107266 iter(g262): 对比度门禁增加非文本配对 2026-10-26 22:24:07 +08:00
HIsheR 67a9c23360 chore(backlog): 记录 g261 完成 2026-10-26 22:23:22 +08:00
HIsheR 047b6c599c iter(g261): 删除标签后焦点归还输入框 2026-10-26 22:23:21 +08:00
HIsheR 5863571e28 chore(backlog): 记录 g260 完成 2026-10-26 22:22:38 +08:00
HIsheR 07a5275a7a iter(g260): 补表格分页联动配方 2026-10-26 22:22:37 +08:00
HIsheR 79cc55270e chore(backlog): 记录 g259 完成 2026-10-26 22:21:54 +08:00
HIsheR 11a22f93c1 iter(g259): 数据表格暴露每页条数候选 2026-10-26 22:21:54 +08:00
HIsheR ee39b6088a chore(backlog): 记录 g258 完成 2026-10-26 22:21:05 +08:00
HIsheR 3036f26505 iter(g258): select 增加可访问名参数 2026-10-26 22:21:04 +08:00
HIsheR 9a1a6f9ed1 chore(backlog): 记录 g257 完成 2026-10-26 22:20:14 +08:00
HIsheR 364ac80f40 iter(g257): 分页条窄屏自动换行 2026-10-26 22:20:13 +08:00
HIsheR 5771651e44 chore(backlog): 记录 g256 完成 2026-10-26 22:19:20 +08:00
HIsheR 036e044070 iter(g256): 抽出分页条并加每页条数切换 2026-10-26 22:19:14 +08:00
HIsheR 8e97e99cf4 chore(backlog): 记录 g255 完成 2026-10-26 22:17:27 +08:00
HIsheR 48efb44a26 iter(g255): 减少动效下归零全站悬停位移 2026-10-26 22:17:19 +08:00
HIsheR 6437132ba3 chore(backlog): 记录 g254 完成 2026-10-26 22:14:58 +08:00
HIsheR 43af4cf29d iter(g254): 派生后页码越界回写内核状态 2026-10-26 22:14:44 +08:00
HIsheR dec3a9ff8a chore(changelog): 刷新变更日志至当前提交 2026-10-26 22:12:02 +08:00
HIsheR ad87db767d chore(backlog): 记录 g253 完成 2026-10-26 22:11:44 +08:00
HIsheR d23d861c45 iter(g253): 文档站组件卡支持一键复制 Markdown 参数表 2026-10-26 22:11:44 +08:00
HIsheR d489a3d9c3 chore(backlog): 记录 g252 完成 2026-10-26 22:09:57 +08:00
HIsheR 80ffee54d7 iter(g252): MCP get_component 增加 format=md 直出 Markdown 2026-10-26 22:09:56 +08:00
HIsheR eb9c606e08 chore(backlog): 记录 g251 完成 2026-10-26 22:08:59 +08:00
HIsheR 4b57f1126c iter(g251): ua tokens 增加 --unused 反向查闲置令牌 2026-10-26 22:08:58 +08:00
HIsheR 33cb04ff68 chore(backlog): 记录 g250 完成 2026-10-26 22:07:54 +08:00
HIsheR b6229e2a1c iter(g250): 新增动效兜底门禁,补上转圈与不确定进度的减少动效降级 2026-10-26 22:07:53 +08:00
HIsheR cd20b0ce99 chore(backlog): 记录 g249 完成 2026-10-26 22:05:40 +08:00
HIsheR 2c348704a0 iter(g249): doctor 增加命令面与门禁链自洽自检 2026-10-26 22:05:39 +08:00
HIsheR 38731955dc chore(backlog): 记录 g248 完成 2026-10-26 22:04:19 +08:00
HIsheR 0715131a70 iter(g248): 补配方 14 标签筛选条联动表格 2026-10-26 22:04:19 +08:00
HIsheR 64289b59ca chore(backlog): 记录 g247 完成 2026-10-26 22:03:38 +08:00
HIsheR 42ea31d5b5 iter(g247): 表格支持整体换数据源,dataTable 可接信号数据 2026-10-26 22:03:37 +08:00
HIsheR 19741b52b6 chore(backlog): 记录 g246 完成 2026-10-26 22:00:08 +08:00
HIsheR c5238c6f3a iter(g246): ua info 增加 --md,输出可直接粘贴的 Markdown 片段 2026-10-26 22:00:07 +08:00
HIsheR ab2bd00614 chore(backlog): 记录 g245 完成 2026-10-26 21:58:59 +08:00
HIsheR 6e52cc6206 iter(g245): 补高对比偏好令牌,对比度门禁扩到四套主题 2026-10-26 21:58:58 +08:00
HIsheR 26c9867576 chore(backlog): 记录 g244 完成 2026-10-26 21:57:56 +08:00
HIsheR ea5149338a iter(g244): 新增 inputTag 标签输入组件,回车成标签并支持满额禁输 2026-10-26 21:57:52 +08:00
HIsheR cd8a8e9b12 chore(backlog): 记录 g243 完成 2026-10-26 21:55:31 +08:00
HIsheR f396a5545c iter(g243): 文档站与演示页外壳配色改为从令牌派生,消除暗色强调色漂移 2026-10-26 21:55:27 +08:00
HIsheR aa27a16912 chore(backlog): 记录 g242 完成 2026-10-26 21:53:38 +08:00
HIsheR 29d2d8d70f iter(g242): 新增令牌对比度门禁,修掉暗色主按钮与淡底强调字不达标 2026-10-26 21:53:33 +08:00
HIsheR aa7be506c9 chore(backlog): 记录 g241 完成 2026-10-26 21:42:09 +08:00
HIsheR 69c9cfeade iter(g241): verify 增加演示锚点大写防护,防大写锚点被静默吞掉误报缺段 2026-10-26 21:41:50 +08:00
HIsheR 9aa71e7cf4 chore(backlog): 记录 g240 完成 2026-10-26 21:41:00 +08:00
HIsheR 594051975b iter(g240): 按钮 loading 态,转圈禁点支持布尔与信号动态同步 2026-10-26 21:40:43 +08:00
HIsheR 1ce98bdacd chore(backlog): 记录 g239 完成 2026-10-26 21:38:14 +08:00
HIsheR 53338960be iter(g239): 焦点描边令牌化,outline 收敛为三档复合令牌并纳入审计第十四类 2026-10-26 21:37:57 +08:00
HIsheR 9e85ef3f12 chore(backlog): 记录 g238 完成 2026-10-26 21:35:34 +08:00
HIsheR 226271986e iter(g238): 新增 highlight 关键词高亮,命中片段包 mark 并随信号实时联动(75 组件) 2026-10-26 21:35:18 +08:00
HIsheR 5f150dccaa chore(backlog): 记录 g237 完成 2026-10-26 21:31:07 +08:00
HIsheR fc8c5ad776 iter(g237): 新增 kbd 键盘按键标记,键帽样式支持单键与组合键(74 组件) 2026-10-26 21:30:52 +08:00
HIsheR 55980c5ec1 chore(backlog): 记录 g236 完成 2026-10-26 21:29:12 +08:00
HIsheR d50455318d iter(g236): 新增 countdown 倒计时组件,秒级递减自动停表并带句柄控制(73 组件) 2026-10-26 21:28:56 +08:00
HIsheR 671c87456e chore(backlog): 记录 g235 完成 2026-10-26 21:26:44 +08:00
HIsheR 5443aad8be iter(g235): 新增 copyButton 复制按钮,剪贴板写入带已复制反馈与降级(72 组件) 2026-10-26 21:26:11 +08:00
HIsheR 7e5e9f50d6 chore(backlog): 记录 g234 完成 2026-10-26 21:23:15 +08:00
HIsheR 8740ab02a9 iter(g234): 新增 pinInput 验证码输入组件,N 格数字输入支持进格回删移格与粘贴分发(71 组件) 2026-10-26 21:22:57 +08:00
HIsheR 45e56d06bb chore(backlog): 记录 g233 完成 2026-10-26 21:17:01 +08:00
HIsheR 0eb67c48fe iter(g233): 位移量令牌化,transform 位移收敛为 --ua-translate-* 七档并纳入审计口径 2026-10-26 21:16:22 +08:00
HIsheR 39f53041e4 chore(backlog): 修正 g232 留痕哈希 2026-10-26 20:47:59 +08:00
HIsheR be9d45e2e8 chore(changelog): 刷新变更日志至 g232 2026-10-26 20:47:29 +08:00
HIsheR 5f38d8a0e0 chore(backlog): 记录 g232 完成 2026-10-26 20:47:18 +08:00
HIsheR 61f07e4c37 iter(g232): doctor 补 git 身份检查 2026-10-26 20:47:14 +08:00
HIsheR 94c8b8646c chore(backlog): 记录 g231 完成 2026-10-26 20:46:14 +08:00
HIsheR cd15c7079c iter(g231): search 覆盖配方域三层 2026-10-26 20:46:01 +08:00
HIsheR 051baf0e01 chore(backlog): 记录 g230 完成 2026-10-26 20:43:27 +08:00
HIsheR 0d3c166674 iter(g230): demo 挂页级命令面板跳转 2026-10-26 20:43:13 +08:00
HIsheR 4a571af7ba chore(backlog): 记录 g229 完成 2026-10-26 20:41:17 +08:00
HIsheR 36c787a203 iter(g229): 混色浓度令牌化并入视觉棘轮 2026-10-26 20:40:52 +08:00
HIsheR 7579170912 chore(backlog): 记录 g228 完成 2026-10-26 20:30:30 +08:00
HIsheR a692cab80f iter(g228): 新增 ua doctor 环境自检命令 2026-10-26 20:29:52 +08:00
HIsheR b19ea5d2b9 chore(backlog): 补记 g220 完成 2026-10-26 20:07:37 +08:00
HIsheR 4b113215c7 chore(backlog): 记录 g227 完成 2026-10-26 20:07:12 +08:00
HIsheR 87bdc03213 iter(g227): 打开后焦点进入时列 2026-10-26 20:07:05 +08:00
HIsheR 11abb36f00 chore(backlog): 记录 g226 完成 2026-10-26 20:06:35 +08:00
HIsheR 93a7c675fe iter(g226): 通用约定补单一来源规则 2026-10-26 20:06:30 +08:00
HIsheR fa247fc259 chore(backlog): 记录 g225 完成 2026-10-26 20:05:59 +08:00
HIsheR 9c783cd834 iter(g225): 时分两列补分界与列宽 2026-10-26 20:05:52 +08:00
HIsheR 33a7a0bc6f chore(docs): 同步索引产物 2026-10-26 20:05:27 +08:00
HIsheR 11ab8a13e7 chore(backlog): 记录 g224 完成 2026-10-26 20:05:15 +08:00
HIsheR 63351dcaba iter(g224): 收起态上下键微调时间 2026-10-26 20:05:07 +08:00
HIsheR c5826b7211 chore(backlog): 记录 g223 完成 2026-10-26 20:04:34 +08:00
HIsheR 126da0583a iter(g223): 非档值按最近档对齐 2026-10-26 20:04:27 +08:00
HIsheR 063b6cb72b chore(backlog): 记录 g222 完成 2026-10-26 20:03:21 +08:00
HIsheR 3067c6380a iter(g222): 时间选择支持高度档位 2026-10-26 20:03:10 +08:00
HIsheR 467de41d4e chore(backlog): 记录 g221 完成 2026-10-26 20:02:27 +08:00
HIsheR 92bc8bb78a iter(g221): verify 校验滚动容器覆盖 2026-10-26 20:02:21 +08:00
HIsheR 0c978c9811 chore(docs): 同步配方 13 的索引产物 2026-10-26 20:02:20 +08:00
HIsheR 1ac8baedfe iter(g220): 时间列并入滚动条统一规则 2026-10-26 20:01:17 +08:00
HIsheR 5c80346179 chore(backlog): 记录 g219 完成 2026-10-26 20:00:37 +08:00
HIsheR f042c668da iter(g219): verify 增加重复关键帧检测 2026-10-26 20:00:29 +08:00
HIsheR 5172abda5d chore(backlog): 记录 g218 完成 2026-10-26 19:59:29 +08:00
HIsheR ed2dd1513f iter(g218): 未选值时两列停在此刻 2026-10-26 19:59:22 +08:00
HIsheR 39a5d0d9b8 chore(backlog): 记录 g217 完成 2026-10-26 19:58:49 +08:00
HIsheR 34985fff43 iter(g217): 补 frame 内核冒烟并修复 effect 注销 2026-10-26 19:58:40 +08:00
HIsheR d7ad79963b chore(backlog): 记录 g216 完成 2026-10-26 19:57:27 +08:00
HIsheR 17da5618f5 iter(g216): 时间选择支持清空 2026-10-26 19:57:20 +08:00
HIsheR 050534a07f chore(backlog): 记录 g215 完成 2026-10-26 19:55:47 +08:00
HIsheR 0fb919893f iter(g215): 新增日期加时间组合配方 2026-10-26 19:55:38 +08:00
HIsheR 756a7e899d chore(backlog): 记录 g214 完成 2026-10-26 19:54:56 +08:00
HIsheR a607a40d19 iter(g214): 浮层入场动效收敛为统一 keyframes 2026-10-26 19:54:47 +08:00
HIsheR 1dca26901b chore(backlog): 记录 g213 完成 2026-10-26 19:53:35 +08:00
HIsheR 350544927d iter(g213): 新增 timePicker 时间选择组件 2026-10-26 19:53:22 +08:00
HIsheR 3b0888d56b chore(backlog): 记录 g212 完成 2026-10-26 19:50:44 +08:00
HIsheR 6ccc341998 iter(g212): 页签溢出时自动把激活页签拉回可视区 2026-10-26 19:50:33 +08:00
HIsheR 7fa60fbf57 chore(backlog): 记录 g211 完成 2026-10-26 19:49:13 +08:00
HIsheR fd2e9517d3 iter(g211): verify 增加令牌双源一致性校验 2026-10-26 19:49:05 +08:00
HIsheR 82fa8c8732 chore(backlog): 记录 g210 完成 2026-10-26 19:47:29 +08:00
HIsheR 053f38918d iter(g210): 滚动条令牌化并适配暗色主题 2026-10-26 19:47:14 +08:00
HIsheR 5a1d1660e9 chore(backlog): 记录 g209 完成 2026-10-26 19:22:57 +08:00
HIsheR e0cf55e875 iter(g209): 演示页补齐新参数现场演示 2026-10-26 19:22:56 +08:00
HIsheR b30dfe3501 chore(backlog): 记录 g208 完成 2026-10-26 19:20:36 +08:00
HIsheR 9180e2c9fd iter(g208): multiSelect 支持一键清空 2026-10-26 19:20:35 +08:00
HIsheR b91c1952e9 chore(backlog): 记录 g207 完成 2026-10-26 19:18:07 +08:00
HIsheR 4eaa177c6d iter(g207): progress 支持自定义进度文本 2026-10-26 19:18:05 +08:00
HIsheR e1e39bb087 chore(backlog): 记录 g206 完成 2026-10-26 19:16:49 +08:00
HIsheR f763f635d1 iter(g206): rate 支持再次点击清除评分 2026-10-26 19:16:48 +08:00
HIsheR dae6353b58 chore(backlog): 记录 g205 完成 2026-10-26 19:15:31 +08:00
HIsheR 6ee86ac732 iter(g205): textField 支持密码显隐切换 2026-10-26 19:15:30 +08:00
HIsheR 5e14fc0dde chore(backlog): 记录 g204 完成 2026-10-26 19:13:53 +08:00
HIsheR 5bc854b939 iter(g204): textarea 支持内容自适应高度 2026-10-26 19:13:52 +08:00
HIsheR 197d57b4f8 chore(backlog): 记录 g203 完成 2026-10-26 19:11:15 +08:00
HIsheR df514784c5 iter(g203): 文档站亮暗切换与 demo 主题参数联动 2026-10-26 19:11:10 +08:00
HIsheR 387f9f2dad chore(backlog): 记录 g202 完成 2026-10-26 19:03:50 +08:00
HIsheR f3325003b7 iter(g202): 修正按下态可见性并归并 accent 混 bg 变体 2026-10-26 19:03:48 +08:00
HIsheR 2971f9a61f fix(ui): 输入附加块撑满组高消除上下色缝 2026-10-26 18:47:39 +08:00
HIsheR 106bbcaa56 fix(ui): 收敛激活态描边、修正内层圆角溢出并替换 emoji 图标 2026-10-26 18:47:04 +08:00
HIsheR 6c4796cded chore(backlog): 更正 g200/g201 留痕口径至复核定稿 2026-10-26 18:34:22 +08:00
HIsheR 0821286329 fix(ui): 归位 reduced-motion 兜底并收敛分层入场延迟
- 删除被入场声明反超的 reduced-motion 块,
  modal/drawer 内容层并入末尾 !important 清单,与全库口径统一
- 内容层 delay 由 fast 档 150ms 改为
  --ua-duration-step × 3 = 60ms,与 g200 验收一致
- 补 .ua-btn-ghost:active 撤内高光,键盘/触屏按下不再露白线
- 交错入场第 8 项起锁定同一档(n+8),顺序单调不倒退
- 更正 g201 焦点环注释归因(菜单面板无 overflow,未裁切)
2026-10-26 18:33:36 +08:00
HIsheR e4351c4b25 chore(backlog): 补录 g199-g201 审美提升第二批留痕 2026-10-26 18:25:26 +08:00
HIsheR e64f72b9fa iter(g201): 补全点击型控件键盘焦点环
全库清点:38 处 :focus-visible、7 处裸 :focus 全部落在输入类
(选择搜索、输入组内层、传搜、自动完成、色板 hex、提及),
按口径保留输入类常驻环,点击型控件不再有 :focus 残留。
修掉两处「吞环」:页签条是 overflow 滚动容器,外偏移环被裁,
改内嵌描边(与 seg-btn 同法);选择面板 / 菜单项同样被面板裁切,
补内嵌描边(与 cascader 项同法),Tab 导航重新可见。

Refs: g201
2026-10-26 18:24:11 +08:00
HIsheR 91aa304d94 iter(g200): 编排浮层分层入场与弹层子项交错
3 处私写缓动归档:进度条与页签指示器归 ease-out,
弹窗本体过冲归新增的 --ua-ease-spring 回弹档。
弹窗 / 抽屉内容层(body、foot)在面板落位后淡入,
delay 走 --ua-duration-fast 近似 60ms 错峰,不新增时长令牌。
下拉菜单与选择面板前 8 项按 nth-child 逐个递进一个微步
(新增 --ua-duration-step 0.02s,由 calc 派生,不写字面时长)。
减少动效偏好下上述动画一律 none 且 delay 清零。

Refs: g200
2026-10-26 18:23:23 +08:00
HIsheR bfb9178733 iter(g199): 新增主按钮内高光与卡片边框减负
tokens 增补:--ua-color-highlight(暗色降到 8%,暗底白高光刺眼)、
--ua-shadow-highlight(1px 内高光走令牌,皮肤不出现阴影字面值)、
--ua-tracking-wide / --ua-tracking-tight 两档字距。
主按钮叠内高光 + 悬停主题色投影,press 保留内高光;
卡片补贴地 1 档阴影,hoverable 悬停升到 2 档并同步减淡边框;
表头、选项分组标题、命令面板大写小标题统一 0.04em,
ui.css 三处字距字面值全部归零。
幽灵按钮无实色底,显式退出质感层避免透明底露白线。

Refs: g199
2026-10-26 18:22:44 +08:00
HIsheR ae1ff24dae chore(backlog): 增补 g202 按下态可见性修正条目 2026-10-26 18:17:35 +08:00
HIsheR 83763d488d fix(ui): 输入类悬停规则排除聚焦态避免覆盖聚焦边框 2026-10-26 18:17:11 +08:00
HIsheR bc32fe9490 chore(backlog): 补录 g196-g198 审美提升首批留痕 2026-10-26 18:11:09 +08:00
HIsheR 2996b878e9 iter(g198): 全控件按下态补齐悬停反馈闭环 2026-10-26 18:09:45 +08:00
HIsheR 8148317f09 iter(g197): accent 淡底五档收敛为两档语义令牌 2026-10-26 18:08:03 +08:00
HIsheR 9ef863739d iter(g196): 中性色阶地基补表面文字遮罩中间档令牌 2026-10-26 18:07:05 +08:00
HIsheR fb69321fea chore(backlog): 记录 g195 完成 2026-10-26 17:58:19 +08:00
HIsheR 8a0a907dca iter(g195): 内距字面值归零视觉基线清零 2026-10-26 17:58:11 +08:00
HIsheR 4f2ee02a3e chore(backlog): 记录 g194 完成 2026-10-26 17:57:13 +08:00
HIsheR 1476a401ae iter(g194): 审计 json 输出命中明细 2026-10-26 17:57:07 +08:00
HIsheR 0ebd6da6a8 chore(backlog): 记录 g193 完成 2026-10-26 17:56:23 +08:00
HIsheR e64097f2fd iter(g193): 阴影字面值归零补描边族令牌 2026-10-26 17:56:14 +08:00
HIsheR 8fffaf5d1d chore(backlog): 记录 g192 完成 2026-10-26 17:55:20 +08:00
HIsheR 68bfbddc82 iter(g192): 动效时长字面值归零补循环档 2026-10-26 17:55:13 +08:00
HIsheR 0e996e42af chore(backlog): 记录 g191 完成 2026-10-26 17:53:59 +08:00
HIsheR bb28f86a11 iter(g191): 焦点环变体归零补语义环令牌 2026-10-26 17:53:54 +08:00
HIsheR 654a9ddfb4 chore(backlog): 记录 g190 完成 2026-10-26 17:52:53 +08:00
HIsheR a67a9fe0a7 iter(g190): 字号字面值归零补展示与缩放档 2026-10-26 17:52:44 +08:00
HIsheR 43d35fcb81 chore(backlog): 记录 g189 完成 2026-10-26 17:51:42 +08:00
HIsheR b83cf59362 iter(g189): 补命令面板组合配方 2026-10-26 17:51:32 +08:00
HIsheR 529455ca5a chore(backlog): 记录 g188 完成 2026-10-26 17:50:36 +08:00
HIsheR 7e47a54ca5 iter(g188): 新增命令面板组件 2026-10-26 17:50:22 +08:00
HIsheR 276a46cccb chore(backlog): 记录 g186 完成 2026-10-26 17:48:05 +08:00
HIsheR 5908202cd7 iter(g186): 固化视觉回归抽查清单 2026-10-26 17:47:50 +08:00
HIsheR 85ae90a5af chore(backlog): 记录 g185 完成 2026-10-26 17:45:59 +08:00
HIsheR 999f3f6ec1 iter(g185): 分割线统一改走细分边框令牌 2026-10-26 17:45:43 +08:00
HIsheR ecd24d55be chore(backlog): 记录 g184 完成 2026-10-26 17:40:50 +08:00
HIsheR 2d81907065 iter(g184): 层级字面值归零 2026-10-26 17:40:37 +08:00
HIsheR 551b2f6407 chore(backlog): 记录 g183 完成 2026-10-26 17:36:55 +08:00
HIsheR fe7972574a iter(g183): 字重行高令牌化 2026-10-26 17:36:39 +08:00
HIsheR 089527fd7d chore(backlog): 记录 g182 完成 2026-10-26 17:33:27 +08:00
HIsheR 9ee04d7bb0 iter(g182): 暗色阴影与焦点环适配 2026-10-26 17:33:14 +08:00
HIsheR 03df0d1294 chore(backlog): 记录 g181 完成 2026-10-26 17:30:12 +08:00
HIsheR 85f009dff8 iter(g181): 控件高度统一三档 2026-10-26 17:30:00 +08:00
HIsheR 1289b54af3 chore(backlog): 记录 g180 完成 2026-10-26 17:22:33 +08:00
HIsheR 90ed48cf86 iter(g180): 内距字面值令牌化 2026-10-26 17:22:19 +08:00
HIsheR 320dac3baf chore(backlog): 记录 g179 完成 2026-10-26 17:17:42 +08:00
HIsheR 7b4adb01a0 iter(g179): 弹层阴影语义化归档 2026-10-26 17:17:26 +08:00
HIsheR a9a084ca97 chore(backlog): 记录 g187 完成 2026-10-26 17:09:36 +08:00
HIsheR 20ff9d4349 iter(g187): 新增虚拟列表组件 2026-10-26 17:09:11 +08:00
HIsheR a3943ecb19 fix(cli): 视觉审计在样式表有未提交改动时归因到工作区
原实现总是回查最近提交,工作区脏时会误报成上一次提交引入。改为先看
git status:脏了就提示「来自工作区未提交的改动」,干净再逐提交倒推。
2026-10-26 16:50:10 +08:00
HIsheR d048717e76 docs(iteration): 选题规则增加域轮换与视觉欠账优先
- backlog 条目新增可选字段 domain(new/tune/visual/tooling),存量 225 条
  按标题关键词回填(new 53 / tooling 38 / tune 134)
- 每轮选题改为先取 open 且 domain=visual 的条目,无视觉欠账再按数组顺序
- 新增《域轮换与视觉欠账优先》三条:连续 3 轮至少 1 轮 visual、视觉欠账
  优先于新增组件、新增组件必须声明只消费 --ua-* 令牌
- AGENTS 落点清单第 4 条补禁止新增硬编码阴影/时长/字号;门禁链描述补
  lint-iterations 与 audit:visual
- 灌入 8 条 domain=visual 欠账(g179…g186),验收标准均可枚举或可机检
2026-10-26 16:49:49 +08:00
HIsheR ee6d84457a chore(cli): 写入视觉迁移后的漂移基线
迁移后实跑 audit:visual --baseline 定档:字号 5 / 时长 9 / 阴影 17 /
圆角 0 / 焦点环 4,合计 35 处。基线只允许下降:任一类上涨即 check 失败。
2026-10-26 16:49:44 +08:00
HIsheR f125369744 feat(cli): 新增视觉漂移审计门禁与幂等迁移脚本
- 新增 lib/visual-rules.mjs 作为规则表单一来源:字号阶梯、时长三档、
  阴影四档、焦点环收敛、圆角档位的判定与改写集中在一处,迁移与审计共用
- 新增 lib/visual-audit.mjs + ua audit:visual:按五类统计硬编码视觉字面量,
  支持 --json 与 --baseline,默认与 .ua/visual-baseline.json 棘轮比对,
  超基线退出码 1 并按 git 历史倒推疑似引入提交
- 新增 scripts/visual-migrate.mjs:幂等迁移 ui.css(重复运行零 diff),
  另带 --seed 灌视觉欠账、--backfill-domain 回填域字段
- 实跑迁移:283 处降到 35 处(字号 27→5、时长 170→9、阴影 56→17、
  圆角 2→0、焦点环 28→4),159 行逐行等价替换,括号/分号/行数不变
- package.json 把 audit:visual 追加到 check 链末尾,并补 visual:migrate
2026-10-26 16:49:40 +08:00
HIsheR 5a1d5ba6d7 feat(tokens): 扩充视觉令牌到八类并统一焦点环与动效
- 保留原有颜色/圆角/间距/字号四组,新增字重行高、边框、阴影、动效、
  焦点环、控件高度、层级七组,共十一组;每个变量补中文用途注释
- 阴影收敛为四档 elevation(柔和优先:扩散更大、透明度更低),并在
  [data-theme=dark] 覆盖为更高黑度,解决暗色下阴影沿用亮色投影的问题
- 动效时长收为 fast/base/slow 三档(fast 取 0.15s,贴合现有主流时长),
  补 --ua-ease / --ua-ease-out 两条主缓动
- 焦点环统一为 --ua-focus-ring / --ua-focus-ring-inset 两个槽位
- 字号补 xs/xl/2xl,控件高度三档,层级五档
- tokens.json 资产清单按新分组同步
2026-10-26 16:49:35 +08:00
HIsheR a1e8384711 chore(backlog): 记录 g178 完成 2026-10-26 16:30:47 +08:00
HIsheR 92189333ae iter(g178): 校验配方是否认出组件 2026-10-26 16:30:46 +08:00
HIsheR 77afc693ad chore(backlog): 记录 g177 完成 2026-10-26 16:29:32 +08:00
HIsheR c6fd8b8afb iter(g177): 补充配方查询子命令 2026-10-26 16:29:26 +08:00
HIsheR ee758817d3 chore(backlog): 记录 g176 完成 2026-10-26 16:27:03 +08:00
HIsheR cc9ec02c94 iter(g176): 支持按参数说明检索组件 2026-10-26 16:26:53 +08:00
HIsheR 31650b1275 chore(backlog): 记录 g175 完成 2026-10-26 16:23:24 +08:00
HIsheR cbce645b3b iter(g175): 确认气泡支持异步确认 2026-10-26 16:23:23 +08:00
HIsheR 66c39e0145 chore(backlog): 记录 g174 完成 2026-10-26 16:21:49 +08:00
HIsheR 2316445b1e iter(g174): 时间线支持双侧交替 2026-10-26 16:21:48 +08:00
HIsheR 7084fe1feb chore(backlog): 记录 g173 完成 2026-10-26 16:20:31 +08:00
HIsheR ea4e781bd4 iter(g173): 上传图片显示缩略图 2026-10-26 16:20:30 +08:00
HIsheR 2c17696cc5 chore(backlog): 记录 g172 完成 2026-10-26 16:19:33 +08:00
HIsheR 250601dc78 iter(g172): 列表命令支持 JSON 输出 2026-10-26 16:19:32 +08:00
HIsheR a071cb041e chore(backlog): 修正 g171 留痕提交哈希 2026-10-26 16:19:12 +08:00
HIsheR 6a605bace3 iter(g171): 刷新变更日志与配方目录 2026-10-26 16:19:07 +08:00
HIsheR dd55fee177 chore(backlog): 记录 g171 完成 2026-10-26 16:17:56 +08:00
HIsheR 8c2ff7bc21 chore(changelog): 刷新变更日志并补配方目录 2026-10-26 16:17:48 +08:00
HIsheR 3804759f45 chore(backlog): 记录 g170 完成 2026-10-26 16:17:07 +08:00
HIsheR a754bd6b25 iter(g170): 分页器支持极简模式与单页隐藏 2026-10-26 16:17:05 +08:00
HIsheR 8c9541d209 chore(backlog): 记录 g169 完成 2026-10-26 16:16:08 +08:00
HIsheR bd98fc8e70 iter(g169): 表单字段支持跨列 2026-10-26 16:16:07 +08:00
HIsheR adc94cabf5 chore(backlog): 记录 g168 完成 2026-10-26 16:15:00 +08:00
HIsheR 879be290d4 iter(g168): 补配方 11 数据看板筛选区 2026-10-26 16:14:59 +08:00
HIsheR 649ebf3291 chore(backlog): 记录 g167 完成 2026-10-26 16:14:20 +08:00
HIsheR ff9442904b iter(g167): 弹窗支持内置页脚与异步确认 2026-10-26 16:14:19 +08:00
HIsheR b6a6827f9b chore(backlog): 记录 g166 完成 2026-10-26 16:13:06 +08:00
HIsheR a80644c35b iter(g166): 下拉选项支持分组标题 2026-10-26 16:13:05 +08:00
HIsheR e1b7de6f20 chore(backlog): 记录 g165 完成 2026-10-26 16:11:07 +08:00
HIsheR 804ab3b18f iter(g165): 步骤条支持竖向排列 2026-10-26 16:11:06 +08:00
HIsheR dddbccd97d chore(backlog): 记录 g164 完成 2026-10-26 16:10:00 +08:00
HIsheR b533903994 iter(g164): 描述列表支持竖排与标题操作区 2026-10-26 16:09:59 +08:00
HIsheR bb1625a06d chore(backlog): 记录 g163 完成 2026-10-26 16:08:24 +08:00
HIsheR 6febeba211 iter(g163): 留痕校验补编号与时间线约束 2026-10-26 16:08:22 +08:00
HIsheR eea5074edf chore(backlog): 记录 g162 完成 2026-10-26 16:07:28 +08:00
HIsheR fe78275304 iter(g162): 门禁补暗色令牌齐全校验 2026-10-26 16:07:27 +08:00
HIsheR a8e13e38e7 chore(backlog): 记录 g161 完成 2026-10-26 16:05:06 +08:00
HIsheR 0bcf73fb76 iter(g161): 通知支持同 key 原地更新 2026-10-26 16:05:05 +08:00
HIsheR 8b742061ec chore(backlog): 记录 g160 完成 2026-10-26 16:04:01 +08:00
HIsheR ef564fc72b iter(g160): 滑块支持双滑块区间 2026-10-26 16:03:55 +08:00
HIsheR 084ffcd980 chore(backlog): 记录 g159 完成 2026-10-26 16:02:30 +08:00
HIsheR 312e80118f iter(g159): 多选下拉支持标签折叠 2026-10-26 16:02:24 +08:00
HIsheR cbf0526408 chore(backlog): 记录 g158 完成 2026-10-26 16:00:58 +08:00
HIsheR 638f623750 iter(g158): 日期选择支持快捷区间预设 2026-10-26 16:00:40 +08:00
HIsheR 476747aaca chore(backlog): 记录 g157 完成 2026-10-26 15:56:57 +08:00
HIsheR c2412fb360 iter(g157): 补配方 10 暗色主题切换
说清三件事:只切 data-theme 不改令牌名、初值先读 localStorage、
同步 color-scheme 让原生控件跟色。MCP get_recipe 10 可取骨架。
2026-10-26 15:56:56 +08:00
HIsheR 1831aecab6 chore(backlog): 记录 g156 完成 2026-10-26 15:55:38 +08:00
HIsheR bbebb7072e iter(g156): ua tokens 支持按名过滤
传子串只列匹配令牌并在末尾标注匹配词,--json 同步过滤。
无匹配时报总数并以 1 退出,替代「全表打印后再肉眼找」。
2026-10-26 15:55:37 +08:00
HIsheR 2848dd555d chore(backlog): 记录 g155 完成 2026-10-26 15:55:14 +08:00
HIsheR a3ece117c7 iter(g155): llms.txt 补配方目录
速览末尾输出 9 条配方的 id / 标题 / 要点数与三种取法,Agent 不必先猜。
顺带修正 cli 清单里只写了 3 个 MCP 工具的过期描述。
2026-10-26 15:55:13 +08:00
HIsheR ea77f4bb34 chore(backlog): 记录 g154 完成 2026-10-26 15:54:06 +08:00
HIsheR ab758ab9fc iter(g154): MCP 搜索补组件级命中
原 search_components 只搜包级字段,命中后返回包,Agent 还得再猜组件 id。
改为返回 { packages, components } 两级,组件按 id / 标题 / props 名命中并带 package。
2026-10-26 15:54:05 +08:00
HIsheR c423d9cb2d chore(backlog): 记录 g153 完成 2026-10-26 15:53:22 +08:00
HIsheR 06091757c7 iter(g153): ua search 支持多关键词
多词按「最弱命中」做 AND 过滤,排序仍按 id > 标题 > props > 步骤。
未命中输出相近建议并以 1 退出,脚本能直接判断成败。
2026-10-26 15:53:21 +08:00
HIsheR 313954f464 chore(backlog): 记录 g152 完成 2026-10-26 15:52:39 +08:00
HIsheR bc984ae837 iter(g152): ua list 增加组件全览
无参仍列包,但每行带上组件数;--components 按包分组列 id / 标题 / 演示锚点。
末尾给出总数与后续命令提示,替代「没有关键词就只能肉眼猜 id」。
2026-10-26 15:52:38 +08:00
HIsheR df75058db4 chore(backlog): 记录 g151 完成 2026-10-26 15:51:49 +08:00
HIsheR f59e9b70e3 iter(g151): verify 校验 MCP 工具清单
MCP_TOOL_NAMES 收敛为单一来源,cmdMcp 启动时自证注册表与常量一致。
verify 再查 README / AGENTS / CLI 清单是否逐个写出工具名,缺失即报错。
2026-10-26 15:51:48 +08:00
HIsheR 7418b7970b chore(backlog): 记录 g150 完成 2026-10-26 15:50:44 +08:00
HIsheR 04f6df87f5 iter(g150): ua info 反查所属配方
查完 props 顺手给出「这个组件出现在哪几条配方」,把两套入口接上。
--json 与 MCP get_component 的组件级返回同步带 recipes 字段。
2026-10-26 15:50:44 +08:00
HIsheR 1455f82602 chore(backlog): 记录 g149 完成 2026-10-26 15:49:56 +08:00
HIsheR d0eea04b48 iter(g149): 补 ua search 关键词检索
按 id / 标题 / props 名 / 步骤四档打分排序,支持 --json 结构化输出。
ua info 与 search 未命中时给出编辑距离 ≤3 的相近 id,拼错也能自己纠正。
usage 与 CLI 清单、README、AGENTS 的命令说明同步。
2026-10-26 15:49:52 +08:00
HIsheR 16032c871a chore(backlog): 记录 g148 完成 2026-10-26 15:48:25 +08:00
HIsheR 4a655d2dea iter(g148): MCP 暴露组合配方检索
新增 list_recipes / get_recipe 两个工具,配方解析统一走 recipes.md。
顺带把误嵌在 renderAgentDocs 内部的 loadRecipes 提到顶层,
并给 verify 加配方结构校验:编号连续、代码骨架、要点、通用约定段。
2026-10-26 15:48:21 +08:00
HIsheR 38284ac425 chore(backlog): 记录 g147 完成 2026-10-26 15:44:45 +08:00
HIsheR 2387335548 iter(g147): 补三条组合配方 2026-10-26 15:44:44 +08:00
HIsheR 734eb33c48 chore(backlog): 记录 g146 完成 2026-10-26 15:43:32 +08:00
HIsheR 2adae6e29e iter(g146): 进度条支持环形形态 2026-10-26 15:43:31 +08:00
HIsheR adc69fa880 chore(backlog): 记录 g145 完成 2026-10-26 15:41:23 +08:00
HIsheR af09500238 iter(g145): 滑块支持刻度标注 2026-10-26 15:41:22 +08:00
HIsheR 99214ff867 chore(backlog): 记录 g144 完成 2026-10-26 15:40:02 +08:00
HIsheR 1009c891d9 iter(g144): 新增头像组组件 avatarGroup 2026-10-26 15:40:01 +08:00
HIsheR 9b1c068ead chore(backlog): 记录 g143 完成 2026-10-26 15:38:51 +08:00
HIsheR 3058da101d iter(g143): 页签栏支持右侧操作区 2026-10-26 15:38:50 +08:00
HIsheR 5b8472eb1f chore(backlog): 记录 g142 完成 2026-10-26 15:37:35 +08:00
HIsheR 5af2139366 iter(g142): 间距容器支持分隔符 2026-10-26 15:37:34 +08:00
HIsheR 508d11a29e chore(backlog): 记录 g141 完成 2026-10-26 15:36:29 +08:00
HIsheR 6cde61829e iter(g141): 通知支持四角停靠 2026-10-26 15:36:28 +08:00
HIsheR 36087b1b20 chore(backlog): 记录 g140 完成 2026-10-26 15:35:04 +08:00
HIsheR e40cd2c1fd iter(g140): 表格支持整行点击 2026-10-26 15:35:03 +08:00
HIsheR 2a7fdcd9d7 chore(backlog): 记录 g139 完成 2026-10-26 15:32:47 +08:00
HIsheR 08ceb8f127 iter(g139): 提示气泡支持左右方位 2026-10-26 15:32:47 +08:00
HIsheR a3db5e66fc chore(backlog): 记录 g138 完成 2026-10-26 15:31:27 +08:00
HIsheR 1af4e57a16 iter(g138): 日期选择支持区间模式 2026-10-26 15:31:26 +08:00
HIsheR b3fd20c103 chore(backlog): 记录 g137 完成 2026-10-26 15:29:51 +08:00
HIsheR 55da08f27c iter(g137): 演示页补暗色主题开关 2026-10-26 15:29:50 +08:00
HIsheR a8026bbee2 chore(backlog): 记录 g136 完成 2026-10-26 15:28:21 +08:00
HIsheR f6706c1622 iter(g136): 新增链接组件 link 2026-10-26 15:28:20 +08:00
HIsheR b1bc8c6ed0 chore(backlog): 记录 g135 完成 2026-10-26 15:27:19 +08:00
HIsheR 21950d9ddd iter(g135): 表单支持多列布局 2026-10-26 15:27:18 +08:00
HIsheR 7b41630b96 chore(backlog): 记录 g134 完成 2026-10-26 15:25:53 +08:00
HIsheR 2a9193ce19 iter(g134): 新增 ua sync-counts 自动同步组件计数 2026-10-26 15:25:52 +08:00
HIsheR 6701c226fb chore(backlog): 记录 g133 完成 2026-10-26 15:24:26 +08:00
HIsheR 0f6331ccc6 iter(g133): 新增树形选择 treeSelect 2026-10-26 15:24:26 +08:00
HIsheR fc195dfb56 chore(backlog): 记录 g132 完成 2026-10-26 15:22:59 +08:00
HIsheR 9d0c195beb iter(g132): 新增 @提及输入 mentions 2026-10-26 15:22:59 +08:00
HIsheR eac02f0c80 chore(backlog): 记录 g131 完成 2026-10-26 15:21:24 +08:00
HIsheR d9559aee1d iter(g131): 新增悬浮操作按钮 floatButton 2026-10-26 15:21:23 +08:00
HIsheR 42e43c0415 chore(backlog): 记录 g130 完成 2026-10-26 15:20:05 +08:00
HIsheR 23d1e063dc iter(g130): 新增栅格容器 grid 2026-10-26 15:20:04 +08:00
HIsheR 88c2515f2e chore(backlog): 记录 g129 完成 2026-10-26 15:18:37 +08:00
HIsheR ea2a846734 iter(g129): 新增排版文本组件 typography 2026-10-26 15:18:27 +08:00
HIsheR 6158f49583 chore(backlog): 记录 g128 完成 2026-10-26 14:52:35 +08:00
HIsheR b39964011c iter(g128): 收尾刷新变更日志 2026-10-26 14:52:17 +08:00
HIsheR 6a5e12b5a1 chore(backlog): 记录 g127 完成 2026-10-26 14:51:40 +08:00
HIsheR 48b0f356d4 iter(g127): 补长表单编辑页组合配方 2026-10-26 14:51:30 +08:00
HIsheR fc58442bbc chore(backlog): 记录 g126 完成 2026-10-26 14:49:42 +08:00
HIsheR 023a2eb7a0 iter(g126): 数字输入补 precision 小数位 2026-10-26 14:49:20 +08:00
HIsheR 686b0f7fb8 chore(backlog): 记录 g125 完成 2026-10-26 14:44:59 +08:00
HIsheR 490c9fa6ed iter(g125): 开关补 loading 加载态 2026-10-26 14:44:37 +08:00
HIsheR 56e5d18753 chore(backlog): 记录 g124 完成 2026-10-26 14:37:12 +08:00
HIsheR 63d3c910a7 iter(g124): 复选框补 indeterminate 半选态 2026-10-26 14:37:02 +08:00
HIsheR 85731a206a chore(backlog): 记录 g123 完成 2026-10-26 14:35:26 +08:00
HIsheR ba102e06fd iter(g123): 收尾刷新变更日志 2026-10-26 14:34:52 +08:00
HIsheR 0338f21dab chore(backlog): 记录 g122 完成 2026-10-26 14:34:12 +08:00
HIsheR 72c5d24961 iter(g122): 评分组件支持 allowHalf 半星 2026-10-26 14:34:03 +08:00
HIsheR f4a51017ae chore(backlog): 记录 g121 完成 2026-10-26 14:31:55 +08:00
HIsheR 151d901bb9 iter(g121): textField 补 maxCount 字数统计 2026-10-26 14:31:15 +08:00
HIsheR 3e1e4ea758 chore(backlog): 记录 g120 完成 2026-10-26 14:13:19 +08:00
HIsheR 5a6c7a5ba0 iter(g120): 补新增组件落点清单 2026-10-26 14:13:18 +08:00
HIsheR 85e63455cd chore(backlog): 记录 g119 完成 2026-10-26 14:12:37 +08:00
HIsheR 176504b6c4 iter(g119): 空状态支持自定义图标 2026-10-26 14:12:36 +08:00
HIsheR c67b167f44 chore(backlog): 记录 g118 完成 2026-10-26 14:11:42 +08:00
HIsheR 123e7e6ce8 iter(g118): 收尾再刷变更日志 2026-10-26 14:11:37 +08:00
HIsheR 2f9f83eb80 chore(backlog): 记录 g117 完成 2026-10-26 14:11:13 +08:00
HIsheR 6cf4905a71 iter(g117): 指示条与滑块跟随尺寸重算 2026-10-26 14:11:09 +08:00
HIsheR dcd5e0e818 chore(backlog): 记录 g116 完成 2026-10-26 14:10:22 +08:00
HIsheR 2ab9406902 iter(g116): 抽屉支持非模态模式 2026-10-26 14:10:17 +08:00
HIsheR 9a118f662d chore(backlog): 记录 g115 完成 2026-10-26 14:09:03 +08:00
HIsheR c2900a4852 iter(g115): 新增月历组件 calendar 2026-10-26 14:08:58 +08:00
HIsheR b4ab92ed98 chore(backlog): 记录 g114 完成 2026-10-26 14:07:17 +08:00
HIsheR cb2601d3d6 iter(g114): 收尾刷新变更日志 2026-10-26 14:07:12 +08:00
HIsheR b814e47473 chore(backlog): 记录 g113 完成 2026-10-26 14:06:45 +08:00
HIsheR ec7aaf0a7d iter(g113): 吸顶容器支持贴底固定 2026-10-26 14:06:32 +08:00
HIsheR 5c25e88c98 chore(backlog): 记录 g112 完成 2026-10-26 14:05:44 +08:00
HIsheR 5372fdd1f3 iter(g112): 消息提示支持撤销类操作 2026-10-26 14:05:38 +08:00
HIsheR bf1bd2241b chore(backlog): 记录 g111 完成 2026-10-26 14:04:44 +08:00
HIsheR f40ef6d3ea iter(g111): 面包屑支持超长路径折叠 2026-10-26 14:04:40 +08:00
HIsheR f90033707c chore(backlog): 记录 g110 完成 2026-10-26 14:03:25 +08:00
HIsheR e042143468 iter(g110): 下拉选择支持清空 2026-10-26 14:03:19 +08:00
HIsheR 9abadf0c4d chore(backlog): 记录 g109 完成 2026-10-26 14:02:01 +08:00
HIsheR 1bf35dacdc iter(g109): 提示气泡支持触摸点按 2026-10-26 14:01:57 +08:00
HIsheR d88bcc0cca chore(backlog): 记录 g108 完成 2026-10-26 14:01:11 +08:00
HIsheR 5601e59cf4 iter(g108): 表单补错误汇总条与 onError 2026-10-26 14:01:07 +08:00
HIsheR 4d76ba086a chore(backlog): 记录 g107 完成 2026-10-26 13:59:53 +08:00
HIsheR 48f94659e4 iter(g107): 新增二次确认气泡 popconfirm 2026-10-26 13:59:48 +08:00
HIsheR 137bf87d83 chore(backlog): 记录 g106 完成 2026-10-26 13:58:26 +08:00
HIsheR c66bcf35da iter(g106): 滚动监听改为卸载自清理 2026-10-26 13:58:22 +08:00
HIsheR 7d57f261df chore(backlog): 记录 g105 完成 2026-10-26 13:57:00 +08:00
HIsheR f398f09ab2 iter(g105): 标签页支持可关闭页签 2026-10-26 13:56:56 +08:00
HIsheR 1132220a23 chore(backlog): 记录 g104 完成 2026-10-26 13:55:22 +08:00
HIsheR 8e2175df29 iter(g104): 留痕校验回查提交真实性 2026-10-26 13:55:18 +08:00
HIsheR 10eb402c72 chore(backlog): 记录 g103 完成 2026-10-26 13:54:09 +08:00
HIsheR e497c160f2 iter(g103): 区分表格两种空状态 2026-10-26 13:54:06 +08:00
HIsheR f4701af984 chore(backlog): 记录 g102 完成 2026-10-26 13:51:46 +08:00
HIsheR abfbb821b7 iter(g102): 焦点陷阱下沉为共享助手 2026-10-26 13:51:40 +08:00
HIsheR f373b8cf31 chore(backlog): 记录 g101 完成 2026-10-26 13:50:16 +08:00
HIsheR 265842ef63 iter(g101): 新增锚点导航组件 anchor 2026-10-26 13:50:12 +08:00
HIsheR befed761d9 chore(backlog): 记录 g100 完成 2026-10-26 13:17:42 +08:00
HIsheR b190718774 iter(g100): 收尾刷新变更日志 2026-10-26 13:17:42 +08:00
HIsheR aa165318ed chore(backlog): 记录 g99 完成 2026-10-26 13:17:13 +08:00
HIsheR a5d4fafab1 iter(g99): 加载指示器补可定制播报 2026-10-26 13:17:12 +08:00
HIsheR 44efe8b547 chore(backlog): 记录 g98 完成 2026-10-26 13:16:33 +08:00
HIsheR c32f20cc9b iter(g98): 弹窗与抽屉补遮罩关闭开关 2026-10-26 13:16:32 +08:00
HIsheR 13266f0816 chore(backlog): 记录 g97 完成 2026-10-26 13:15:35 +08:00
HIsheR 3fe1fe3bc5 iter(g97): 收尾刷新变更日志 2026-10-26 13:15:34 +08:00
HIsheR c8bf4a92a4 chore(backlog): 记录 g96 完成 2026-10-26 13:15:01 +08:00
HIsheR 306fd90ab7 iter(g96): 抽屉支持上下滑出方位 2026-10-26 13:15:00 +08:00
HIsheR 6e6860abab chore(backlog): 记录 g95 完成 2026-10-26 13:14:06 +08:00
HIsheR 7e0612ef47 iter(g95): 统计数值支持倒计时模式 2026-10-26 13:14:05 +08:00
HIsheR 5e81cfe42b chore(backlog): 记录 g94 完成 2026-10-26 13:13:08 +08:00
HIsheR 03eab7ab67 iter(g94): 补充数据表联动导出配方 2026-10-26 13:13:08 +08:00
HIsheR c82446aba7 chore(backlog): 记录 g93 完成 2026-10-26 13:12:28 +08:00
HIsheR 90bba1c0b6 iter(g93): 树形组件支持按标签搜索 2026-10-26 13:12:28 +08:00
HIsheR 1b106168a7 chore(backlog): 记录 g92 完成 2026-10-26 13:11:29 +08:00
HIsheR ac5c5cd233 iter(g92): 徽标支持数字角标与红点 2026-10-26 13:11:29 +08:00
HIsheR 8e6c84ade3 chore(backlog): 记录 g91 完成 2026-10-26 13:10:41 +08:00
HIsheR 47756c899a iter(g91): 表单字段补辅助说明 hint 2026-10-26 13:10:40 +08:00
HIsheR 46a90daf32 chore(backlog): 记录 g90 完成 2026-10-26 13:09:49 +08:00
HIsheR 1e6898be65 iter(g90): 刷新变更日志至最新提交 2026-10-26 13:09:48 +08:00
HIsheR 2fc4d17615 chore(backlog): 记录 g89 完成 2026-10-26 13:09:11 +08:00
HIsheR 68ca2ba2a7 iter(g89): 演示产物启用保守压缩 2026-10-26 13:09:10 +08:00
HIsheR ce662bd0ff chore(backlog): 记录 g88 完成 2026-10-26 13:08:26 +08:00
HIsheR 9a7c46a3c6 iter(g88): 数据表支持导出 CSV 2026-10-26 13:08:25 +08:00
HIsheR fa07101f76 chore(backlog): 记录 g87 完成 2026-10-26 13:06:13 +08:00
HIsheR cf4ec96946 iter(g87): 轮播补焦点暂停与轮播语义 2026-10-26 13:06:12 +08:00
HIsheR 9ff2c7b651 chore(backlog): 记录 g86 完成 2026-10-26 13:05:35 +08:00
HIsheR a123140fab iter(g86): 弹层外部点击监听改按需挂载 2026-10-26 13:05:34 +08:00
HIsheR 249fa03f01 chore(backlog): 记录 g85 完成 2026-10-26 13:03:44 +08:00
HIsheR bd64e437b1 iter(g85): 进度条补可访问名与语义色 2026-10-26 13:03:43 +08:00
HIsheR b2beb43736 chore(backlog): 记录 g84 完成 2026-10-26 13:02:41 +08:00
HIsheR db5d38c5f4 iter(g84): 数据表定宽列支持拖拽调宽 2026-10-26 13:02:41 +08:00
HIsheR f5db24a52f chore(backlog): 记录 g83 完成 2026-10-26 13:01:17 +08:00
HIsheR 151b54243e iter(g83): 开关改 switch 语义并补可访问名 2026-10-26 13:01:16 +08:00
HIsheR ab0f1a5286 chore(backlog): 记录 g82 完成 2026-10-26 13:00:21 +08:00
HIsheR 37412b31ef iter(g82): 文件选择补类型体积数量校验 2026-10-26 13:00:20 +08:00
HIsheR 9dfe9cfac5 chore(backlog): 记录 g81 完成 2026-10-26 12:58:56 +08:00
HIsheR f3c83e9552 iter(g81): 树组件改用 roving tabindex 2026-10-26 12:58:56 +08:00
HIsheR ba196b8f3f chore(backlog): 记录 g80 完成 2026-10-26 12:58:08 +08:00
HIsheR c324afcf05 iter(g80): 通知悬停与聚焦暂停自动关闭计时 2026-10-26 12:58:08 +08:00
HIsheR 263fbe9e70 chore(backlog): 记录 g79 完成 2026-10-26 12:57:15 +08:00
HIsheR 2c0b9fdad6 iter(g79): 表头排序补键盘可达与 scope 语义 2026-10-26 12:57:15 +08:00
HIsheR 907b8eb09c chore(backlog): 记录 g78 完成 2026-10-26 12:56:09 +08:00
HIsheR b9261fb4ab iter(g78): 新增 space 间距容器组件 2026-10-26 12:55:53 +08:00
HIsheR 1de7ec3166 docs(iteration): 补充多实例错位接力机制 2026-10-26 12:52:38 +08:00
HIsheR 9bb0704b0a chore(backlog): 记录 g77 完成 2026-10-26 12:46:20 +08:00
HIsheR 6b895bfab1 iter(g77): 刷新 CHANGELOG 至最新提交 2026-10-26 12:46:04 +08:00
HIsheR 1a913fa593 chore(backlog): 记录 g76 完成 2026-10-26 12:45:40 +08:00
HIsheR 45a00f6c21 iter(g76): 文字头像补 role=img 使名称可播报 2026-10-26 12:45:22 +08:00
HIsheR d487303eaa chore(backlog): 记录 g75 完成 2026-10-26 12:44:54 +08:00
HIsheR b77eb18142 iter(g75): 评分组件键盘可达性与焦点保持 2026-10-26 12:44:34 +08:00
HIsheR ae9d500e9c chore(backlog): 记录 g74 完成 2026-10-26 12:43:49 +08:00
HIsheR 640c2860b9 iter(g74): 弹窗与抽屉补 aria-labelledby 标题关联 2026-10-26 12:43:32 +08:00
HIsheR 182158bfd7 chore(backlog): 记录 g73 完成 2026-10-26 12:42:40 +08:00
HIsheR ebd579380f iter(g73): 表格分页区补导航语义与页码播报 2026-10-26 12:42:24 +08:00
HIsheR 706d86344c chore(backlog): 记录 g72 完成 2026-10-26 12:41:42 +08:00
HIsheR 2aaf30fc09 iter(g72): 文档站搜索框与结果区补无障碍语义 2026-10-26 12:41:24 +08:00
HIsheR 6c5e331248 chore(backlog): 记录 g71 完成 2026-10-26 12:40:40 +08:00
HIsheR a6d0692e15 iter(g71): 数据表表头补 aria-sort 三态播报 2026-10-26 12:40:24 +08:00
HIsheR 532dcbb84f chore(backlog): 记录 g70 完成 2026-10-26 12:39:45 +08:00
HIsheR 5fb61e1fc9 iter(g70): 冒烟补多列排序、页大小与 reset 重置断言 2026-10-26 12:39:28 +08:00
HIsheR 5c0caeb94c chore(backlog): 记录 g69 完成 2026-10-26 12:39:02 +08:00
HIsheR e6cf8751d7 iter(g69): 修正单击排序升降序循环与 getPageInfo 陈旧快照 2026-10-26 12:38:46 +08:00
HIsheR 3fe59167f0 chore(backlog): 记录 g68 完成 2026-10-26 12:37:35 +08:00
HIsheR 99981feb5f iter(g68): alert 增加语义图标与通栏形态 2026-10-26 12:37:16 +08:00
HIsheR 1cd47317a2 chore(backlog): 记录 g67 完成 2026-10-26 12:36:32 +08:00
HIsheR cc3c649b80 iter(g67): tag 支持 checkable 筛选片,选中态与 aria-pressed 同步 2026-10-26 12:36:14 +08:00
HIsheR 173c3d520a chore(backlog): 记录 g66 完成 2026-10-26 12:34:39 +08:00
HIsheR 805b54d035 iter(g66): verify 反向校验入参接口字段均已登记文档 2026-10-26 12:34:23 +08:00
HIsheR 31442958ce chore(backlog): 修正 g65 留痕时间格式 2026-10-26 12:33:02 +08:00
HIsheR 900ceeb5f1 chore(backlog): 记录 g65 完成 2026-10-26 12:32:42 +08:00
HIsheR b7824f7e2a iter(g65): verify 校验文档 props 落到实现,并补齐漏记字段 2026-10-26 12:32:20 +08:00
HIsheR 9e3d91493b chore(backlog): 记录 g64 完成 2026-10-26 12:29:47 +08:00
HIsheR 4ca6f5e460 iter(g64): 刷新 CHANGELOG 并说明刷新时机 2026-10-26 12:29:46 +08:00
HIsheR 2b91b77afd chore(backlog): 记录 g63 完成 2026-10-26 12:28:57 +08:00
HIsheR 835967e507 iter(g63): card 支持标题操作区、底部操作区与悬停态 2026-10-26 12:28:56 +08:00
HIsheR fd1de1a5c8 chore(backlog): 记录 g62 完成 2026-10-26 12:27:35 +08:00
HIsheR a6cd41c0c5 iter(g62): verify 校验 demo 实际调用每个组件 2026-10-26 12:27:34 +08:00
HIsheR 175f6dbf95 chore(backlog): 记录 g61 完成 2026-10-26 12:26:47 +08:00
HIsheR f56ca51cb0 iter(g61): tree 支持勾选多选与父子级联 2026-10-26 12:26:46 +08:00
HIsheR 0dd0f50985 chore(backlog): 记录 g60 完成 2026-10-26 12:25:18 +08:00
HIsheR dbdae9ffdf iter(g60): 新增 multiSelect 多选下拉组件 2026-10-26 12:25:13 +08:00
HIsheR 184f972486 chore(backlog): 记录 g59 完成 2026-10-26 12:24:11 +08:00
HIsheR 9ef1b41954 iter(g59): verify 检测 README 组件清单重复项 2026-10-26 12:24:10 +08:00
HIsheR a0db91657c chore(backlog): 记录 g58 完成 2026-10-26 12:23:30 +08:00
HIsheR 7684ae2b10 iter(g58): 新增 affix 固钉组件 2026-10-26 12:23:25 +08:00
HIsheR 89cddada21 chore(backlog): 记录 g57 完成 2026-10-26 12:22:13 +08:00
HIsheR fcec391074 iter(g57): form 校验支持异步规则 2026-10-26 12:22:13 +08:00
HIsheR 7f947237bb chore(backlog): 记录 g56 完成 2026-10-26 12:20:34 +08:00
HIsheR e6bf801679 iter(g56): ua style 增加单文件 200 行门禁 2026-10-26 12:20:33 +08:00
HIsheR a755758577 chore(backlog): 记录 g55 完成 2026-10-26 12:19:24 +08:00
HIsheR e54fdb6f22 iter(g55): 新增 checkboxGroup 多选组组件 2026-10-26 12:19:23 +08:00
HIsheR 73b37b1cdf chore(backlog): 记录 g54 完成 2026-10-26 12:18:25 +08:00
HIsheR 818e20eb95 iter(g54): 新增 watermark 水印组件 2026-10-26 12:18:24 +08:00
HIsheR 30f5a4558d chore(backlog): 记录 g53 完成 2026-10-26 12:17:23 +08:00
HIsheR 697d354703 iter(g53): 新增 tour 引导漫游组件 2026-10-26 12:17:22 +08:00
HIsheR bb8c44df60 chore(backlog): 记录 g52 完成 2026-10-26 12:16:12 +08:00
HIsheR 81ea18936c iter(g52): 新增 colorPicker 颜色选择组件 2026-10-26 12:16:11 +08:00
HIsheR 95b46a05e7 chore(backlog): 记录 g51 完成 2026-10-26 12:15:04 +08:00
HIsheR b93b6dd09b iter(g51): 新增 backTop 返回顶部按钮 2026-10-26 12:15:03 +08:00
HIsheR 1eff67bccb chore(backlog): 记录 g50 完成 2026-10-26 12:14:00 +08:00
HIsheR e270e09903 iter(g50): 新增 datePicker 日期选择组件 2026-10-26 12:13:56 +08:00
HIsheR e5c7778de5 chore(backlog): 记录 g49 完成 2026-10-26 12:12:56 +08:00
HIsheR cb8a9ea357 iter(g49): 新增 autoComplete 自动补全组件 2026-10-26 12:12:52 +08:00
HIsheR 1fa593317d chore(backlog): 记录 g48 完成 2026-10-26 12:11:45 +08:00
HIsheR d7bfabb3ab iter(g48): 新增 splitter 可拖拽分栏容器 2026-10-26 12:11:44 +08:00
HIsheR 5c10eb2a5f chore(backlog): 记录 g47 完成 2026-10-26 12:10:25 +08:00
HIsheR d81b724408 iter(g47): 新增 cascader 级联选择组件 2026-10-26 12:10:21 +08:00
HIsheR cdef6050d0 chore(backlog): 记录 g46 完成 2026-10-26 12:08:27 +08:00
HIsheR 1fe76e26b5 iter(g46): 新增 transfer 穿梭框组件 2026-10-26 12:08:24 +08:00
HIsheR a650b6d91e chore(backlog): 记录 g45 完成 2026-10-26 12:02:17 +08:00
HIsheR 0a0dc7a853 iter(g45): modal/drawer/image 打开时锁定背景滚动 2026-10-26 12:02:16 +08:00
HIsheR 7437fed395 chore(backlog): 记录 g44 完成 2026-10-26 12:00:20 +08:00
HIsheR b50315bb86 iter(g44): accordion 标题行方向键导航 2026-10-26 12:00:19 +08:00
HIsheR c42eb4b33c chore(backlog): 记录 g43 完成 2026-10-26 11:57:55 +08:00
HIsheR fc8a541fe6 iter(g43): select/menu/popover/image 的 Esc 接入统一弹层栈 2026-10-26 11:57:50 +08:00
HIsheR f0b115db94 chore(backlog): 记录 g42 完成 2026-10-26 11:53:51 +08:00
HIsheR 314366aa8d iter(g42): pagination 支持每页条数切换 2026-10-26 11:53:49 +08:00
HIsheR 39d105ad30 chore(backlog): 记录 g41 完成 2026-10-26 11:52:27 +08:00
HIsheR cf251640b3 iter(g41): verify 校验 README 分组行与清单一致 2026-10-26 11:52:25 +08:00
HIsheR 931ef705da chore(backlog): 记录 g40 完成 2026-10-26 11:51:43 +08:00
HIsheR d685f66527 iter(g40): 修表单双提交并支持 Enter 提交 2026-10-26 11:51:41 +08:00
HIsheR 78779d7bb6 chore(backlog): 记录 g39 完成 2026-10-26 11:50:43 +08:00
HIsheR fa8b0b839c iter(g39): 统一键盘焦点描边并补可访问性约定 2026-10-26 11:50:41 +08:00
HIsheR b45e6fb561 chore(backlog): 记录 g38 完成 2026-10-26 11:50:05 +08:00
HIsheR 82909c5ed1 iter(g38): select 支持 searchable 搜索过滤 2026-10-26 11:50:00 +08:00
HIsheR 587ea92edb chore(backlog): 记录 g37 完成 2026-10-26 11:48:33 +08:00
HIsheR 3143a8bd34 iter(g37): llms-full 增加组合配方章节 2026-10-26 11:48:31 +08:00
HIsheR 94669974ee chore(backlog): 记录 g36 完成 2026-10-26 11:47:49 +08:00
HIsheR 8ac71c4efd iter(g36): 新增 result 结果页组件 2026-10-26 11:47:48 +08:00
HIsheR f1703b3e68 chore(backlog): 记录 g35 完成 2026-10-26 11:46:56 +08:00
HIsheR 8b5f355d7e iter(g35): 新增 notification 通知卡片组件 2026-10-26 11:46:54 +08:00
HIsheR 8711fae1af chore(backlog): 记录 g34 完成 2026-10-26 11:45:48 +08:00
HIsheR d461a3ed33 iter(g34): ua info 支持组件 id 查询并输出可读 props 表 2026-10-26 11:45:46 +08:00
HIsheR fe165a3cba chore(backlog): 记录 g33 完成 2026-10-26 11:44:36 +08:00
HIsheR f20e977fdf iter(g33): 新增 upload 文件选择组件 2026-10-26 11:44:34 +08:00
HIsheR 052c215609 chore(backlog): 记录 g32 完成 2026-10-26 11:43:17 +08:00
HIsheR 097f79fa1e iter(g32): tree 补 ↑↓/Home/End 行间导航 2026-10-26 11:43:15 +08:00
HIsheR e203c9ed5d chore(backlog): 记录 g31 完成 2026-10-26 11:42:31 +08:00
HIsheR 4b0dbfecec iter(g31): 新增 tree 树形组件 2026-10-26 11:42:25 +08:00
HIsheR 4f512b0d6b chore(backlog): 记录 g30 完成 2026-10-26 11:41:06 +08:00
HIsheR 1a161d6e1f iter(g30): check 链并入 demo 构建并校验产物锚点 2026-10-26 11:41:01 +08:00
HIsheR 086f145b4f chore(backlog): 记录 g29 完成 2026-10-26 11:40:12 +08:00
HIsheR e5ed297842 iter(g29): 新增 carousel 轮播组件 2026-10-26 11:40:07 +08:00
HIsheR 84f83df615 chore(backlog): 记录 g28 完成 2026-10-26 11:38:50 +08:00
HIsheR d9feeda0fd iter(g28): 浮层 Esc 逐层关闭,阻止冒泡连带关外层 2026-10-26 11:38:44 +08:00
HIsheR b263075e65 chore(backlog): 记录 g27 完成 2026-10-26 11:37:51 +08:00
HIsheR d80e132a45 iter(g27): 抽出弹层 Esc 栈,多弹层只关最上层 2026-10-26 11:37:45 +08:00
HIsheR c6ce184aeb chore(backlog): 记录 g26 完成 2026-10-26 11:36:40 +08:00
HIsheR 6ad0765e4b iter(g26): verify 增加令牌引用校验并统一令牌来源 2026-10-26 11:36:33 +08:00
HIsheR d961d570b5 chore(backlog): 记录 g25 完成 2026-10-26 11:35:00 +08:00
HIsheR 563730c755 iter(g25): 新增 ua tokens 命令与 MCP get_tokens 工具 2026-10-26 11:34:55 +08:00
HIsheR ccb99c2065 chore(backlog): 记录 g24 完成 2026-10-26 11:33:42 +08:00
HIsheR e6b4a84085 iter(g24): 新增 list 列表组件(含空状态回落与可点击条目) 2026-10-26 11:33:27 +08:00
HIsheR 2b65922c7e chore(backlog): 记录 g23 完成 2026-10-26 11:31:25 +08:00
HIsheR 9e8b435175 iter(g23): tabs 支持 Home/End 跳转首末页签 2026-10-26 11:31:20 +08:00
HIsheR 59dc091210 chore(backlog): 记录 g22 完成 2026-10-26 11:13:48 +08:00
HIsheR 9bef9d20b7 iter(g22): menu 键入字母快跳菜单项 2026-10-26 11:13:43 +08:00
HIsheR 874c2b901d chore(backlog): 记录 g21 完成 2026-10-26 11:11:29 +08:00
HIsheR c5a4a37508 iter(g21): pagination 跳页输入框 2026-10-26 11:11:23 +08:00
HIsheR e009f5c775 chore(backlog): 记录 g20 完成 2026-10-26 11:08:39 +08:00
HIsheR 646fed92d5 iter(g20): inputNumber 长按连发步进 2026-10-26 11:08:35 +08:00
HIsheR 42a954cba2 chore(backlog): 记录 g19 完成 2026-10-26 11:06:39 +08:00
HIsheR 34abe3f1e8 iter(g19): textarea 字数统计 2026-10-26 11:06:34 +08:00
HIsheR bf05e991ba chore(backlog): 记录 g18 完成 2026-10-26 11:04:03 +08:00
HIsheR 44b86971ec iter(g18): textField 可清空按钮 2026-10-26 11:03:58 +08:00
HIsheR cfe299c377 chore(backlog): 记录 g17 完成 2026-10-26 11:00:21 +08:00
HIsheR 27d797ab63 iter(g17): select 键入字母快跳选项 2026-10-26 11:00:17 +08:00
HIsheR 2e48d9413f chore(backlog): 记录 g16 完成 2026-10-26 10:58:01 +08:00
HIsheR a036b0abfd iter(g16): image 加载失败占位 2026-10-26 10:57:58 +08:00
HIsheR e781e72320 chore(backlog): 记录 g15 完成 2026-10-26 10:55:36 +08:00
HIsheR 85988f0efc iter(g15): toast 悬停暂停自动关闭 2026-10-26 10:55:11 +08:00
HIsheR 6cd991d6ba chore(backlog): 记录 g11 完成 2026-10-26 10:52:36 +08:00
HIsheR 00a7b90f44 iter(g11): 浮层空间不足时自动翻转 2026-10-26 10:51:35 +08:00
HIsheR 62784b1f2d docs(iteration): 记录调度器粒度限制与自动化改动自检规则 2026-10-26 10:42:45 +08:00
HIsheR 11ac538d5c chore(backlog): 记录 g13 完成 2026-10-26 10:31:17 +08:00
HIsheR 90d3b64fa0 iter(g13): 新增 ua changelog 生成变更日志 2026-10-26 10:31:03 +08:00
HIsheR 3161340852 chore(backlog): 记录 g14 完成 2026-10-26 10:29:19 +08:00
HIsheR b3c3bc70a4 iter(g14): 演示站目录支持关键字过滤 2026-10-26 10:29:03 +08:00
HIsheR 4ffc6545ef chore(backlog): 记录 g12 完成 2026-10-26 10:27:12 +08:00
HIsheR 1b75fb46d7 iter(g12): verify 增加 backlog 与留痕交叉校验 2026-10-26 10:27:06 +08:00
HIsheR 07b9b759c7 chore(backlog): 记录 g10 完成 2026-10-26 10:25:19 +08:00
HIsheR 2f8ccb68d8 iter(g10): 新增 statistic 统计数值组件 2026-10-26 10:25:07 +08:00
HIsheR cf65939af4 chore(backlog): 记录 g09 完成 2026-10-26 10:23:32 +08:00
HIsheR d8d45fcac0 iter(g09): 新增 descriptions 描述列表组件 2026-10-26 10:23:22 +08:00
HIsheR 086831d474 chore(backlog): 记录 g08 完成 2026-10-26 10:21:00 +08:00
HIsheR 0e385cdb3a iter(g08): 新增 segmented 分段控制器组件 2026-10-26 10:20:45 +08:00
HIsheR 7cf1f2877a chore(backlog): 记录 g07 完成 2026-10-26 10:18:11 +08:00
HIsheR db96a30439 iter(g07): 新增 timeline 时间线组件 2026-10-26 10:17:38 +08:00
HIsheR 511a3901a7 chore(backlog): 记录 g06 完成 2026-10-23 19:08:38 +08:00
HIsheR b6c948998b iter(g06): 组件数门禁覆盖 README 2026-10-23 19:08:35 +08:00
HIsheR 8d439f068f chore(backlog): 记录 g05 完成 2026-10-23 19:07:37 +08:00
HIsheR 9651c39f83 iter(g05): verify 增加 llms-full 同步校验 2026-10-23 19:07:34 +08:00
HIsheR 86c72f7090 chore(backlog): 记录 g04 完成 2026-10-23 19:05:49 +08:00
HIsheR c0bb8d705d iter(g04): dataTable 筛选框加搜索图标前缀 2026-10-23 19:05:46 +08:00
HIsheR 5131977f54 chore(backlog): 记录 g03 完成 2026-10-23 19:04:29 +08:00
HIsheR bfa70132da iter(g03): 输入组件补 prefix/suffix 附加块 2026-10-23 19:04:27 +08:00
HIsheR d5ac26af31 chore(backlog): 记录 g02 完成 2026-10-23 18:59:35 +08:00
HIsheR 33979b23a0 iter(g02): 新增 image 图片组件 2026-10-23 18:59:32 +08:00
HIsheR 0c42c6f71e chore(backlog): 记录 g01 完成 2026-10-23 18:55:25 +08:00
HIsheR 98f557cba8 iter(g01): ua docs 幂等化防时间戳弄脏工作区 2026-10-23 18:55:22 +08:00
HIsheR 7ce8bf72d4 chore(backlog): 记录 f06 完成 2026-10-23 18:53:34 +08:00
HIsheR a5439fe4a6 iter(f06): 锁文件入库并补全目录树 2026-10-23 18:53:31 +08:00
HIsheR 35c9f00e26 chore(backlog): 记录 f05 完成 2026-10-23 18:52:13 +08:00
HIsheR 265060fdb3 iter(f05): index 幂等化防时间戳弄脏工作区 2026-10-23 18:52:09 +08:00
HIsheR fbe0348b47 chore(backlog): 记录 f04 完成 2026-10-23 18:48:37 +08:00
HIsheR 99ce0cdd63 iter(f04): commit-lint 收严强制 scope 2026-10-23 18:48:34 +08:00
HIsheR f1363e81f7 chore(backlog): 记录 f03 完成 2026-10-23 18:46:56 +08:00
HIsheR f57da4c74e iter(f03): 新增留痕格式门禁 lint-iterations 2026-10-23 18:46:53 +08:00
HIsheR 3fb3cd2395 chore(backlog): 记录 f02 完成 2026-10-23 18:44:09 +08:00
HIsheR f2fc286eeb iter(f02): check 链描述补 verify 第六步 2026-10-23 18:44:06 +08:00
HIsheR b9b05f53fb chore(backlog): 记录 f01 完成 2026-10-23 18:42:46 +08:00
HIsheR c19f83b413 iter(f01): 组件数漂移纳入 verify 门禁并修正文档 2026-10-23 18:42:43 +08:00
HIsheR 67374c78c3 chore(backlog): 灌入审计产出的 f01-f06 应修条目 2026-10-23 18:36:58 +08:00
HIsheR cbf1d4a248 chore: 留痕补 commit 哈希 2026-10-23 18:14:50 +08:00
HIsheR c457e0cc33 iter(e05): 新增 divider 分隔线组件 2026-10-23 18:14:47 +08:00
HIsheR 84d008b5fd chore: 留痕补 commit 哈希 2026-10-23 18:12:01 +08:00
HIsheR fd4ee018e6 iter(e04): 新增 empty 空状态组件 2026-10-23 18:11:47 +08:00
HIsheR 62d4ce648d chore: 留痕补 commit 哈希 2026-10-23 18:07:59 +08:00
HIsheR ee20322e3f iter(e03): 新增 rate 评分组件 2026-10-23 18:07:42 +08:00
HIsheR dc4bac4d17 docs: 同步提交规范到 AGENTS/ITERATION/cli 清单 2026-10-23 18:07:39 +08:00
HIsheR 7072f77f88 feat(cli): 新增 commit-lint 提交信息校验 2026-10-23 18:07:37 +08:00
137 changed files with 28331 additions and 1026 deletions
+2679 -38
View File
File diff suppressed because it is too large Load Diff
+3220 -89
View File
File diff suppressed because it is too large Load Diff
+327 -36
View File
@@ -1,38 +1,329 @@
{"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-23T15:34:23+08:00", "id": "b01", "result": "done", "commit": "a57577a", "summary": "新增 tooltip 悬浮提示组件(包裹式 API / 150ms 延迟 / Esc 收起 / reduced-motion 兜底),同步 manifest + llms-full.txt + demo 区块,并修复 ua style 对 URL 中 // 的误判;npm run check 全绿"}
{"ts": "2026-10-23T15:50:20+08:00", "id": "b02", "result": "done", "commit": "ece38bc", "summary": "新增 toast 消息提示组件:四语义工厂 + showToast,右上角堆叠,默认 3s 自动消失,微动画 + reduced-motion 兜底;manifest/docs/llms 同步,check 全绿"}
{"ts": "2026-10-23T15:53:34+08:00", "id": "b03", "result": "done", "commit": "2882057", "summary": "新增 skeleton 骨架屏组件:text/rect/circle 三形状 + 扫光微动画 + reduced-motion 静态化;manifest/docs/llms/demo 同步,check 全绿"}
{"ts": "2026-10-23T16:00:13+08:00", "id": "b04", "result": "done", "commit": "8c9a63b", "summary": "新增独立 pagination 分页器(page 信号双向绑定 + 省略号折叠 + 键盘可达),dataTable 新增 page 受控桥接实现联动;demo 联动卡片,check 全绿"}
{"ts": "2026-10-23T16:03:47+08:00", "id": "b05", "result": "done", "commit": "83bc1e2", "summary": "文档站核查确认已 manifest 驱动;补齐默认值信息量缺口:CLI propsTable 新增默认值列(df 字段)+ llms-full 默认标注,16 处默认值提取;check 全绿"}
{"ts": "2026-10-23T16:08:19+08:00", "id": "c01", "result": "done", "commit": "2a5892a", "summary": "button/checkbox/switchButton 补 disabled(boolean|Signal),复用 h() 布尔/信号既有语义零辅助代码;CSS 通用禁用态 + demo 信号联动示例;check 全绿"}
{"ts": "2026-10-23T16:10:32+08:00", "id": "c02", "result": "done", "commit": "f9324dd", "summary": "textField/textarea 补 disabled(boolean|Signal),与 c01 同模式;禁用态 CSS 覆盖输入类;demo locked 信号联动;check 全绿"}
{"ts": "2026-10-23T16:46:00+08:00", "id": "d01", "result": "done", "commit": "84277a7", "summary": "新增下拉菜单 menu:触发器 + 自绘弹层,disabled/danger/divider 条目,↑↓ 环形导航 + Esc 回焦,reduced-motion 兜底;manifest/llms-full/demo 同步,check 全绿"}
{"ts": "2026-10-23T16:47:03+08:00", "id": "d02", "result": "done", "commit": "6b9fa95", "summary": "select/radioGroup 补 disabled(布尔|信号),并修复 select 根节点缺 ua-open 导致箭头不旋转的既有缺陷;CSS :has() 行级弱化;demo/manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:47:56+08:00", "id": "d03", "result": "done", "commit": "df2fb34", "summary": "新增折叠面板 accordion:单开/多开 + active 受控 + 禁用面板,grid 行高 0fr→1fr 过渡免测量,aria 关联完整;manifest/llms/demo 同步,check 全绿"}
{"ts": "2026-10-23T16:48:36+08:00", "id": "d04", "result": "done", "commit": "ccbdcb9", "summary": "dataTable 新增 loading(布尔|信号):表体换骨架行、筛选与翻页禁用、aria-busy;demo 加开关,manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:49:45+08:00", "id": "d05", "result": "done", "commit": "60c0524", "summary": "新增面包屑 breadcrumb:nav/ol 语义结构 + 末项 aria-current,href 走 <a>、无 href 走 onNavigate,分隔符可配;manifest/llms/demo 同步,check 全绿(中途被 style 门禁拦下多行注释,已改单行)"}
{"ts": "2026-10-23T16:50:49+08:00", "id": "d06", "result": "done", "commit": "582318a", "summary": "新增 ua verify 门禁:清单 id/exports/props/steps/example 与 demo 锚点一致性,纳入 npm run check;顺带补齐 badge/alert/spinner 手把手第三条,check 全绿"}
{"ts": "2026-10-23T16:51:48+08:00", "id": "d07", "result": "done", "commit": "fb9ab88", "summary": "新增可交互气泡卡片 popover:点击/hover 触发、点外部与 Esc 收起、内容可交互且内部点击不误关;manifest/llms/demo 同步,check 全绿"}
{"ts": "2026-10-23T16:52:28+08:00", "id": "d08", "result": "done", "commit": "1a4b2c1", "summary": "演示页改双栏 shell:sticky 侧边目录由 .sec 自动反推 + IntersectionObserver 滚动高亮,窄屏隐藏、reduced-motion 关平滑滚动;check 全绿"}
{"ts": "2026-10-23T16:53:06+08:00", "id": "d09", "result": "done", "commit": "a11157b", "summary": "新增滑块 slider:value 信号双向绑定 + min/max/step + 布尔|信号禁用 + showValue,原生 range 承载键盘与触屏,accent-color 令牌化;manifest/llms/demo 同步,check 全绿"}
{"ts": "2026-10-23T16:53:44+08:00", "id": "d10", "result": "done", "commit": "a08f157", "summary": "toast 新增 closable(× 手动关闭)与同屏上限 4 条,退场统一走 dismiss() 双兜底;is-closable 才接收指针;demo/manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:54:36+08:00", "id": "d11", "result": "done", "commit": "d1a2864", "summary": "modal 新增 size 三档(sm/md/lg)与 footer 操作区(右对齐 + 上分割线),缺省行为不变;demo/manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:55:10+08:00", "id": "d12", "result": "done", "commit": "9eefdcb", "summary": "新增可关闭标签 tag(五档语义色 + closable/onClose,无状态委托调用方重渲染);demo 用 renderList 演示真实删除;manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:55:55+08:00", "id": "d13", "result": "done", "commit": "730ee1a", "summary": "dataTable 列定义新增皮肤层 TableColumn(align + format),内核 ColumnDef 不变;表头/单元格共用对齐类,format 走 textContent;demo/manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:56:36+08:00", "id": "d14", "result": "done", "commit": "d3fc754", "summary": "新增抽屉 drawer:open 信号驱动侧滑、placement/width 可配、footer 操作区、遮罩/×/Esc 关闭并聚焦首元素;demo/manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:57:22+08:00", "id": "d15", "result": "done", "commit": "1c03c81", "summary": "CLI 包补 ua.manifest.json(type: tooling,七个子命令 + 约定),注册表 5 包;修正 AGENTS/ARCHITECTURE 过期组件数 17→27;check 全绿"}
{"ts": "2026-10-23T16:58:52+08:00", "id": "d16", "result": "done", "commit": "fa168b9", "summary": "dataTable 消费内核列宽 width:colgroup 固定像素 / flex 自适应,选择列 44px;demo 三列演示宽档;manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T16:59:46+08:00", "id": "d17", "result": "done", "commit": "bdc1fec", "summary": "表单错误态打通包裹式控件:focusout/change 取代 blur + aria-invalid + .ua-invalid 后代选择器覆盖 select/radio/slider;demo 加必填 select;check 全绿"}
{"ts": "2026-10-23T17:00:55+08:00", "id": "d18", "result": "done", "commit": "4012923", "summary": "ua verify 新增皮肤硬编码颜色门禁(#hex 禁止,rgba 仅限阴影/遮罩),清掉 ui.css 5 处 #fff 改令牌;负向测试确认能拦;check 全绿"}
{"ts": "2026-10-23T17:01:46+08:00", "id": "d19", "result": "done", "commit": "4555542", "summary": "dataTable 新增 selected 受控信号:与内核选择双向同步(内容比较 + 拷贝保护),demo 展示已选计数与清空;check 全绿"}
{"ts": "2026-10-23T17:02:24+08:00", "id": "d20", "result": "done", "commit": "9cfdb43", "summary": "progress 支持不确定态:value 可选,缺省时 35% 填充块往复 + aria-label 进行中;定量态补 aria-valuenow;demo/manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T17:03:02+08:00", "id": "d21", "result": "done", "commit": "00a6104", "summary": "无障碍收尾:modal × 补 aria-label/type、spinner role=status、skeleton aria-hidden、tablist aria-label + 禁用页签 aria-disabled;conventions 记录基线;check 全绿"}
{"ts": "2026-10-23T17:03:49+08:00", "id": "d22", "result": "done", "commit": "f86da98", "summary": "新增头像 avatar(src 图片 / 首字占位 + size + 四档语义底色),demo 独立 sec-avatar;中途被 verify 的 demo 锚点检查拦下已补;check 全绿"}
{"ts": "2026-10-23T17:04:28+08:00", "id": "d23", "result": "done", "commit": "f361a19", "summary": "alert 新增 title / closable / onClose 与 role=alert,关闭走 200ms 退场后摘除;demo 独立 sec-alert;manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T17:05:46+08:00", "id": "d24", "result": "done", "commit": "6efe3ab", "summary": "重写 README(定位/快速开始/28 组件/目录/门禁/Agent 接入/流水线),组件清单做成标记区块并由 ua verify 校验防漂移;负向测试通过,check 全绿"}
{"ts": "2026-10-23T17:06:17+08:00", "id": "d25", "result": "done", "commit": "6a5e662", "summary": "menu 条目新增 shortcut 快捷键提示(右侧 kbd 灰字,两端对齐 + 单行省略,不绑按键);demo/manifest/llms 同步,check 全绿"}
{"ts": "2026-10-23T17:08:53+08:00", "id": "d26", "result": "done", "commit": "82f48aa", "summary": "select 键盘导航:↑↓ 环形移动、Home/End 跳转、Esc 回焦触发器"}
{"ts": "2026-10-23T17:10:37+08:00", "id": "d27", "result": "done", "commit": "fac74a3", "summary": "modal 焦点陷阱 + dialog 语义"}
{"ts": "2026-10-23T17:11:31+08:00", "id": "d28", "result": "done", "commit": "8e16829", "summary": "drawer 焦点陷阱对齐 modal"}
{"ts": "2026-10-23T17:12:43+08:00", "id": "d29", "result": "done", "commit": "f8934ba", "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,与实际六步链一致"}
{"ts":"2026-10-23T18:46:55+08:00","id":"f03","result":"done","commit":"f57da4c","summary":"ua 新增 lint-iterations 子命令校验留痕格式并并入 check 链,36 条历史违规行按 git author date 回填修复"}
{"ts":"2026-10-23T18:48:36+08:00","id":"f04","result":"done","commit":"99ce0cd","summary":"commit-lint 收严:非 iter type 必须带 scope,裸 chore:/docs:/iter: 反向用例全部拒绝,COMMITTING.md 同步"}
{"ts":"2026-10-23T18:52:12+08:00","id":"f05","result":"done","commit":"265060f","summary":"ua index 幂等化:剔除 generatedAt 比对且行尾归一化,内容无变化跳写,连续两次 check 后工作区干净"}
{"ts":"2026-10-23T18:53:33+08:00","id":"f06","result":"done","commit":"a5439fe","summary":"pnpm-lock.yaml 经 pnpm install --lockfile-only 生成入库;ARCHITECTURE.md 目录树补 scripts/、COMMITTING、ITERATION、pnpm-workspace、.ua 明细"}
{"ts":"2026-10-23T18:55:24+08:00","id":"g01","result":"done","commit":"98f557c","summary":"writeIfChanged 支持归一化比较,llms-full.txt 仅时间戳之差时跳写,docs 双跑工作区干净;backlog 灌入 g01-g03"}
{"ts":"2026-10-23T18:59:34+08:00","id":"g02","result":"done","commit":"33979b2","summary":"新增 image 组件:缩略图点击/回车开遮罩大图,Esc/点击遮罩关闭并归还焦点;组件 33→34,manifest/docs/README/demo 同步"}
{"ts":"2026-10-23T19:04:29+08:00","id":"g03","result":"done","commit":"bfa7013","summary":"textField/textarea/inputNumber 新增 prefix/suffix(addonWrap 零侵入包装,无附加不包裹),样式聚焦光环由组承载;demo 与 manifest 同步"}
{"ts":"2026-10-23T19:05:48+08:00","id":"g04","result":"done","commit":"c0bb8d7","summary":"dataTable 筛选输入框用 g03 的 prefix 放搜索图标,事件冒泡与筛选回归正常,门禁全绿"}
{"ts":"2026-10-23T19:07:37+08:00","id":"g05","result":"done","commit":"9651c39","summary":"verify 内联重渲染 Agent 文档与 llms-full.txt 归一化比对,漂移即拦截提示 npm run docs"}
{"ts":"2026-10-23T19:08:37+08:00","id":"g06","result":"done","commit":"b6c9489","summary":"checkCountDocs 扫描列表加入 README.md,三份文档组件计数文本全部纳入漂移门禁"}
{"ts":"2026-10-26T10:17:59+08:00","id":"g07","result":"done","commit":"db96a30","summary":"新增 timeline 时间线组件:语义色节点 + 左右轴线 + pending 进行中节点,demo/manifest/README/llms-full 同步"}
{"ts":"2026-10-26T10:20:58+08:00","id":"g08","result":"done","commit":"0e385cd","summary":"新增 segmented 分段控制器:胶囊分段 + 滑动滑块 + 键盘导航,manifest/README/llms-full 同步"}
{"ts":"2026-10-26T10:23:31+08:00","id":"g09","result":"done","commit":"d8d45fc","summary":"新增 descriptions 描述列表:label:value 网格 + 列数变量化 + 窄屏单列 + bordered 表格形态"}
{"ts":"2026-10-26T10:25:17+08:00","id":"g10","result":"done","commit":"2f8ccb6","summary":"新增 statistic 统计数值:rAF 滚动过渡 + 千分位精度 + 涨红跌绿趋势,减弱动效直落终值"}
{"ts":"2026-10-26T10:27:10+08:00","id":"g12","result":"done","commit":"1b75fb4","summary":"verify 交叉校验 backlog done 与 iterations.log 留痕双向一致,注入违规即拦截"}
{"ts":"2026-10-26T10:29:17+08:00","id":"g14","result":"done","commit":"b3c3bc7","summary":"演示站侧边目录加关键字过滤框:输入即筛、空态提示、Esc 清空,滚动高亮不受影响"}
{"ts":"2026-10-26T10:31:15+08:00","id":"g13","result":"done","commit":"90d3b64","summary":"新增 ua changelog:按提交类型分组生成 CHANGELOG.md,支持 --check 幂等校验"}
{"ts":"2026-10-26T10:52:00+08:00","id":"g11","result":"done","commit":"00a7b90","summary":"新增 flip() 翻转判定:popover/tooltip/select/menu 打开时按视口空间自动上下翻转,弹层不再溢出被裁切"}
{"ts":"2026-10-26T10:55:35+08:00","id":"g15","result":"done","commit":"85988f0","summary":"toast 悬停暂停倒计时、移出按剩余时长继续,长文案不再来不及读"}
{"ts":"2026-10-26T10:58:01+08:00","id":"g16","result":"done","commit":"a036b0a","summary":"image 加载失败自动替换虚线占位块,fallback 可自定义文案,demo 附失效地址示例"}
{"ts":"2026-10-26T11:00:20+08:00","id":"g17","result":"done","commit":"27d797a","summary":"select 展开后键入字母按前缀环形快跳选项,1s 无输入重置缓冲,既有键盘导航不变"}
{"ts":"2026-10-26T11:04:02+08:00","id":"g18","result":"done","commit":"44b8697","summary":"textField clearable 开启后有值即显一键清空按钮,点击同步信号与 onInput,空值自动隐藏"}
{"ts":"2026-10-26T11:06:38+08:00","id":"g19","result":"done","commit":"34abe3f","summary":"textarea maxCount 设原生 maxlength 并在下方附加块显示实时 n/limit 计数"}
{"ts":"2026-10-26T11:08:38+08:00","id":"g20","result":"done","commit":"646fed9","summary":"inputNumber 长按 −/+ 400ms 后每 100ms 连发步进,松开或移出即停,单击不变"}
{"ts":"2026-10-26T11:11:28+08:00","id":"g21","result":"done","commit":"c5a4a37","summary":"pagination jumpable 时末尾显示页码输入与总页数,回车跳转自动钳制范围"}
{"ts":"2026-10-26T11:13:47+08:00","id":"g22","result":"done","commit":"9bef9d2","summary":"menu 展开后键入字母按前缀环形快跳菜单项,与 select 行为对齐"}
{"ts":"2026-10-26T11:31:23+08:00","id":"g23","result":"done","commit":"9e8b435","summary":"tabs 聚焦时 Home/End 直达首个/末个启用页签并激活,方向键环形行为不变"}
{"ts":"2026-10-26T11:33:41+08:00","id":"g24","result":"done","commit":"e6b4a84","summary":"新增 list 列表组件:header/footer 外壳 + avatar/title/description/extra 条目,空数组回落空状态,组件数 38→39"}
{"ts":"2026-10-26T11:34:59+08:00","id":"g25","result":"done","commit":"563730c","summary":"新增 ua tokens 命令(--json 可选)与 MCP get_tokens 工具,llms-full 令牌名统一 kebab-case"}
{"ts":"2026-10-26T11:36:38+08:00","id":"g26","result":"done","commit":"6ad0765","summary":"verify 新增令牌引用校验(拼错变量名即失败),ua tokens 与 llms-full 统一从 tokens.css 取令牌"}
{"ts":"2026-10-26T11:37:50+08:00","id":"g27","result":"done","commit":"d80e132","summary":"新增 overlay.ts Esc 栈:modal/drawer 后开先关,栈空自动摘全局监听"}
{"ts":"2026-10-26T11:38:48+08:00","id":"g28","result":"done","commit":"d9feeda","summary":"浮层 Esc 逐层关闭:menu/select/popover/tooltip/image 各自阻止冒泡,弹窗内的下拉收起不再连带关弹窗"}
{"ts":"2026-10-26T11:40:10+08:00","id":"g29","result":"done","commit":"e5ed297","summary":"新增 carousel 轮播:横排位移 + 指示点/箭头/←→ 键 + autoplay 悬停暂停 + 环形回绕,组件数 39→40"}
{"ts":"2026-10-26T11:41:05+08:00","id":"g30","result":"done","commit":"1a161d6","summary":"check 链加入 demo 构建,verify 新增产物锚点校验(负向删除锚点即失败)"}
{"ts":"2026-10-26T11:42:29+08:00","id":"g31","result":"done","commit":"4b0dbfe","summary":"新增 tree 树形:递归节点 + 箭头展开收起 + 点选/Enter/→/← 键盘 + 禁用不响应,组件数 40→41"}
{"ts":"2026-10-26T11:43:15+08:00","id":"g32","result":"done","commit":"097f79f","summary":"tree 补 ↑↓/Home/End 行间导航:只移焦点不改选中,收起分支子节点不参与遍历"}
{"ts":"2026-10-26T11:44:35+08:00","id":"g33","result":"done","commit":"f20e977","summary":"新增 upload 文件选择:点击/拖拽/键盘选择 + 已选清单移除 + 同名去重,组件数 41→42"}
{"ts":"2026-10-26T11:45:47+08:00","id":"g34","result":"done","commit":"d461a3e","summary":"ua info 支持组件 id:输出 props 表 + 步骤 + 示例,MCP get_component 同步支持组件名;顺带修皮肤包描述里的过时组件枚举"}
{"ts":"2026-10-26T11:46:55+08:00","id":"g35","result":"done","commit":"8b5f355","summary":"新增 notification 通知卡片:右下角堆叠、标题+描述+操作区、返回 {el,close}、duration 0 常驻,组件数 42→43"}
{"ts":"2026-10-26T11:47:48+08:00","id":"g36","result":"done","commit":"8ac71c4","summary":"新增 result 结果页:四档语义色 + 居中终态布局 + role=status,组件数 43→44"}
{"ts":"2026-10-26T11:48:31+08:00","id":"g37","result":"done","commit":"3143a8b","summary":"新增 recipes.md 组合配方(表单校验提交/危险确认/表格分页批量/空态加载)并由 ua docs 追加进 llms-full"}
{"ts":"2026-10-26T11:50:04+08:00","id":"g38","result":"done","commit":"82909c5","summary":"select 新增 searchable:弹层搜索框实时过滤、打开即聚焦、无命中提示,键盘只在可见项间移动;README 分组行补齐新组件"}
{"ts":"2026-10-26T11:50:41+08:00","id":"g39","result":"done","commit":"fa8b0b8","summary":"ui.css 顶部统一 focus-visible 描边兜底(消费 --ua-color-accent),README 补可访问性约定章节"}
{"ts":"2026-10-26T11:51:41+08:00","id":"g40","result":"done","commit":"d685f66","summary":"button 增 type(缺省 button)修掉表单内 click+submit 双触发,form 交按钮走原生 submit 并支持单行输入 Enter 提交"}
{"ts":"2026-10-26T11:52:25+08:00","id":"g41","result":"done","commit":"cf25164","summary":"verify 新增 README「分组:」行校验:漏写/多写组件均报错(formItem 别名放行),负向测试通过"}
{"ts":"2026-10-26T11:53:50+08:00","id":"g42","result":"done","commit":"314366a","summary":"pagination 支持 pageSizes 条数切换(切换回第 1 页并按新条数重算总页数),分页区与附加区分组避免重建跳页框"}
{"ts":"2026-10-26T11:57:39+08:00","id":"g43","result":"done","commit":"fc8a541","summary":"select/menu/popover/image Esc 接入统一弹层栈,多层叠加逐层关闭"}
{"ts":"2026-10-26T12:00:19+08:00","id":"g44","result":"done","commit":"b50315b","summary":"accordion 标题 ↑↓/Home/End 环形导航,跳过禁用行"}
{"ts":"2026-10-26T12:02:16+08:00","id":"g45","result":"done","commit":"0a0dc7a","summary":"overlay 新增 lockScroll 引用计数,模态层全关才还原 body 滚动"}
{"ts":"2026-10-26T12:08:17+08:00","id":"g46","result":"done","commit":"1fe76e2","summary":"新增 transfer 穿梭框:两栏按 targetKeys 派生、逐栏搜索、全选含半选、禁用条目不参与,组件数 44→45"}
{"ts":"2026-10-26T12:10:25+08:00","id":"g47","result":"done","commit":"d81b724","summary":"新增 cascader 级联选择:多列联动/悬停下钻/点击提交/changeOnSelect/全键盘,浏览路径与已提交路径分离,组件数 45→46"}
{"ts":"2026-10-26T12:11:45+08:00","id":"g48","result":"done","commit":"d7bfabb","summary":"新增 splitter 可拖拽分栏:指针拖拽+方向键微调+双击复位+min 夹逼+横纵两向,组件数 46→47"}
{"ts":"2026-10-26T12:12:56+08:00","id":"g49","result":"done","commit":"cb8a9ea","summary":"新增 autoComplete 自动补全:输入即过滤、↑↓ 环形高亮、Enter 选中、Esc 入栈、options 支持函数式远程联想,组件数 47→48"}
{"ts":"2026-10-26T12:14:00+08:00","id":"g50","result":"done","commit":"e270e09","summary":"新增 datePicker 日期选择:单月网格+翻月/今天+min-max 闭区间+逐日逐周逐月键盘导航,组件数 48→49"}
{"ts":"2026-10-26T12:15:04+08:00","id":"g51","result":"done","commit":"b93b6dd","summary":"新增 backTop 返回顶部:阈值浮现+conic 进度环+平滑回顶(尊重 reduced-motion)+内滚容器支持,组件数 49→50"}
{"ts":"2026-10-26T12:16:11+08:00","id":"g52","result":"done","commit":"81ea189","summary":"新增 colorPicker:预设色板(缺省语义令牌色)+原生取色器+手输 CSS 颜色串,组件数 50→51"}
{"ts":"2026-10-26T12:17:23+08:00","id":"g53","result":"done","commit":"697d354","summary":"新增 tour 引导漫游:聚光挖洞+卡片自动选方位+滚动重算+完成/跳过回调+Esc 入栈,组件数 51→52"}
{"ts":"2026-10-26T12:18:24+08:00","id":"g54","result":"done","commit":"818e20e","summary":"新增 watermark:canvas 平铺单元+令牌色解析+多行旋转间距可配+覆盖层交互穿透,组件数 52→53"}
{"ts":"2026-10-26T12:19:23+08:00","id":"g55","result":"done","commit":"e54fdb6","summary":"新增 checkboxGroup:数组信号+min/max 禁用边界+checkAll 半选+纵横向,组件数 53→54"}
{"ts":"2026-10-26T12:20:34+08:00","id":"g56","result":"done","commit":"e6bf801","summary":"ua style 新增单文件 200 行门禁(分层包 src 实现文件,types/index 豁免),CODING 约定变可执行检查"}
{"ts":"2026-10-26T12:22:13+08:00","id":"g57","result":"done","commit":"fcec391","summary":"form 校验支持异步规则:validator 可返回 Promise,formItem 新增 validateAsync 并按轮次丢弃过期结论,提交期间禁用按钮"}
{"ts":"2026-10-26T12:23:29+08:00","id":"g58","result":"done","commit":"7684ae2","summary":"新增 affix 固钉:阈值转 fixed + 外层等高占位防塌陷 + 宽度横向自适应,组件数 54→55"}
{"ts":"2026-10-26T12:24:10+08:00","id":"g59","result":"done","commit":"9ef1b41","summary":"verify 检测 README 区块与分组行的重复组件项(整词计数),补齐文档漂移门禁"}
{"ts":"2026-10-26T12:25:17+08:00","id":"g60","result":"done","commit":"dbdae9f","summary":"新增 multiSelect 多选下拉:标签回显/搜索/点选不收起/max 禁用边界/键盘导航,组件数 55→56"}
{"ts":"2026-10-26T12:26:46+08:00","id":"g61","result":"done","commit":"f56ca51","summary":"tree 新增 multi 勾选:勾父选子、子全选回勾父、半选态、禁用项不参与级联,勾选不触发选中"}
{"ts":"2026-10-26T12:27:35+08:00","id":"g62","result":"done","commit":"a6cd41c","summary":"verify 要求每个组件被 demo 真实调用(id( 或 id.),补齐「有锚点无渲染」的漏检"}
{"ts":"2026-10-26T12:28:56+08:00","id":"g63","result":"done","commit":"835967e","summary":"card 增加 extra/footer/bordered/hoverable,标题行改 flex 头(视觉不变),悬停浮起带 reduced-motion 兜底"}
{"ts":"2026-10-26T12:29:47+08:00","id":"g64","result":"done","commit":"4ca6f5e","summary":"重新生成 CHANGELOG(199 提交)并在 AGENTS 说明它为何不进 check 链、只在发版前刷新"}
{"ts":"2026-10-26T12:32:31+08:00","id":"g65","result":"done","commit":"b7824f7","summary":"verify 新增文档 props 必须落到实现源码的校验(幽灵字段直接报错),并补齐 image/textField/textarea/pagination 四处漏记字段"}
{"ts":"2026-10-26T12:34:27+08:00","id":"g66","result":"done","commit":"805b54d","summary":"verify 反向解析 types.ts 的 Props 字段并与文档 props 表双向比对,清单新增 propsType 覆盖接口名,补齐 form.onReset"}
{"ts":"2026-10-26T12:36:19+08:00","id":"g67","result":"done","commit":"cc3c649","summary":"tag 增加 checkable 筛选片:button + aria-pressed 与信号双向绑定,选中态走 accent 淡底,不整块重渲染"}
{"ts":"2026-10-26T12:37:22+08:00","id":"g68","result":"done","commit":"99981fe","summary":"alert 增加 showIcon/icon 语义图标(aria-hidden 纯装饰)与 banner 通栏形态,补齐文档与演示"}
{"ts":"2026-10-26T12:38:50+08:00","id":"g69","result":"done","commit":"e6cf875","summary":"修正 toggleSort 非 multi 永远停在 asc 的循环 bug 与 getPageInfo 陈旧快照,冒烟补三条不变量"}
{"ts":"2026-10-26T12:39:32+08:00","id":"g70","result":"done","commit":"5fb61e1","summary":"冒烟补多列排序优先级、setPageSize 回首页、reset 回默认态三条不变量,断言 9 → 16"}
{"ts":"2026-10-26T12:40:29+08:00","id":"g71","result":"done","commit":"a6d0692","summary":"数据表可排序表头补 aria-sort 三态(ascending/descending/none),与视觉箭头同源"}
{"ts":"2026-10-26T12:41:30+08:00","id":"g72","result":"done","commit":"2aaf30f","summary":"文档站搜索框补 aria-label 与 type=search,新增 sr-only 状态区播报匹配结果数"}
{"ts":"2026-10-26T12:42:29+08:00","id":"g73","result":"done","commit":"ebd5793","summary":"表格底栏分页加 role=navigation 与 aria-label,页码行数改 role=status aria-live 播报"}
{"ts":"2026-10-26T12:43:37+08:00","id":"g74","result":"done","commit":"640c286","summary":"modal/drawer 生成唯一标题 id 并挂 aria-labelledby,读屏能念出对话框名称"}
{"ts":"2026-10-26T12:44:42+08:00","id":"g75","result":"done","commit":"b77eb18","summary":"评分组件补 tabindex 与每轮键绑定、重渲染后归还焦点,方向键可连按,补 focus-visible 光环"}
{"ts":"2026-10-26T12:45:27+08:00","id":"g76","result":"done","commit":"45a00f6","summary":"文字头像容器补 role=img,aria-label 才会被读屏当作可访问名播报"}
{"ts":"2026-10-26T12:46:11+08:00","id":"g77","result":"done","commit":"6b895bf","summary":"重新生成 CHANGELOG(226 提交),changelog:check 恢复一致"}
{"ts":"2026-10-26T12:56:05+08:00","id":"g78","result":"done","commit":"b9261fb","summary":"新增 space 间距容器:flex gap 托管子元素间距,size 走令牌或像素,direction/align/justify/wrap/block 可调,清单与演示同步"}
{"ts":"2026-10-26T12:57:15+08:00","id":"g79","result":"done","commit":"2c0b9fd","summary":"可排序表头进 Tab 序,Enter/空格 与点击共用同一 toggle,补 scope=col"}
{"ts":"2026-10-26T12:58:08+08:00","id":"g80","result":"done","commit":"c324afc","summary":"通知自动关闭改为可暂停:悬停/聚焦扣减已流逝时间,离开按剩余续计"}
{"ts":"2026-10-26T12:58:56+08:00","id":"g81","result":"done","commit":"f3c83e9","summary":"树改 roving tabindex:整棵树一个 Tab 停点,focusin 跟随焦点,keydown 复用 keyOf"}
{"ts":"2026-10-26T13:00:21+08:00","id":"g82","result":"done","commit":"37412b3","summary":"upload 新增 maxSize/maxCount,accept 对拖拽生效,拒收原因走 role=status 提示行"}
{"ts":"2026-10-26T13:01:16+08:00","id":"g83","result":"done","commit":"151b542","summary":"开关 input 补 role=switch,新增 ariaLabel 兜底可访问名,演示加无文案实例"}
{"ts":"2026-10-26T13:02:41+08:00","id":"g84","result":"done","commit":"db5d38c","summary":"新增 columnResize 手柄:拖动/方向键改列宽、双击复位,定宽表头挂载"}
{"ts":"2026-10-26T13:03:44+08:00","id":"g85","result":"done","commit":"bd64e43","summary":"progress 新增 label 可访问名与 variant 语义色,满格不回退 success"}
{"ts":"2026-10-26T13:05:35+08:00","id":"g86","result":"done","commit":"a123140","summary":"overlay 新增 bindOutside,8 个弹层改用它,popover 删空 keydown"}
{"ts":"2026-10-26T13:06:13+08:00","id":"g87","result":"done","commit":"cf4ec96","summary":"轮播 focusin/focusout 也暂停自动播放,区域与幻灯片补 aria-roledescription"}
{"ts":"2026-10-26T13:08:26+08:00","id":"g88","result":"done","commit":"9a7c46a","summary":"内核 getAllRows 跳过分页取全量派生行,表格 exportable 导出 CSV(BOM+转义),冒烟补 4 条"}
{"ts":"2026-10-26T13:09:11+08:00","id":"g89","result":"done","commit":"68ca2ba","summary":"esbuild 开 minifyWhitespace/minifySyntax,demo.html 278KB→205KB,标识符保留"}
{"ts":"2026-10-26T13:09:49+08:00","id":"g90","result":"done","commit":"1e6898b","summary":"重新生成 CHANGELOG(253 提交),changelog:check 恢复一致"}
{"ts":"2026-10-26T13:10:40+08:00","id":"g91","result":"done","commit":"47756c8","summary":"formItem 新增 hint 辅助说明,报错时自动让位给错误文案"}
{"ts":"2026-10-26T13:11:29+08:00","id":"g92","result":"done","commit":"ac5c5cd","summary":"badge 新增 count/max/dot 三形态,label 转可选,样式走令牌"}
{"ts":"2026-10-26T13:12:28+08:00","id":"g93","result":"done","commit":"90bba1c","summary":"tree 新增 searchable:命中保留父链并临时展开,隐藏枝退出键盘遍历"}
{"ts":"2026-10-26T13:13:08+08:00","id":"g94","result":"done","commit":"03eab7a","summary":"recipes 增补配方 5:数据表受控分页联动 + 导出全量行 + 列宽拖拽"}
{"ts":"2026-10-26T13:14:05+08:00","id":"g95","result":"done","commit":"7e0612e","summary":"statistic 新增 countdown 模式:每秒 DD 天 HH:MM:SS,到点或卸载自动停表"}
{"ts":"2026-10-26T13:15:00+08:00","id":"g96","result":"done","commit":"306fd90","summary":"drawer placement 扩到四向,上下滑出按高度、横向铺满,复用焦点陷阱与 Esc 栈"}
{"ts":"2026-10-26T13:15:34+08:00","id":"g97","result":"done","commit":"3fe1fe3","summary":"重新生成 CHANGELOG(267 提交),收尾确认与提交史一致"}
{"ts":"2026-10-26T13:16:32+08:00","id":"g98","result":"done","commit":"c32f20c","summary":"modal/drawer 新增 closeOnOverlay,false 时点遮罩不关,× 与 Esc 不变"}
{"ts":"2026-10-26T13:17:13+08:00","id":"g99","result":"done","commit":"a5d4faf","summary":"spinner 新增 label 可访问名,缺省仍为加载中"}
{"ts":"2026-10-26T13:17:42+08:00","id":"g100","result":"done","commit":"b190718","summary":"重新生成 CHANGELOG(273 提交),本次唤起 22 轮收尾"}
{"ts":"2026-10-26T13:50:16+08:00","id":"g101","result":"done","commit":"265842e","summary":"新增 anchor 锚点导航:滚动高亮 + aria-current、offset 偏移定位、replaceState 写 hash,组件 57→58"}
{"ts":"2026-10-26T13:51:46+08:00","id":"g102","result":"done","commit":"abfbb82","summary":"trapFocus 下沉到 overlay,modal/drawer 改调用,image 预览层补焦点锁与初始聚焦"}
{"ts":"2026-10-26T13:54:09+08:00","id":"g103","result":"done","commit":"e497c16","summary":"dataTable 空状态分「暂无数据 / 没有符合关键词的行 + 一键清除筛选」,空状态行抽 tableEmpty.ts"}
{"ts":"2026-10-26T13:55:22+08:00","id":"g104","result":"done","commit":"8e2175d","summary":"lint-iterations 回查留痕提交:哈希须真实存在且主题指向同一条目"}
{"ts":"2026-10-26T13:56:59+08:00","id":"g105","result":"done","commit":"f398f09","summary":"tabs 支持 closable 页签(× / Delete 触发 onClose,指示条量外壳,触摸常显)"}
{"ts":"2026-10-26T13:58:26+08:00","id":"g106","result":"done","commit":"c66bcf3","summary":"新增 watchViewport:affix/backTop/anchor/tour 的 scroll+resize 卸载后自摘,防空转累积"}
{"ts":"2026-10-26T13:59:52+08:00","id":"g107","result":"done","commit":"48f9465","summary":"新增 popconfirm 二次确认气泡(popover 补受控 open),组件 58→59"}
{"ts":"2026-10-26T14:01:11+08:00","id":"g108","result":"done","commit":"5601e59","summary":"form 提交失败统计不通项数,顶部 role=alert 汇总条 + onError(count)"}
{"ts":"2026-10-26T14:02:01+08:00","id":"g109","result":"done","commit":"1bf35da","summary":"tooltip 触摸屏改为按下即出、抬手延迟 1.2s 收,鼠标/键盘路径不变"}
{"ts":"2026-10-26T14:03:25+08:00","id":"g110","result":"done","commit":"e042143","summary":"select 新增 clearable:有值显 ×(Delete/Backspace 亦可清),复位到未选态"}
{"ts":"2026-10-26T14:04:44+08:00","id":"g111","result":"done","commit":"f40ef6d","summary":"breadcrumb 新增 maxItems:首项+「…」+末几项,点开完整展开,末项恒为当前页"}
{"ts":"2026-10-26T14:05:44+08:00","id":"g112","result":"done","commit":"5372fdd","summary":"toast 新增 actionLabel/onAction:撤销按钮点后先收起再回调"}
{"ts":"2026-10-26T14:06:40+08:00","id":"g113","result":"done","commit":"ec7aaf0","summary":"affix 新增 position=bottom:占位底边越线才贴底固定,边距改 bottom"}
{"ts":"2026-10-26T14:07:17+08:00","id":"g114","result":"done","commit":"cb2601d","summary":"重新生成 CHANGELOG(301 提交),本次唤起 14 轮收尾"}
{"ts":"2026-10-26T14:09:03+08:00","id":"g115","result":"done","commit":"c2900a4","summary":"新增 calendar 月历(marker 徽标进 aria-label),日期工具下沉 dateKit 与 datePicker 共用,组件 59→60"}
{"ts":"2026-10-26T14:10:22+08:00","id":"g116","result":"done","commit":"2ab9406","summary":"drawer 新增 modal:false:透明遮罩不锁滚动不困焦点,页面可继续操作"}
{"ts":"2026-10-26T14:11:13+08:00","id":"g117","result":"done","commit":"6cf4905","summary":"新增 trackSize(ResizeObserver 自断),tabs 指示条与 segmented 滑块随尺寸重算"}
{"ts":"2026-10-26T14:11:42+08:00","id":"g118","result":"done","commit":"123e7e6","summary":"重新生成 CHANGELOG(309 提交),本次唤起 18 轮收尾"}
{"ts":"2026-10-26T14:12:37+08:00","id":"g119","result":"done","commit":"176504b","summary":"empty 新增 icon:自定义插图进图标位,缺省 ⌀ 且保持 aria-hidden"}
{"ts":"2026-10-26T14:13:19+08:00","id":"g120","result":"done","commit":"5a6c7a5","summary":"AGENTS.md 新增 10 行落点清单:新增/改参组件要动的文件与门禁原因"}
{"ts":"2026-10-26T14:31:31+08:00","id":"g121","result":"done","commit":"151d901","summary":"textField 补 maxCount 字数统计(原生上限 + 附加组内 n/limit 实时计数)"}
{"ts":"2026-10-26T14:34:12+08:00","id":"g122","result":"done","commit":"72c5d24","summary":"rate 支持 allowHalf 半星(左半区 0.5 分、键盘 0.5 步进、渐变裁字半格填充)"}
{"ts":"2026-10-26T14:35:01+08:00","id":"g123","result":"done","commit":"ba102e0","summary":"收尾刷新 CHANGELOG(319 提交)"}
{"ts":"2026-10-26T14:37:11+08:00","id":"g124","result":"done","commit":"63d3c91","summary":"checkbox 补 indeterminate 半选态(原生属性同步,零新增 CSS)"}
{"ts":"2026-10-26T14:44:38+08:00","id":"g125","result":"done","commit":"490c9fa","summary":"开关补 loading 加载态(转圈滑块 + aria-busy,单一 effect 防双写)"}
{"ts":"2026-10-26T14:49:32+08:00","id":"g126","result":"done","commit":"023a2eb","summary":"inputNumber 补 precision 小数位(toFixed 归一,消除步进浮点尾差)"}
{"ts":"2026-10-26T14:51:40+08:00","id":"g127","result":"done","commit":"48b0f35","summary":"recipes 补配方 6:长表单编辑页(吸底操作条 + 提交三处锁定)"}
{"ts":"2026-10-26T14:52:25+08:00","id":"g128","result":"done","commit":"b399640","summary":"收尾刷新 CHANGELOG(329 提交),顺带提交 g127 漏带的 llms-full.txt"}
{"ts":"2026-10-26T15:18:37+08:00","id":"g129","result":"done","commit":"ea2a846","summary":"新增 typography 排版文本组件(标题 h1-h5/段落/行内 + 语义色/装饰位/对齐 + 多行截断 + 复制按钮),组件 60→61"}
{"ts":"2026-10-26T15:20:05+08:00","id":"g130","result":"done","commit":"23d1e06","summary":"新增 grid 栅格容器(columns 固定列 / min auto-fit 自适应 / gutter / align / dense,窄屏单列),组件 61→62"}
{"ts":"2026-10-26T15:21:23+08:00","id":"g131","result":"done","commit":"d9559ae","summary":"新增 floatButton(左下/右下停靠、label 胶囊与纯图标圆形、badge 角标、tooltip 兼 aria-label、reduced-motion 兜底),组件 62→63"}
{"ts":"2026-10-26T15:22:59+08:00","id":"g132","result":"done","commit":"9d0c195","summary":"新增 mentions(@ 触发弹层、名字/关键词过滤、↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层、按光标替换写回信号),组件 63→64"}
{"ts":"2026-10-26T15:24:26+08:00","id":"g133","result":"done","commit":"0f6331c","summary":"新增 treeSelect(复用 tree 的搜索/多选/级联,单选即收起、多选保持展开,共享 Esc 栈 + 点外收起),组件 64→65"}
{"ts":"2026-10-26T15:25:53+08:00","id":"g134","result":"done","commit":"2a9193c","summary":"新增 ua sync-counts 幂等改写三处组件计数,verify 正则覆盖 README 标题「共 N 个」并给修法提示"}
{"ts":"2026-10-26T15:27:19+08:00","id":"g135","result":"done","commit":"21950d9","summary":"form 新增 columns(1/2/3)复用 grid 铺列,窄屏自动单列、按钮区整行;demo 注册表单改两列"}
{"ts":"2026-10-26T15:28:20+08:00","id":"g136","result":"done","commit":"f6706c1","summary":"新增 link(站外自动补 ↗ 与 rel=noopener noreferrer 并播报新窗口,muted/disabled 齐全),组件 65→66;sync-counts 首跑改写 3 份文档计数"}
{"ts":"2026-10-26T15:29:51+08:00","id":"g137","result":"done","commit":"55da08f","summary":"演示页头部加暗色主题开关(data-theme + localStorage 记忆,初值跟随系统),--page-* 变量改为响应显式主题、媒体查询仅兜未指定时"}
{"ts":"2026-10-26T15:31:27+08:00","id":"g138","result":"done","commit":"1af4e57","summary":"datePicker 新增 range/end/onRangeChange:首点定起点不收弹层、次点定终点并自动排序,区间内部低比重底色连成一条"}
{"ts":"2026-10-26T15:32:47+08:00","id":"g139","result":"done","commit":"08ceb8f","summary":"position 新增 flipSide 四向判定,tooltip 支持 left/right 并自动翻转;气泡定位与出场位移拆成两条规则,避免通用 .ua-open 覆盖方位"}
{"ts":"2026-10-26T15:35:04+08:00","id":"g140","result":"done","commit":"e40cd2c","summary":"dataTable 新增 onRowClick:传了行变 tabindex=0 并响应点击/Enter/空格,未传保持纯展示;文件压到 199 行"}
{"ts":"2026-10-26T15:36:29+08:00","id":"g141","result":"done","commit":"6cde618","summary":"notification 新增 placement 四角停靠:每角独立容器与 3 条上限,顶部角落 column-reverse 让新通知贴边出现"}
{"ts":"2026-10-26T15:37:34+08:00","id":"g142","result":"done","commit":"5af2139","summary":"space 新增 split:只在 2 个以上子元素间插分隔符,aria-hidden + 弱化色 + 不可选中"}
{"ts":"2026-10-26T15:38:51+08:00","id":"g143","result":"done","commit":"3058da1","summary":"tabs 新增 extra 右侧操作区:与页签同一行共用分隔线,不传时 DOM 结构不变"}
{"ts":"2026-10-26T15:40:02+08:00","id":"g144","result":"done","commit":"1009c89","summary":"新增 avatarGroup(叠放 + max 折叠 +N,整组 title 列全部姓名,+N 补可访问名),组件 66→67"}
{"ts":"2026-10-26T15:41:22+08:00","id":"g145","result":"done","commit":"af09500","summary":"slider 新增 marks:按 min-max 换算百分比绝对定位在轨道下方,aria-hidden 纯装饰,轨道包一层不影响右侧数值"}
{"ts":"2026-10-26T15:43:32+08:00","id":"g146","result":"done","commit":"2adae6e","summary":"progress 新增 type=circle:createElementNS 造 SVG 双圆 + dashoffset 出弧,中间百分比与 aria-label 自家拼,variant 换弧色"}
{"ts":"2026-10-26T15:44:44+08:00","id":"g147","result":"done","commit":"2387335","summary":"recipes.md 补配方 7-9:筛选栏(treeSelect + 日期区间)、评论输入(mentions + avatarGroup)、详情页排版(grid + typography + descriptions)"}
{"ts":"2026-10-26T15:48:24+08:00","id":"g148","result":"done","commit":"4a655d2","summary":"ua mcp 新增 list_recipes/get_recipe 取组合配方,verify 校验配方结构"}
{"ts":"2026-10-26T15:49:55+08:00","id":"g149","result":"done","commit":"d0eea04","summary":"ua search 按 id/标题/props/步骤检索,未命中回相近 id"}
{"ts":"2026-10-26T15:50:44+08:00","id":"g150","result":"done","commit":"04f6df8","summary":"ua info / MCP get_component 带出所属配方线索"}
{"ts":"2026-10-26T15:51:49+08:00","id":"g151","result":"done","commit":"f59e9b7","summary":"verify 卡住 MCP 工具清单三处漂移"}
{"ts":"2026-10-26T15:52:38+08:00","id":"g152","result":"done","commit":"bc984ae","summary":"ua list --components 出 67 组件全览"}
{"ts":"2026-10-26T15:53:22+08:00","id":"g153","result":"done","commit":"0609175","summary":"ua search 多词 AND 与未命中退出码 1"}
{"ts":"2026-10-26T15:54:06+08:00","id":"g154","result":"done","commit":"ab758ab","summary":"MCP search_components 返回组件级命中"}
{"ts":"2026-10-26T15:55:14+08:00","id":"g155","result":"done","commit":"a3ece11","summary":"llms.txt 输出配方目录,修正 MCP 工具描述"}
{"ts":"2026-10-26T15:55:38+08:00","id":"g156","result":"done","commit":"bbebb70","summary":"ua tokens <子串> 过滤令牌"}
{"ts":"2026-10-26T15:56:57+08:00","id":"g157","result":"done","commit":"c2412fb","summary":"新增配方 10:暗色主题切换与 color-scheme 同步"}
{"ts":"2026-10-26T16:00:46+08:00","id":"g158","result":"done","commit":"638f623","summary":"datePicker 新增 presets 快捷区间(days 相对今天 / from-to 绝对日期,min-max 钳制)"}
{"ts":"2026-10-26T16:02:24+08:00","id":"g159","result":"done","commit":"312e801","summary":"multiSelect 新增 maxTagCount 标签折叠(+N 虚线条目,悬停看全量、点击展开)"}
{"ts":"2026-10-26T16:03:56+08:00","id":"g160","result":"done","commit":"ef564fc","summary":"slider 新增 range 双滑块(叠轨 + 已选段,越界自动压平)"}
{"ts":"2026-10-26T16:05:06+08:00","id":"g161","result":"done","commit":"0bcf73f","summary":"notification 支持 key 复用与 update 原地改标题/描述/语义色"}
{"ts":"2026-10-26T16:07:27+08:00","id":"g162","result":"done","commit":"fe78275","summary":"verify 新增暗色令牌校验(颜色必覆盖、反向查多余变量),ua tokens 标注缺暗色"}
{"ts":"2026-10-26T16:08:23+08:00","id":"g163","result":"done","commit":"6febeba","summary":"verify 补 backlog 编号递增/去重与留痕 ts 时区、单调、未来三类校验"}
{"ts":"2026-10-26T16:09:59+08:00","id":"g164","result":"done","commit":"b533903","summary":"descriptions 新增 layout=vertical 与 extra 标题插槽"}
{"ts":"2026-10-26T16:11:06+08:00","id":"g165","result":"done","commit":"804ab3b","summary":"stepper 新增 direction=vertical(圆点下方连线,已走段用主色)"}
{"ts":"2026-10-26T16:13:05+08:00","id":"g166","result":"done","commit":"a80644c","summary":"select/multiSelect 支持 option.group 分组小标题(过滤后空组标题隐藏)"}
{"ts":"2026-10-26T16:14:19+08:00","id":"g167","result":"done","commit":"ff94429","summary":"modal 新增 confirmText/cancelText/onConfirm 内置页脚,Promise 期间按钮转处理中"}
{"ts":"2026-10-26T16:14:59+08:00","id":"g168","result":"done","commit":"879be29","summary":"配方 11:区间滑块 + 日期预设 + 分组下拉 + 表格的筛选区拼装"}
{"ts":"2026-10-26T16:16:07+08:00","id":"g169","result":"done","commit":"bd98fc8","summary":"formItem 新增 span 跨列(窄屏/单列自动退回整行)"}
{"ts":"2026-10-26T16:17:06+08:00","id":"g170","result":"done","commit":"a754bd6","summary":"pagination 新增 simple 极简模式与 hideOnSinglePage"}
{"ts":"2026-10-26T16:17:49+08:00","id":"g171","result":"done","commit":"6a605ba","summary":"刷新 CHANGELOG 至 400 提交,补 llms.txt 的配方 11 目录行"}
{"ts":"2026-10-26T16:19:32+08:00","id":"g172","result":"done","commit":"250601d","summary":"ua list 新增 --json(包 + 组件 id 结构化输出),CLI 清单与 AGENTS 同步"}
{"ts":"2026-10-26T16:20:30+08:00","id":"g173","result":"done","commit":"ea4e781","summary":"upload 图片文件带 28px 缩略图,对象 URL 随文件离场 revoke"}
{"ts":"2026-10-26T16:21:48+08:00","id":"g174","result":"done","commit":"2316445","summary":"timeline 新增 mode=alternate(轴线居中、奇偶分列、窄屏回落单侧)"}
{"ts":"2026-10-26T16:23:24+08:00","id":"g175","result":"done","commit":"cbce645","summary":"popconfirm 的 onConfirm 可返回 Promise(就地 loading,成功才收起),modal 同步放宽类型"}
{"ts": "2026-10-26T16:27:02+08:00", "id": "g176", "result": "done", "commit": "cc9ec02", "summary": "ua search 覆盖参数说明(五层字段表,命中非 id 标题时标出字段),MCP search_components 复用同表并补 matched 输出"}
{"ts": "2026-10-26T16:29:32+08:00", "id": "g177", "result": "done", "commit": "c6fd8b8", "summary": "新增 ua recipes / ua recipes <编号>(含 --json),MCP 的 list_recipes/get_recipe 复用同一组件抽取函数,补 AGENTS 与 tools manifest"}
{"ts": "2026-10-26T16:30:46+08:00", "id": "g178", "result": "done", "commit": "9218933", "summary": "verify 新增配方校验:代码块里必须至少认出一个已注册组件 id,拼错组件名直接报错(负向自检已确认会拦)"}
{"ts":"2026-10-26T17:09:15+08:00","id":"g187","result":"done","commit":"20ff9d4","summary":"新增 virtualList 虚拟列表(固定行高窗口渲染 + rAF 合帧 + scrollTo 句柄),组件 67→68;上游人工推入 g179–g186 视觉域条目后改号 g187,rebase 后门禁全绿"}
{"ts":"2026-10-26T17:17:27+08:00","id":"g179","result":"done","commit":"7b4adb0","summary":"弹层阴影语义化:8 处主题色投影归入 shadow-1..4 与新增 accent 两档令牌,shadow-literal 17→9,新基线 27 处,intro 补对照表"}
{"ts":"2026-10-26T17:22:20+08:00","id":"g180","result":"done","commit":"90ed48c","summary":"内距令牌化:space-literal 第六类规则 + 四档半步令牌,字面值 160→13,新基线 40 处"}
{"ts":"2026-10-26T17:30:00+08:00","id":"g181","result":"done","commit":"85f009d","summary":"控件高度统一 control-h 三档:四组件新增 size 入参,control-h-literal 门禁计数 0,demo 三档并排"}
{"ts":"2026-10-26T17:33:15+08:00","id":"g182","result":"done","commit":"9ee04d7","summary":"暗色焦点环两令牌补覆盖 + verify 暗色齐全扩展到 shadow/focus 前缀(负向已拦)+ ua tokens 暗色列可见"}
{"ts": "2026-10-26T17:36:54+08:00", "id": "g183", "result": "done", "commit": "fe79725", "summary": "字重行高令牌化:weight/leading 两类规则,72 处字面值全部归零,补 bold/none 档,三档归属注释就位"}
{"ts":"2026-10-26T17:40:37+08:00","id":"g184","result":"done","commit":"2d81907","summary":"z-index 归零:20 处浮层字面值按五档令牌归位(z-literal 规则 + 幂等迁移),新基线 40 处"}
{"ts":"2026-10-26T17:45:53+08:00","id":"g185","result":"done","commit":"999f3f6","summary":"分割线与轮廓分色:新增 border-literal 第十一类门禁(色值字面量,纯黑 rgba 边可迁移),19 处 1px 分割线改走 --ua-border-subtle,计数 0、基线 40 处"}
{"ts":"2026-10-26T17:47:55+08:00","id":"g186","result":"done","commit":"5908202","summary":"视觉回归抽查清单固化:ITERATION.md 六组件 26 条可勾选项(五维度标签),ua verify 新增章节/条目/维度三重校验(负向已拦),AGENTS 与 cli 清单同步"}
{"ts":"2026-10-26T17:50:31+08:00","id":"g188","result":"done","commit":"7e47a54","summary":"新增 commandPalette 命令面板(第 69 个组件):⌘K 唤起、label/hint/keywords 三层过滤、↑↓ 环形高亮 + aria-activedescendant、Esc 走弹层栈、开关归还焦点;样式全令牌,视觉基线仍 40 处"}
{"ts":"2026-10-26T17:51:39+08:00","id":"g189","result":"done","commit":"b83cf59","summary":"补配方 12:命令面板 ⌘K 入口(id 分发 / keywords 兜拼音 / 二次确认外置 / hotkey 单实例),ua recipes 12 可取出骨架与六条要点"}
{"ts":"2026-10-26T17:52:50+08:00","id":"g190","result":"done","commit":"a67a9fe","summary":"字号字面值归零:字号令牌 6→11 档(22/26/34px 展示档 + 0.92em/0.85em 相对缩放档),迁移 5 处,font-size-literal 0,总计数 40→35 并写新基线"}
{"ts":"2026-10-26T17:53:57+08:00","id":"g191","result":"done","commit":"bb28f86","summary":"焦点环变体归零:新增 warning/danger/pulse/selected 四档语义环(亮暗各一套),四处自绘环改走令牌,focus-ring-variant 4→0、总计数 35→27 并写新基线"}
{"ts":"2026-10-26T17:55:17+08:00","id":"g192","result":"done","commit":"68bfbdd","summary":"动效时长归零:交互档扩到四档(新增 slower 0.35s)+ 循环四档 0.6/0.8/1.2/1.6s,迁移仅对 animation+infinite 走循环表,9 处全清,总计数 27→18"}
{"ts":"2026-10-26T17:56:20+08:00","id":"g193","result":"done","commit":"e64097f","summary":"阴影字面值归零:新增描边族 hairline/inset-border/inset-accent 与断口 gap 四档(亮暗各一套),5 处「用阴影画线」改走令牌,shadow-literal 0、总计数 18→13"}
{"ts":"2026-10-26T17:57:11+08:00","id":"g194","result":"done","commit":"1476a40","summary":"audit:visual --json 增加 hits 明细(每类 line/prop/value/hint,空类目给空数组),棘轮报红时可直接定位到行;人读输出与退出码不变"}
{"ts":"2026-10-26T17:58:16+08:00","id":"g195","result":"done","commit":"8a0a907","summary":"内距字面值归零:新增 space-6(24px) 与 space-indent(22px),13 处微距就近收敛,11 类硬编码合计 0 并写入零基线;负向自检确认任何新增字面值直接报红"}
{"ts": "2026-10-26T18:10:33+08:00", "id": "g196", "result": "done", "commit": "9ef8637", "summary": "中性色阶地基:tokens 新增 surface-2/3/inset、text-secondary/faint、overlay/strong 9 令牌(亮暗双向),ui.css 遮罩 5 处 rgba 归档 + 虚拟列表私写分割线回归 border-subtle + 凹陷散点 3 处归档"}
{"ts": "2026-10-26T18:10:33+08:00", "id": "g197", "result": "done", "commit": "8148317", "summary": "accent 淡底配方化:tint-accent(10%)/tint-accent-weak(5%) 亮暗各套,37 处 transparent 变体收敛(16 弱 + 21 标准),混 bg 与高浓档按规则保留"}
{"ts": "2026-10-26T18:10:33+08:00", "id": "g198", "result": "done", "commit": "2996b87", "summary": "全控件按下态补齐:input/textarea 补 hover 边框过渡,select-trigger/menu-item/option/tab/acc-head/page-btn/calendar-day 补 :active(surface-2 深一档),9 规则 + 2 transition 补齐,:not(:disabled) 守卫"}
{"ts": "2026-10-26T18:25:13+08:00", "id": "g199", "result": "done", "commit": "bfb9178", "summary": "质感层:新增 highlight 内高光/shadow-highlight/tracking-wide/tight 令牌(亮暗覆盖),主按钮内高光+hover 主题色投影,卡片贴地影+hover 升档边框减淡,字距 4 处全走令牌;幽灵按钮显式退出质感层(透明底白高光露线)"}
{"ts": "2026-10-26T18:25:13+08:00", "id": "g200", "result": "done", "commit": "91aa304", "summary": "动效编排:私写缓动 3 处归零(ease-out/spring 归档),modal/drawer 内容分层入场(fast 档错峰),select/menu 弹层子项 nth-child 2..8 交错(--ua-duration-step 0.02s 派生),专块 reduced-motion 兜底"}
{"ts": "2026-10-26T18:25:13+08:00", "id": "g201", "result": "done", "commit": "e64f72b", "summary": "焦点可达性:清点确认点击型控件已无裸 :focus 残留(38 处 focus-visible/7 处输入类保留);修「吞环」3 处——tab/option/menu-item 键焦改内嵌描边(面板滚动裁切场景)"}
{"ts":"2026-10-26T19:03:16+08:00","id":"g202","result":"done","commit":"f332500","summary":"按下态可见性修正:option/menu-item/tab 改 tint-accent 并数值复核,四处 accent 混 bg 并入 tint-accent,字面 ease 全量换令牌"}
{"ts":"2026-10-26T19:11:13+08:00","id":"g203","result":"done","commit":"df51478","summary":"文档站页头主题切换(亮/暗/跟随系统,localStorage 持久),demo 读 ?theme= 参数,iframe 带参重载联动暗色"}
{"ts":"2026-10-26T19:13:53+08:00","id":"g204","result":"done","commit":"5bc854b","summary":"textarea 新增 autosize:布尔/对象(minRows/maxRows)双态,输入与信号写回都重算高度,行高从 computed style 推导零硬编码"}
{"ts":"2026-10-26T19:15:31+08:00","id":"g205","result":"done","commit":"6ee86ac","summary":"textField 新增 password:原生打点 + 内置显示/隐藏切换按钮(复用 clear 样式),与 clearable/计数不互斥"}
{"ts":"2026-10-26T19:16:49+08:00","id":"g206","result":"done","commit":"f763f63","summary":"rate 新增 allowClear:再点当前分(半星须精确相等)清零,aria-valuemin 同步 0/1"}
{"ts":"2026-10-26T19:18:06+08:00","id":"g207","result":"done","commit":"4eaa177","summary":"progress 新增 format 回调:线/环形进度文本可定制(如 MB 计数),缺省行为不变,环形 aria-label 同步"}
{"ts":"2026-10-26T19:20:36+08:00","id":"g208","result":"done","commit":"9180e2c","summary":"multiSelect 新增 clearable:右缘一键清空(复用 select 清空钮样式 + --ua-space-2 内收),清空保持焦点不误展开"}
{"ts":"2026-10-26T19:22:56+08:00","id":"g209","result":"done","commit":"e0cf55e","summary":"演示页补齐 password/autosize/allowClear/clearable 四处现场演示,section 描述同步"}
{"ts":"2026-10-26T19:47:23+08:00","id":"g210","result":"done","commit":"053f389","summary":"滚动条令牌化:新增 color.scrollbar / scrollbarHover 双令牌(含暗色覆盖),13 个滚动容器统一 thin + 令牌拇指、WebKit 伪元素胶囊拇指与悬停加深,audit:visual 十一类计数维持 0"}
{"ts":"2026-10-26T19:49:12+08:00","id":"g211","result":"done","commit":"fd2e951","summary":"verify 新增 checkTokenParity:tokens.json color 键折算成 --ua-color-* 后与 tokens.css 双向查漏,并逐项比对亮/暗值;负向自检(改值、删键)均能报错点名变量"}
{"ts":"2026-10-26T19:50:43+08:00","id":"g212","result":"done","commit":"6ccc341","summary":"tabs 激活页签自动回拉:溢出时只滚 .ua-tabs-head、就近对齐留 8px 边、已可见不动手、减弱动效直接跳位;demo 增八个页签窄容器演示"}
{"ts":"2026-10-26T19:53:33+08:00","id":"g213","result":"done","commit":"3505449","summary":"新增 timePicker(70 个组件):时 / 分两列弹层,HH:mm 双向绑定、minuteStep 可配、激活格居中滚动、列内 ↑↓/Home/End 导航,走 Esc 栈与 flip 翻转"}
{"ts":"2026-10-26T19:54:55+08:00","id":"g214","result":"done","commit":"a607a40","summary":"七个弹层的 7 份重复入场 keyframes 收敛为 ua-pop-drop / ua-pop-rise 两个,时长与缓动全走令牌,五个 .is-flip 弹层补方向覆盖(翻转后不再朝反方向滑入)"}
{"ts":"2026-10-26T19:55:45+08:00","id":"g215","result":"done","commit":"0fb9198","summary":"配方 13:日期 + 时间组合预约时点,示范两段各管一段(YYYY-MM-DD / HH:mm)、拼装交给 computed、约束优先于校验"}
{"ts":"2026-10-26T19:57:26+08:00","id":"g216","result":"done","commit":"17da561","summary":"timePicker 补 clearable:有值时右侧出现 ×(不进 Tab 序),Delete/Backspace 等价清空,清空写回空串并收起弹层"}
{"ts":"2026-10-26T19:58:47+08:00","id":"g217","result":"done","commit":"34985ff","summary":"新增 frame 内核冒烟 14 条(跑 dist、无 DOM),顺带修掉 effect 的两个真缺陷:注销是空实现、重跑不清理旧依赖"}
{"ts":"2026-10-26T19:59:28+08:00","id":"g218","result":"done","commit":"ed2dd15","summary":"timePicker 未选值时两列改停在此刻(分钟按步长折算并钳在列长内),打开即可就近选,不用从 00:00 往下翻"}
{"ts":"2026-10-26T20:00:36+08:00","id":"g219","result":"done","commit":"f042c66","summary":"verify 新增重复关键帧检测(帧体剥注释归一后比对,报出两个动画名),首跑即抓出 ua-switch-spin ≡ ua-spin,开关转圈改为复用"}
{"ts":"2026-10-26T20:02:26+08:00","id":"g221","result":"done","commit":"92bc8bb","summary":"verify 新增 checkScrollCoverage:括号配平扫出全部 overflow 容器,逐个核对是否在细条规则内(14/14 覆盖),负向自检摘掉 .ua-timepicker-col 即报错"}
{"ts":"2026-10-26T20:03:19+08:00","id":"g222","result":"done","commit":"3067c63","summary":"timePicker 补 size 三档:高度并入顶部「控件高度三档」统一规则、组件段删掉重复高度声明,与 textField/select 同源"}
{"ts":"2026-10-26T20:04:32+08:00","id":"g223","result":"done","commit":"126da05","summary":"timePicker 非档值(如 09:07 + 15 分钟档)改为按最近档高亮与滚动,锚点优先跟值,消除「有值却无高亮」的空弹层"}
{"ts":"2026-10-26T20:05:13+08:00","id":"g224","result":"done","commit":"63351dc","summary":"timePicker 收起态支持 ↑↓ 按步长微调、Shift + ↑↓ 按小时微调,跨零点环形回绕,与收回合同一套 keydown"}
{"ts":"2026-10-26T20:05:58+08:00","id":"g225","result":"done","commit":"9c783cd","summary":"时分两列补 1px 分界线 + 左侧内距,弹层与单元格定最小宽度并居中,消除「两列像一列」「弹层比触发器窄」的观感问题"}
{"ts":"2026-10-26T20:06:34+08:00","id":"g226","result":"done","commit":"93a7c67","summary":"配方《通用约定》补三条单一来源规则:动效走 ua-pop-drop / ua-pop-rise、新滚动容器进 scrollbar 名单、颜色令牌两处双写"}
{"ts":"2026-10-26T20:07:11+08:00","id":"g227","result":"done","commit":"87bdc03","summary":"timePicker 打开弹层即把焦点送进时列当前格(preventScroll 保住滚动位置),键盘用户不必再 Tab 找,Esc 仍归还触发器"}
{"ts":"2026-10-26T20:07:32+08:00","id":"g220","result":"done","commit":"1ac8bae","summary":"滚动条统一规则补入 .ua-timepicker-col:脚本枚举 14 个 overflow 容器逐个核对,覆盖由 13/14 补到 14/14"}
{"ts":"2026-10-26T20:30:30+08:00","id":"g228","result":"done","commit":"a692cab","summary":"新增 ua doctor 环境自检命令:node 版本/依赖/git 钩子/.ua 元数据秒级七项检查,任一失败退出码 1,AGENTS.md 接手流程改为先 doctor 再 check"}
{"ts":"2026-10-26T20:41:17+08:00","id":"g229","result":"done","commit":"36c787a","summary":"混色浓度令牌化:--ua-alpha-* 六档落地,ui.css 44 处 color-mix 百分比全量换令牌并新增 audit:visual 第 12 类棘轮 mix-literal(基线 0),渐变停靠/几何百分比豁免"}
{"ts":"2026-10-26T20:43:26+08:00","id":"g230","result":"done","commit":"0d3c166","summary":"demo 页级命令面板:Ctrl+K 输入即跳 63 个演示区块(与侧边目录同源反推),演示卡实例改 hotkey:false 示范多实例约定"}
{"ts":"2026-10-26T20:46:13+08:00","id":"g231","result":"done","commit":"cd15c70","summary":"ua search 与 MCP search_components 覆盖配方域(标题/组件/要点三层),跨组件诉求可直达配方并带 ua recipes 指路"}
{"ts":"2026-10-26T20:47:17+08:00","id":"g232","result":"done","commit":"61f07e4","summary":"ua doctor 升到 8 项:git user.name/user.email 未配置时 FAIL 并提示配置命令,堵住「全过却提交不了」的死角"}
{"ts":"2026-10-26T21:17:00+08:00","id":"g233","result":"done","commit":"0eb67c4","summary":"位移量令牌化:transform 位移收敛为 --ua-translate-* 七档(10px 并入 8 档),audit:visual 新增 translate-literal 第十三类,ui.css 33 处字面量迁移后基线归零"}
{"ts":"2026-10-26T21:23:14+08:00","id":"g234","result":"done","commit":"8740ab0","summary":"新增 pinInput 验证码输入(组件 70→71):进格/回删/移格/粘贴分发 + mask 密文,value 信号双向绑定,样式零新增视觉字面量"}
{"ts":"2026-10-26T21:26:43+08:00","id":"g235","result":"done","commit":"5443aad","summary":"新增 copyButton 复制按钮(组件 71→72):已复制文案反馈 + execCommand 降级,text 支持函数实时取值,复用 .ua-btn 零新增样式"}
{"ts":"2026-10-26T21:29:12+08:00","id":"g236","result":"done","commit":"d504553","summary":"新增 countdown 倒计时(组件 72→73):归零自动停表 + onFinish,句柄 start/stop/reset,缺省 mm:ss 可自定义格式"}
{"ts":"2026-10-26T21:31:07+08:00","id":"g237","result":"done","commit":"fc8c5ad","summary":"新增 kbd 键盘按键标记(组件 73→74):键帽样式单键/组合键(+ 连接对读屏隐藏),纯令牌零字面量"}
{"ts":"2026-10-26T21:35:34+08:00","id":"g238","result":"done","commit":"2262719","summary":"新增 highlight 关键词高亮(组件 74→75):mark 包裹命中片段,大小写不敏感全部命中,双信号响应式联动"}
{"ts":"2026-10-26T21:38:14+08:00","id":"g239","result":"done","commit":"5333896","summary":"焦点描边令牌化:outline 三形态收敛为 --ua-focus-outline(_inset/_soft),audit 新增 outline-literal 第十四类,20 处迁移后基线归零"}
{"ts":"2026-10-26T21:40:59+08:00","id":"g240","result":"done","commit":"5940519","summary":"button 增加 loading 态:转圈禁点 + aria-busy,布尔/信号双口径,reduced-motion 降级静态环"}
{"ts":"2026-10-26T21:42:08+08:00","id":"g241","result":"done","commit":"69c9cfe","summary":"verify 增加演示锚点大写防护:sec- 锚点含大写直接报错,堵住锚点被正则静默吞掉的排查坑"}
{"ts": "2026-10-26T21:53:37+08:00", "id": "g242", "result": "done", "commit": "29d2d8d", "summary": "新增 ua audit:contrast 对比度门禁(16 组文字×底色配对、亮暗分算、color-mix 求值),并修掉两条真实不达标:亮色辅助文字压凹陷底 4.39、暗色主按钮反白 3.92/强调文字压淡底 4.44"}
{"ts": "2026-10-26T21:55:31+08:00", "id": "g243", "result": "done", "commit": "f396a55", "summary": "文档站与演示页外壳配色统一从 tokens.json 派生(新增 lib/shell-palette.mjs 单一来源),旧手写暗色 accent 漂移归零"}
{"ts": "2026-10-26T21:57:55+08:00", "id": "g244", "result": "done", "commit": "ea51493", "summary": "新增 inputTag 标签输入(组件 76):回车/逗号成标签、chip 逐个删、空框退格删末个、失焦自动入列,max 满额禁输,样式复用输入组与标签零新增字面量"}
{"ts": "2026-10-26T21:58:58+08:00", "id": "g245", "result": "done", "commit": "6e52cc6", "summary": "新增 prefers-contrast: more 高对比令牌(边框/弱文字加深一档,只改既有变量),audit:contrast 升为亮/暗 × 常规/高对比四套主题 16 对全验"}
{"ts": "2026-10-26T22:00:07+08:00", "id": "g246", "result": "done", "commit": "c5238c6", "summary": "ua info 增加 --md:输出标题+参数表+用法+示例的 Markdown 片段,联合类型竖线自动转义,文本版与 --json 行为不变"}
{"ts": "2026-10-26T22:03:38+08:00", "id": "g247", "result": "done", "commit": "42ea31d", "summary": "table-core 新增 setRows(换源保留排序/筛选/页码,按 rowKey 剔除失效选择),dataTable 的 data 支持信号数据源,冒烟 21→26 条"}
{"ts": "2026-10-26T22:04:19+08:00", "id": "g248", "result": "done", "commit": "0715131", "summary": "新增配方 14「标签筛选条 → 表格联动」:多条件在信号层派生、给表格传信号而非数组、且或语义与空状态复用,配方 13→14 条"}
{"ts": "2026-10-26T22:05:40+08:00", "id": "g249", "result": "done", "commit": "2c34870", "summary": "doctor 升到 11 项:usage↔dispatch 双向覆盖、check 链覆盖全部门禁步骤、CLI 清单声明命令都有实现;当场抓到 usage 漏 recipes 的真问题"}
{"ts": "2026-10-26T22:07:53+08:00", "id": "g250", "result": "done", "commit": "b6229e2", "summary": "新增 ua audit:motion 动效兜底门禁(扫描 infinite 动画是否被 reduced-motion 关掉),当场补掉转圈与不确定态进度两处漏网,门禁链升到 11 步"}
{"ts": "2026-10-26T22:08:59+08:00", "id": "g251", "result": "done", "commit": "4b57f11", "summary": "ua tokens 增加 --unused:按「皮肤样式表 + 令牌自身派生」算消费面,列出闲置令牌(当前 3 个:surface-3 / text-secondary / font-size-xl),只报不删"}
{"ts": "2026-10-26T22:09:57+08:00", "id": "g252", "result": "done", "commit": "80ffee5", "summary": "MCP get_component 增加 format:md,与 ua info --md 共用渲染;tools/call 对字符串结果原样返回,Agent 直接拿到可粘贴 Markdown"}
{"ts": "2026-10-26T22:11:44+08:00", "id": "g253", "result": "done", "commit": "d23d861", "summary": "文档站每个组件卡加「复制参数表」按钮,前端按同一口径生成 Markdown(竖线转义 + 围栏),无剪贴板权限时退回 execCommand"}
{"ts":"2026-10-26T22:14:54+08:00","id":"g254","result":"done","commit":"43af4cf","summary":"TablePlugin 增 settle 钩子:派生后把越界页码收敛回内核 state,换源缩水不再滞留旧页码、数据恢复也不跳页;smoke 26→31 条"}
{"ts":"2026-10-26T22:17:26+08:00","id":"g255","result":"done","commit":"48efb44","summary":"audit:motion 增加第二类必查「悬停/按压位移」;ui.css 用 ua- 类通配一条规则把全站 14 处位移在减少动效下归零(颜色/阴影过渡保留),负向自检确认报 8 处未归零"}
{"ts":"2026-10-26T22:19:20+08:00","id":"g256","result":"done","commit":"036e044","summary":"分页条抽成 tablePager 并加每页条数下拉(select 自绘、候选自动补当前值),首末页按钮置灰;dataTable 回落到 199 行"}
{"ts":"2026-10-26T22:20:13+08:00","id":"g257","result":"done","commit":"364ac80","summary":"分页条改 flex-wrap 折行 + gap 两值(行距紧一档),页码信息与页大小下拉用 auto 外边距分组,窄屏不再横向溢出"}
{"ts":"2026-10-26T22:21:05+08:00","id":"g258","result":"done","commit":"3036f26","summary":"select 增加 ariaLabel(缺省不落属性),表格分页条传「每页条数」,补 manifest 参数表与步骤"}
{"ts":"2026-10-26T22:21:54+08:00","id":"g259","result":"done","commit":"11a22f9","summary":"DataTableProps 增加 pageSizes 候选并透传到分页条(缺省 5/10/20,当前值自动补);dataTable 控制在 200 行内"}
{"ts":"2026-10-26T22:22:38+08:00","id":"g260","result":"done","commit":"07a5275","summary":"新增配方 15:表格分页与独立分页器共用 page 信号 + pageSizes 候选,写清写回顺序与越界收敛四条要点"}
{"ts":"2026-10-26T22:23:22+08:00","id":"g261","result":"done","commit":"047b6c5","summary":"inputTag 删除 chip 后焦点交回输入框,连续删多个标签不再每次丢焦点"}
{"ts":"2026-10-26T22:24:07+08:00","id":"g262","result":"done","commit":"62d8107","summary":"audit:contrast 增两条非文本配对(焦点描边 × 页面底/表面,阈值 3),配对表 16→18 并同步四处文档表述"}
{"ts":"2026-10-26T22:25:45+08:00","id":"g263","result":"done","commit":"f8bf00d","summary":"inputTag 支持多值粘贴(逗号/换行/制表符拆分后逐个入列),单值粘贴仍走默认行为;分隔符统一为顶部 SEP 常量"}
{"ts":"2026-10-26T22:26:53+08:00","id":"g264","result":"done","commit":"fe9f1fb","summary":"数据表格新增 emptyText 自定义空状态文案(有筛选词时仍显示关键词+清除按钮),emptyRow 加可选参数不新增调用行"}
{"ts":"2026-10-26T22:48:11+08:00","id":"g265","result":"done","commit":"71cff84","summary":"新增布局骨架组件 layout(76→77):四区一次成型 + 侧栏可折叠 + 页头吸顶"}
{"ts":"2026-10-26T22:50:11+08:00","id":"g266","result":"done","commit":"90bd60e","summary":"新增 JSON 树查看组件 jsonViewer(77→78):按层折叠 + 类型着色 + 计数摘要"}
{"ts":"2026-10-26T22:51:24+08:00","id":"g267","result":"done","commit":"cf44880","summary":"补 Windows 强制色高对比兜底:结构补真边框 + 选中态走 Highlight + 占位走 GrayText"}
{"ts":"2026-10-26T22:52:36+08:00","id":"g268","result":"done","commit":"9380304","summary":"verify 增加孤儿演示反向校验:demo 里每个 section 必须被某组件 demo 认领"}
{"ts":"2026-10-26T22:53:49+08:00","id":"g269","result":"done","commit":"368d47c","summary":"新增文本差异对比组件 diffView(78→79):剪前后缀 + LCS 对齐 + 双通道着色"}
{"ts":"2026-10-26T22:54:56+08:00","id":"g270","result":"done","commit":"b8f90b8","summary":"新增代码块组件 codeBlock(79→80):行号不进剪贴板 + 头部标题 + 一键复制"}
{"ts":"2026-10-26T22:56:38+08:00","id":"g271","result":"done","commit":"1680c4e","summary":"新增手写签名板组件 signaturePad(80→81):DPR 位图 + 指针跟手 + 快照保笔迹"}
{"ts":"2026-10-26T22:58:03+08:00","id":"g272","result":"done","commit":"b6c2afb","summary":"视觉抽查清单扩到十一段(新增五类新组件抽查位),总数下限改为段数推导"}
{"ts":"2026-10-26T22:59:07+08:00","id":"g273","result":"done","commit":"d6e4319","summary":"新增区域加载遮罩组件 loading(81→82):延迟出场 + 内容留位 + aria-busy"}
{"ts":"2026-10-26T22:59:58+08:00","id":"g274","result":"done","commit":"b93e8b0","summary":"补三条组合配方:后台页骨架 / 表格刷新遮罩 / 变更审阅面板"}
{"ts":"2026-10-26T23:00:45+08:00","id":"g275","result":"done","commit":"b454d5f","summary":"新增相对时间组件 relativeTime(82→83):自动刷新 + 超阈值转绝对日期 + 脏数据兜底"}
{"ts":"2026-10-26T23:02:16+08:00","id":"g276","result":"done","commit":"eac7d4b","summary":"新增迷你趋势线组件 sparkline(83→84):归一折线 + 面积 + 末点,颜色走 currentColor"}
{"ts":"2026-10-26T23:03:29+08:00","id":"g277","result":"done","commit":"b5576c7","summary":"新增日历热力图组件 heatmap(84→85):一周一列 + 四档浓度 + 末列对齐本周"}
{"ts":"2026-10-26T23:04:20+08:00","id":"g278","result":"done","commit":"c36b507","summary":"热力图图例与网格尺寸同源(删掉样式表第二份宽高),抽查清单加 heatmap / sparkline 至十三段"}
{"ts":"2026-10-26T23:05:23+08:00","id":"g279","result":"done","commit":"ef58a41","summary":"diffView 增加 ignoreWhitespace:比对键归一行内空白、展示仍用原文,统计条标注口径"}
{"ts":"2026-10-26T23:06:22+08:00","id":"g280","result":"done","commit":"865477c","summary":"formItem 增加 visible:隐藏即清错误态并退出同步 / 异步校验"}
{"ts":"2026-10-26T23:07:50+08:00","id":"g281","result":"done","commit":"82e69df","summary":"抽出 diffKit 纯逻辑 + 皮肤冒烟 8 条断言并入 check 链,smoke 变三段"}
{"ts":"2026-10-26T23:08:29+08:00","id":"g282","result":"done","commit":"c98f98e","summary":"codeBlock 增加 wrap:折行时保留缩进、行号仍贴首行,缺省行为不变"}
+13
View File
@@ -0,0 +1,13 @@
import { readFileSync, writeFileSync, appendFileSync } from 'node:fs';
// 用法:node .ua/log-one.mjs <id> <commit> <result> <summary> <ts>
const [id, commit, result, summary, ts] = process.argv.slice(2);
const b = JSON.parse(readFileSync('.ua/backlog.json', 'utf8'));
const item = b.items.find((i) => i.id === id);
if (item) item.status = result === 'done' ? 'done' : 'open'; // 失败保持 open
writeFileSync('.ua/backlog.json', JSON.stringify(b, null, 2) + '\n', 'utf8');
// 留痕行:字段顺序固定 ts/id/result/commit/summary,ts 由 date 命令实测传入
appendFileSync('.ua/iterations.log', JSON.stringify({ ts, id, result, commit, summary }) + '\n', 'utf8');
console.log('logged', id, result, commit, ts);
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "视觉漂移基线:各类硬编码视觉字面量的允许上限,任一类超出即 ua audit:visual 退出码 1;迁移后用 --baseline 重写",
"file": "packages/skin/ui/ui.css",
"commit": "9e85ef3",
"generatedAt": "2026-10-26T13:36:51.222Z",
"lines": 5241,
"total": 0,
"counts": {
"font-size-literal": 0,
"duration-literal": 0,
"shadow-literal": 0,
"radius-literal": 0,
"focus-ring-variant": 0,
"space-literal": 0,
"control-h-literal": 0,
"weight-literal": 0,
"leading-literal": 0,
"z-literal": 0,
"border-literal": 0,
"mix-literal": 0,
"translate-literal": 0,
"outline-literal": 0
}
}
+33 -8
View File
@@ -7,13 +7,13 @@
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. **环境自检 + 验证基线**:先跑 `node packages/tools/cli/bin/ua.js doctor`(秒级 11 项检查:node 版本 / 依赖安装 / git 钩子 / git 身份 / .ua 元数据可解析 / usage 串 ↔ dispatch 一致 / check 链覆盖全部门禁步骤 / CLI 清单声明的命令都有实现,任一失败退出码 1),通过后再跑 `npm run check`(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion)确认基线全绿,再开始改代码。
## 编码规范(强制)
所有 TS / JS 代码遵循 `CODING.md`:注释一律中文(函数级 `/*** 作用 ***/`、行尾 `// 短注释`),代码紧凑 + 行工整、卫语句单行,高可扩展、低抽象、可读性优先。
**注释强制门禁**:`npm run style`(即 `ua style`)机器检查——导出必须有 `/*** ***/` 注释、接口字段必须有行尾注释、行注释必须中文;有违规即退出码 1,改完才能提交。
**注释强制门禁**:`npm run style`(即 `ua style`)机器检查——导出必须有 `/*** ***/` 注释、接口字段必须有行尾注释、行注释必须中文,并强制**分层包 src 下单文件 ≤ 200 行**(`types.ts` 与 `index.ts` 作为类型/导出汇总豁免);有违规即退出码 1,改完才能提交。
## 项目结构(权威定义见 ARCHITECTURE.md)
@@ -21,15 +21,21 @@
- `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核
- `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架
- `packages/skin/ui`(@ui-arch/ui):27 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌
- `packages/skin/ui`(@ui-arch/ui):85 个 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` = 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 -->` 区块与清单组件集合完全一致。
**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual + audit:contrast + audit:motion,提交前必过。其中 `smoke` 是三段断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 31 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除 + 越界页码回写)+ `@ui-arch/ui` 8 条(皮肤层纯逻辑:行级差异的剪前后缀 / LCS 对齐 / 忽略空白 / 超限退化,跑的是 dist/diffKit.js);改内核语义或纯逻辑前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 18 组「前景色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档、焦点描边 × 页面底/表面)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua audit:motion` 守的是动效面,两类必查:① 带 `infinite` 的动画是否在 `prefers-reduced-motion: reduce` 块里被 `animation: none` 关掉——循环动画是前庭敏感用户的硬伤,漏一条整页就在抖(它抓出过转圈与不确定态进度两处漏网);② 悬停 / 按压的位移(`translate`)是否在减少动效下归零——位移最容易诱发前庭不适,且只提供手感、不表达状态(一条 `ua-` 类通配的 `transform: none !important` 兜住全站 14 处,新增组件不用各自补)。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),反向也查孤儿演示(demo 里用 section() 定义的每个区块都必须被某个组件的 demo 字段认领,加了区块忘登记会直接报错),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `<!-- ua:components -->` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。
## Agent 查询组件库的三种方式
`ua verify` 另外校验 `ITERATION.md` 的《视觉回归抽查清单》:button / textField / select / modal / dataTable / toast / layout / jsonViewer / diffView / codeBlock / signaturePad / heatmap / sparkline 十三段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥39 条(下限由段数 × 3 推导,加抽查位不必手改)且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。
**变更日志**:`CHANGELOG.md` 由 git 提交史生成(`npm run changelog`),**不进 `npm run check`**——它包含每个提交自身,提交后必然与历史差一条,只能作为「发版前刷新」步骤;`npm run changelog:check` 用于确认是否漂移。
**提交信息规范**:`COMMITTING.md`(`<type>(<scope>): <中文祈使句>`),`ua commit-lint` + commit-msg 钩子自动校验,迭代提交用 `iter(<条目id>): <subject>`。
## Agent 查询组件库的三种方式(`ua info <组件id>` 直接出 props/步骤/示例)
1. **MCP(推荐)**:在 `~/.workbuddy/mcp.json` 注册:
@@ -44,11 +50,11 @@
}
```
可用工具:`list_components` / `get_component` / `search_components`。
可用工具:`list_components` / `get_component`(传 `format: "md"` 直接拿 Markdown 片段,等同 `ua info --md`)/ `search_components` / `get_tokens`(返回 --ua-* 令牌清单,避免硬编码颜色间距)/ `list_recipes` / `get_recipe`(取跨组件组合配方:骨架代码 + 要点 + 用到的组件 id,配好再逐个 `get_component` 查参数)。
2. **CLI**:`node packages/tools/cli/bin/ua.js list | info <name> | index | docs | style | verify`
2. **CLI**:`node packages/tools/cli/bin/ua.js list [--json|--components] | search <关键词> | info <name> [--json|--md](--md 出可粘贴的 Markdown 片段:标题 + 参数表 + 用法 + 示例) | recipes [编号] [--json] | index | docs | style | verify | changelog`(`--json` 给脚本与 Agent 用,不必解析文本);`search` 覆盖 id / 标题 / 参数名 / 参数说明 / 步骤五层 + 配方三层(标题 / 配方组件 / 要点,命中行直接给 `ua recipes <编号>` 指路),命中非 id 标题时会标出命中字段(如「← 参数说明含『处理中』」),`--json` 输出 `{ components, recipes }` 两数组、各项带 `matched` 字段
3. **静态索引**:`.ua/index.json`(机器可读索引)、`llms.txt`(速览)或 **`llms-full.txt`(全量 Agent 文档:逐组件参数 / 回调 / 手把手步骤 / 令牌,无图形纯文本,喂给 Agent 首选)**。
3. **静态索引**:`.ua/index.json`(机器可读索引)、`llms.txt`(速览)或 **`llms-full.txt`(全量 Agent 文档:逐组件参数 / 回调 / 手把手步骤 / 令牌 / 组合配方,无图形纯文本,喂给 Agent 首选)**。
## 贡献新组件的强制约定
@@ -58,3 +64,22 @@
4. 皮肤包禁止硬编码颜色/间距,一律使用 `--ua-*` 令牌
5. 选择类状态一律存 rowKey,不存行索引
6. 改动后运行 `npm run index` 刷新注册表;新增组件记得在 manifest 的 `docs.components` 里补参数表与手把手步骤,`npm run docs` 会同步生成人类版文档站与 llms-full.txt
## 新增组件落点清单(照单打勾,漏一处 `npm run check` 就会红)
一个新组件要动 10 处,缺任何一处 verify 都会报错,别靠记忆:
| # | 落点 | 具体动作 |
|---|---|---|
| 1 | `packages/skin/ui/src/<id>.ts` | 组件实现(单文件 ≤ 200 行,导出带 `/*** */` 注释) |
| 2 | `packages/skin/ui/src/types.ts` | `<组件>Props` 接口 + 逐字段行尾注释;嵌套项类型(如 `TabItem`)也放这里 |
| 3 | `packages/skin/ui/src/index.ts` | 末尾追加 `export { xxx } from './xxx.js';` |
| 4 | `packages/skin/ui/ui.css` | 样式只消费 `--ua-*`(禁 `#hex`),动效要带 `prefers-reduced-motion` 兜底;**禁止新增硬编码 `box-shadow` / 动效时长 / `font-size` 字面值**,一律用 elevation(`--ua-shadow-1..4`)/ motion(`--ua-duration-*` + `--ua-ease`)/ 字号(`--ua-font-size-*`)令牌,焦点环用 `--ua-focus-ring`,`npm run audit:visual` 会让计数上涨的提交直接红 |
| 5 | `packages/skin/ui/ua.manifest.json` → `api.exports` | 一行 `xxx(props):一句话说明` |
| 6 | 同上 → `docs.components` | `{ id, title, demo, props[], example, steps[] }`:props 里每个字段名必须在实现源码里真实出现,且 `<组件>Props` 的字段一个都不能漏(verify 双向校验) |
| 7 | `apps/demo/main.ts` | 新增 `section('<demo 锚点>', ...)` 并**真实调用**该组件(verify 会查) |
| 8 | `README.md` | `<!-- ua:components -->` 区块补 id、「分组:」行补归组(数字交给下一步自动同步) |
| 9 | `npm run sync-counts` | 自动把三份文档里的「N 个组件 / 共 N 个」改成清单实际数量(幂等;漏改时 verify 报行号) |
| 10 | `npm run docs` | 重新生成 `llms-full.txt` 与 `apps/docs/index.html`(不跑 verify 会报 llms-full 漂移) |
改**既有**组件的入参同样要走 5、6、10 三步;只加一个可选参数也要补文档行,否则双向校验失败。
+10 -2
View File
@@ -34,16 +34,24 @@ ui-arch-mvp/
│ │ ├── table-core/ # 表格状态内核
│ │ └── frame/ # 信号 + hyperscript 微框架
│ ├── skin/ # 皮肤层(DOM 组件,可发布)
│ │ └── ui/ # 27 个组件 + ui.css
│ │ └── ui/ # 85 个组件 + ui.css
│ ├── design/ # 资产层(可发布)
│ │ └── tokens/ # --ua-* 设计令牌
│ └── tools/ # 工程层(private,不发布)
│ └── cli/ # ua 命令行 + MCP 注册表服务
├── scripts/ # 工程脚本:gitea.mjs(git 推送封装)、gitea-api.mjs(Gitea REST API)
├── ARCHITECTURE.md # 本文件:结构规范
├── AGENTS.md # Agent 接入手册(兼项目入口文档)
├── CODING.md # 编码规范
├── COMMITTING.md # 提交信息规范(commit-lint 机器校验)
├── ITERATION.md # 无人值守自我迭代协议(自动化 Agent 执行)
├── pnpm-workspace.yaml # pnpm 工作区定义
├── pnpm-lock.yaml # 依赖锁定(入库,保证依赖树可复现)
├── llms-full.txt # Agent 全量文档(ua docs 生成)
└── .ua/index.json + llms.txt # 注册表索引(ua index 生成,提交入库)
└── .ua/ # 迭代元数据
├── index.json # 注册表索引(ua index 生成,提交入库)
├── backlog.json # 迭代待办清单(open/done)
└── iterations.log # 每轮迭代留痕(lint-iterations 机器校验)
```
## 包规范(每包强制)
+418
View File
@@ -0,0 +1,418 @@
# 变更日志
> 由 `ua changelog` 依据 git 提交史生成,请勿手改;条目按提交时间倒序。
## 迭代
- `b454d5f` iter(g275): 新增相对时间组件
- `b93e8b0` iter(g274): 补三条新组件的组合配方
- `d6e4319` iter(g273): 新增区域加载遮罩组件
- `b6c2afb` iter(g272): 视觉抽查清单扩到十一个组件
- `1680c4e` iter(g271): 新增手写签名板组件
- `b8f90b8` iter(g270): 新增代码块组件
- `368d47c` iter(g269): 新增文本差异对比组件
- `9380304` iter(g268): verify 增加孤儿演示区块反向校验
- `cf44880` iter(g267): 补 Windows 强制色高对比兜底
- `90bd60e` iter(g266): 新增 JSON 树查看组件
- `71cff84` iter(g265): 新增布局骨架组件 layout
- `fe9f1fb` iter(g264): 数据表格可自定义空状态文案
- `f8bf00d` iter(g263): 标签输入支持多值粘贴
- `62d8107` iter(g262): 对比度门禁增加非文本配对
- `047b6c5` iter(g261): 删除标签后焦点归还输入框
- `07a5275` iter(g260): 补表格分页联动配方
- `11a22f9` iter(g259): 数据表格暴露每页条数候选
- `3036f26` iter(g258): select 增加可访问名参数
- `364ac80` iter(g257): 分页条窄屏自动换行
- `036e044` iter(g256): 抽出分页条并加每页条数切换
- `48efb44` iter(g255): 减少动效下归零全站悬停位移
- `43af4cf` iter(g254): 派生后页码越界回写内核状态
- `d23d861` iter(g253): 文档站组件卡支持一键复制 Markdown 参数表
- `80ffee5` iter(g252): MCP get_component 增加 format=md 直出 Markdown
- `4b57f11` iter(g251): ua tokens 增加 --unused 反向查闲置令牌
- `b6229e2` iter(g250): 新增动效兜底门禁,补上转圈与不确定进度的减少动效降级
- `2c34870` iter(g249): doctor 增加命令面与门禁链自洽自检
- `0715131` iter(g248): 补配方 14 标签筛选条联动表格
- `42ea31d` iter(g247): 表格支持整体换数据源,dataTable 可接信号数据
- `c5238c6` iter(g246): ua info 增加 --md,输出可直接粘贴的 Markdown 片段
- `6e52cc6` iter(g245): 补高对比偏好令牌,对比度门禁扩到四套主题
- `ea51493` iter(g244): 新增 inputTag 标签输入组件,回车成标签并支持满额禁输
- `f396a55` iter(g243): 文档站与演示页外壳配色改为从令牌派生,消除暗色强调色漂移
- `29d2d8d` iter(g242): 新增令牌对比度门禁,修掉暗色主按钮与淡底强调字不达标
- `69c9cfe` iter(g241): verify 增加演示锚点大写防护,防大写锚点被静默吞掉误报缺段
- `5940519` iter(g240): 按钮 loading 态,转圈禁点支持布尔与信号动态同步
- `5333896` iter(g239): 焦点描边令牌化,outline 收敛为三档复合令牌并纳入审计第十四类
- `2262719` iter(g238): 新增 highlight 关键词高亮,命中片段包 mark 并随信号实时联动(75 组件)
- `fc8c5ad` iter(g237): 新增 kbd 键盘按键标记,键帽样式支持单键与组合键(74 组件)
- `d504553` iter(g236): 新增 countdown 倒计时组件,秒级递减自动停表并带句柄控制(73 组件)
- `5443aad` iter(g235): 新增 copyButton 复制按钮,剪贴板写入带已复制反馈与降级(72 组件)
- `8740ab0` iter(g234): 新增 pinInput 验证码输入组件,N 格数字输入支持进格回删移格与粘贴分发(71 组件)
- `0eb67c4` iter(g233): 位移量令牌化,transform 位移收敛为 --ua-translate-* 七档并纳入审计口径
- `61f07e4` iter(g232): doctor 补 git 身份检查
- `cd15c70` iter(g231): search 覆盖配方域三层
- `0d3c166` iter(g230): demo 挂页级命令面板跳转
- `36c787a` iter(g229): 混色浓度令牌化并入视觉棘轮
- `a692cab` iter(g228): 新增 ua doctor 环境自检命令
- `87bdc03` iter(g227): 打开后焦点进入时列
- `93a7c67` iter(g226): 通用约定补单一来源规则
- `9c783cd` iter(g225): 时分两列补分界与列宽
- `63351dc` iter(g224): 收起态上下键微调时间
- `126da05` iter(g223): 非档值按最近档对齐
- `3067c63` iter(g222): 时间选择支持高度档位
- `92bc8bb` iter(g221): verify 校验滚动容器覆盖
- `1ac8bae` iter(g220): 时间列并入滚动条统一规则
- `f042c66` iter(g219): verify 增加重复关键帧检测
- `ed2dd15` iter(g218): 未选值时两列停在此刻
- `34985ff` iter(g217): 补 frame 内核冒烟并修复 effect 注销
- `17da561` iter(g216): 时间选择支持清空
- `0fb9198` iter(g215): 新增日期加时间组合配方
- `a607a40` iter(g214): 浮层入场动效收敛为统一 keyframes
- `3505449` iter(g213): 新增 timePicker 时间选择组件
- `6ccc341` iter(g212): 页签溢出时自动把激活页签拉回可视区
- `fd2e951` iter(g211): verify 增加令牌双源一致性校验
- `053f389` iter(g210): 滚动条令牌化并适配暗色主题
- `e0cf55e` iter(g209): 演示页补齐新参数现场演示
- `9180e2c` iter(g208): multiSelect 支持一键清空
- `4eaa177` iter(g207): progress 支持自定义进度文本
- `f763f63` iter(g206): rate 支持再次点击清除评分
- `6ee86ac` iter(g205): textField 支持密码显隐切换
- `5bc854b` iter(g204): textarea 支持内容自适应高度
- `df51478` iter(g203): 文档站亮暗切换与 demo 主题参数联动
- `f332500` iter(g202): 修正按下态可见性并归并 accent 混 bg 变体
- `e64f72b` iter(g201): 补全点击型控件键盘焦点环
- `91aa304` iter(g200): 编排浮层分层入场与弹层子项交错
- `bfb9178` iter(g199): 新增主按钮内高光与卡片边框减负
- `2996b87` iter(g198): 全控件按下态补齐悬停反馈闭环
- `8148317` iter(g197): accent 淡底五档收敛为两档语义令牌
- `9ef8637` iter(g196): 中性色阶地基补表面文字遮罩中间档令牌
- `8a0a907` iter(g195): 内距字面值归零视觉基线清零
- `1476a40` iter(g194): 审计 json 输出命中明细
- `e64097f` iter(g193): 阴影字面值归零补描边族令牌
- `68bfbdd` iter(g192): 动效时长字面值归零补循环档
- `bb28f86` iter(g191): 焦点环变体归零补语义环令牌
- `a67a9fe` iter(g190): 字号字面值归零补展示与缩放档
- `b83cf59` iter(g189): 补命令面板组合配方
- `7e47a54` iter(g188): 新增命令面板组件
- `5908202` iter(g186): 固化视觉回归抽查清单
- `999f3f6` iter(g185): 分割线统一改走细分边框令牌
- `2d81907` iter(g184): 层级字面值归零
- `fe79725` iter(g183): 字重行高令牌化
- `9ee04d7` iter(g182): 暗色阴影与焦点环适配
- `85f009d` iter(g181): 控件高度统一三档
- `90ed48c` iter(g180): 内距字面值令牌化
- `7b4adb0` iter(g179): 弹层阴影语义化归档
- `20ff9d4` iter(g187): 新增虚拟列表组件
- `9218933` iter(g178): 校验配方是否认出组件
- `c6fd8b8` iter(g177): 补充配方查询子命令
- `cc9ec02` iter(g176): 支持按参数说明检索组件
- `cbce645` iter(g175): 确认气泡支持异步确认
- `2316445` iter(g174): 时间线支持双侧交替
- `ea4e781` iter(g173): 上传图片显示缩略图
- `250601d` iter(g172): 列表命令支持 JSON 输出
- `6a605ba` iter(g171): 刷新变更日志与配方目录
- `a754bd6` iter(g170): 分页器支持极简模式与单页隐藏
- `bd98fc8` iter(g169): 表单字段支持跨列
- `879be29` iter(g168): 补配方 11 数据看板筛选区
- `ff94429` iter(g167): 弹窗支持内置页脚与异步确认
- `a80644c` iter(g166): 下拉选项支持分组标题
- `804ab3b` iter(g165): 步骤条支持竖向排列
- `b533903` iter(g164): 描述列表支持竖排与标题操作区
- `6febeba` iter(g163): 留痕校验补编号与时间线约束
- `fe78275` iter(g162): 门禁补暗色令牌齐全校验
- `0bcf73f` iter(g161): 通知支持同 key 原地更新
- `ef564fc` iter(g160): 滑块支持双滑块区间
- `312e801` iter(g159): 多选下拉支持标签折叠
- `638f623` iter(g158): 日期选择支持快捷区间预设
- `c2412fb` iter(g157): 补配方 10 暗色主题切换
- `bbebb70` iter(g156): ua tokens 支持按名过滤
- `a3ece11` iter(g155): llms.txt 补配方目录
- `ab758ab` iter(g154): MCP 搜索补组件级命中
- `0609175` iter(g153): ua search 支持多关键词
- `bc984ae` iter(g152): ua list 增加组件全览
- `f59e9b7` iter(g151): verify 校验 MCP 工具清单
- `04f6df8` iter(g150): ua info 反查所属配方
- `d0eea04` iter(g149): 补 ua search 关键词检索
- `4a655d2` iter(g148): MCP 暴露组合配方检索
- `2387335` iter(g147): 补三条组合配方
- `2adae6e` iter(g146): 进度条支持环形形态
- `af09500` iter(g145): 滑块支持刻度标注
- `1009c89` iter(g144): 新增头像组组件 avatarGroup
- `3058da1` iter(g143): 页签栏支持右侧操作区
- `5af2139` iter(g142): 间距容器支持分隔符
- `6cde618` iter(g141): 通知支持四角停靠
- `e40cd2c` iter(g140): 表格支持整行点击
- `08ceb8f` iter(g139): 提示气泡支持左右方位
- `1af4e57` iter(g138): 日期选择支持区间模式
- `55da08f` iter(g137): 演示页补暗色主题开关
- `f6706c1` iter(g136): 新增链接组件 link
- `21950d9` iter(g135): 表单支持多列布局
- `2a9193c` iter(g134): 新增 ua sync-counts 自动同步组件计数
- `0f6331c` iter(g133): 新增树形选择 treeSelect
- `9d0c195` iter(g132): 新增 @提及输入 mentions
- `d9559ae` iter(g131): 新增悬浮操作按钮 floatButton
- `23d1e06` iter(g130): 新增栅格容器 grid
- `ea2a846` iter(g129): 新增排版文本组件 typography
- `b399640` iter(g128): 收尾刷新变更日志
- `48b0f35` iter(g127): 补长表单编辑页组合配方
- `023a2eb` iter(g126): 数字输入补 precision 小数位
- `490c9fa` iter(g125): 开关补 loading 加载态
- `63d3c91` iter(g124): 复选框补 indeterminate 半选态
- `ba102e0` iter(g123): 收尾刷新变更日志
- `72c5d24` iter(g122): 评分组件支持 allowHalf 半星
- `151d901` iter(g121): textField 补 maxCount 字数统计
- `5a6c7a5` iter(g120): 补新增组件落点清单
- `176504b` iter(g119): 空状态支持自定义图标
- `123e7e6` iter(g118): 收尾再刷变更日志
- `6cf4905` iter(g117): 指示条与滑块跟随尺寸重算
- `2ab9406` iter(g116): 抽屉支持非模态模式
- `c2900a4` iter(g115): 新增月历组件 calendar
- `cb2601d` iter(g114): 收尾刷新变更日志
- `ec7aaf0` iter(g113): 吸顶容器支持贴底固定
- `5372fdd` iter(g112): 消息提示支持撤销类操作
- `f40ef6d` iter(g111): 面包屑支持超长路径折叠
- `e042143` iter(g110): 下拉选择支持清空
- `1bf35da` iter(g109): 提示气泡支持触摸点按
- `5601e59` iter(g108): 表单补错误汇总条与 onError
- `48f9465` iter(g107): 新增二次确认气泡 popconfirm
- `c66bcf3` iter(g106): 滚动监听改为卸载自清理
- `f398f09` iter(g105): 标签页支持可关闭页签
- `8e2175d` iter(g104): 留痕校验回查提交真实性
- `e497c16` iter(g103): 区分表格两种空状态
- `abfbb82` iter(g102): 焦点陷阱下沉为共享助手
- `265842e` iter(g101): 新增锚点导航组件 anchor
- `b190718` iter(g100): 收尾刷新变更日志
- `a5d4faf` iter(g99): 加载指示器补可定制播报
- `c32f20c` iter(g98): 弹窗与抽屉补遮罩关闭开关
- `3fe1fe3` iter(g97): 收尾刷新变更日志
- `306fd90` iter(g96): 抽屉支持上下滑出方位
- `7e0612e` iter(g95): 统计数值支持倒计时模式
- `03eab7a` iter(g94): 补充数据表联动导出配方
- `90bba1c` iter(g93): 树形组件支持按标签搜索
- `ac5c5cd` iter(g92): 徽标支持数字角标与红点
- `47756c8` iter(g91): 表单字段补辅助说明 hint
- `1e6898b` iter(g90): 刷新变更日志至最新提交
- `68ca2ba` iter(g89): 演示产物启用保守压缩
- `9a7c46a` iter(g88): 数据表支持导出 CSV
- `cf4ec96` iter(g87): 轮播补焦点暂停与轮播语义
- `a123140` iter(g86): 弹层外部点击监听改按需挂载
- `bd64e43` iter(g85): 进度条补可访问名与语义色
- `db5d38c` iter(g84): 数据表定宽列支持拖拽调宽
## 功能
- `f125369` feat(cli): 新增视觉漂移审计门禁与幂等迁移脚本
- `5a1d5ba` feat(tokens): 扩充视觉令牌到八类并统一焦点环与动效
## 修复
- `2971f9a` fix(ui): 输入附加块撑满组高消除上下色缝
- `106bbca` fix(ui): 收敛激活态描边、修正内层圆角溢出并替换 emoji 图标
- `0821286` fix(ui): 归位 reduced-motion 兜底并收敛分层入场延迟
- `83763d4` fix(ui): 输入类悬停规则排除聚焦态避免覆盖聚焦边框
- `a3943ec` fix(cli): 视觉审计在样式表有未提交改动时归因到工作区
## 文档
- `d048717` docs(iteration): 选题规则增加域轮换与视觉欠账优先
## 维护
- `72432e8` chore(backlog): 记录 g275 完成
- `fa84a39` chore(backlog): 记录 g274 完成
- `2b8deb5` chore(backlog): 记录 g273 完成
- `1cb9d7a` chore(backlog): 记录 g272 完成
- `2c08c11` chore(backlog): 记录 g271 完成
- `6e048c1` chore(backlog): 记录 g270 完成
- `02d7eb3` chore(backlog): 记录 g269 完成
- `dccf79b` chore(backlog): 记录 g268 完成
- `8fc4ec6` chore(backlog): 记录 g267 完成
- `a7b3489` chore(backlog): 记录 g266 完成
- `e2fb02f` chore(iterations): 修正 g265 留痕时区格式
- `6238012` chore(backlog): 记录 g265 完成
- `6172691` chore(backlog): 记录 g264 完成
- `80671f8` chore(backlog): 记录 g263 完成
- `854f425` chore(changelog): 收录 g254-g262 迭代记录
- `2ab472c` chore(backlog): 记录 g262 完成
- `67a9c23` chore(backlog): 记录 g261 完成
- `5863571` chore(backlog): 记录 g260 完成
- `79cc552` chore(backlog): 记录 g259 完成
- `ee39b60` chore(backlog): 记录 g258 完成
- `9a1a6f9` chore(backlog): 记录 g257 完成
- `5771651` chore(backlog): 记录 g256 完成
- `8e97e99` chore(backlog): 记录 g255 完成
- `6437132` chore(backlog): 记录 g254 完成
- `dec3a9f` chore(changelog): 刷新变更日志至当前提交
- `ad87db7` chore(backlog): 记录 g253 完成
- `d489a3d` chore(backlog): 记录 g252 完成
- `eb9c606` chore(backlog): 记录 g251 完成
- `33cb04f` chore(backlog): 记录 g250 完成
- `cd20b0c` chore(backlog): 记录 g249 完成
- `3873195` chore(backlog): 记录 g248 完成
- `64289b5` chore(backlog): 记录 g247 完成
- `19741b5` chore(backlog): 记录 g246 完成
- `ab2bd00` chore(backlog): 记录 g245 完成
- `26c9867` chore(backlog): 记录 g244 完成
- `cd8a8e9` chore(backlog): 记录 g243 完成
- `aa27a16` chore(backlog): 记录 g242 完成
- `aa7be50` chore(backlog): 记录 g241 完成
- `9aa71e7` chore(backlog): 记录 g240 完成
- `1ce98bd` chore(backlog): 记录 g239 完成
- `9e85ef3` chore(backlog): 记录 g238 完成
- `5f150dc` chore(backlog): 记录 g237 完成
- `55980c5` chore(backlog): 记录 g236 完成
- `671c874` chore(backlog): 记录 g235 完成
- `7e5e9f5` chore(backlog): 记录 g234 完成
- `45e56d0` chore(backlog): 记录 g233 完成
- `39f5304` chore(backlog): 修正 g232 留痕哈希
- `be9d45e` chore(changelog): 刷新变更日志至 g232
- `5f38d8a` chore(backlog): 记录 g232 完成
- `94c8b86` chore(backlog): 记录 g231 完成
- `051baf0` chore(backlog): 记录 g230 完成
- `4a571af` chore(backlog): 记录 g229 完成
- `7579170` chore(backlog): 记录 g228 完成
- `b19ea5d` chore(backlog): 补记 g220 完成
- `4b11321` chore(backlog): 记录 g227 完成
- `11abb36` chore(backlog): 记录 g226 完成
- `fa247fc` chore(backlog): 记录 g225 完成
- `33a7a0b` chore(docs): 同步索引产物
- `11ab8a1` chore(backlog): 记录 g224 完成
- `c5826b7` chore(backlog): 记录 g223 完成
- `063b6cb` chore(backlog): 记录 g222 完成
- `467de41` chore(backlog): 记录 g221 完成
- `0c978c9` chore(docs): 同步配方 13 的索引产物
- `5c80346` chore(backlog): 记录 g219 完成
- `5172abd` chore(backlog): 记录 g218 完成
- `39a5d0d` chore(backlog): 记录 g217 完成
- `d7ad799` chore(backlog): 记录 g216 完成
- `050534a` chore(backlog): 记录 g215 完成
- `756a7e8` chore(backlog): 记录 g214 完成
- `1dca269` chore(backlog): 记录 g213 完成
- `3b0888d` chore(backlog): 记录 g212 完成
- `7fa60fb` chore(backlog): 记录 g211 完成
- `82fa8c8` chore(backlog): 记录 g210 完成
- `5a1d166` chore(backlog): 记录 g209 完成
- `b30dfe3` chore(backlog): 记录 g208 完成
- `b91c195` chore(backlog): 记录 g207 完成
- `e1e39bb` chore(backlog): 记录 g206 完成
- `dae6353` chore(backlog): 记录 g205 完成
- `5e14fc0` chore(backlog): 记录 g204 完成
- `197d57b` chore(backlog): 记录 g203 完成
- `387f9f2` chore(backlog): 记录 g202 完成
- `6c4796c` chore(backlog): 更正 g200/g201 留痕口径至复核定稿
- `e4351c4` chore(backlog): 补录 g199-g201 审美提升第二批留痕
- `ae1ff24` chore(backlog): 增补 g202 按下态可见性修正条目
- `bc32fe9` chore(backlog): 补录 g196-g198 审美提升首批留痕
- `fb69321` chore(backlog): 记录 g195 完成
- `4f2ee02` chore(backlog): 记录 g194 完成
- `0ebd6da` chore(backlog): 记录 g193 完成
- `8fffaf5` chore(backlog): 记录 g192 完成
- `0e996e4` chore(backlog): 记录 g191 完成
- `654a9dd` chore(backlog): 记录 g190 完成
- `43d35fc` chore(backlog): 记录 g189 完成
- `529455c` chore(backlog): 记录 g188 完成
- `276a46c` chore(backlog): 记录 g186 完成
- `85ae90a` chore(backlog): 记录 g185 完成
- `ecd24d5` chore(backlog): 记录 g184 完成
- `551b2f6` chore(backlog): 记录 g183 完成
- `089527f` chore(backlog): 记录 g182 完成
- `03df0d1` chore(backlog): 记录 g181 完成
- `1289b54` chore(backlog): 记录 g180 完成
- `320dac3` chore(backlog): 记录 g179 完成
- `a9a084c` chore(backlog): 记录 g187 完成
- `ee6d844` chore(cli): 写入视觉迁移后的漂移基线
- `a1e8384` chore(backlog): 记录 g178 完成
- `77afc69` chore(backlog): 记录 g177 完成
- `ee75881` chore(backlog): 记录 g176 完成
- `31650b1` chore(backlog): 记录 g175 完成
- `66c39e0` chore(backlog): 记录 g174 完成
- `7084fe1` chore(backlog): 记录 g173 完成
- `2c17696` chore(backlog): 记录 g172 完成
- `a071cb0` chore(backlog): 修正 g171 留痕提交哈希
- `dd55fee` chore(backlog): 记录 g171 完成
- `8c2ff7b` chore(changelog): 刷新变更日志并补配方目录
- `3804759` chore(backlog): 记录 g170 完成
- `8c9541d` chore(backlog): 记录 g169 完成
- `adc94ca` chore(backlog): 记录 g168 完成
- `649ebf3` chore(backlog): 记录 g167 完成
- `b6a6827` chore(backlog): 记录 g166 完成
- `e1b7de6` chore(backlog): 记录 g165 完成
- `dddbccd` chore(backlog): 记录 g164 完成
- `bb1625a` chore(backlog): 记录 g163 完成
- `eea5074` chore(backlog): 记录 g162 完成
- `a8e13e3` chore(backlog): 记录 g161 完成
- `8b74206` chore(backlog): 记录 g160 完成
- `084ffcd` chore(backlog): 记录 g159 完成
- `cbf0526` chore(backlog): 记录 g158 完成
- `476747a` chore(backlog): 记录 g157 完成
- `1831aec` chore(backlog): 记录 g156 完成
- `2848dd5` chore(backlog): 记录 g155 完成
- `ea77f4b` chore(backlog): 记录 g154 完成
- `c423d9c` chore(backlog): 记录 g153 完成
- `313954f` chore(backlog): 记录 g152 完成
- `df75058` chore(backlog): 记录 g151 完成
- `7418b79` chore(backlog): 记录 g150 完成
- `1455f82` chore(backlog): 记录 g149 完成
- `16032c8` chore(backlog): 记录 g148 完成
- `38284ac` chore(backlog): 记录 g147 完成
- `734eb33` chore(backlog): 记录 g146 完成
- `adc69fa` chore(backlog): 记录 g145 完成
- `99214ff` chore(backlog): 记录 g144 完成
- `9b1c068` chore(backlog): 记录 g143 完成
- `5b8472e` chore(backlog): 记录 g142 完成
- `508d11a` chore(backlog): 记录 g141 完成
- `36087b1` chore(backlog): 记录 g140 完成
- `2a7fdcd` chore(backlog): 记录 g139 完成
- `a3db5e6` chore(backlog): 记录 g138 完成
- `b3fd20c` chore(backlog): 记录 g137 完成
- `a8026bb` chore(backlog): 记录 g136 完成
- `b1bc8c6` chore(backlog): 记录 g135 完成
- `7b41630` chore(backlog): 记录 g134 完成
- `6701c22` chore(backlog): 记录 g133 完成
- `fc195df` chore(backlog): 记录 g132 完成
- `eac02f0` chore(backlog): 记录 g131 完成
- `42e43c0` chore(backlog): 记录 g130 完成
- `88c2515` chore(backlog): 记录 g129 完成
- `6158f49` chore(backlog): 记录 g128 完成
- `6a5e12b` chore(backlog): 记录 g127 完成
- `fc58442` chore(backlog): 记录 g126 完成
- `686b0f7` chore(backlog): 记录 g125 完成
- `56e5d18` chore(backlog): 记录 g124 完成
- `85731a2` chore(backlog): 记录 g123 完成
- `0338f21` chore(backlog): 记录 g122 完成
- `f4a5101` chore(backlog): 记录 g121 完成
- `3e1e4ea` chore(backlog): 记录 g120 完成
- `85e6345` chore(backlog): 记录 g119 完成
- `c67b167` chore(backlog): 记录 g118 完成
- `2f9f83e` chore(backlog): 记录 g117 完成
- `dcd5e0e` chore(backlog): 记录 g116 完成
- `9a118f6` chore(backlog): 记录 g115 完成
- `b4ab92e` chore(backlog): 记录 g114 完成
- `b814e47` chore(backlog): 记录 g113 完成
- `5c25e88` chore(backlog): 记录 g112 完成
- `bf1bd22` chore(backlog): 记录 g111 完成
- `f900337` chore(backlog): 记录 g110 完成
- `9abadf0` chore(backlog): 记录 g109 完成
- `d88bcc0` chore(backlog): 记录 g108 完成
- `4d76ba0` chore(backlog): 记录 g107 完成
- `137bf87` chore(backlog): 记录 g106 完成
- `7d57f26` chore(backlog): 记录 g105 完成
- `1132220` chore(backlog): 记录 g104 完成
- `10eb402` chore(backlog): 记录 g103 完成
- `f4701af` chore(backlog): 记录 g102 完成
- `f373b8c` chore(backlog): 记录 g101 完成
- `befed76` chore(backlog): 记录 g100 完成
- `aa16531` chore(backlog): 记录 g99 完成
- `44efe8b` chore(backlog): 记录 g98 完成
- `13266f0` chore(backlog): 记录 g97 完成
- `c8bf4a9` chore(backlog): 记录 g96 完成
- `6e6860a` chore(backlog): 记录 g95 完成
- `5e81cfe` chore(backlog): 记录 g94 完成
- `c82446a` chore(backlog): 记录 g93 完成
- `1b10616` chore(backlog): 记录 g92 完成
- `8e6c84a` chore(backlog): 记录 g91 完成
- `46a90da` chore(backlog): 记录 g90 完成
- `2fc4d17` chore(backlog): 记录 g89 完成
- `ce662bd` chore(backlog): 记录 g88 完成
- `fa07101` chore(backlog): 记录 g87 完成
- `9ff2c7b` chore(backlog): 记录 g86 完成
- `249fa03` chore(backlog): 记录 g85 完成
- `b2beb43` chore(backlog): 记录 g84 完成
+2 -1
View File
@@ -66,4 +66,5 @@ let cached: RowModel | null = null; // memo:缓存产物
## 7. 落地方式
- 新代码必须符合本规范;旧代码顺手迁移,不强制一次性全量重写。
- 后续可做成 `ua style` 检查项:导出缺注释、接口字段缺注释、类型散落实现文件、闭包状态未集中顶部。
- 已落地为 `ua style` 检查项:导出缺注释、接口字段缺注释、行注释非中文、分层包 src 内单文件超过 200 行(`types.ts` / `index.ts` 豁免)。
- 仍靠人工守的:类型散落实现文件、闭包状态未集中顶部。
+1
View File
@@ -31,6 +31,7 @@
- 应用:`demo` / `docs`
- 元数据:`backlog` / `repo` / `protocol`
- `iter` 提交的 scope 固定为条目 id:`b02`、`c01`、`d29` …
- **scope 强制**:除 Merge/Revert 自动信息外,所有 type 必须带 scope;`ua commit-lint` 已机器校验,裸 type(`chore:` / `docs:`)一律拒绝
## subject 规则
+124 -4
View File
@@ -10,6 +10,22 @@
- **时间预算**:单次唤起最多连续迭代约 50 分钟,到点在完成当前轮后停止(防止与下次唤起重叠)。**预算判断必须基于客观时钟**:点火时用 `date +%s` 记录起点并写入 `.ua/loop.lock`,每轮开始时重新取 `date +%s` 与起点求差,差值 ≥ 2700 秒(45 分钟)即到点;**禁止凭感觉估算时长**——LLM 无内部时钟,自估时长系统性偏高(实测自报 40+ 分钟而实际约 30 分钟)。
- **熔断**:同一 backlog 条目连续 2 轮失败 → 停止循环,锁照常释放,等待下次唤起。
- **重启语义**:锁心跳超过 15 分钟视为循环中断(崩溃/超时),调度器删旧锁、重新点火。
- **调度器约束(踩过的坑,勿重复)**:本地调度器只支持 `HOURLY` 及以上粒度(`DAILY/WEEKLY/MONTHLY/YEARLY`),**`FREQ=MINUTELY` / `FREQ=SECONDLY` 不受支持**——写入后 `next_run_at` 会变成 `NULL`,首跑之后永久静默(实测 10/23–10/26 停摆三天)。因此「高频监护」不能靠加密 rrule 实现,只能靠**单次唤起内的连续循环**;心跳间隔 = 1 小时,循环预算 45–50 分钟,两者共同保证覆盖面。任何对自动化的改动(改 prompt、改 rrule、暂停恢复)都必须回查 `next_run_at` 非空,改 prompt 会把状态重置为 `PAUSED`,需再单独置 `ACTIVE`。
- **有效性自检**:每轮迭代首步除同步仓库外,检查 `.ua/loop.lock` 与 `automation_runtime_state.last_run_at`;若发现上次运行距今超过 2 小时(说明调度中断),在留痕 `summary` 里注明「调度中断后恢复」,便于周报统计停摆时长。
## 多实例错位接力
单条自动化的占空比受平台语义限制:**`next_run_at` = 本次运行结束时刻 + `INTERVAL`,不是整点对齐**(实测 12:46:50 结束 → 下次 13:46:50)。`INTERVAL` 下限 1 小时,45 分钟预算下单条仅 `45/(45+60) ≈ 43%` 覆盖面,即「干 45 分、歇 60 分」。因此用**两条同构自动化错位 30 分钟**补齐空档:
| 实例 | 锚点(错位) | 角色 |
|---|---|---|
| A `无人值守迭代循环` | 整点链(当前 13:46:50 → 逐轮顺延) | 主循环 |
| B `…B(错位接力)` | A 之后 30 分钟(当前 14:16:50) | 补位循环 |
- **接力靠 `missed` 队列自动完成**:到点时若另一实例仍持锁,该次被记为 `missed` 并保留 `missedScheduledAt`,待当前实例释放锁后**立即补跑**(实测间隔 9 秒)。无需人工干预,也不占用额外配置。
- **抢锁失败是正常行为**:看到 15 分钟内新鲜锁的实例直接回复「循环存活,未干预」退出——不计失败、不写留痕、不改 backlog。
- **同一把锁、同一份 backlog**:多实例共享 `.ua/loop.lock` 与 `qa/*` 分支流水线,任何时刻只有一个实例在写,冲突由锁而非分支解决。
- **调整排期的方法**:改 `validFrom`(`next = max(now, validFrom) + INTERVAL`),改完必须回查 `next_run_at` 非空且落在期望时刻;改 prompt 会把状态重置为 `PAUSED`,须再单独置 `ACTIVE`。
## 分支流水线
@@ -26,12 +42,12 @@ qa/probe ──门禁通过──▶ qa/stable ──人工审阅──▶ main
## 每轮迭代流程(自动化 Agent 执行)
1. **同步**:`git fetch --all --prune`,切到 `qa/stable` 并 `git pull`;从其最新提交强制重建 `qa/probe` 并切换过去。
2. **选题**:读 `.ua/backlog.json`,取第一条 `status === "open"` 的条目;若全部关闭,按《选题规则》自行补充一条新点子后继续。
2. **选题**:读 `.ua/backlog.json`,先取 `status === "open"` 且 `domain === "visual"` 的条目(有多条取编号最小的一条);没有视觉欠账时再按数组顺序取第一条 `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 → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion)。
- **通过**:提交到 `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,**字段统一**:`{"ts":"本地ISO时间","id":"条目id","result":"done|aborted","commit":"短哈希","summary":"一句话"}`。硬性规范:`ts` 必须来自 `date` 命令实测(本地时间,带 +08:00 偏移,**禁止手写或用 UTC 换算**);时长结论必须来自两次时间查询之差,禁止使用「大约/接近」等估算词;并发实例格式不得自创变体。随本轮提交。
5. **留痕**:在 `.ua/iterations.log` 追加一行 JSON,**字段统一**:`{"ts":"本地ISO时间","id":"条目id","result":"done|aborted","commit":"短哈希","summary":"一句话"}`。硬性规范:`ts` 必须来自 `date` 命令实测(本地时间,带 +08:00 偏移,**禁止手写或用 UTC 换算**);时长结论必须来自两次时间查询之差,禁止使用「大约/接近」等估算词;并发实例格式不得自创变体。随本轮提交。`ua lint-iterations` 会把每行 `commit` 拿到 git 历史里回查:哈希必须真实存在、主题必须指向同一条目(`iter(<id>)`,早期无 scope 的 `iter: <id>` 也认),伪造或抄错哈希直接判违规。
6. **边界**:不改 `main` 分支保护、不删分支、不动 `.workbuddy/`、不重写历史、不 force push 到 `qa/stable` 与 `main`。
## 选题规则(自主补点子时)
@@ -40,6 +56,110 @@ qa/probe ──门禁通过──▶ qa/stable ──人工审阅──▶ main
- 单轮改动范围可控:新增文件 + 少量既有文件修改,避免跨包大规模重构。
- 每条点子必须写清 `title / motivation / acceptance`(验收标准,可被 `npm run check` 与人工检查验证)。
### 域轮换与视觉欠账优先
`backlog.json` 的条目带可选字段 `domain`:`new`(新增组件)/ `tune`(既有组件打磨)/ `visual`(视觉地基与观感)/ `tooling`(门禁、CLI、文档、配方)。选题按下面三条排队,保证视觉欠账会被持续消化,而不是永远排在「加新组件」后面:
1. **域轮换**:连续 3 轮迭代中,至少 1 轮的条目 `domain` 为 `visual`。若已连续 3 轮都不是 visual,下一轮必须从 open 的 visual 条目里选。
2. **视觉欠账优先**:只要存在 `status === "open"` 且 `domain === "visual"` 的条目,它的优先级高于 `domain === "new"` 的条目——先清视觉欠账,再加新组件(视觉地基不牢,新组件只是把漂移再复制一份)。
3. **新增组件的入库门槛**:`domain === "new"` 的条目,验收标准里必须写明「样式只消费 `--ua-*` 令牌,禁止新增硬编码阴影/时长/字号」,否则该条不得入库。视觉类改进的验收标准要能落到 `npm run audit:visual` 的计数上(涨了就是没做完),涉及颜色令牌的还要落到 `npm run audit:contrast`(对比度跌破阈值即没做完)。
## 视觉回归抽查清单(人工,视觉类条目收尾时逐条过)
视觉改动只能靠人眼验收,凭记忆看一遍必然漏项。本节把十三个代表组件(六个高频 + 容器 / 数据展示 / 图表类各取代表)的观感要点固化成可勾选项,视觉类条目(`domain === "visual"`)提交前照单过一遍,勾不上的就是没做完。
- **格式约定**:三条横线以下是清单本体,每个组件一个 `### <组件id>` 段,每条以 `- [ ] 【维度】` 开头;维度取值固定五类:`边框` / `阴影` / `过渡` / `焦点环` / `暗色`。
- **门禁口径**:`ua verify` 校验本节存在、六个组件齐全、每个组件 ≥3 条、合计 ≥18 条、五个维度全覆盖;条目改写或删漏直接报错。
- **与自动门禁的分工**:`npm run audit:visual` 管「字面量有没有回流成硬编码」,本节管「令牌用对了但观感对不对」——两者都过才算一轮视觉改动完成。
---
### button
- [ ] 【边框】次按钮与描边按钮的 1px 描边在页面底与卡片底上亮度一致,不出现某个按钮偏黑
- [ ] 【阴影】hover 只上浮 1–2px 且走 elevation 令牌,没有「影子 + 边框」双层叠加
- [ ] 【过渡】hover 上浮与按下回缩能看出先后,时长不超过 `--ua-duration-fast`
- [ ] 【焦点环】键盘 Tab 到按钮时环完整贴边不被容器裁切,与描边按钮的描边不重影
- [ ] 【暗色】暗色下主按钮文字与底色对比仍够,描边按钮的边不消失
### textField
- [ ] 【边框】默认 / hover / 聚焦三态只变色不变粗细(1px 始终 1px)
- [ ] 【焦点环】聚焦是光环外扩而非描边变粗;带前后缀时环包住整组而不只是输入框
- [ ] 【过渡】聚焦与失焦的边框色过渡在 `--ua-duration-fast` 内完成,无闪烁感
- [ ] 【暗色】暗色下占位文字(placeholder)与输入文字都可读,边框仍可见
### select
- [ ] 【边框】触发器与相邻 textField 同高同粗细,并排时基线不歪
- [ ] 【阴影】下拉弹层用 `--ua-shadow-2` 档,出现在滚动容器内时边缘不被裁成硬边
- [ ] 【过渡】弹层进出各一次(位移 + 淡入),关闭后不残留一帧
- [ ] 【焦点环】键盘 ↑↓ 唤出弹层时焦点环留在触发器上,不跳到 body
### modal
- [ ] 【阴影】弹窗用 `--ua-shadow-4`,与遮罩形成明确层级,不糊成一片
- [ ] 【边框】页头 / 页脚分割线用 `--ua-border-subtle`,观感与卡片页脚一致
- [ ] 【过渡】弹入有轻微缩放位移,Esc 关闭后无残影、无布局跳动
- [ ] 【焦点环】打开后焦点落在弹窗内首个可聚焦元素,Tab 不逃出弹窗
- [ ] 【暗色】暗色下弹窗表面与遮罩可区分,标题与正文层级仍清楚
### dataTable
- [ ] 【边框】行分割线用 `--ua-border-subtle`,连续 20 行不显重,表头下沿不发黑
- [ ] 【阴影】粘性表头滚动透底无效(背景实色 + 底部 1px 细线贴合)
- [ ] 【过渡】行 hover 与选中底色过渡柔和,选中行不抢表头与首列的视线
- [ ] 【暗色】暗色下斑马纹可辨但不突兀,选中行与 hover 行仍能区分
### toast
- [ ] 【阴影】通知用最高档投影,堆叠三条时前后层级清楚
- [ ] 【边框】通知卡片描边不抢语义色图标,警示色不溢出到边框
- [ ] 【过渡】进场 / 出场各一次,堆叠时其余条目平滑让位,不出现跳位
- [ ] 【暗色】四种语义色(info / success / warning / error)在暗底上图标与文字都不糊
### layout
- [ ] 【边框】页头细线与侧栏描边各只一道,两者交汇处不出现双线加深
- [ ] 【过渡】侧栏折叠展开展收干净,内容区不出现横向抖动或文字重排闪烁
- [ ] 【暗色】暗色下页头、侧栏、内容区三层表面能分出层次,不糊成同一块灰
### jsonViewer
- [ ] 【边框】嵌套层级的左侧竖线与容器外框同色同粗细,深三层不显重
- [ ] 【过渡】展开 / 收起即时切换,箭头旋转不与行高变化打架(不推动下方内容跳动)
- [ ] 【暗色】四种叶子类型(字符串 / 数字 / 布尔 / null)在凹陷底色上都能分辨,不互相撞色
- [ ] 【焦点环】Tab 到折叠开关时环贴住整行左缘,不被容器 overflow 裁掉
### diffView
- [ ] 【边框】统计条下沿与差异区之间只一条细线,与代码块头部观感一致
- [ ] 【过渡】滚动长差异时行底色不渗出双行号列,行号列保持静止
- [ ] 【暗色】增行为绿、删行为红的底色在暗底上仍可辨,文字对比不被底色吃掉
### codeBlock
- [ ] 【边框】头部与代码区之间一条细线,悬停整行提亮不出界、不盖住行号
- [ ] 【过渡】悬停提亮瞬时完成,连续快速移动不留下残影行
- [ ] 【暗色】行号(faint)与代码正文在凹陷底上对比都够,行号不糊到底色里
### signaturePad
- [ ] 【边框】画布与底部工具条之间一条细线,四角圆角裁切笔迹不溢出
- [ ] 【过渡】「清空」在未落笔时变为不可点,禁用态与可用态能一眼分清
- [ ] 【暗色】暗色下笔迹(走 text 令牌)在画布表面上清晰可见,不发灰
### heatmap
- [ ] 【边框】五个色阶块与网格格子同尺寸同圆角,图例看起来像「网格的一小段」而不是另做的一排
- [ ] 【过渡】空窗周(零档)与有数据的列并排时,凹陷底与页面底能区分,不出现「缺了一块」的错觉
- [ ] 【暗色】暗色下四档浓度仍能依次分辨(相邻两档不糊成一档),最高档不刺眼
### sparkline
- [ ] 【边框】折线不被 viewBox 边缘裁掉,末点圆点完整可见(内缩留白够)
- [ ] 【过渡】数值全等时折线落在中线而不是贴顶或贴底(除零保护生效)
- [ ] 【暗色】暗色下面积填充比折线淡一档,不压过主数值文字
## 人工介入点
- 审阅 `qa/stable` 的提交,确认质量后合入 `main`(PR 或本地 merge 后推送)。
+18 -9
View File
@@ -6,18 +6,27 @@
```bash
pnpm install # 包间依赖用 workspace:*,npm 不支持
npm run check # 门禁一条龙:build → style → typecheck → smoke → index → verify
npm run check # 门禁一条龙:build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion
npm run demo # 打开演示:apps/demo/dist/demo.html
npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt
npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移)
```
## 组件(共 30 个)
## 组件(共 85 个)
<!-- ua:components -->
accordion · alert · avatar · badge · breadcrumb · button · card · checkbox · dataTable · drawer · form · inputNumber · menu · modal · pagination · popover · progress · radioGroup · select · skeleton · slider · spinner · stepper · switchButton · tabs · tag · textField · textarea · toast · tooltip
accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · codeBlock · colorPicker · commandPalette · copyButton · countdown · dataTable · datePicker · descriptions · diffView · divider · drawer · empty · floatButton · form · grid · heatmap · highlight · image · inputNumber · inputTag · jsonViewer · kbd · layout · link · list · loading · menu · modal · mentions · multiSelect · notification · pagination · pinInput · popover · popconfirm · progress · radioGroup · rate · relativeTime · result · segmented · select · signaturePad · skeleton · slider · space · sparkline · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · timePicker · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark
<!-- /ua:components -->
分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / form / formItem)、反馈(alert / toast / spinner / progress / skeleton / badge)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper)、数据(dataTable / tag / avatar / card)。
分组:表单(button / copyButton / signaturePad / textField / inputNumber / inputTag / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / timePicker / pinInput / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / loading / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic / countdown)、浮层(tooltip / popover / popconfirm / modal / drawer / menu / commandPalette)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / jsonViewer / diffView / codeBlock / relativeTime / sparkline / heatmap / descriptions / card / list / kbd / highlight / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / layout / link / space / divider / splitter / backTop / affix / tour / watermark)。
## 可访问性约定
- 对比度:令牌层 18 组「前景色 × 底色」在亮 / 暗 × 常规 / 高对比四套主题下都不低于 WCAG 阈值(正文 7、常规 4.5、禁用 2.6、焦点描边等非文本 3),`npm run audit:contrast` 是这条约定的门禁;系统开启「提高对比度」时边框与弱文字自动加深一档
- 键盘焦点:所有库内元素统一 `outline: 2px solid var(--ua-color-accent)`(focus-visible 专属,鼠标点击不出现),组件自绘的 box-shadow 光环与它叠加不冲突。
- 关键交互都有键盘等价:Tab 进焦点、Enter/空格 激活、↑↓/Home/End 在列表类组件内移动、Esc 逐层关闭(内层先关,不连带外层)。
- 高对比模式:除「提高对比度」偏好外,Windows 强制色(`@media (forced-colors: active)`)下也有兜底——浮层与控件改回真边框(不再靠阴影)、选中态改用系统 Highlight 高亮底、占位与骨架走 GrayText,避免高对比下「控件隐形」。
- 语义与播报:modal/drawer 带 `role="dialog" aria-modal`、toast/notification/empty/result 带 `role="status"/"alert"`,切换类组件用 `aria-selected`/`aria-expanded` 同步状态。
## 目录
@@ -25,7 +34,7 @@ accordion · alert · avatar · badge · breadcrumb · button · card · checkbo
|---|---|
| `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:30 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
| `packages/skin/ui` | @ui-arch/ui:85 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
| `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) |
| `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server |
| `apps/demo` | 交互演示(esbuild 打包成单文件) |
@@ -33,15 +42,15 @@ accordion · alert · avatar · badge · breadcrumb · button · card · checkbo
## 三道门禁
1. `ua style`:注释规范——导出必须有 `/*** 作用 ***/`、接口字段行尾注释、行注释用中文。
2. `ua verify`:清单 ↔ 文档 ↔ 演示一致(id 唯一、exports 对应、props 带 n/t/d、steps ≥ 3、demo 锚点存在),皮肤样式表禁止 `#hex` 硬编码颜色。
1. `ua style`:注释规范——导出必须有 `/*** 作用 ***/`、接口字段行尾注释、行注释用中文,且分层包 src 内单文件 ≤ 200 行(`types.ts` / `index.ts` 豁免)。
2. `ua verify`:清单 ↔ 文档 ↔ 演示一致(id 唯一、exports 对应、props 带 n/t/d、steps ≥ 3、demo 锚点存在),皮肤样式表禁止 `#hex` 硬编码颜色,`var(--ua-*)` 必须引用 tokens.css 已定义或组件自设的变量。
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`
- 命令行:`node packages/tools/cli/bin/ua.js list | search <关键词> | info <包名|组件id> | docs | tokens | verify | mcp`
- MCP:在 `~/.workbuddy/mcp.json` 注册 `ua mcp`,可用 `list_components` / `get_component` / `search_components` / `get_tokens` / `list_recipes` / `get_recipe`
规范见 `AGENTS.md`(接入手册)、`ARCHITECTURE.md`(分层与依赖方向)、`CODING.md`(编码规范)、`ITERATION.md`(自动迭代协议)。
+13 -3
View File
@@ -6,6 +6,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { shellPalette } from '../../packages/tools/cli/lib/shell-palette.mjs'; // 外壳配色与文档站同源:从 tokens.json 派生
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..', '..'); // apps/demo 上两级 = 仓库根
@@ -21,6 +22,8 @@ const result = await esbuild.build({
format: 'iife',
target: 'es2020',
charset: 'utf8', // 中文保留原文,不转义为 \uXXXX
minifyWhitespace: true, // 保守压缩:去空白与多余括号,保留标识符,产物仍可读可调
minifySyntax: true,
write: false,
legalComments: 'none',
alias: {
@@ -32,6 +35,9 @@ const result = await esbuild.build({
const js = result.outputFiles[0].text.replace(/<\/script>/gi, '<\\/script>'); // 防提前闭合
const css = [read('packages/design/tokens/tokens.css'), read('packages/skin/ui/ui.css')].join('\n');
// 外壳 chrome 配色从 tokens.json 派生:手写值时改暗色 accent 会让页面外壳与组件各显示一种蓝
const tokensPath = path.join(root, 'packages/design/tokens/tokens.json');
const shell = shellPalette(fs.existsSync(tokensPath) ? JSON.parse(fs.readFileSync(tokensPath, 'utf8')) : null);
const html = `<!DOCTYPE html>
<html lang="zh-CN">
@@ -41,21 +47,25 @@ const html = `<!DOCTYPE html>
<title>@ui-arch 组件库 · 现场演示</title>
<style>
${css}
:root{--page-bg:#ffffff;--page-text:#1c1e21;--page-muted:#6b7280;--page-border:rgba(0,0,0,.14)}
@media (prefers-color-scheme:dark){:root{--page-bg:#101216;--page-text:#e7e9ec;--page-muted:#888780;--page-border:rgba(255,255,255,.15)}}
:root{--page-bg:${shell.bg};--page-text:${shell.text};--page-muted:${shell.muted};--page-border:${shell.border}}
:root[data-theme=dark]{--page-bg:${shell.darkBg};--page-text:${shell.darkText};--page-muted:${shell.darkMuted};--page-border:${shell.darkBorder}}@media (prefers-color-scheme:dark){:root:not([data-theme]){--page-bg:${shell.darkBg};--page-text:${shell.darkText};--page-muted:${shell.darkMuted};--page-border:${shell.darkBorder}}}
*{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}
.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-search{width:100%;box-sizing:border-box;margin-bottom:8px;padding:4px 8px;border:1px solid var(--ua-color-border);border-radius:6px;background:var(--ua-color-bg);color:var(--page-text);font-size:12px}
.toc-search:focus{outline:none;border-color:var(--ua-color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--ua-color-accent) 18%,transparent)}
.toc-link[hidden]{display:none}
.toc-empty{color:var(--page-muted);font-size:12px;padding:4px 8px;margin:0}
.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}
header{margin-bottom:30px;display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
h1{font-size:20px;font-weight:500;margin:0}
.sub{color:var(--page-muted);font-size:13px;margin:8px 0 0}
.sec{margin-bottom:40px}
+1470 -39
View File
File diff suppressed because it is too large Load Diff
+2281 -78
View File
File diff suppressed because it is too large Load Diff
+104 -19
View File
@@ -45,17 +45,18 @@
## @ui-arch/ui (v0.1.0)
- 类型: skin-dom · 目录: packages/skin/ui
- 描述: 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-* 令牌。
- 描述: DOM 皮肤组件库:表单 / 反馈 / 浮层 / 导航 / 数据展示,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌(组件全量见 api.exports 或刚体清单)
- API:
- button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用
- textField(props):文本输入,value 信号双向绑定,disabled 支持布尔或信号禁用
- inputNumber(props):数字输入,−/+ 按钮 + 键盘 ↑↓ 调节,value 信号双向绑定,min/max/step 超界自动夹取
- textarea(props):多行输入,value 信号双向绑定,disabled 支持布尔或信号禁用
- button(props):按钮,variant 三档视觉强度,disabled / loading 支持布尔或信号动态同步(loading 转圈并禁点),onClick 点击回调
- textField(props):文本输入,value 信号双向绑定,disabled 支持布尔或信号,onInput 输入回调;prefix/suffix 可选渲染为输入框内侧附加块,clearable 开启后有值即显一键清空按钮,maxCount 设原生上限并实时显示字数
- inputNumber(props):数字输入,−/+ 按钮与键盘 ↑↓ 调节(长按连发步进),min/max/step 夹取,precision 小数位归一;prefix/suffix 可选渲染为整组前后附加块
- textarea(props):多行输入,value 信号双向绑定,disabled 支持布尔或信号,rows 控行数;prefix/suffix 可选渲染为上下附加块,maxCount 显示实时字数并设原生上限
- slider(props):滑块,value 信号双向绑定,min/max/step 可配,原生 range 承载键盘交互,右侧可显示当前值
- checkbox(props):复选框,checked 信号双向绑定,disabled 支持布尔或信号禁用
- switchButton(props):开关,checked 信号双向绑定,disabled 支持布尔或信号禁用
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画,disabled 支持布尔或信号禁用
- menu(props):下拉菜单,触发器 + 自绘弹层,条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部 / Esc 关闭
- checkbox(props):复选框,checked 信号双向绑定,indeterminate 半选态(读屏播报 mixed),disabled 支持布尔或信号禁用
- switchButton(props):开关,checked 信号双向绑定,loading 加载锁(滑块转圈 + aria-busy),disabled 支持布尔或信号禁用
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画,disabled 支持布尔或信号禁用,空间不足自动翻转,键入字母快跳选项;searchable 开启弹层搜索框,clearable 有值时给清空按钮
- menu(props):下拉菜单,触发器 + 自绘弹层,条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部 / Esc 关闭,空间不足自动翻转,键入字母快跳菜单项
- flip(trigger, panel, preferred):浮层上下翻转判定,按触发器与面板的视口几何决定 preferred 方位放不下时是否翻转,返回实际生效方位 'top' | 'bottom'
- radioGroup(props):单选组,options + value 信号双向绑定,disabled 整组禁用
- accordion(props):折叠面板,标题按钮 + grid 行高过渡,单开(手风琴)或多开,支持禁用面板与 active 信号受控
- breadcrumb(props):面包屑,nav + ol 语义结构,末项为当前页(aria-current),有 href 走原生链接、无 href 交给 onNavigate
@@ -64,20 +65,75 @@
- 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 可放按钮等交互元素
- toast.info/success/warning/error(message, duration?):消息提示工厂,右上角堆叠,默认 3s 自动消失(悬停暂停、移出续时),出入场微动画;showToast(props) 为完整入参版本(closable 显示关闭按钮,同屏最多 4 条)
- tooltip(props, trigger):包裹式悬浮提示,hover / 键盘聚焦延迟出场,Esc 收起;触摸屏按下即出、抬手延迟 1.2s 收起,气泡 pointer-events: none 不挡点击
- popover(props, trigger):可交互气泡卡片,包裹触发器,默认点击开合 + 点外部 / Esc 收起,content 可放按钮等交互元素;视口一侧空间不足时自动翻转
- skeleton(props):骨架屏占位,variant = text | rect | circle,text 支持 rows 多行,扫光微动画
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,键盘可达
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,jumpable 时末尾可输入页码直接跳转,键盘可达;pageSizes 可切每页条数
- spinner(props):加载指示器,size 控制直径
- progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签;页签项 closable 时带关闭按钮(聚焦后 Delete 同样可关),关闭只回调 onClose 由调用方摘数据
- stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳
- rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配,allowHalf 开启半星(左半区 0.5 分)
- empty(props):空状态占位,title/description 可选,action 放操作区
- divider(props):分隔线,水平/垂直两种方向,可带居中文案标签,dashed 切虚线
- space(props):间距容器,flex gap 统一子元素间距,direction / size / align / justify / wrap / block 可调
- 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):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态;loading 为真时数据行换骨架行且筛选/翻页禁用,内核为 table-core
- image(props):图片,缩略图点击 / 回车开遮罩大图预览(Esc 或点击遮罩关闭),width 控制缩略宽,加载失败自动落占位(fallback 自定义文案)
- timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点
- segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用
- descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列
- statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值
- list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态
- carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控
- tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应
- upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)
- notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭
- result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态
- transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选
- cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作
- splitter(props):可拖拽分栏容器,分隔条支持指针拖拽 / 方向键微调 / 双击复位,sizes 为百分比数组信号
- autoComplete(props):输入联想,输入即过滤候选、↑↓ 环形高亮 / Enter 选中 / Esc 收起,options 可传函数做远程联想
- datePicker(props):日期选择,单月网格 + 翻月 / 今天快捷键,value 为 YYYY-MM-DD 信号,min/max 闭区间限制,方向键逐日逐周可导航
- backTop(props):返回顶部悬浮按钮,越过阈值才浮现,外环 conic-gradient 显示已滚比例,点击平滑回顶
- colorPicker(props):颜色选择,预设色板(缺省语义令牌色)+ 原生取色器 + 手输 CSS 颜色串,value 为颜色串信号
- tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose
- watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互
- checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项
- affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应
- multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用
- anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器
- popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」
- calendar(props):月历,整月网格内嵌页面(排期 / 值班 / 日程),每日可带 marker 标记文案,方向键漫游 + Enter 选中,翻月走 onMonthChange
- typography(props):排版文本,variant 决定语义(标题出 h1-h5 / 段落出 p / 行内出 span),支持语义色、装饰位、对齐、多行截断与复制按钮
- grid(props, ...children):栅格容器,columns 固定列数或 min 走 auto-fit 自适应列宽,gutter 统一列间距,窄屏自动降为单列
- floatButton(props):悬浮操作按钮,固定停靠左下 / 右下角,label 缺省为纯图标圆形,badge 走角标,tooltip 兼作 aria-label
- mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value
- treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起
- link(props):链接,站内跳转与站外跳转统一入口,external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报
- avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名
- virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄
- commandPalette(props):命令面板,⌘K / Ctrl+K 唤起,输入即过滤(主文案/说明/关键词三层),↑↓ 环形选择 + 回车执行 + Esc 收起,选中与关闭都归还焦点;返回 { el, open, close } 句柄
- timePicker(props):时间选择,时 / 分两列弹层,value 信号双向绑定 HH:mm,分钟可配步长,Esc 收起
- pinInput(props):验证码输入,N 格单字符数字输入,输入自动进格、退格回删、方向键移格、粘贴自动分发,value 信号双向绑定,mask 可密文显示
- copyButton(props):复制按钮,点击把 text 写入剪贴板并短暂显示已复制,text 可传函数取实时值,clipboard API 不可用时降级 execCommand
- countdown(props):倒计时,秒级递减到 0 自动停表并触发 onFinish,缺省 mm:ss 展示,返回句柄 start/stop/reset
- kbd(props):键盘按键标记,键帽样式展示单个按键或组合键(多键以 + 连接),用于快捷键提示
- highlight(props):关键词高亮,命中片段包 mark(大小写不敏感全部命中),text / keyword 支持信号响应式刷新
- inputTag(props):标签输入,输入后回车 / 逗号成标签、chip 可单个删除、空框退格删末个,value 信号双向绑定,max 满额自动禁输
- layout(props, ...children):布局骨架,页头 / 侧栏 / 内容 / 页脚四区一次成型,侧栏可折叠(collapsed 信号双向绑定)
- jsonViewer(props):JSON 树查看,按层折叠、分支带计数摘要、叶子按类型着色,只读不编辑
- diffView(props):文本差异对比,行级 LCS 对齐 + 增删着色 + 双行号列,顶部带 +N / −N 统计
- codeBlock(props):代码块,等宽多行 + 行号列 + 标题行 + 一键复制原文,只展示不高亮
- signaturePad(props):手写签名板,指针绘制 + DPR 位图,返回 handle(el / clear / toDataURL / isEmpty)
- loading(props, ...children):区域加载遮罩,内容原地降透明度 + 转圈文案,带延迟出场避免快请求闪烁
- relativeTime(props):把时间戳渲染成「3 分钟前」并自动刷新,超阈值改显示绝对日期,title 恒给精确值
- sparkline(props):迷你趋势线,把数值序列压成折线(可填充 + 末点标记),无坐标轴无交互
- heatmap(props):日历热力图,按周列 × 星期行铺格子、四档浓度表示当天数量,悬停看明细
- 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')`
- 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
- 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
@@ -89,14 +145,43 @@
- 类型: tooling · 目录: packages/tools/cli
- 描述: ua 命令行与 MCP 注册表服务:把各包 ua.manifest.json 汇总成机器可读索引(.ua/index.json)、Agent 全量文档(llms-full.txt)与人类文档站,并提供清单一致性门禁。
- API:
- ua doctor:11 项环境与自洽预检(node / 依赖 / git 钩子与身份 / .ua 元数据可解析 / usage 串 ↔ dispatch 一致 / check 链覆盖全部门禁步骤 / CLI 清单声明的命令都有实现),任一失败退出码 1
- ua index:扫描 packages/ 两层目录的 ua.manifest.json,生成 .ua/index.json 与 llms.txt
- ua list:列出注册表内所有包与组件数
- ua info <name>:查看单个包的清单详情(api / 约定 / 文档步骤)
- ua list:列出注册表内所有包与组件数;--json 输出「包 + 组件 id」结构化数组,--components 逐组件展开
- ua info <包名|组件id>:包名返回摘要,组件 id 返回 props 表 + 步骤 + 示例;--json 输出原始清单,--md 直接给可粘贴的 Markdown 片段(标题 + 参数表 + 用法 + 示例)
- ua tokens [关键词] [--json|--unused]:打印 --ua-* 令牌清单(颜色带暗色值);--unused 反向列出皮肤包与令牌自身都没引用的令牌,用来收口令牌面
- 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 mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components
- 用法: `node packages/tools/cli/bin/ua.js <index|list|info <name>|docs|style|verify|mcp>;仓库内等价于 npm run index / docs / style / verify`
- ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在 / 视觉回归抽查清单六组件各 ≥3 条且五维度齐全)
- ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1
- ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里各类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环/内距/控件高度/字重/行高/层级/边框色)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数与每类命中行号(line / prop / value / hint,Agent 可直接定位)
- ua audit:contrast [--json|--list]:对比度门禁,把令牌里 18 组「前景色 × 底色」配对按 WCAG 公式算成对比度(求值支持 var() 与 color-mix,半透明底先压到页面底色),亮/暗 × 常规/高对比四套分算,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)退出码 1;--json 出逐对明细,--list 只列配对表
- ua audit:motion [--json]:动效兜底门禁,两类必查——① 带 infinite 的动画逐个核对是否在 prefers-reduced-motion 块里 animation: none;② 悬停 / 按压的位移(translate)是否在减少动效下归零(认 ua- 类通配的全局规则)。缺兜底退出码 1(--json 出逐条明细)
- ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe;get_component 支持 format: md 直接取可粘贴的 Markdown 片段
- 用法: `node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id> [--json|--md]|docs|tokens [--json|--unused]|style|verify|commit-lint|audit:visual|audit:contrast|audit:motion|doctor|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual / audit:contrast / audit:motion / doctor`
- 约定: 零依赖:只用 node 标准库,Node >= 18
- 约定: 清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生
- 约定: 门禁只增不改:新增校验必须能被人手复现,报错要指出文件与字段
## 组合配方(跨组件拼装)
> 全文见 `packages/tools/cli/recipes.md` 或 llms-full.txt;MCP 用 `list_recipes` / `get_recipe` 取骨架与要点。
- 配方 1:表单校验 + 提交反馈(2 条要点)
- 配方 2:危险操作二次确认(2 条要点)
- 配方 3:表格筛选 + 分页 + 批量操作(3 条要点)
- 配方 4:长列表数据加载与空态(2 条要点)
- 配方 5:数据表 + 独立分页器 + 导出(4 条要点)
- 配方 6:长表单编辑页(吸底操作条 + 提交锁定)(4 条要点)
- 配方 7:筛选栏(层级归属 + 日期区间)(3 条要点)
- 配方 8:评论输入(@ 提及 + 参与人头像组)(3 条要点)
- 配方 9:详情页排版(栅格 + 排版文本 + 描述列表)(3 条要点)
- 配方 10:暗色主题切换(只切 data-theme,不改令牌名)(3 条要点)
- 配方 11:数据看板筛选区(区间滑块 + 日期预设 + 分组下拉)(3 条要点)
- 配方 12:命令面板(⌘K 全局操作入口)(6 条要点)
- 配方 13:预约时点(日期 + 时间两段拼装)(5 条要点)
- 配方 14:标签筛选条 → 表格联动(5 条要点)
- 配方 15:表格分页与独立分页器联动(每页条数 + 受控页码)(4 条要点)
- 配方 16:后台页骨架(3 条要点)
- 配方 17:表格刷新遮罩(3 条要点)
- 配方 18:变更审阅面板(3 条要点)
+11 -3
View File
@@ -7,14 +7,22 @@
"scripts": {
"build": "tsc -p packages/core/table-core/tsconfig.json && tsc -p packages/core/frame/tsconfig.json && tsc -p packages/skin/ui/tsconfig.json",
"typecheck": "tsc -p packages/core/table-core/tsconfig.json --noEmit && tsc -p packages/core/frame/tsconfig.json --noEmit && tsc -p packages/skin/ui/tsconfig.json --noEmit && tsc -p apps/demo/tsconfig.json",
"smoke": "node packages/core/table-core/scripts/smoke.mjs",
"smoke": "node packages/core/frame/scripts/smoke.mjs && node packages/core/table-core/scripts/smoke.mjs && node packages/skin/ui/scripts/smoke.mjs",
"demo": "node apps/demo/build.mjs",
"style": "node packages/tools/cli/bin/ua.js style",
"index": "node packages/tools/cli/bin/ua.js index",
"docs": "node packages/tools/cli/bin/ua.js index && node packages/tools/cli/bin/ua.js docs",
"check": "npm run build && npm run style && npm run typecheck && npm run smoke && npm run index && npm run verify",
"check": "npm run build && npm run demo && npm run style && npm run typecheck && npm run smoke && npm run index && npm run verify && npm run lint-iterations && npm run audit:visual && npm run audit:contrast && npm run audit:motion",
"ua": "node packages/tools/cli/bin/ua.js",
"verify": "node packages/tools/cli/bin/ua.js verify"
"verify": "node packages/tools/cli/bin/ua.js verify",
"lint-iterations": "node packages/tools/cli/bin/ua.js lint-iterations",
"audit:visual": "node packages/tools/cli/bin/ua.js audit:visual",
"visual:migrate": "node scripts/visual-migrate.mjs",
"changelog": "node packages/tools/cli/bin/ua.js changelog",
"changelog:check": "node packages/tools/cli/bin/ua.js changelog --check",
"sync-counts": "node packages/tools/cli/bin/ua.js sync-counts",
"audit:contrast": "node packages/tools/cli/bin/ua.js audit:contrast",
"audit:motion": "node packages/tools/cli/bin/ua.js audit:motion"
},
"devDependencies": {
"typescript": "^5.6.0"
+99
View File
@@ -0,0 +1,99 @@
/*** 作用:@ui-arch/frame 内核冒烟——信号 / 副作用 / 计算值的语义断言(纯 JS,无需 DOM;跑 npm run smoke 执行) ***/
import { computed, effect, signal } from '../dist/signal.js';
let pass = 0;
let fail = 0;
/*** 断言:失败也继续跑完,最后统一给退出码(否则第一个红就看不到后面) ***/
const ok = (label, cond) => {
if (cond) {
pass += 1;
console.log(`ok - ${label}`);
return;
}
fail += 1;
console.error(`NOT OK - ${label}`);
};
// —— 1. 信号的读写与同值短路 ——
const count = signal(0);
let hits = 0;
effect(() => {
count();
hits += 1;
});
ok('effect 创建时立即执行一次', hits === 1);
count(1);
ok('写值后重跑', hits === 2);
count(1);
ok('写入相同值不触发(Object.is 短路)', hits === 2);
// —— 2. 注销后不再重跑 ——
const live = signal('a');
let liveHits = 0;
const dispose = effect(() => {
live();
liveHits += 1;
});
live('b');
ok('注销前正常重跑', liveHits === 2);
dispose();
live('c');
ok('注销后写值不再重跑(dispose 必须真的摘订阅)', liveHits === 2);
// —— 3. 条件分支重追踪:上一轮读过、这一轮不读的信号不该再唤它 ——
const flag = signal(true);
const left = signal(0);
const right = signal(0);
let branchHits = 0;
effect(() => {
branchHits += 1;
if (flag()) left();
else right();
});
const afterInit = branchHits;
flag(false);
ok('切分支本身触发一次重跑', branchHits === afterInit + 1);
const afterSwitch = branchHits;
left(9);
ok('切走后旧的依赖不再唤它(重追踪要清旧订阅)', branchHits === afterSwitch);
right(9);
ok('切走后新依赖仍唤它', branchHits === afterSwitch + 1);
// —— 4. computed:派生、链式、同值短路 ——
const price = signal(10);
const qty = signal(2);
const total = computed(() => price() * qty());
ok('computed 首次读即得派生值', total() === 20);
price(3);
ok('源变化后 computed 立即跟着变', total() === 6);
let derivedHits = 0;
effect(() => {
total();
derivedHits += 1;
});
const derivedBase = derivedHits;
qty(3);
ok('computed 变更会唤上层 effect(链式依赖成立)', derivedHits === derivedBase + 1);
ok('链式计算值正确', total() === 9);
// —— 5. 多订阅者:扇出与互不影响 ——
const fan = signal(0);
let a = 0;
let b = 0;
const disposeA = effect(() => {
fan();
a += 1;
});
effect(() => {
fan();
b += 1;
});
fan(1);
ok('一次写值唤全部订阅者', a === 2 && b === 2);
disposeA();
fan(2);
ok('注销一个订阅者不影响另一个', a === 2 && b === 3);
console.log(`\n${fail === 0 ? 'SMOKE OK' : 'SMOKE FAILED'}(${pass} 通过 / ${fail} 失败)`);
process.exit(fail === 0 ? 0 : 1);
+18 -3
View File
@@ -1,5 +1,7 @@
// 当前正在收集依赖的 effect;null 表示非追踪态
let active: (() => void) | null = null;
// 当前 effect 已订阅到的「信号订阅集」集合:重跑前据此清旧订阅,注销时据此摘干净
let activeDeps: Set<Set<() => void>> | null = null;
/*** 响应式信号:无参调用读值并收集依赖,传参写值并触发订阅 ***/
export interface Signal<T> {
@@ -13,7 +15,10 @@ export function signal<T>(initial: T): Signal<T> {
const subs = new Set<() => void>();
const read = (...args: unknown[]) => {
if (args.length === 0) {
if (active) subs.add(active); // 依赖收集
if (active) {
subs.add(active); // 依赖收集
activeDeps?.add(subs); // 同时记下「我订阅了哪个集」,注销时才能摘干净
}
return value;
}
const v = args[0] as T;
@@ -24,20 +29,30 @@ export function signal<T>(initial: T): Signal<T> {
return read as Signal<T>;
}
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑 ***/
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑;返回注销函数 ***/
export function effect(fn: () => void): () => void {
const deps = new Set<Set<() => void>>(); // 本 effect 订阅到的所有信号订阅集
let stopped = false; // 注销标志:注销后即使还在某个订阅集里也不再执行
const run = () => {
if (stopped) return; // 卫语句:注销后不再跑
for (const subs of deps) subs.delete(run); // 清上一轮订阅:条件分支换掉后的旧依赖不该再唤它
deps.clear();
const prev = active;
const prevDeps = activeDeps;
active = run;
activeDeps = deps;
try {
fn();
} finally {
active = prev;
activeDeps = prevDeps;
}
};
run();
return () => {
/* 演示级实现:不做依赖清理 */
stopped = true;
for (const subs of deps) subs.delete(run); // 真摘订阅:否则卸载后的组件仍会被信号唤醒
deps.clear();
};
}
+130
View File
@@ -80,4 +80,134 @@ const before = table.getVisibleRows(); // memo 基线
table.dispatch({ type: 'clearSelection' }); // 无关状态变更
assert(table.getVisibleRows() === before, 'derive stages memoized across unrelated state changes');
// —— 补充不变量 ——
const fresh = createTable({
data: users,
columns: [{ key: 'name', header: 'Name', sortable: true }],
rowKey: 'id',
plugins: [paginationPlugin()],
initialState: { pagination: { page: 2, pageSize: 2 } },
});
assert(
fresh.getPageInfo().page === 2 && fresh.getPageInfo().pageSize === 2,
'getPageInfo 无需先取可见行即返回 initialState 生效值',
);
assert(fresh.getPageInfo().totalPages === 3, 'getPageInfo 的 totalPages 由上游 total 推导');
const marks = createTable({
data: users,
columns: [{ key: 'name', header: 'Name' }, { key: 'city', header: 'City' }],
rowKey: 'id',
plugins: [filterPlugin(), paginationPlugin(), selectionPlugin()],
initialState: { pagination: { page: 1, pageSize: 2 } },
});
marks.selectAllVisible(); // 选中第 1 页的 id 1 / 2
marks.dispatch({ type: 'setFilter', key: 'city', value: 'Beijing' }); // 可见行整体换掉
assert(
JSON.stringify(marks.getState().selection) === JSON.stringify([1, 2]),
'selection 以 rowKey 记账,筛选换掉可见行后仍保留',
);
const toggle = createTable({
data: users,
columns: [{ key: 'age', header: 'Age', sortable: true }],
rowKey: 'id',
plugins: [sortPlugin()],
});
toggle.dispatch({ type: 'toggleSort', key: 'age' });
toggle.dispatch({ type: 'toggleSort', key: 'age' });
toggle.dispatch({ type: 'toggleSort', key: 'age' });
assert(toggle.getState().sorting.length === 0, '同一列连点三次回到无序(asc → desc → 取消)');
const multi = createTable({
data: users,
columns: [{ key: 'city', header: 'City' }, { key: 'age', header: 'Age', sortable: true }],
rowKey: 'id',
plugins: [sortPlugin({ multi: true })],
});
multi.dispatch({ type: 'toggleSort', key: 'city', multi: true });
multi.dispatch({ type: 'toggleSort', key: 'age', multi: true });
assert(multi.getState().sorting.length === 2, 'multi 排序保留两列规则');
assert(
JSON.stringify(multi.getVisibleRows().rows.map((r) => r.id)) === JSON.stringify([4, 2, 5, 3, 1]),
'多列排序按规则顺序生效(city 升序,同城内 age 升序:Beijing 4→2、Shanghai 5→3)',
);
const sizing = createTable({
data: users,
columns: [{ key: 'name', header: 'Name' }],
rowKey: 'id',
plugins: [paginationPlugin()],
initialState: { pagination: { page: 1, pageSize: 2 } },
});
sizing.dispatch({ type: 'setPage', page: 2 });
sizing.dispatch({ type: 'setPageSize', pageSize: 3 });
assert(sizing.getPageInfo().page === 1 && sizing.getPageInfo().pageSize === 3, '改页大小回到第 1 页');
sizing.dispatch({ type: 'toggleSort', key: 'name' });
sizing.dispatch({ type: 'reset' });
assert(
sizing.getState().sorting.length === 0 &&
sizing.getState().filters.length === 0 &&
sizing.getState().pagination.page === 1 &&
sizing.getState().pagination.pageSize === 20,
'reset 回到默认态(排序/筛选清空,分页回 1/20)',
);
// getAllRows:过筛选 + 排序但跳过分页,供导出用
const all = createTable({
data: users,
columns: [{ key: 'name', header: 'Name' }, { key: 'city', header: 'City' }],
rowKey: 'id',
plugins: [filterPlugin(), sortPlugin(), paginationPlugin()],
initialState: { pagination: { page: 1, pageSize: 2 } },
});
assert(all.getVisibleRows().rows.length === 2, 'getVisibleRows 受分页约束只给当页');
all.dispatch({ type: 'setFilter', key: 'city', value: 'Beijing' });
assert(all.getAllRows().rows.length === 2 && all.getVisibleRows().rows.length === 2, 'getAllRows 跳过分页但与筛选一致(Beijing 2 行)');
all.dispatch({ type: 'toggleSort', key: 'age' });
assert(
JSON.stringify(all.getAllRows().rows.map((r) => r.id)) === JSON.stringify([4, 2]),
'getAllRows 仍按排序规则排(age 升序 4→2)',
);
assert(all.getPageInfo().totalPages === 1, 'getAllRows 不影响分页快照(2 行 / 每页 2 = 1 页)');
// setRows:整体换数据源,选择状态按 rowKey 剔除失效行,页码越界由插件收敛
const swapped = createTable({
data: users,
columns: [{ key: 'name', header: 'Name' }],
rowKey: 'id',
plugins: [selectionPlugin(), paginationPlugin()],
initialState: { pagination: { page: 1, pageSize: 2 } },
});
swapped.dispatch({ type: 'toggleSelect', key: 1 }); // 选中两行,后面验证失效行被剔除
swapped.dispatch({ type: 'toggleSelect', key: 4 });
assert(swapped.isRowSelected(1) && swapped.isRowSelected(4), 'setRows 前:两行已选');
swapped.setRows(users.filter((u) => u.id !== 4));
assert(swapped.getVisibleRows().total === 4, 'setRows 后:total 跟随新数据(5 行 → 4 行)');
assert(swapped.isRowSelected(1) && !swapped.isRowSelected(4), 'setRows 后:失效行键从选择里剔除,有效行保留');
assert(swapped.getState().pagination.page === 1, 'setRows 不动页码(用户翻页意图不被打断)');
const sameRows = users.filter((u) => u.id !== 4);
let notified = 0;
swapped.subscribe(() => (notified += 1));
swapped.setRows(sameRows);
assert(notified === 1 && swapped.getVisibleRows().total === 4, 'setRows 换引用:通知一次并重算 total');
// 页码收敛:数据缩水后越界页码范围回生效页,state 与视图保持一致(不再滞留旧页码)
const clamp = createTable({
data: users,
columns: [{ key: 'name', header: 'Name' }],
rowKey: 'id',
plugins: [paginationPlugin()],
initialState: { pagination: { page: 3, pageSize: 2 } },
});
assert(clamp.getVisibleRows().rows.length === 1 && clamp.getPageInfo().page === 3, '收敛前:5 行 / 每页 2 = 3 页,停在第 3 页');
clamp.setRows(users.slice(0, 1)); // 只剩 1 行 → 总页数 1
assert(clamp.getPageInfo().page === 1 && clamp.getState().pagination.page === 1, '数据缩水后:越界页码回写为 1(state 与生效页一致)');
clamp.setRows(users); // 数据恢复 5 行
assert(clamp.getVisibleRows().rows.length === 2 && clamp.getState().pagination.page === 1, '数据恢复后:仍停第 1 页,不记忆已失效的旧页码');
clamp.dispatch({ type: 'setPage', page: 9 }); // 显式跳到越界页
assert(clamp.getVisibleRows().rows.length === 1 && clamp.getState().pagination.page === 3, '跳越界页:派生钳制后回写为末页 3');
assert(clamp.getPageInfo().totalPages === 3, '收敛不改分页快照语义(totalPages 仍按 total 推算)');
console.log('SMOKE OK');
+31 -3
View File
@@ -13,7 +13,8 @@ import type {
export function createTable<T>(
options: TableOptions<T>,
): TableInstance<T> & Record<string, unknown> {
const { data, columns, plugins = [], onStateChange } = options;
let data = options.data; // 可变:setRows 整体换数据源(筛选结果、轮询刷新),派生管线照旧跑
const { columns, plugins = [], onStateChange } = options;
const rowKey = options.rowKey ?? ('id' as keyof T & string); // 默认行标识
const getKey: (row: T) => RowKey =
@@ -80,20 +81,47 @@ export function createTable<T>(
return cachedBase;
}
/*** 可见行模型:依序过插件 derive(filter → sort → paginate) ***/
function getVisibleRows(): RowModel<T> {
/*** 可见行模型:依序过插件 derive(filter → sort → paginate);paged=false 时跳过分页阶段取全量派生行
* 派生末尾跑一轮 settle:插件可把派生期算出的生效值回写自己的状态切片(如页码越界钳制),
* 保证 state 与视图一致;回写只走 onStateChange、不广播订阅,避免在读取中重入。 ***/
function getVisibleRows(paged = true): RowModel<T> {
ctx.paged = paged; // 由插件自行判断是否裁剪,核心不认插件细节
let model = baseModel();
for (const p of plugins) {
if (p.derive) model = p.derive(model, state, ctx);
}
ctx.paged = true; // 还原缺省,避免影响后续调用
let settled = false;
for (const p of plugins) {
if (!p.settle) continue;
const next = p.settle(state);
if (next !== state) { state = next; settled = true; }
}
if (settled) onStateChange?.(state); // 状态被收敛:回调通知外部,订阅方靠本次读取结果自更新
return model;
}
/*** 整体替换数据源:引用未变或内容长度与行键全同则不动(避免无意义重算),变了就通知订阅方
* 选择状态按 rowKey 存活:新数据里不再存在的行键会从 selection 里剔除,避免批量操作命中幽灵行。 ***/
function setRows(next: T[]): void {
if (next === data) return;
data = next;
const keys = new Set(next.map(getKey));
const kept = state.selection.filter((key) => keys.has(key));
if (kept.length !== state.selection.length) {
state = { ...state, selection: kept }; // 只动选择:排序 / 筛选 / 页码保持用户原意
}
onStateChange?.(state);
for (const fn of listeners) fn();
}
const instance: TableInstance<T> = {
getState: () => state,
dispatch,
subscribe,
setRows,
getVisibleRows,
getAllRows: () => getVisibleRows(false), // 筛选 + 排序后、未分页的全量行(导出等场景)
getColumns: () => columns,
getCellValue,
isRowSelected: (key) => state.selection.includes(key),
@@ -1,5 +1,6 @@
import type {
Action,
DeriveContext,
RowModel,
TableInstance,
TablePlugin,
@@ -19,6 +20,7 @@ export function paginationPlugin<T = unknown>(): TablePlugin<T> {
let lastPage: { page: number; pageSize: number } | null = null;
let cached: RowModel<T> | null = null;
let pageInfo: PageInfo = { page: 1, pageSize: 20, totalPages: 1 };
let fresh = false; // 快照是否来自「按当前 state 完整派生」,settle 只认新鲜快照
return {
name: 'pagination',
@@ -32,7 +34,8 @@ export function paginationPlugin<T = unknown>(): TablePlugin<T> {
return state;
}
},
derive(model: RowModel<T>, state: TableState): RowModel<T> {
derive(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T> {
if (ctx.paged === false) { fresh = false; return model; } // 不分页:原样放行上游模型(导出全量行)
if (model === lastInput && state.pagination === lastPage && cached) return cached;
const { page, pageSize } = state.pagination;
const totalPages = Math.max(1, Math.ceil(model.total / pageSize));
@@ -46,15 +49,24 @@ export function paginationPlugin<T = unknown>(): TablePlugin<T> {
pageInfo = { page: safePage, pageSize, totalPages };
lastInput = model;
lastPage = state.pagination;
fresh = true;
return cached;
},
settle(state: TableState): TableState {
// 只把「当前 state 派生出的生效页」回写;快照过期(未按当前 state 派生)时不动,避免写入错误页码
if (!fresh || state.pagination !== lastPage || pageInfo.page === state.pagination.page) return state;
return { ...state, pagination: { ...state.pagination, page: pageInfo.page } };
},
api(table: TableInstance<T>) {
return {
setPage: (page: number) => table.dispatch({ type: 'setPage', page }),
nextPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page + 1 }),
prevPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page - 1 }),
setPageSize: (pageSize: number) => table.dispatch({ type: 'setPageSize', pageSize }),
getPageInfo: (): PageInfo => ({ ...pageInfo }),
getPageInfo: (): PageInfo => {
table.getVisibleRows(); // 先跑一遍派生(内部有 memo),保证快照对应当前 state
return { ...pageInfo };
},
};
},
};
+7 -5
View File
@@ -41,11 +41,13 @@ export function sortPlugin<T = unknown>(
case 'setSorting':
return { ...state, sorting: action.sorting };
case 'toggleSort': {
const rules = action.multi ? [...state.sorting] : []; // 非 multi 直接单列重置
const idx = rules.findIndex((r) => r.key === action.key);
if (idx === -1) rules.push({ key: action.key, dir: initialDir });
else if (rules[idx].dir === 'asc') rules[idx] = { key: action.key, dir: 'desc' };
else rules.splice(idx, 1); // desc 再点即取消
const idx = state.sorting.findIndex((r) => r.key === action.key); // 本列当前方向的位置
// 非 multi 只保留本列(单选语义),multi 保留全部列;不能一味清空,否则本列永远停在 asc
const rules = action.multi ? [...state.sorting] : idx === -1 ? [] : [state.sorting[idx]];
const at = rules.findIndex((r) => r.key === action.key);
if (at === -1) rules.push({ key: action.key, dir: initialDir });
else if (rules[at].dir === 'asc') rules[at] = { key: action.key, dir: 'desc' };
else rules.splice(at, 1); // desc 再点即取消
return { ...state, sorting: rules };
}
default:
+5 -1
View File
@@ -61,15 +61,17 @@ export interface RowModel<T = unknown> {
/*** 派生上下文:核心注入给插件的取值能力 ***/
export interface DeriveContext<T = unknown> {
paged?: boolean; // 是否分页裁剪;false 时 pagination 插件原样放行(导出全量行用)
getCellValue(row: T, key: string): unknown; // 单元格取值(走列 accessor)
getKey(row: T): RowKey; // 行标识取值
}
/*** 插件三钩子:patchState 改状态、derive 插入数据管线、api 向实例注入方法 ***/
/*** 插件四钩子:patchState 改状态、derive 插入数据管线、settle 派生后收敛自身状态、api 向实例注入方法 ***/
export interface TablePlugin<T = unknown> {
name: string; // 插件名,诊断用
patchState?(state: TableState, action: Action): TableState; // 处理自己关心的动作,无关动作原样返回
derive?(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T>; // 插入派生管线阶段
settle?(state: TableState): TableState; // 派生后回写修正(如页码越界钳制);无需修正须原样返回同一引用
api?(table: TableInstance<T>): Record<string, unknown>; // 向实例注入便捷方法
}
@@ -88,7 +90,9 @@ export interface TableInstance<T = unknown> {
getState(): TableState; // 当前状态快照
dispatch(action: Action): void; // 派发动作,唯一变更入口
subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数
setRows(rows: T[]): void; // 整体换数据源(筛选结果 / 轮询刷新),选择状态按 rowKey 剔除失效行
getVisibleRows(): RowModel<T>; // 可见行模型(过完整管线)
getAllRows(): RowModel<T>; // 筛选 + 排序后、未分页的全量行(导出用)
getColumns(): ColumnDef<T>[]; // 列定义
getCellValue(row: T, key: string): unknown; // 单元格取值
isRowSelected(key: RowKey): boolean; // 行是否已选
+1 -1
View File
@@ -39,7 +39,7 @@
"变更状态:只能 dispatch action,如 table.dispatch({ type: 'toggleSort', key: 'name' });状态不可变",
"订阅刷新:table.subscribe(fn),fn 里重新 getVisibleRows() 并交给渲染层",
"分页信息:api.getPageInfo() 返回 { page, pageSize, totalPages };越界页码自动钳制",
"扩展能力:实现 TablePlugin(derive / patchState / api 三钩子)传入 plugins 即插入管线"
"扩展能力:实现 TablePlugin(derive / patchState / settle / api 四钩子)传入 plugins 即插入管线"
]
}
}
+200 -31
View File
@@ -1,40 +1,209 @@
/* @ui-arch design tokens - light theme defaults + dark theme overrides */
/* @ui-arch design tokens - light theme defaults + dark theme overrides
分组:颜色 / 混色浓度 / 圆角 / 间距 / 字号 / 字距 / 字重与行高 / 边框 / 阴影 / 动效 / 焦点环 / 控件高度 / 层级
约定:皮肤包(packages/skin/ui/ui.css)只能消费 var(--ua-*),禁止硬编码颜色、阴影、时长、字号;
本文件是令牌唯一来源,ua tokens / llms-full.txt / MCP get_tokens 全部从这里派生。 */
:root {
--ua-color-bg: #ffffff;
--ua-color-surface: #f6f7f9;
--ua-color-text: #1c1e21;
--ua-color-text-muted: #6b7280;
--ua-color-border: rgba(0, 0, 0, 0.15);
--ua-color-accent: #185fa5;
--ua-color-accent-contrast: #ffffff;
--ua-color-danger: #a32d2d;
--ua-color-success: #0f6e56;
--ua-color-warning: #9a6b1f;
/* ---- 颜色:语义色,暗色主题逐个覆盖 ---- */
--ua-color-bg: #ffffff; /* 页面底色:弹层、卡片之外的最底层 */
--ua-color-surface: #f6f7f9; /* 表面色:卡片 / 表头 / 次级容器 */
--ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 4%, var(--ua-color-bg)); /* 表面二阶:悬停底、次级容器 */
--ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 8%, var(--ua-color-bg)); /* 表面三阶:按下底、最深容器 */
--ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg)); /* 凹陷区:输入框 / 代码块底 */
--ua-color-text: #1c1e21; /* 主文本色:正文与标题共用 */
--ua-color-text-muted: #676d78; /* 辅助文本色:说明、占位、次要信息(比原值略深:凹陷底上原值仅 4.39,过不了 AA 4.5) */
--ua-color-text-secondary: #4b5158; /* 次级文本色:介于主文本与辅助文本之间 */
--ua-color-text-faint: #9aa0a8; /* 三级文本色:禁用、极弱说明 */
--ua-color-overlay: rgba(0, 0, 0, 0.45); /* 遮罩:抽屉 / 弹窗 / 命令面板等半透明底 */
--ua-color-overlay-strong: rgba(0, 0, 0, 0.6); /* 强遮罩:大图预览等需要更强聚焦的浮层 */
--ua-color-border: rgba(0, 0, 0, 0.15); /* 基础边框色:输入框 / 卡片 / 分割线 */
--ua-color-scrollbar: rgba(0, 0, 0, 0.22); /* 滚动条拇指色:比边框重一档,白底上看得清又不抢内容 */
--ua-color-scrollbar-hover: rgba(0, 0, 0, 0.38); /* 滚动条拇指悬停色:加深表示可拖拽 */
--ua-color-highlight: rgba(255, 255, 255, 0.5); /* 内高光白:主按钮等实色控件顶缘的 1px 高光(暗色必须降档,否则刺眼) */
--ua-color-accent: #185fa5; /* 强调色:主操作、选中态、焦点环底色 */
--ua-color-accent-contrast: #ffffff; /* 强调色上的文字色:保证主按钮对比度 */
--ua-color-accent-soft: color-mix(in srgb, var(--ua-color-accent) 72%, var(--ua-color-bg)); /* 柔和强调色:细线 / 指示条 / 内嵌焦点环用,纯 accent 做 2px 实线会发刺 */
--ua-color-tint-accent: color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-soft), transparent); /* 语义淡底标准档:选中底、激活项底 */
--ua-color-tint-accent-weak: color-mix(in srgb, var(--ua-color-accent) 5%, transparent); /* 语义淡底弱档:悬停底、极弱强调底 */
--ua-color-danger: #a32d2d; /* 危险色:错误、删除、校验失败 */
--ua-color-success: #0f6e56; /* 成功色:完成、通过、正向结果 */
--ua-color-warning: #9a6b1f; /* 警告色:提醒、需注意但不阻断 */
--ua-radius-sm: 4px;
--ua-radius-md: 8px;
--ua-radius-lg: 12px;
/* ---- 混色浓度:color-mix 里「混入多少」的档位,百分比亮暗通用(近档已就近收敛:12→10、16/20/24→18、40→45、65/80→70) ---- */
--ua-alpha-soft: 10%; /* 淡底档:选中底、激活项底、语义色淡底 */
--ua-alpha-tint: 18%; /* 弱强调档:hover 淡底、警示淡底、白色细纹 */
--ua-alpha-line: 30%; /* 描边调和档:边框色里混入文本色加深 */
--ua-alpha-mid: 45%; /* 中浓档:半透虚线边、淡化滚动条底 */
--ua-alpha-strong: 55%; /* 强调档:语义色图标底、强调描边 */
--ua-alpha-heavy: 70%; /* 重档:渐变端点、近实色的弱化描边 */
--ua-space-1: 4px;
--ua-space-2: 8px;
--ua-space-3: 12px;
/* ---- 圆角:三档,控件用小、卡片用中、弹层用大 ---- */
--ua-radius-sm: 4px; /* 小圆角:按钮 / 输入框 / 标签等控件 */
--ua-radius-md: 8px; /* 中圆角:卡片 / 面板 / 分组容器 */
--ua-radius-lg: 12px; /* 大圆角:弹窗 / 抽屉等浮层外壳 */
--ua-radius-md-inset: calc(var(--ua-radius-md) - 1px); /* 内衬圆角:贴边的内层块(输入附加块等)与外层弧线对齐,减去 1px 边框 */
/* ---- 间距:4px 基准四档 + 表格单元格复合值 ---- */
--ua-space-1: 4px; /* 间距 1 档:图标与文字之间、紧邻元素 */
--ua-space-2: 8px; /* 间距 2 档:控件内边距、同组元素间隔 */
--ua-space-3: 12px; /* 间距 3 档:表单行内边距、卡片内边距 */
--ua-space-4: 16px;
--ua-space-table-cell: var(--ua-space-2) var(--ua-space-3);
--ua-space-05: 2px; /* 半步:微型内距(紧凑徽标内衬、选中点偏移) */
--ua-space-15: 6px; /* 一档半:紧凑控件内距(小按钮 / 标签) */
--ua-space-25: 10px; /* 二档半:标准控件内距(介于 8 与 12 之间的既有主流值) */
--ua-space-35: 14px; /* 三档半:宽松行内距(列表行 / 菜单行) */ /* 间距 4 档:区块内边距、卡片间距 */
--ua-space-table-cell: var(--ua-space-2) var(--ua-space-3); /* 表格单元格内边距:上下 2 档 / 左右 3 档 */
--ua-space-6: 24px; /* 六档:空状态整行留白等大面积竖直呼吸(比 4 档再松一档,不进半步梯子) */
--ua-space-indent: 22px; /* 层级缩进:树 / 时间线每级缩进量与图标列宽度对齐,不参与常规内距梯子 */
--ua-font-size-sm: 12px;
--ua-font-size-md: 13px;
--ua-font-size-lg: 14px;
/* ---- 字号:五档主轴 + 三档展示字号 + 两档相对缩放(10/11px 统一归入 xs,避免单独成档) ---- */
--ua-font-size-xs: 11px; /* 最小字号:角标 / 辅助说明 / 密排表格 */
--ua-font-size-sm: 12px; /* 小字号:次级文本、表头、提示 */
--ua-font-size-md: 13px; /* 正文字号:控件文本与默认正文 */
--ua-font-size-lg: 14px; /* 大字号:强调正文、卡片标题 */
--ua-font-size-xl: 16px; /* 次标题字号:分组标题、弹窗标题 */
--ua-font-size-2xl: 18px; /* 标题字号:页面区块标题 */
--ua-font-size-3xl: 22px; /* 图标字号:评分星、开关内嵌图标等由字符撑起的图形 */
--ua-font-size-4xl: 26px; /* 指标字号:统计卡片的主数值 */
--ua-font-size-5xl: 34px; /* 插画字号:空状态、结果页的大号字符图标 */
--ua-font-size-scale-md: 0.92em; /* 相对缩放档(相对父级):代码片段等需跟上下文缩放的文本 */
--ua-font-size-scale-sm: 0.85em; /* 相对缩放小档:行内小图标随文字一起缩,不建绝对像素 */
/* ---- 字距:两档,宽档给表头 / 分组小标题做节奏,紧档给大标题收边 ---- */
--ua-tracking-wide: 0.04em; /* 宽档:表头、分组标题、大写小标题(小字号需要字距才不糊成一团) */
--ua-tracking-tight: -0.01em; /* 紧档:大标题反向收边,字号越大越需要收紧才不散 */
/* ---- 字重与行高:标题 / 正文 / 辅助三档 ---- */
--ua-weight-normal: 400; /* 正文档:正文与表单值 */
--ua-weight-medium: 500; /* 辅助强调档:控件文本、表头、次要标题 */
--ua-weight-semibold: 600; /* 标题档:区块标题、关键数值 */
--ua-weight-bold: 700; /* 特重档:统计大数字等少数强调位 */
--ua-leading-none: 1; /* 单行档:图标 / 数字 / 按钮文字的垂直居中 */
--ua-leading-tight: 1.35; /* 紧凑档:标题、单行控件 */
--ua-leading-normal: 1.55; /* 正文档:多行正文、说明文字 */
/* ---- 边框:细分(比基础边框更淡,用于分割线,避免 1px 线过重) ---- */
--ua-border-subtle: color-mix(in srgb, var(--ua-color-border) 60%, transparent); /* 细分边框色(专用于分割线):表格行线 / 卡片页脚 / 弹窗抽屉页头页脚 / 列表项 / 选项卡底边 / 折叠面板与步骤条连接线 / 分隔线组件 */
/* ---- 阴影:四档 elevation,柔和优先(扩散大、透明度低) ---- */
--ua-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06); /* 1 档:贴地投影,用于卡片 / 输入组 / 轻浮起 */
--ua-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.1); /* 2 档:下拉菜单 / 选择面板 / 气泡提示 */
--ua-shadow-3: 0 10px 28px rgba(0, 0, 0, 0.12); /* 3 档:浮层 / 悬浮面板 / 拖拽中的卡片 */
--ua-shadow-4: 0 18px 44px rgba(0, 0, 0, 0.14); /* 4 档:模态弹窗 / 抽屉 / 通知中心等最高层 */
--ua-shadow-accent: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent); /* 主题色投影:主按钮 / 强调控件悬停浮起 */
--ua-shadow-accent-strong: 0 8px 20px color-mix(in srgb, var(--ua-color-accent) 35%, transparent); /* 主题色强投影:返回顶部等悬浮强调件 */
--ua-shadow-highlight: inset 0 1px 0 var(--ua-color-highlight); /* 内高光型:实色控件顶缘 1px 高光(1px 是细线语义,不进 elevation 四档) */
--ua-shadow-hairline: inset 0 -1px 0 var(--ua-border-subtle); /* 描边型:粘性表头 / 吸底条底部的 1px 细线(用阴影画线,不占布局高度) */
--ua-shadow-inset-border: inset 0 0 0 1px var(--ua-color-border); /* 描边型:日历「今天」等只需要一圈细内框、不想改盒模型的场景 */
--ua-shadow-inset-accent: inset 0 0 0 1px var(--ua-color-accent); /* 描边型:强调色内框,标记「今天」这类弱选中态 */
--ua-shadow-gap: 0 0 0 2px var(--ua-color-bg); /* 断口型:用底色描一圈把相邻元素分开(头像组叠层、角标与按钮边缘) */
/* ---- 动效:四档交互时长 + 四档循环时长 + 两条主缓动曲线 ---- */
--ua-duration-fast: 0.15s; /* 快:hover / 聚焦 / 颜色与边框变化(既有样式的主流时长) */
--ua-duration-base: 0.2s; /* 中:展开收起 / 位移 / 透明度过渡 */
--ua-duration-slow: 0.25s; /* 慢:浮层出入场 / 较大面积变化 */
--ua-duration-slower: 0.35s; /* 更慢:进度条宽度推进 / 描边长度等大跨度形变(比浮层出场再长一档) */
--ua-duration-loop-fast: 0.6s; /* 循环快档:条纹滚动等细微循环 */
--ua-duration-loop-mid: 0.8s; /* 循环中档:加载转圈 / 开关内转圈(0.7 与 0.8 收敛到此档) */
--ua-duration-loop-slow: 1.2s; /* 循环慢档:骨架扫光 / 不确定进度条(1.4 收敛到此档) */
--ua-duration-loop-long: 1.6s; /* 循环长档:时间线呼吸等低频脉冲,别再用在加载态上 */
--ua-duration-step: 0.02s; /* 微步档:交错入场的单项间隔(20ms),只供 calc(n * var(--ua-duration-step)) 派生,不直接当过渡时长 */
--ua-ease: cubic-bezier(0.2, 0.8, 0.2, 1); /* 标准缓动:先快后缓,控件交互默认用这条 */
--ua-ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* 出场缓动:元素进入视口 / 弹层出现时使用 */
--ua-ease-spring: cubic-bezier(0.34, 1.3, 0.5, 1); /* 回弹缓动:弹窗本体、悬浮按钮这类需要一点过冲的入场 */
--ua-translate-1: 1px; /* 位移 1 档:hover 微上浮(行内小件) */
--ua-translate-2: 2px; /* 位移 2 档:hover 上浮标准档(按钮 / 卡片 / 悬浮件) */
--ua-translate-3: 3px; /* 位移 3 档:贴边入场偏移(tooltip 箭头侧滑等几何配对位移) */
--ua-translate-4: 4px; /* 位移 4 档:小型弹层入场(下拉菜单 / 气泡滑入) */
--ua-translate-6: 6px; /* 位移 6 档:入场位移中档(alert / 通知条滑入) */
--ua-translate-8: 8px; /* 位移 8 档:浮动件入场(悬浮按钮弹出 / 大弹层滑入;既有 10px 收敛到此档) */
--ua-translate-12: 12px; /* 位移 12 档:长距离滑入(toast 侧边进出场 / 开关行程) */
/* ---- 焦点环:统一外环与内嵌两种形态,全部由强调色派生 ---- */
--ua-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 22%, transparent); /* 外焦点环:输入框 / 按钮 / 可聚焦控件 */
--ua-focus-ring-inset: inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent); /* 内嵌焦点环:内部已裁切或需要贴合边框的控件 */
--ua-focus-ring-warning: 0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 25%, transparent); /* 语义警告焦点环:评分等以警告色为主色的控件 */
--ua-focus-ring-danger: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 18%, transparent); /* 错误强调环:校验失败时叠在被标红控件外侧,比 danger 描边更外扩一圈 */
--ua-focus-ring-pulse: 0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 4%, transparent); /* 呼吸光晕档:与焦点环交替出现做脉冲动画(时间线条目) */
--ua-focus-ring-selected: 0 0 0 1.5px var(--ua-color-accent-soft); /* 选中环(非焦点):细一档 + 柔和色,2px 实色在色板/网格里像加粗边框 */
--ua-focus-outline: 2px solid var(--ua-color-accent); /* 外焦点描边(outline 路线):键盘可见性标准档 */
--ua-focus-outline-inset: 1.5px solid var(--ua-color-accent-soft); /* 内嵌焦点描边:密集列表细一档 + 柔和色,2px 实线像粗边框 */
--ua-focus-outline-soft: 2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent); /* 柔和外描边:大面积控件用淡化主题色 */
/* ---- 控件高度:三档,略放大以提升可点面积 ---- */
--ua-control-h-sm: 28px; /* 小控件高度:紧凑工具栏、标签内嵌按钮 */
--ua-control-h-md: 34px; /* 中控件高度:输入框 / 下拉 / 按钮的默认档 */
--ua-control-h-lg: 40px; /* 大控件高度:主按钮、搜索框、移动端触控目标 */
/* ---- 层级:五档,数值留出间隙便于中间插入 ---- */
--ua-z-dropdown: 100; /* 下拉层:菜单 / 选择面板 / 气泡 */
--ua-z-sticky: 200; /* 粘性层:表头、吸顶工具栏 */
--ua-z-overlay: 300; /* 遮罩层:模态背后的半透明遮罩 */
--ua-z-modal: 400; /* 模态层:弹窗 / 抽屉本体 */
--ua-z-toast: 500; /* 通知层:全局消息提示,永远最高 */
}
[data-theme='dark'] {
--ua-color-bg: #101216;
--ua-color-surface: #1a1c22;
--ua-color-text: #e7e9ec;
--ua-color-text-muted: #888780;
--ua-color-border: rgba(255, 255, 255, 0.15);
--ua-color-accent: #378add;
--ua-color-accent-contrast: #042c53;
--ua-color-danger: #d96a6a;
--ua-color-success: #4fc39e;
--ua-color-warning: #d9a94f;
--ua-color-bg: #101216; /* 暗色页面底色 */
--ua-color-surface: #1a1c22; /* 暗色表面色 */
--ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg)); /* 暗色表面二阶:悬停底、次级容器 */
--ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 10%, var(--ua-color-bg)); /* 暗色表面三阶:按下底、最深容器 */
--ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-bg)); /* 暗色凹陷区:输入框 / 代码块底 */
--ua-color-text: #e7e9ec; /* 暗色主文本色 */
--ua-color-text-muted: #888780; /* 暗色辅助文本色 */
--ua-color-text-secondary: #b4bac2; /* 暗色次级文本色 */
--ua-color-text-faint: #62676f; /* 暗色三级文本色:禁用、极弱说明 */
--ua-color-overlay: rgba(0, 0, 0, 0.6); /* 暗色遮罩:暗底上需加深才可分辨 */
--ua-color-overlay-strong: rgba(0, 0, 0, 0.7); /* 暗色强遮罩 */
--ua-color-border: rgba(255, 255, 255, 0.15); /* 暗色基础边框色:改用白色低透明度,避免黑边在暗底上消失 */
--ua-color-scrollbar: rgba(255, 255, 255, 0.24); /* 暗色滚动条拇指:暗底上黑拇指完全看不见,改白色低透明度 */
--ua-color-scrollbar-hover: rgba(255, 255, 255, 0.42); /* 暗色滚动条拇指悬停:亮度再升一档表示可拖拽 */
--ua-color-highlight: rgba(255, 255, 255, 0.08); /* 暗色内高光:暗底上白高光刺眼,降到 8% 只留一道细光 */
--ua-color-accent: #4a9aeb; /* 暗色强调色:提亮以保证暗底可读(原值压在白字/淡底上只有 3.92/4.44,过不了 AA,详见 ua audit:contrast) */
--ua-color-accent-contrast: #042c53; /* 暗色强调色上的文字色:深色反白,维持对比 */
--ua-color-accent-soft: color-mix(in srgb, var(--ua-color-accent) 82%, var(--ua-color-bg)); /* 暗色柔和强调色:与亮色同结构,混入暗底即自然降饱和 */
--ua-color-tint-accent: color-mix(in srgb, var(--ua-color-accent) 14%, transparent); /* 暗色淡底标准档:暗底上淡底不可见,透明度升一档 */
--ua-color-tint-accent-weak: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); /* 暗色淡底弱档:透明度升一档 */
--ua-color-danger: #d96a6a; /* 暗色危险色 */
--ua-color-success: #4fc39e; /* 暗色成功色 */
--ua-color-warning: #d9a94f; /* 暗色警告色 */
/* 暗色下投影需要更高黑度才看得见:亮色那套在暗底上几乎不可见 */
--ua-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3); /* 暗色 1 档:贴地投影 */
--ua-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.4); /* 暗色 2 档:下拉 / 气泡 */
--ua-shadow-3: 0 10px 28px rgba(0, 0, 0, 0.45); /* 暗色 3 档:浮层 / 悬浮面板 */
--ua-shadow-4: 0 18px 44px rgba(0, 0, 0, 0.5); /* 暗色 4 档:模态 / 抽屉 / 通知 */
--ua-shadow-accent: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 30%, transparent); /* 暗色主题色投影:透明度略升补可见性 */
--ua-shadow-accent-strong: 0 8px 20px color-mix(in srgb, var(--ua-color-accent) 40%, transparent); /* 暗色主题色强投影 */
--ua-shadow-highlight: inset 0 1px 0 var(--ua-color-highlight); /* 暗色内高光:值不变、靠 --ua-color-highlight 降到 8%,暗底上仍是一道细光 */
--ua-shadow-hairline: inset 0 -1px 0 var(--ua-border-subtle); /* 暗色描边细线:随 border-subtle 翻转 */
--ua-shadow-inset-border: inset 0 0 0 1px var(--ua-color-border); /* 暗色内框 */
--ua-shadow-inset-accent: inset 0 0 0 1px var(--ua-color-accent); /* 暗色强调内框 */
--ua-shadow-gap: 0 0 0 2px var(--ua-color-bg); /* 暗色断口:底色翻转后仍断开 */
--ua-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 34%, transparent); /* 暗色焦点环:透明度略升补对比 */
--ua-focus-ring-inset: inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 52%, transparent); /* 暗色内嵌焦点环 */
--ua-focus-ring-warning: 0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 34%, transparent); /* 暗色警告焦点环 */
--ua-focus-ring-danger: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 30%, transparent); /* 暗色错误强调环 */
--ua-focus-ring-pulse: 0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 8%, transparent); /* 暗色呼吸光晕:暗底上低透明度看不见,加倍 */
--ua-focus-ring-selected: 0 0 0 1.5px var(--ua-color-accent-soft); /* 暗色选中环:随 accent 翻转并与亮色同口径 */
--ua-focus-outline: 2px solid var(--ua-color-accent); /* 暗色外焦点描边:随 accent 翻转,与亮色同口径 */
--ua-focus-outline-inset: 1.5px solid var(--ua-color-accent-soft); /* 暗色内嵌焦点描边:accent-soft 随底色重混 */
--ua-focus-outline-soft: 2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent); /* 暗色柔和外描边:浓度档与亮色一致 */
}
/* ---- 高对比偏好:系统设置「提高对比度」时加深边界与弱文字 ----
为什么放文件末尾:媒体查询里的同选择器规则要靠后置胜出,写在暗色块之前会被顶掉。
只重设已有变量、不新增名字:verify 的颜色令牌 ↔ tokens.json 双向核对保持一一对应。 */
@media (prefers-contrast: more) {
:root {
--ua-color-border: rgba(0, 0, 0, 0.45); /* 边界加深:控件轮廓在强光 / 低视力下仍可辨(边框细分线随之加深,它是 border 的混色派生) */
--ua-color-text-muted: #4b5158; /* 辅助文字升级为次级文字色,说明文字不再是浅灰 */
--ua-color-text-faint: #6b7280; /* 三级文字升到原辅助档:禁用态也要读得清 */
--ua-color-scrollbar: rgba(0, 0, 0, 0.45); /* 滚动条拇指加深,可拖拽区域更明确 */
}
[data-theme='dark'] {
--ua-color-border: rgba(255, 255, 255, 0.45); /* 暗色同样加深:白边提亮而不是加深黑 */
--ua-color-text-muted: #b4bac2;
--ua-color-text-faint: #888780;
--ua-color-scrollbar: rgba(255, 255, 255, 0.5);
}
}
+272 -9
View File
@@ -1,14 +1,277 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"color": {
"bg": { "light": "#ffffff", "dark": "#101216" },
"surface": { "light": "#f6f7f9", "dark": "#1a1c22" },
"text": { "light": "#1c1e21", "dark": "#e7e9ec" },
"textMuted": { "light": "#6b7280", "dark": "#888780" },
"accent": { "light": "#185fa5", "dark": "#378add" },
"danger": "#a32d2d",
"success": "#0f6e56"
"bg": {
"light": "#ffffff",
"dark": "#101216"
},
"surface": {
"light": "#f6f7f9",
"dark": "#1a1c22"
},
"text": {
"light": "#1c1e21",
"dark": "#e7e9ec"
},
"textMuted": {
"light": "#676d78",
"dark": "#888780"
},
"border": {
"light": "rgba(0, 0, 0, 0.15)",
"dark": "rgba(255, 255, 255, 0.15)"
},
"scrollbar": {
"light": "rgba(0, 0, 0, 0.22)",
"dark": "rgba(255, 255, 255, 0.24)"
},
"scrollbarHover": {
"light": "rgba(0, 0, 0, 0.38)",
"dark": "rgba(255, 255, 255, 0.42)"
},
"highlight": {
"light": "rgba(255, 255, 255, 0.5)",
"dark": "rgba(255, 255, 255, 0.08)"
},
"accent": {
"light": "#185fa5",
"dark": "#4a9aeb"
},
"accentContrast": {
"light": "#ffffff",
"dark": "#042c53"
},
"danger": {
"light": "#a32d2d",
"dark": "#d96a6a"
},
"success": {
"light": "#0f6e56",
"dark": "#4fc39e"
},
"warning": {
"light": "#9a6b1f",
"dark": "#d9a94f"
},
"surface2": {
"light": "color-mix(in srgb, var(--ua-color-text) 4%, var(--ua-color-bg))",
"dark": "color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))"
},
"surface3": {
"light": "color-mix(in srgb, var(--ua-color-text) 8%, var(--ua-color-bg))",
"dark": "color-mix(in srgb, var(--ua-color-text) 10%, var(--ua-color-bg))"
},
"surfaceInset": {
"light": "color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))",
"dark": "color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-bg))"
},
"textSecondary": {
"light": "#4b5158",
"dark": "#b4bac2"
},
"textFaint": {
"light": "#9aa0a8",
"dark": "#62676f"
},
"overlay": {
"light": "rgba(0, 0, 0, 0.45)",
"dark": "rgba(0, 0, 0, 0.6)"
},
"overlayStrong": {
"light": "rgba(0, 0, 0, 0.6)",
"dark": "rgba(0, 0, 0, 0.7)"
},
"tintAccent": {
"light": "color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-soft), transparent)",
"dark": "color-mix(in srgb, var(--ua-color-accent) 14%, transparent)"
},
"tintAccentWeak": {
"light": "color-mix(in srgb, var(--ua-color-accent) 5%, transparent)",
"dark": "color-mix(in srgb, var(--ua-color-accent) 8%, transparent)"
},
"accentSoft": {
"light": "color-mix(in srgb, var(--ua-color-accent) 72%, var(--ua-color-bg))",
"dark": "color-mix(in srgb, var(--ua-color-accent) 82%, var(--ua-color-bg))"
}
},
"radius": { "sm": "4px", "md": "8px", "lg": "12px" },
"space": { "1": "4px", "2": "8px", "3": "12px", "4": "16px" }
"alpha": {
"soft": "10%",
"tint": "18%",
"line": "30%",
"mid": "45%",
"strong": "55%",
"heavy": "70%"
},
"radius": {
"sm": "4px",
"md": "8px",
"lg": "12px",
"mdInset": "calc(var(--ua-radius-md) - 1px)"
},
"space": {
"1": "4px",
"2": "8px",
"3": "12px",
"4": "16px",
"15": "6px",
"25": "10px",
"35": "14px",
"05": "2px",
"6": "24px",
"indent": "22px"
},
"fontSize": {
"xs": "11px",
"sm": "12px",
"md": "13px",
"lg": "14px",
"xl": "16px",
"2xl": "18px",
"3xl": "22px",
"4xl": "26px",
"5xl": "34px",
"scale-md": "0.92em",
"scale-sm": "0.85em"
},
"weight": {
"normal": 400,
"medium": 500,
"semibold": 600,
"bold": 700
},
"tracking": {
"wide": "0.04em",
"tight": "-0.01em"
},
"leading": {
"tight": 1.35,
"normal": 1.55,
"none": 1
},
"border": {
"subtle": "color-mix(in srgb, var(--ua-color-border) 60%, transparent)"
},
"shadow": {
"1": {
"light": "0 1px 2px rgba(0, 0, 0, 0.06)",
"dark": "0 1px 2px rgba(0, 0, 0, 0.3)"
},
"2": {
"light": "0 4px 12px rgba(0, 0, 0, 0.1)",
"dark": "0 4px 12px rgba(0, 0, 0, 0.4)"
},
"3": {
"light": "0 10px 28px rgba(0, 0, 0, 0.12)",
"dark": "0 10px 28px rgba(0, 0, 0, 0.45)"
},
"4": {
"light": "0 18px 44px rgba(0, 0, 0, 0.14)",
"dark": "0 18px 44px rgba(0, 0, 0, 0.5)"
},
"accent": {
"light": "0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent)",
"dark": "0 3px 10px color-mix(in srgb, var(--ua-color-accent) 30%, transparent)"
},
"accentStrong": {
"light": "0 8px 20px color-mix(in srgb, var(--ua-color-accent) 35%, transparent)",
"dark": "0 8px 20px color-mix(in srgb, var(--ua-color-accent) 40%, transparent)"
},
"highlight": {
"light": "inset 0 1px 0 var(--ua-color-highlight)",
"dark": "inset 0 1px 0 var(--ua-color-highlight)"
},
"hairline": {
"light": "inset 0 -1px 0 var(--ua-border-subtle)",
"dark": "inset 0 -1px 0 var(--ua-border-subtle)"
},
"insetBorder": {
"light": "inset 0 0 0 1px var(--ua-color-border)",
"dark": "inset 0 0 0 1px var(--ua-color-border)"
},
"insetAccent": {
"light": "inset 0 0 0 1px var(--ua-color-accent)",
"dark": "inset 0 0 0 1px var(--ua-color-accent)"
},
"gap": {
"light": "0 0 0 2px var(--ua-color-bg)",
"dark": "0 0 0 2px var(--ua-color-bg)"
}
},
"motion": {
"duration": {
"fast": "0.15s",
"base": "0.2s",
"slow": "0.25s",
"slower": "0.35s",
"loopFast": "0.6s",
"loopMid": "0.8s",
"loopSlow": "1.2s",
"loopLong": "1.6s",
"step": "0.02s"
},
"ease": "cubic-bezier(0.2, 0.8, 0.2, 1)",
"easeOut": "cubic-bezier(0.16, 1, 0.3, 1)",
"easeSpring": "cubic-bezier(0.34, 1.3, 0.5, 1)"
},
"translate": {
"1": "1px",
"2": "2px",
"3": "3px",
"4": "4px",
"6": "6px",
"8": "8px",
"12": "12px"
},
"focus": {
"ring": {
"light": "0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 22%, transparent)",
"dark": "0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 34%, transparent)"
},
"ringInset": {
"light": "inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent)",
"dark": "inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 52%, transparent)"
},
"ringWarning": {
"light": "0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 25%, transparent)",
"dark": "0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 34%, transparent)"
},
"ringDanger": {
"light": "0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 18%, transparent)",
"dark": "0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 30%, transparent)"
},
"ringPulse": {
"light": "0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 4%, transparent)",
"dark": "0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 8%, transparent)"
},
"ringSelected": {
"light": "0 0 0 2px var(--ua-color-accent)",
"dark": "0 0 0 2px var(--ua-color-accent)"
},
"outline": {
"light": "2px solid var(--ua-color-accent)",
"dark": "2px solid var(--ua-color-accent)"
},
"outlineInset": {
"light": "1.5px solid var(--ua-color-accent-soft)",
"dark": "1.5px solid var(--ua-color-accent-soft)"
},
"outlineSoft": {
"light": "2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent)",
"dark": "2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent)"
}
},
"control": {
"height": {
"sm": "28px",
"md": "34px",
"lg": "40px"
}
},
"layer": {
"dropdown": 100,
"sticky": 200,
"overlay": 300,
"modal": 400,
"toast": 500
}
}
+40
View File
@@ -0,0 +1,40 @@
/*** 皮肤包冒烟:只测纯逻辑(今日覆盖行级差异对齐),DOM 渲染靠演示页人工过 ***/
import { alignMiddle, diffLines, normalizeLine, toRows, DP_LIMIT } from '../dist/diffKit.js';
let failed = 0;
/*** 断言:打印一条结果,失败计数 +1 ***/
const ok = (label, cond) => {
if (cond) { console.log(`ok - ${label}`); return; }
console.error(`FAIL - ${label}`);
failed += 1;
};
// 1) 完全相同:全是 same,没有增删
const same = diffLines('a\nb\nc', 'a\nb\nc', false);
ok('相同文本全是 same', same.length === 3 && same.every((o) => o.type === 'same'));
// 2) 纯新增一行
const added = diffLines('a\nc', 'a\nb\nc', false);
ok('中间插入一行 → 恰好 1 条 add', added.filter((o) => o.type === 'add').length === 1 && added.filter((o) => o.type === 'del').length === 0);
// 3) 纯删除一行
const removed = diffLines('a\nb\nc', 'a\nc', false);
ok('中间删除一行 → 恰好 1 条 del', removed.filter((o) => o.type === 'del').length === 1 && removed.filter((o) => o.type === 'add').length === 0);
// 4) 尾部换行不产生假差异
ok('尾部换行不算差异', diffLines('a\nb\n', 'a\nb', false).every((o) => o.type === 'same'));
// 5) 忽略行内空白:只差缩进时无增删,且显示保留原文
const ws = diffLines('if (a) {\n return 1;\n}', 'if (a) {\n return 1;\n}', true);
ok('忽略空白后缩进差不计入差异', ws.every((o) => o.type === 'same') && ws[1].text.includes(' '));
// 6) 不忽略空白时同一对文本必须报差异(对照组:证明开关真的起作用)
ok('不忽略空白时同一对文本报增删', diffLines('if (a) {\n return 1;\n}', 'if (a) {\n return 1;\n}', false).some((o) => o.type !== 'same'));
// 7) 行内多空格归一
ok('行内连续空白压成一个空格', normalizeLine(' a b\t c ') === 'a b c');
// 8) 超限退化:仍保证「旧行全 del + 新行全 add」,不丢内容
const bigA = Array.from({ length: 700 }, (_, i) => `a${i}`);
const bigB = Array.from({ length: 700 }, (_, i) => `b${i}`);
const big = alignMiddle(toRows(bigA.join('\n'), false), toRows(bigB.join('\n'), false));
ok(`超限(>${DP_LIMIT})退化为整段替换`, big.filter((o) => o.type === 'del').length === 700 && big.filter((o) => o.type === 'add').length === 700);
if (failed) {
console.error(`皮肤冒烟失败 ${failed} 条`);
process.exit(1);
}
console.log('SKIN SMOKE OK');
+21 -1
View File
@@ -51,5 +51,25 @@ export function accordion(props: AccordionProps): HTMLElement {
),
);
return h('div', { class: 'ua-accordion' }, ...rows);
const root = h('div', { class: 'ua-accordion' }, ...rows);
// —— 键盘导航:↑↓ 行间环形移动、Home/End 首尾直达,均跳过禁用行(对齐 tabs/tree 约定) ——
const heads = (): HTMLButtonElement[] => rows.map((r) => r.firstElementChild as HTMLButtonElement); // 各行标题按钮
const focusFrom = (start: number, dir: 1 | -1): void => {
const len = heads().length;
for (let n = 1; n <= len; n++) {
const cand = heads()[(((start + dir * n) % len) + len) % len]; // 环形取候选:越界回绕保证落在 [0, len)
if (!cand.disabled) { cand.focus(); return; } // 跳过禁用行,绕满一圈即止
}
};
root.addEventListener('keydown', (e: KeyboardEvent) => {
const list = heads();
const idx = list.indexOf(document.activeElement as HTMLButtonElement);
if (idx < 0) return; // 卫语句:焦点不在标题行
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); focusFrom(idx, e.key === 'ArrowDown' ? 1 : -1); }
else if (e.key === 'Home') { e.preventDefault(); focusFrom(-1, 1); } // 从头正向找第一个可用
else if (e.key === 'End') { e.preventDefault(); focusFrom(0, -1); } // 从尾反向找最后一个可用
});
return root;
}
+13
View File
@@ -0,0 +1,13 @@
import { h } from '@ui-arch/frame';
/*** 输入附加块包装:prefix/suffix 存在时把控件包进附加组,否则原样返回(零侵入,结构不变) ***/
export function addonWrap(
el: HTMLElement,
prefix?: string | HTMLElement,
suffix?: string | HTMLElement,
column = false, // 多行控件用纵向布局:prefix 在上、suffix 在下
): HTMLElement {
if (!prefix && !suffix) return el; // 卫语句:无附加不包裹
const addon = (v?: string | HTMLElement) => (v == null ? null : h('span', { class: 'ua-input-addon' }, v));
return h('div', { class: column ? 'ua-input-group ua-input-group-column' : 'ua-input-group' }, addon(prefix), el, addon(suffix));
}
+45
View File
@@ -0,0 +1,45 @@
import { h } from '@ui-arch/frame';
import type { AffixProps } from './types.js';
import { watchViewport } from './watch.js';
/*** 固钉:内容滚动越过阈值后转 fixed 贴住视口边缘(top / bottom),外层保留占位高度避免布局跳动 ***/
export function affix(props: AffixProps, ...children: unknown[]): HTMLElement {
// —— 状态集中声明 ——
const offset = props.offset ?? 0; // 触发固定的阈值
const atBottom = props.position === 'bottom'; // 贴底还是贴顶(底部适合操作条)
const scroller = props.target ?? window; // 滚动容器
const inner = h('div', { class: 'ua-affix-inner' }, ...children) as HTMLElement; // 真正会被固定的内容
const wrap = h('div', { class: 'ua-affix' }, inner) as HTMLElement; // 占位容器,固定时撑住原位
let fixed = false; // 当前是否已固定,避免每次滚动都重写样式
/*** 同步固定态:越过阈值即 fixed + 撑占位高度,回到阈值以上就还原 ***/
const sync = (): void => {
const box = wrap.getBoundingClientRect(); // 占位容器几何:left / width 决定固定后的横向位置
const should = atBottom ? box.bottom >= window.innerHeight - offset : box.top <= offset; // 贴底:占位落到阈值线下即固定
if (should === fixed) {
if (fixed) { // 固定中:宽度与横向位置跟随容器(窗口缩放 / 父容器变宽)
inner.style.width = `${box.width}px`;
inner.style.left = `${box.left}px`;
}
return; // 卫语句:状态未变不重写其余样式
}
fixed = should;
if (fixed) {
wrap.style.height = `${inner.offsetHeight}px`; // 先撑住占位,再让 inner 脱离文档流
inner.classList.add('is-fixed');
if (atBottom) inner.style.bottom = `${offset}px`; // 贴底:固定边距写 bottom,top 保持空
else inner.style.top = `${offset}px`;
inner.style.left = `${box.left}px`;
inner.style.width = `${box.width}px`;
} else {
inner.classList.remove('is-fixed');
inner.style.top = '';
inner.style.bottom = '';
inner.style.width = '';
wrap.style.height = '';
}
};
watchViewport(wrap, scroller, sync); // 滚动 / 缩放后重算,节点脱离文档即自摘监听
return wrap;
}
+11 -2
View File
@@ -1,15 +1,24 @@
import { h } from '@ui-arch/frame';
import type { AlertProps } from './types.js';
/*** 提示条:variant 决定语义色,可选标题与关闭按钮;关闭即退场并从 DOM 移除(内容由调用方持有) ***/
const DEFAULT_ICON: Record<string, string> = { info: 'ℹ', success: '✓', warning: '!', danger: '✕' }; // 语义缺省图标
/*** 提示条:variant 决定语义色,可选标题/语义图标/关闭按钮;banner 为通栏形态,关闭即退场并从 DOM 移除 ***/
export function alert(props: AlertProps): HTMLElement {
const variant = props.variant ?? 'info'; // 语义档位
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);
const glyph = props.icon ?? (props.showIcon ? DEFAULT_ICON[variant] : null); // 自定义图标优先
const el = h(
'div',
{ class: `ua-alert ua-alert-${variant}${props.banner ? ' is-banner' : ''}`, role: 'alert' },
glyph ? h('span', { class: 'ua-alert-icon', 'aria-hidden': 'true' }, glyph) : null, // 图标纯装饰,读屏靠正文
body,
);
if (!props.closable) return el; // 纯展示形态
el.append(
+76
View File
@@ -0,0 +1,76 @@
import { h } from '@ui-arch/frame';
import type { AnchorItem, AnchorProps } from './types.js';
import { watchViewport } from './watch.js';
/*** 锚点导航:竖排区块链接,滚动时高亮当前区块,点击平滑跳转(尊重减少动效偏好) ***/
export function anchor(props: AnchorProps): HTMLElement {
// —— 状态集中声明 ——
const items = props.items; // 锚点项,href 形如 #sec-xxx
const offset = props.offset ?? 0; // 目标顶部预留距离(吸顶栏高度)
const scroller = props.container instanceof HTMLElement ? props.container : null; // 内滚容器:null 表示整页滚动
const links: HTMLAnchorElement[] = []; // 与 items 一一对应的链接节点
const root = h('nav', { class: 'ua-anchor', 'aria-label': props.label ?? '锚点导航' });
/*** 目标元素:href 去掉 # 后按 id 查找,找不到返回 null ***/
const targetOf = (href: string): HTMLElement | null => document.getElementById(href.replace(/^#/, ''));
/*** 目标相对判定原点的垂直坐标:整页用视口坐标,容器用容器内偏移 ***/
const topOf = (el: HTMLElement): number => (scroller
? el.getBoundingClientRect().top - scroller.getBoundingClientRect().top
: el.getBoundingClientRect().top);
/*** 当前区块:取最后一个已越过判定线的目标,全未越过时落到首项 ***/
const current = (): AnchorItem | undefined => {
let hit: AnchorItem | undefined = items[0];
for (const item of items) {
const el = targetOf(item.href);
if (!el) continue; // 卫语句:目标不存在就跳过,不打断整表判定
if (topOf(el) - offset <= 1) hit = item;
}
return hit;
};
/*** 同步高亮:类名与 aria-current 一起切换,读屏能念出当前所在区块 ***/
const sync = (): void => {
const hit = current();
links.forEach((link, i) => {
const on = items[i] === hit;
link.classList.toggle('is-active', on);
if (on) link.setAttribute('aria-current', 'true');
else link.removeAttribute('aria-current');
});
};
/*** 跳转:按偏移量滚动到位并回写 hash,减少动效偏好下改瞬移 ***/
const jump = (item: AnchorItem): void => {
const el = targetOf(item.href);
if (!el) return; // 卫语句:目标不在页面上就什么都不做
const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto';
const top = topOf(el) + (scroller ? scroller.scrollTop : window.scrollY) - offset;
if (scroller) scroller.scrollTo({ top, behavior });
else window.scrollTo({ top, behavior });
history.replaceState(null, '', item.href); // 用 replaceState:跳转不堆积历史条目
props.onChange?.(item.href);
sync();
};
for (const item of items) {
const link = h('a', {
class: item.disabled ? 'ua-anchor-link is-disabled' : 'ua-anchor-link',
href: item.href,
'aria-disabled': item.disabled ? 'true' : undefined,
onClick: (e: Event) => {
e.preventDefault(); // 一律接管:自绘偏移量,不走浏览器默认锚点定位
if (item.disabled) return; // 卫语句:禁用项只吞掉点击
jump(item);
},
}, item.title) as HTMLAnchorElement;
links.push(link);
root.append(link);
}
const host: HTMLElement | Window = scroller ?? window;
watchViewport(root, host, sync); // 滚动 / 缩放后同步高亮,节点脱离文档即自摘监听
return root;
}
+113
View File
@@ -0,0 +1,113 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { AutoCompleteOption, AutoCompleteProps } from './types.js';
let seq = 0; // 实例序号:给弹层列表一个稳定 id,供 aria-controls 关联
/*** 自动补全:输入即过滤候选项,↑↓ 高亮 / Enter 选中 / Esc 收起,value 信号双向绑定 ***/
export function autoComplete(props: AutoCompleteProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string> = props.value ?? signal(''); // 输入值
const open = signal(false); // 弹层开合
const query = signal(''); // 当前过滤关键字(与 value 同步,独立信号便于受控写入)
const active = signal(-1); // 键盘高亮下标,-1 表示无高亮
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
const max = props.maxItems ?? 8;
/*** 按关键字取候选:函数式入参交给调用方计算(可远程联想),结果截断到 maxItems ***/
const candidates = (): AutoCompleteOption[] => {
const all = typeof props.options === 'function' ? props.options(query()) : props.options;
const q = query().trim().toLowerCase();
const hit = q ? all.filter((o) => o.value.toLowerCase().includes(q)) : all; // 包含匹配,大小写不敏感
return hit.slice(0, max);
};
const input = h('input', {
type: 'text',
class: 'ua-autocomplete-input',
placeholder: props.placeholder ?? '',
'aria-label': props.placeholder ?? '自动补全',
role: 'combobox',
'aria-expanded': () => String(open()),
'aria-autocomplete': 'list',
disabled: props.disabled,
value: () => value(), // 外部写入信号时回填输入框
oninput: (e: Event) => {
const v = (e.target as HTMLInputElement).value;
value(v);
query(v);
active(-1);
open(v.trim().length > 0); // 空串收起,避免开屏就弹
props.onInput?.(v);
},
}) as HTMLInputElement;
const list = h('ul', { class: 'ua-autocomplete-list', role: 'listbox', id: `ua-ac-${(seq += 1)}` }) as HTMLElement;
input.setAttribute('aria-controls', list.id);
const pop = h('div', { class: () => `ua-autocomplete-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement;
const root = h('div', { class: 'ua-autocomplete' }, input, pop) as HTMLElement;
/*** 选中候选:写值 + 收起 + 回调,焦点回到输入框便于继续输入 ***/
const pick = (opt: AutoCompleteOption): void => {
value(opt.value);
query(opt.value);
open(false);
active(-1);
props.onSelect?.(opt);
input.focus();
};
/*** 重绘候选行:高亮项由 active 决定,空结果渲染提示行 ***/
const render = (): void => {
const opts = candidates();
if (!opts.length) {
list.replaceChildren(h('li', { class: 'ua-autocomplete-empty' }, props.emptyText ?? '无匹配项'));
input.removeAttribute('aria-activedescendant');
return;
}
const rows = opts.map((opt, i) => {
const row = h(
'li',
{
class: () => `ua-autocomplete-opt${active() === i ? ' is-active' : ''}`,
role: 'option',
id: `${list.id}-${i}`,
'aria-selected': String(active() === i),
onmousedown: (e: Event) => { e.preventDefault(); pick(opt); }, // 阻止默认:保住输入框焦点,blur 后仍能选中
},
h('span', { class: 'ua-autocomplete-value' }, opt.value),
opt.hint ? h('span', { class: 'ua-autocomplete-hint' }, opt.hint) : null,
);
return row;
});
list.replaceChildren(...rows);
const cur = rows[active()];
if (cur) input.setAttribute('aria-activedescendant', cur.id); // 读屏跟随高亮
else input.removeAttribute('aria-activedescendant');
};
root.addEventListener('keydown', (e: KeyboardEvent) => {
const opts = candidates();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); // 阻止光标跳行
if (!open() && opts.length) { open(true); active(0); return; } // 未展开:先展开并高亮首项
const dir = e.key === 'ArrowDown' ? 1 : -1;
active((active() + dir + opts.length) % opts.length); // 环形移动
return;
}
if (e.key === 'Enter' && open() && active() >= 0) { e.preventDefault(); pick(opts[active()]); return; } // 有高亮才回车选中
if (e.key === 'Tab') { open(false); return; } // 失焦前收起,避免残留浮层
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render); // 依赖 query / active / options,任一变化即重绘
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
else { releaseEsc?.(); releaseEsc = null; }
});
effect(() => { if (open()) place(flip(input, pop, 'bottom')); }); // 空间不足自动翻转
return root;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export function avatar(props: AvatarProps): HTMLElement {
{ 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)),
: h('span', { class: 'ua-avatar-text', role: 'img', 'aria-label': props.name }, firstChar(props.name)), // 文字头像补 role=img,aria-label 才会被读屏当名称播报
);
}
+22
View File
@@ -0,0 +1,22 @@
import { h } from '@ui-arch/frame';
import { avatar } from './avatar.js';
import type { AvatarGroupProps } from './types.js';
/*** 头像组:横向叠放(负边距 + 底色描边切口),超出 max 折叠成 +N,整组 title 列出全部姓名 ***/
export function avatarGroup(props: AvatarGroupProps): HTMLElement {
const size = props.size ?? 32; // 直径与单个 avatar 一致
const max = Math.max(props.max ?? 4, 1); // 至少留一个位置,避免传 0 后只剩 +N
const shown = props.items.slice(0, max); // 展示前 max 个
const rest = props.items.length - shown.length;
const group = h('div', {
class: 'ua-avatar-group',
title: props.items.map((item) => item.name).join('、'), // 折叠后仍能看到全部成员
}, ...shown.map((item) => avatar({ name: item.name, src: item.src, variant: item.variant, size })));
if (rest <= 0) return group; // 卫语句:没超上限就不渲染 +N
group.append(h('span', {
class: 'ua-avatar ua-avatar-more',
style: `width:${size}px;height:${size}px;font-size:${Math.round(size * 0.38)}px`,
'aria-label': `还有 ${rest} 人`, // 纯文本 +N 读屏会念成「加 3」,补可访问名
}, `+${rest}`));
return group;
}
+45
View File
@@ -0,0 +1,45 @@
import { h } from '@ui-arch/frame';
import type { BackTopProps } from './types.js';
import { watchViewport } from './watch.js';
/*** 返回顶部:滚动越过阈值浮现,环形进度显示已滚比例,点击平滑回顶(尊重减少动效偏好) ***/
export function backTop(props: BackTopProps = {}): HTMLElement {
// —— 状态集中声明 ——
const threshold = props.threshold ?? 300; // 浮现阈值
const scroller = props.target ?? window; // 滚动容器
const el = props.target instanceof HTMLElement ? props.target : null; // 元素容器:null 表示整页
const ring = h('span', { class: 'ua-backtop-ring', 'aria-hidden': 'true' }); // 进度环(纯装饰)
const btn = h(
'button',
{ type: 'button', class: 'ua-backtop', 'aria-label': '回到顶部', onClick: () => jump() },
ring,
h('span', { class: 'ua-backtop-icon', 'aria-hidden': 'true' }, '↑'),
) as HTMLButtonElement;
/*** 当前滚动位置:整页用 window.scrollY,容器用 scrollTop ***/
const offset = (): number => (el ? el.scrollTop : window.scrollY);
/*** 可滚动总距离:不足 0 时按 0 处理,避免除零与负比例 ***/
const distance = (): number => (el ? el.scrollHeight - el.clientHeight : document.documentElement.scrollHeight - window.innerHeight) || 0;
/*** 同步浮现态与进度环:仅在阈值上下切换时才改类名,减少样式重算 ***/
const sync = (): void => {
const y = offset();
const total = distance();
btn.classList.toggle('is-visible', y > threshold); // 越过阈值才浮现
if (props.progress === false) return; // 卫语句:关掉进度环就不再算角度
const deg = total > 0 ? Math.min(Math.round((y / total) * 360), 360) : 0;
ring.style.background = `conic-gradient(var(--ua-color-accent-contrast) ${deg}deg, transparent 0)`; // 已滚比例用纯色弧
};
/*** 回顶:平滑滚动,用户偏好减少动效时直接跳转 ***/
const jump = (): void => {
const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (el) el.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
else window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
props.onClick?.();
};
watchViewport(btn, scroller, sync); // 滚动 / 缩放后同步浮现态,节点脱离文档即自摘监听
return btn;
}
+12 -2
View File
@@ -1,7 +1,17 @@
import { h } from '@ui-arch/frame';
import type { BadgeProps } from './types.js';
/*** 徽标:variant 决定语义色,纯展示不持有状态 ***/
/*** 徽标:文字 / 数字角标 / 纯红点三种形态,variant 决定语义色,纯展示不持有状态 ***/
export function badge(props: BadgeProps): HTMLElement {
return h('span', { class: `ua-badge ua-badge-${props.variant ?? 'default'}` }, props.label);
const tone = `ua-badge ua-badge-${props.variant ?? 'default'}`;
if (props.dot) {
// 红点:无可见文字,靠 aria-label 给出含义(如「有未读」)
return h('span', { class: `${tone} ua-badge-dot`, role: 'img', 'aria-label': props.label ?? '有更新' });
}
if (props.count != null) {
const max = props.max ?? 99; // 缺省上限 99
const shown = props.count > max ? `${max}+` : String(Math.max(0, props.count)); // 负数归零,超限折成 N+
return h('span', { class: `${tone} ua-badge-count`, 'aria-label': props.label ? `${props.label} ${shown}` : undefined }, shown);
}
return h('span', { class: tone }, props.label);
}
+34 -13
View File
@@ -1,20 +1,41 @@
import { h } from '@ui-arch/frame';
import { effect, h, signal } from '@ui-arch/frame';
import type { BreadcrumbProps } from './types.js';
/*** 面包屑:nav + ol 语义结构,末项为当前页(不可点、aria-current);有 href 渲染 <a>,无 href 渲染 button 交 onNavigate ***/
/*** 面包屑:nav + ol 语义结构,末项为当前页(不可点、aria-current);条目超过 maxItems 时把中段折叠成「…」按钮 ***/
export function breadcrumb(props: BreadcrumbProps): HTMLElement {
// —— 状态集中声明 ——
const items = props.items;
const sep = props.separator ?? '/';
const sep = props.separator ?? '/'; // 分隔符
const max = props.maxItems ?? Number.POSITIVE_INFINITY; // 展示条数上限:超出折叠中段
const expanded = signal(false); // 折叠段是否已展开(展开后不再折叠)
const list = h('ol', { class: 'ua-crumb-list' });
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));
});
/*** 逐项渲染:有 href 用 <a>,无 href 用 button 交 onNavigate;折叠按钮紧跟首项插入一次 ***/
const render = (): void => {
const fold = !expanded() && items.length > max; // 是否需要折叠
const tail = Math.max(2, max - 1); // 尾部保留条数:至少当前页 + 上一级
const kept = fold ? [0, ...items.slice(-tail).map((_, k) => items.length - tail + k)] : items.map((_, i) => i);
const nodes: HTMLElement[] = [];
kept.forEach((idx, pos) => {
const item = items[idx];
const last = idx === 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, idx) }, item.label);
nodes.push(h('li', { class: 'ua-crumb-item' }, node, last ? null : h('span', { class: 'ua-crumb-sep', 'aria-hidden': 'true' }, sep)));
if (!fold || pos > 0) return; // 卫语句:折叠按钮只插一次
const hidden = items.length - kept.length; // 被折叠的条数
nodes.push(h('li', { class: 'ua-crumb-item' }, h(
'button',
{ type: 'button', class: 'ua-crumb-fold', 'aria-label': `展开折叠的 ${hidden} 项`, onClick: () => expanded(true) },
'…',
), h('span', { class: 'ua-crumb-sep', 'aria-hidden': 'true' }, sep)));
});
list.replaceChildren(...nodes);
};
return h('nav', { class: 'ua-breadcrumb', 'aria-label': '面包屑导航' }, h('ol', { class: 'ua-crumb-list' }, ...entries));
effect(render); // expanded 变化即重绘
return h('nav', { class: 'ua-breadcrumb', 'aria-label': '面包屑导航' }, list);
}
+15 -4
View File
@@ -1,11 +1,22 @@
import { h } from '@ui-arch/frame';
import { effect, h, signal } from '@ui-arch/frame';
import type { ButtonProps } from './types.js';
/*** 按钮:variant 控制视觉强度,disabled 支持布尔或信号禁用,onClick 为点击回调 ***/
/*** 按钮:variant 控制视觉强度,disabled / loading 支持布尔或信号动态同步(loading 转圈并禁点),onClick 为点击回调 ***/
export function button(props: ButtonProps): HTMLElement {
return h(
const loading = typeof props.loading === 'function' ? props.loading : signal(!!props.loading); // 归一成信号:布尔也走同一响应式路径
const spin = h('span', { class: 'ua-spinner ua-btn-spin', 'aria-hidden': 'true' }); // 转圈指示:CSS 默认 display:none,加载态才显形
const el = h(
'button',
{ class: `ua-btn ua-btn-${props.variant ?? 'primary'}`, disabled: props.disabled, onClick: props.onClick }, // false/undefined 移除属性,Signal 走响应式
{
class: `ua-btn ua-btn-${props.variant ?? 'primary'}${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`,
type: props.type ?? 'button',
disabled: () => (typeof props.disabled === 'function' ? props.disabled() : !!props.disabled) || loading(), // 禁点口径:显式禁用或加载中
'aria-busy': () => (loading() ? 'true' : null), // 读屏播报忙碌态(null 移除属性)
onClick: props.onClick,
}, // 缺省 button:表单内不隐式提交
spin,
props.label,
);
effect(() => el.classList.toggle('is-loading', loading())); // 加载态只切类名,不整棵重渲染(保住焦点与子节点)
return el;
}
+89
View File
@@ -0,0 +1,89 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { CalendarProps } from './types.js';
import { fmt, monthGrid, pad, parse, shiftDay, today } from './dateKit.js';
/*** 月历:整月网格直接内嵌在页面里(排期表 / 值班表 / 日程选择),每日可带标记文案,方向键漫游 + Enter 选中 ***/
export function calendar(props: CalendarProps): HTMLElement {
// —— 状态集中声明 ——
const value = props.value ?? signal(''); // 选中日期(YYYY-MM-DD)
const from = parse(value()) ?? today(); // 初始定位:跟随选中值,否则今天
const month = props.month ?? signal(`${from[0]}-${pad(from[1] + 1)}`); // 展示月份(YYYY-MM)
const focus = signal(value() || fmt(...today())); // 键盘漫游焦点日
const grid = h('div', { class: 'ua-calendar-grid', role: 'grid' });
const label = h('span', { class: 'ua-calendar-label' });
/*** 当前展示的年月:月份串非法时回落到初始定位 ***/
const view = (): [number, number] => {
const hit = /^(\d{4})-(\d{2})$/.exec(month());
return hit ? [Number(hit[1]), Number(hit[2]) - 1] : [from[0], from[1]];
};
/*** 翻月:只改展示,不动选中值;跨年自动进位 ***/
const turn = (delta: number): void => {
const [y, m] = view();
const d = new Date(y, m + delta, 1);
const next = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
month(next);
props.onMonthChange?.(next);
};
/*** 选择:写回信号 + 回调,焦点同步到该日 ***/
const pick = (date: string): void => {
value(date);
focus(date);
props.onSelect?.(date);
};
/*** 重绘网格:月初留白、今天与选中分别打标,漫游 tabindex 保证 Tab 只进一次 ***/
const render = (): void => {
const [y, m] = view();
const { lead, total } = monthGrid(y, m);
const cells: HTMLElement[] = Array.from({ length: lead }, () => h('span', { class: 'ua-calendar-blank' }));
const nowKey = fmt(...today());
for (let d = 1; d <= total; d++) {
const date = fmt(y, m, d);
const mark = props.marker?.(date) ?? null;
const cls = `ua-calendar-day${date === value() ? ' is-selected' : ''}${date === nowKey ? ' is-today' : ''}`;
cells.push(h('button', {
type: 'button',
class: cls,
role: 'gridcell',
'aria-selected': String(date === value()),
tabindex: date === focus() ? '0' : '-1',
'aria-label': mark ? `${date},${mark}` : date, // 标记文案同时进无障碍名,读屏也能听到「几项」
onClick: () => pick(date),
}, h('span', { class: 'ua-calendar-num' }, String(d)), mark ? h('span', { class: 'ua-calendar-mark' }, mark) : null));
}
grid.replaceChildren(...cells);
label.textContent = `${y} 年 ${m + 1} 月`;
};
// —— 键盘:←→ 逐日、↑↓ 逐周、Enter / 空格 选中;跨月自动翻页后把焦点交给新网格 ——
grid.addEventListener('keydown', (e: KeyboardEvent) => {
const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
if (!step[e.key]) {
if (e.key !== 'Enter' && e.key !== ' ') return; // 卫语句:只接管选中键
e.preventDefault();
pick(focus());
return;
}
e.preventDefault();
const next = shiftDay(focus(), step[e.key]);
const p = parse(next) ?? today();
focus(next);
const [y, m] = view();
if (p[0] !== y || p[1] !== m) month(`${p[0]}-${pad(p[1] + 1)}`); // 跨月:先翻页,重绘交给 effect
requestAnimationFrame(() => (grid.querySelector('.ua-calendar-day[tabindex="0"]') as HTMLElement | null)?.focus());
});
const head = h(
'div',
{ class: 'ua-calendar-head' },
h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '上一月', onClick: () => turn(-1) }, '‹'),
label,
h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '下一月', onClick: () => turn(1) }, '›'),
);
const week = h('div', { class: 'ua-calendar-week' }, ...['日', '一', '二', '三', '四', '五', '六'].map((w) => h('span', {}, w)));
effect(render); // month / value / focus 任一变化即重绘
return h('div', { class: 'ua-calendar' }, head, week, grid);
}
+14 -4
View File
@@ -1,12 +1,22 @@
import { h } from '@ui-arch/frame';
import type { CardProps } from './types.js';
/*** 卡片容器:可选标题 + 任意子内容 ***/
/*** 卡片容器:可选标题行(含右侧操作区)+ 任意子内容 + 可选底部操作区,bordered / hoverable 控制外观 ***/
export function card(props: CardProps, ...children: unknown[]): HTMLElement {
const cls = `ua-card${props.bordered === false ? ' ua-card-borderless' : ''}${props.hoverable ? ' ua-card-hoverable' : ''}`;
const head = props.title || props.extra
? h(
'div',
{ class: 'ua-card-head' },
props.title ? h('h3', { class: 'ua-card-title' }, props.title) : h('span', {}), // 无标题时占位,保证操作区仍靠右
props.extra ? h('span', { class: 'ua-card-extra' }, props.extra) : null,
)
: null;
return h(
'div',
{ class: 'ua-card' },
props.title ? h('h3', { class: 'ua-card-title' }, props.title) : null,
{ class: cls },
head,
...children,
);
props.footer ? h('div', { class: 'ua-card-foot' }, props.footer) : null, // 底部操作区:右对齐,仅传了才渲染
) as HTMLElement;
}
+68
View File
@@ -0,0 +1,68 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { CarouselProps } from './types.js';
/*** 轮播:items 横排 + translateX 位移;指示点 / 左右箭头 / ←→ 键切换,autoplay 毫秒自动前进且悬停暂停 ***/
export function carousel(props: CarouselProps): HTMLElement {
// —— 状态集中声明 ——
const total = props.items.length;
const index = props.index ?? signal(0); // 未传外部信号时用内部信号,行为一致
const wrap = (i: number) => (total ? ((i % total) + total) % total : 0); // 环形取模,负数也回绕
const dots: HTMLButtonElement[] = []; // 指示点,顺序即下标
const track = h('div', { class: 'ua-carousel-track' }) as HTMLElement; // 幻灯片轨道,靠 transform 位移
const dotsBar = h('div', { class: 'ua-carousel-dots' }) as HTMLElement;
/*** 跳到指定下标(自动环形),并回传 onChange ***/
const go = (i: number) => {
if (!total) return; // 卫语句:无内容不动作
index(wrap(i));
props.onChange?.(index());
};
props.items.forEach((item, i) => {
track.append(h('div', { class: 'ua-carousel-slide', role: 'group', 'aria-roledescription': '幻灯片', 'aria-label': `第 ${i + 1} 张,共 ${total} 张` }, item));
const dot = h('button', { type: 'button', class: 'ua-carousel-dot', 'aria-label': `跳到第 ${i + 1} 张`, onClick: () => go(i) }) as HTMLButtonElement;
dots.push(dot);
dotsBar.append(dot);
});
/*** 生成左右箭头 ***/
const arrow = (dir: 1 | -1, label: string) =>
h('button', { type: 'button', class: `ua-carousel-arrow ua-carousel-${dir === 1 ? 'next' : 'prev'}`, 'aria-label': label, onClick: () => go(index() + dir) }, dir === 1 ? '›' : '‹');
const root = h(
'div',
{ class: 'ua-carousel', tabindex: '0', role: 'region', 'aria-roledescription': '轮播', 'aria-label': props.label ?? '轮播' },
track,
arrow(-1, '上一张'),
arrow(1, '下一张'),
dotsBar,
) as HTMLElement;
if (props.height) root.style.height = `${props.height}px`; // 固定高度由调用方指定,缺省随内容
effect(() => {
const i = wrap(index());
track.style.transform = `translateX(${-i * 100}%)`; // 整屏位移,配合 CSS transition 出滑动效果
dots.forEach((dot, k) => {
dot.classList.toggle('ua-carousel-dot-active', k === i);
dot.setAttribute('aria-current', String(k === i)); // 读屏标注当前张
});
});
root.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; // 卫语句:只接管方向键
e.preventDefault();
e.stopPropagation(); // 不冒泡:避免外层 tabs/menu 同时响应
go(index() + (e.key === 'ArrowRight' ? 1 : -1));
});
if (props.autoplay && total > 1) {
let hover = false; // 悬停中
let focused = false; // 焦点在轮播内(键盘用户没有 hover,必须单独计)
root.addEventListener('pointerenter', () => { hover = true; });
root.addEventListener('pointerleave', () => { hover = false; });
root.addEventListener('focusin', () => { focused = true; });
root.addEventListener('focusout', () => { focused = false; });
window.setInterval(() => { if (!hover && !focused) go(index() + 1); }, props.autoplay); // 两者都不在才前进
}
return root;
}
+143
View File
@@ -0,0 +1,143 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { CascaderOption, CascaderProps } from './types.js';
/*** 沿路径解析节点链:路径失效时在断点截断,避免脏数据把列算错 ***/
function chain(options: CascaderOption[], path: string[]): CascaderOption[] {
const out: CascaderOption[] = [];
let level = options;
for (const v of path) {
const hit = level.find((o) => o.value === v);
if (!hit) break;
out.push(hit);
level = hit.children ?? [];
}
return out;
}
/*** 级联选择:多列联动面板,悬停下钻 / 点击提交,value 信号双向绑定,Esc 与弹层栈共用逐层关闭 ***/
export function cascader(props: CascaderProps): HTMLElement {
// —— 状态集中声明 ——
const open = signal(false); // 面板开合
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位:空间不足自动翻转
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 已提交路径
const path = signal<string[]>([]); // 浏览路径(悬停/下钻都会改,不等于已提交)
const sep = props.separator ?? ' / '; // 路径分隔符
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
const panels = h('div', { class: 'ua-cascader-panels', role: 'menu' }) as HTMLElement; // 多列容器
const labelText = (): string => chain(props.options, value()).map((o) => o.label).join(sep); // 触发器文案
/*** 由浏览路径推出各列选项:根列恒在,逐级 append 子级 ***/
const columns = (): CascaderOption[][] => {
const out: CascaderOption[][] = [props.options];
for (let d = 0; d < path().length; d++) {
const node = out[d].find((o) => o.value === path()[d]);
if (!node?.children?.length) break; // 断在叶子:后续不再出列
out.push(node.children);
}
return out;
};
/*** 提交路径:写信号 + 回调 + 收起并把焦点还给触发器 ***/
const commit = (p: string[]): void => {
value(p);
props.onChange?.([...p]);
open(false);
trigger.focus();
};
/*** 造一列:当前路径高亮、已提交路径打标、有子级显示下钻箭头 ***/
const column = (opts: CascaderOption[], depth: number): HTMLElement => {
const cur = path()[depth]; // 本列浏览中的值
const done = value()[depth]; // 本列已提交的值
const rows = opts.map((o) => {
const hasChild = Boolean(o.children?.length);
const cls = `ua-cascader-opt${o.value === cur ? ' is-active' : ''}${o.value === done ? ' is-picked' : ''}${o.disabled ? ' is-disabled' : ''}`;
return h(
'button',
{
type: 'button',
class: cls,
role: 'menuitem',
'aria-haspopup': hasChild ? 'true' : 'false',
disabled: o.disabled === true, // 禁用项既不可选也不可下钻
onClick: () => {
path([...path().slice(0, depth), o.value]); // 点选即把路径截断到本列
if (!hasChild || props.changeOnSelect) commit([...path().slice(0, depth), o.value]); // 叶子或允许中间层:立即提交
},
onmouseenter: () => {
if (o.disabled || o.value === cur) return; // 卫语句:禁用项与当前项不触发联动
path([...path().slice(0, depth), o.value]);
},
},
h('span', { class: 'ua-cascader-label' }, o.label),
hasChild ? h('span', { class: 'ua-cascader-arrow', 'aria-hidden': 'true' }, '›') : null,
);
});
return h('ul', { class: 'ua-cascader-col', role: 'group' }, ...rows) as HTMLElement;
};
/*** 重绘所有列:列数随浏览深度增长 ***/
const render = (): void => {
const cols = columns();
panels.replaceChildren(...cols.map((opts, d) => column(opts, d)));
};
/*** 把焦点送到某列第 i 项(重建后 DOM 已换,必须等下一帧) ***/
const focusCol = (depth: number, i = 0): void => {
requestAnimationFrame(() => {
const ul = panels.children[depth] as HTMLElement | undefined;
if (!ul) return; // 卫语句:列不存在
(ul.children[Math.min(i, ul.children.length - 1)] as HTMLElement | undefined)?.focus();
});
};
// —— 触发器 ——
const trigger = h(
'button',
{
type: 'button',
class: 'ua-cascader-trigger',
'aria-haspopup': 'menu',
'aria-expanded': () => String(open()),
disabled: props.disabled,
onClick: () => { if (!open()) path([...value()]); open(!open()); },
},
h('span', { class: () => `ua-cascader-value${value().length ? '' : ' is-empty'}` }, () => labelText() || props.placeholder || '请选择'),
h('span', { class: 'ua-cascader-caret', 'aria-hidden': 'true' }, '▾'),
);
const pop = h('div', { class: () => `ua-cascader-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, panels);
const root = h('div', { class: () => `ua-cascader${open() ? ' ua-open' : ''}` }, trigger, pop);
// 面板键盘导航:↑↓ 同列移动、→ 下钻、← 回退、Enter 提交,Esc 交给弹层栈
panels.addEventListener('keydown', (e: KeyboardEvent) => {
const btn = document.activeElement as HTMLElement | null;
const ul = btn?.parentElement as HTMLElement | null;
if (!ul?.classList.contains('ua-cascader-col') || !btn) return; // 卫语句:焦点不在选项上
const siblings = Array.from(ul.children); // 列内项,HTMLCollection 不能直接展开
const depth = Array.from(panels.children).indexOf(ul); // 当前列序号
const at = siblings.indexOf(btn);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const next = siblings[(at + (e.key === 'ArrowDown' ? 1 : -1) + siblings.length) % siblings.length] as HTMLElement;
next?.focus(); // 列内环形移动
return;
}
if (e.key === 'ArrowRight') { e.preventDefault(); btn.click(); if (open()) focusCol(depth + 1); return; } // 下钻并聚焦新列首项
if (e.key === 'ArrowLeft') { e.preventDefault(); focusCol(Math.max(depth - 1, 0), at); return; } // 回退到上一列同序项
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); } // 与鼠标点击同语义
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render); // 依赖 open / path / value,任一变化即重绘
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈:与弹窗/抽屉共用逐层关闭
else { releaseEsc?.(); releaseEsc = null; }
});
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 空间不足自动翻转
return root;
}
+5 -1
View File
@@ -1,7 +1,7 @@
import { effect, h } from '@ui-arch/frame';
import type { CheckboxProps } from './types.js';
/*** 复选框:checked 信号双向绑定,disabled 支持布尔或信号禁用,onChange 为变更回调 ***/
/*** 复选框:checked 信号双向绑定,indeterminate 半选态(视觉与 mixed 播报),disabled 支持布尔或信号禁用,onChange 为变更回调 ***/
export function checkbox(props: CheckboxProps): HTMLElement {
const el = h('input', { type: 'checkbox', class: 'ua-checkbox', disabled: props.disabled }) as HTMLInputElement; // false/undefined 移除属性,Signal 走响应式
if (props.checked) {
@@ -11,6 +11,10 @@ export function checkbox(props: CheckboxProps): HTMLElement {
if (el.checked !== checked()) el.checked = checked(); // 外部变化写回勾选框
});
}
if (props.indeterminate !== undefined) {
const ind = props.indeterminate;
effect(() => { el.indeterminate = typeof ind === 'function' ? ind() : !!ind; }); // 半选态同步原生属性(accent-color 自动渲染横杠,读屏播报 mixed)
}
if (props.onChange) el.addEventListener('change', () => props.onChange!(el.checked));
if (!props.label) return el;
const wrap = h('label', { class: 'ua-check-row' }, el, props.label);
+60
View File
@@ -0,0 +1,60 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import type { CheckboxGroupProps } from './types.js';
/*** 多选组:value 数组信号双向绑定,min/max 用「禁用边界」而非报错,可选全选行(含半选) ***/
export function checkboxGroup(props: CheckboxGroupProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 选中值
const inputs: HTMLInputElement[] = []; // 各选项勾选框
const dir = props.direction ?? 'horizontal'; // 排列方向
const usable = props.options.filter((o) => !o.disabled); // 可勾选项(全选基数)
/*** 整组禁用:布尔直接取,信号在此读取以纳入 effect 依赖 ***/
const groupOff = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true);
/*** 变更落库:写信号 + 回调,边界不在此处拒绝——由 sync 提前禁用越界选项 ***/
const update = (next: string[]): void => {
value(next);
props.onChange?.([...next]);
};
const all = h('input', { type: 'checkbox', class: 'ua-checkbox', 'aria-label': '全选' }) as HTMLInputElement;
all.addEventListener('change', () => {
update(all.checked ? usable.map((o) => o.value) : []); // 全选只覆盖未禁用项
});
const rows = props.options.map((opt) => {
const input = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
input.value = opt.value;
input.addEventListener('change', () => {
const cur = new Set(value());
input.checked ? cur.add(opt.value) : cur.delete(opt.value); // 用户操作写回
update([...cur]);
});
inputs.push(input);
return h('label', { class: 'ua-check-row' }, input, opt.label);
});
/*** 同步勾选与可见可用性:到上限禁用未选项、到下限禁用已选项(不改值只锁操作) ***/
const sync = (): void => {
const cur = value();
const off = groupOff();
const atMax = props.max != null && cur.length >= props.max;
const atMin = props.min != null && cur.length <= props.min;
props.options.forEach((opt, i) => {
const input = inputs[i];
const on = cur.includes(opt.value);
input.checked = on;
input.disabled = off || opt.disabled === true || (on ? atMin : atMax); // 边界项锁死,避免越界
});
const picked = usable.filter((o) => cur.includes(o.value)).length;
all.checked = picked > 0 && picked === usable.length;
all.indeterminate = picked > 0 && picked < usable.length; // 半选:部分已选
all.disabled = off || (picked === usable.length && atMin); // 全选行同样受边界约束
};
effect(sync);
const body = h('div', { class: `ua-check-group ua-check-group-${dir}` }, ...(props.checkAll ? [h('label', { class: 'ua-check-row' }, all, '全选')] : []), ...rows);
return h('div', { class: 'ua-checkbox-group', role: 'group' }, body) as HTMLElement;
}
+30
View File
@@ -0,0 +1,30 @@
import { h } from '@ui-arch/frame';
import { copyButton } from './copyButton.js';
import type { CodeBlockProps } from './types.js';
/*** 代码块:等宽渲染多行代码,可选行号列与标题行(语言 / 文件名),右上角一键复制原文;只做展示不做高亮 ***/
export function codeBlock(props: CodeBlockProps): HTMLElement {
const lines = props.code.replace(/\r?\n$/, '').split(/\r?\n/); // 末尾换行不生成空行:复制走原文,显示不多一行
const showNo = props.lineNumbers !== false && lines.length > 1; // 单行代码给行号只是噪音
const body = h(
'div',
{ class: 'ua-code-body' },
...lines.map((text, i) => h(
'div',
{ class: 'ua-code-line' },
showNo ? h('span', { class: 'ua-code-no', 'aria-hidden': 'true' }, String(i + 1)) : null, // 行号是装饰,读屏跳过
h('span', { class: 'ua-code-text' }, text || ' '), // 空行补一个空格,撑住行高
)),
);
if (props.maxHeight !== undefined) body.style.maxHeight = `${props.maxHeight}px`; // 只在给了上限时限高,超出内部滚动
const title = props.title ?? props.lang; // 标题优先用文件名,其次语言名
const head = props.copyable === false && !title
? null // 既无标题也不可复制:整个头部省掉,不留空条
: h(
'div',
{ class: 'ua-code-head' },
title ? h('span', { class: 'ua-code-title' }, title) : h('span', null, ''),
props.copyable === false ? null : copyButton({ text: props.code, label: '复制', size: 'sm', variant: 'ghost', onCopied: props.onCopy }),
);
return h('div', { class: `ua-code${props.wrap ? ' is-wrap' : ''}`, role: 'group', 'aria-label': props.ariaLabel ?? '代码块' }, head, body); // is-wrap 只改折行,行号与缩进不动
}
+96
View File
@@ -0,0 +1,96 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { ColorPickerProps } from './types.js';
// 缺省色板:直接取语义令牌,天然跟随暗色主题切换,不写死任何十六进制
const DEFAULT_PRESETS = ['var(--ua-color-accent)', 'var(--ua-color-success)', 'var(--ua-color-warning)', 'var(--ua-color-danger)', 'var(--ua-color-text)', 'var(--ua-color-border)'];
/*** 颜色选择:色板点选 + 自定义取色 / 手输,value 信号双向绑定,Esc 收起,色块本身即预览 ***/
export function colorPicker(props: ColorPickerProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string> = props.value ?? signal(''); // 当前颜色
const open = signal(false); // 弹层开合
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
const presets = props.presets ?? DEFAULT_PRESETS; // 预设色板
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
/*** 提交颜色:写信号 + 回调 + 收起,输入框同步回填便于手改 ***/
const commit = (color: string): void => {
const c = color.trim();
if (!c) return; // 卫语句:空值不提交
value(c);
props.onChange?.(c);
open(false);
trigger.focus();
};
const swatch = h('span', { class: 'ua-colorpicker-swatch', 'aria-hidden': 'true' }) as HTMLElement;
const trigger = h(
'button',
{
type: 'button',
class: 'ua-colorpicker-trigger',
'aria-haspopup': 'dialog',
'aria-expanded': () => String(open()),
disabled: props.disabled,
onClick: () => open(!open()),
},
swatch,
h('span', { class: () => `ua-colorpicker-value${value() ? '' : ' is-empty'}` }, () => value() || props.placeholder || '请选择颜色'),
h('span', { class: 'ua-colorpicker-caret', 'aria-hidden': 'true' }, '▾'),
);
// 预设色板:每个色块是一颗按钮,选中项描边标记
const grid = h('div', { class: 'ua-colorpicker-grid', role: 'listbox', 'aria-label': '预设颜色' });
const cells = presets.map((c) =>
h('button', {
type: 'button',
class: () => `ua-colorpicker-cell${value() === c ? ' is-selected' : ''}`,
role: 'option',
'aria-selected': () => String(value() === c),
'aria-label': c,
title: c,
style: `background:${c}`, // 色块底色直接取预设值(令牌或自定义色)
onClick: () => commit(c),
}),
);
grid.append(...cells);
// 自定义区:原生取色器负责拾色,文本框负责粘 CSS 颜色串(含 var(--ua-*))
const native = h('input', { type: 'color', class: 'ua-colorpicker-native', 'aria-label': '取色器', value: () => (/^#[0-9a-f]{6}$/i.test(value()) ? value() : '#000000') }) as HTMLInputElement;
native.addEventListener('input', () => commit(native.value));
const hex = h('input', {
type: 'text',
class: 'ua-colorpicker-hex',
placeholder: 'CSS 颜色,如 var(--ua-color-accent)',
'aria-label': '颜色值',
value: () => value(),
onkeydown: (e: KeyboardEvent) => {
if (e.key !== 'Enter') return; // 卫语句:只处理回车提交
e.preventDefault();
commit((e.target as HTMLInputElement).value);
},
}) as HTMLInputElement;
const pop = h(
'div',
{ class: () => `ua-colorpicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` },
grid,
h('div', { class: 'ua-colorpicker-custom' }, native, hex),
);
const root = h('div', { class: () => `ua-colorpicker${open() ? ' ua-open' : ''}` }, trigger, pop) as HTMLElement;
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
swatch.style.background = value() || 'transparent'; // 触发器色块实时预览
swatch.classList.toggle('is-empty', !value());
});
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈逐层关闭
else { releaseEsc?.(); releaseEsc = null; }
});
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); });
return root;
}
+51
View File
@@ -0,0 +1,51 @@
import { h } from '@ui-arch/frame';
const MIN = 56; // 列宽下限:小于这个值表头文字会被压成竖排
/*** 列宽手柄:右缘拖动改 <col> 宽度(双击复位),← → 每次微调 8px,两者共用 apply 收口 ***/
export function attachResize(th: HTMLElement, col: HTMLElement, start: number, label: string): void {
// —— 状态集中声明 ——
let dragging = false; // 是否处于拖拽中
let originX = 0; // 按下时的指针横坐标
let originW = 0; // 按下时的实际列宽
/*** 唯一写宽入口:夹取下限后写回 <col> ***/
const apply = (w: number): void => { col.style.width = `${Math.max(MIN, Math.round(w))}px`; };
const grip = h('span', {
class: 'ua-col-grip',
role: 'separator', // 可调节分隔符语义:读屏可识别为「可调整」
'aria-orientation': 'vertical',
'aria-label': `调整「${label}」列宽`,
tabindex: '0',
onpointerdown: (e: PointerEvent) => {
dragging = true;
originX = e.clientX;
originW = col.getBoundingClientRect().width; // 取实际渲染宽度,避免与 min-width 打架
grip.setPointerCapture(e.pointerId); // 捕获指针:拖出表头也继续跟手
e.preventDefault(); // 不触发列头排序
e.stopPropagation();
document.body.classList.add('ua-col-resizing'); // 拖拽期间全局禁用文本选中
},
onpointermove: (e: PointerEvent) => { if (dragging) apply(originW + e.clientX - originX); },
onpointerup: stop,
onpointercancel: stop,
onclick: (e: MouseEvent) => e.stopPropagation(), // 点手柄不该触发排序
ondblclick: (e: MouseEvent) => { e.stopPropagation(); apply(start); }, // 双击回到声明宽度
onkeydown: (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; // 卫语句:只接管方向键
e.preventDefault();
e.stopPropagation(); // 不冒泡到表格的行选择等外层快捷键
apply(col.getBoundingClientRect().width + (e.key === 'ArrowRight' ? 8 : -8));
},
}) as HTMLElement;
/*** 拖拽收尾:复位标志与全局样式 ***/
function stop(): void {
if (!dragging) return; // 卫语句:不在拖拽中
dragging = false;
document.body.classList.remove('ua-col-resizing');
}
th.append(grip);
}
+152
View File
@@ -0,0 +1,152 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { CommandPaletteHandle, CommandPaletteItem, CommandPaletteProps } from './types.js';
import { lockScroll, pushOverlay } from './overlay.js';
let seq = 0; // 实例序号:选项与列表的 id 靠它唯一
/*** 作用:条目是否命中搜索词——主文案 / 辅助说明 / 额外关键词任一包含即命中(大小写不敏感) ***/
function matches(item: CommandPaletteItem, q: string): boolean {
if (!q) return true; // 空词不过滤
const hay = [item.label, item.hint ?? '', ...(item.keywords ?? [])].join(' ').toLowerCase();
return hay.includes(q);
}
/*** 命令面板:⌘K 唤起,输入即过滤,↑↓ 选择、回车执行、Esc 收起;返回 { el, open, close } 句柄 ***/
export function commandPalette(props: CommandPaletteProps): CommandPaletteHandle {
// —— 状态集中声明 ——
const uid = `ua-cmdk-${++seq}`; // 实例 id 前缀
const query = signal(''); // 搜索词:输入事件写入,供过滤
const active = signal(0); // 当前高亮项在「过滤后序列」中的下标
let visible: CommandPaletteItem[] = []; // 过滤后的可见条目
let nodes: HTMLElement[] = []; // 与 visible 同序的节点,供高亮就地切换(不整树重绘)
let lastFocus: HTMLElement | null = null; // 打开前焦点,关闭归还
let release: (() => void) | null = null; // Esc 栈注销函数
let releaseLock: (() => void) | null = null; // 滚动锁解锁函数
// —— DOM 骨架:遮罩 → 面板 → 输入框 + 列表 ——
const input = h('input', {
class: 'ua-cmdk-input',
type: 'text',
placeholder: props.placeholder ?? '搜索命令…',
'aria-controls': `${uid}-list`,
role: 'combobox',
'aria-expanded': 'true',
}) as HTMLInputElement;
const list = h('div', { class: 'ua-cmdk-list', id: `${uid}-list`, role: 'listbox', 'aria-label': '命令列表' });
const panel = h('div', { class: 'ua-cmdk-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '命令面板' }, input, list);
const root = h('div', { class: 'ua-cmdk', onClick: (e: Event) => { if (e.target === root) close(); } }, panel);
/*** 选中即执行:先收起(归还焦点)再回调,避免回调里再开弹层时焦点打转 ***/
const run = (item: CommandPaletteItem): void => {
close();
props.onSelect(item);
};
/*** 生成一行的节点:label 左、hint 右,点击与悬停都能选中 ***/
const row = (item: CommandPaletteItem, idx: number): HTMLElement =>
h('div', {
class: 'ua-cmdk-item',
id: `${uid}-opt-${idx}`,
role: 'option',
'aria-selected': 'false',
onClick: () => run(item),
onPointermove: () => setActive(idx), // 鼠标划过即高亮,点之前就知道会选中谁
}, h('span', { class: 'ua-cmdk-label' }, item.label), item.hint ? h('span', { class: 'ua-cmdk-hint' }, item.hint) : null);
/*** 高亮第 idx 项(环形):只切 class + scrollIntoView,不重建列表 ***/
function setActive(idx: number): void {
if (!visible.length) return; // 卫语句:空列表无需高亮
const next = (idx + visible.length) % visible.length; // 环形:首尾相接
active(next);
nodes.forEach((el, i) => {
const on = i === next;
el.classList.toggle('is-active', on);
el.setAttribute('aria-selected', String(on));
});
nodes[next].scrollIntoView({ block: 'nearest' }); // 窄容器内跟随滚动
input.setAttribute('aria-activedescendant', `${uid}-opt-${next}`);
}
/*** 按当前搜索词重绘列表:同组连续,组间插标题;无命中给空状态 ***/
function paint(): void {
const q = query().trim().toLowerCase();
visible = props.items.filter((it) => matches(it, q));
nodes = [];
const parts: HTMLElement[] = [];
let group = ''; // 上一个已画过的组名,用于判定是否该插标题
visible.forEach((item, idx) => {
if (item.group && item.group !== group) {
group = item.group;
parts.push(h('div', { class: 'ua-cmdk-group' }, group));
}
const el = row(item, idx);
nodes.push(el);
parts.push(el);
});
list.replaceChildren(...(parts.length ? parts : [h('div', { class: 'ua-cmdk-empty' }, props.emptyText ?? '没有匹配的命令')]));
input.removeAttribute('aria-activedescendant');
if (visible.length) setActive(0);
}
/*** 打开:清搜索词、重绘、抢焦点(写回信号,由下面的 effect 统一处理副作用) ***/
function open(): void {
if (props.open()) return; // 卫语句:已打开不重复触发
props.open(true);
}
/*** 关闭:写回信号即可,副作用(出栈 / 解锁 / 还焦点)由 effect 收口 ***/
function close(): void {
if (!props.open()) return; // 卫语句:已关闭不重复触发
props.open(false);
}
input.addEventListener('input', () => {
query(input.value);
paint();
});
// —— 键盘:↑↓ 环形选择、Home/End 跳首尾、回车执行(Esc 交给弹层栈,保证只关最上层) ——
panel.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active() + 1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active() - 1); return; }
if (e.key === 'Home') { e.preventDefault(); setActive(0); return; }
if (e.key === 'End') { e.preventDefault(); setActive(visible.length - 1); return; }
if (e.key === 'Enter') {
e.preventDefault();
const item = visible[active()];
if (item) run(item); // 无命中时回车不做事
}
});
// —— 显隐副作用收口:焦点、Esc 栈、背景滚动锁 ——
effect(() => {
const on = props.open();
root.classList.toggle('ua-open', on);
if (on) {
lastFocus = document.activeElement as HTMLElement | null;
query('');
input.value = '';
paint();
release ??= pushOverlay(close);
releaseLock ??= lockScroll();
input.focus();
} else {
release?.();
release = null;
releaseLock?.();
releaseLock = null;
lastFocus?.focus(); // 归还焦点:键盘用户不会被困在隐藏层里
lastFocus = null;
}
});
// —— 全局快捷键:⌘K / Ctrl+K 切换;hotkey=false 时不注册(多实例场景由调用方自己绑) ——
if (props.hotkey !== false) {
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return; // 卫语句:只认 ⌘K / Ctrl+K
e.preventDefault();
if (props.open()) close(); else open(); // 再按一次收起
});
}
return { el: root, open, close };
}
+32
View File
@@ -0,0 +1,32 @@
import { h, signal } from '@ui-arch/frame';
import type { CopyButtonProps } from './types.js';
/*** 复制按钮:点击把 text 写入剪贴板,成功后短暂换成已复制文案再还原;clipboard API 不可用时降级 execCommand ***/
export function copyButton(props: CopyButtonProps): HTMLElement {
const label = signal(props.label ?? '复制'); // 文案信号:成功后短暂切换(h() 对函数型子节点响应式刷新)
let timer = 0; // 还原定时器句柄:连点时先清旧计时,避免文案提前回跳
/*** 写入剪贴板:标准 API 优先,失败(http 上下文 / 旧浏览器)走隐藏 textarea + execCommand 兜底 ***/
const write = async (): Promise<void> => {
const text = typeof props.text === 'function' ? props.text() : props.text; // 函数形式取实时值
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.append(ta);
ta.select();
document.execCommand('copy'); // 降级路径:依赖临时选区
ta.remove();
}
label(props.copiedLabel ?? '已复制');
if (props.onCopied) props.onCopied(text);
clearTimeout(timer);
timer = window.setTimeout(() => label(props.label ?? '复制'), 2000); // 2s 后还原文案
};
// 直接构造按钮(ButtonProps.label 收窄为 string,无法传信号,复用会破坏其类型口径)
return h('button', { class: `ua-btn ua-btn-${props.variant ?? 'ghost'}${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`, type: 'button', disabled: props.disabled, onClick: () => { void write(); } }, label);
}
+27
View File
@@ -0,0 +1,27 @@
import { h, signal } from '@ui-arch/frame';
import type { CountdownHandle, CountdownProps } from './types.js';
/*** 倒计时:秒级递减到 0 自动停表并触发 onFinish,缺省 mm:ss 展示,句柄可 start / stop / reset ***/
export function countdown(props: CountdownProps): CountdownHandle {
const remain = signal(props.seconds); // 剩余秒数(单一来源,h() 函数型子节点响应式刷新文本)
let timer = 0; // setInterval 句柄(0 表示停表中)
const fmt = props.format ?? ((s: number): string => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`); // 缺省 mm:ss 两位补零
const stop = (): void => { if (timer) { clearInterval(timer); timer = 0; } }; // 停表并清句柄,幂等
/*** 开始 / 续跑:重入先停旧表;已归零时视为重开一轮 ***/
const start = (): void => {
stop();
if (remain() <= 0) remain(props.seconds);
timer = window.setInterval(() => {
const next = Math.max(0, remain() - 1);
remain(next);
if (next <= 0) { stop(); if (props.onFinish) props.onFinish(); } // 归零即停表并回调,不留负数帧
}, 1000);
};
const reset = (): void => { stop(); remain(props.seconds); }; // 复位到总时长并停表
const el = h('span', { class: 'ua-countdown', role: 'timer' }, () => fmt(remain())) as HTMLSpanElement;
if (props.running ?? true) start(); // 缺省挂载即开始
return { el, start, stop, reset };
}
+39
View File
@@ -0,0 +1,39 @@
import { h } from '@ui-arch/frame';
import type { RowModel } from '@ui-arch/table-core';
import { button } from './button.js';
import type { TableColumn } from './types.js';
/*** 单元格转义:含逗号 / 引号 / 换行时整体加引号,内部引号翻倍 ***/
function cell(v: unknown): string {
const s = v == null ? '' : String(v);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
/*** 导出 CSV:前置 BOM + CRLF 换行,Excel 打开中文不乱码 ***/
export function downloadCsv(name: string, header: string[], rows: unknown[][]): void {
const text = '\ufeff' + [header, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }));
const a = h('a', { href: url, download: name }) as HTMLAnchorElement;
document.body.appendChild(a); // 挂进文档再点击:Safari 对游离锚点不触发下载
a.click();
a.remove(); // 立即摘除,不留残留节点
URL.revokeObjectURL(url);
}
/*** 导出按钮:取筛选 + 排序后的全量行(不分页),列序与表头一致、单元格走 format ***/
export function exportButton<T>(
table: { getAllRows(): RowModel<T>; getCellValue(row: T, key: string): unknown },
columns: TableColumn<T>[],
name: string,
): HTMLElement {
const run = (): void => {
const rows = table.getAllRows().rows;
const header = columns.map((c) => String(c.header ?? c.key));
const body = rows.map((row) => columns.map((c) => {
const raw = table.getCellValue(row, c.key);
return c.format ? c.format(raw, row) : raw; // 与单元格显示同源,导出内容与看到的一致
}));
downloadCsv(name, header, body);
};
return button({ label: '导出 CSV', variant: 'ghost', onClick: run });
}
+51 -36
View File
@@ -4,31 +4,32 @@ import {
paginationPlugin,
selectionPlugin,
sortPlugin,
type RowKey,
} from '@ui-arch/table-core';
import type { PageInfo, RowKey, TableInstance } from '@ui-arch/table-core';
import { computed, effect, h, renderList, signal } from '@ui-arch/frame';
import { button } from './button.js';
import { iconSearch } from './icons.js'; import { computed, effect, h, renderList, signal } from '@ui-arch/frame';
import { attachResize } from './columnResize.js';
import { exportButton } from './csv.js';
import { skeleton } from './skeleton.js';
import { textField } from './textField.js';
import type { DataTableProps } from './types.js';
import { emptyRow } from './tableEmpty.js';
import { tablePager } from './tablePager.js';
import { attachDataSource, readRows } from './tableDataSource.js';
import type { DataTableProps, TableApi } from './types.js';
/*** 数据表格:粘性表头 + 行选择 + 排序 + 筛选 + 分页 + 空状态;状态由 table-core 持有,信号只做桥接渲染 ***/
export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
// —— 状态集中声明 ——
const selectable = props.selectable ?? false; // 行选择开关
const selectable = props.selectable ?? false; // 状态集中声明:行选择开关
const keyword = signal(''); // 筛选框关键字:受控信号,空状态里的「清除筛选」靠它一键还原
const table = createTable({
data: props.data,
data: readRows(props.data), // 信号数据源先取当前值,后续靠 attachDataSource 换
columns: props.columns,
rowKey: 'id',
plugins: [filterPlugin<T>(), sortPlugin<T>(), paginationPlugin<T>(), selectionPlugin<T>()],
initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } },
});
const api = table as unknown as TableInstance<T> & {
getPageInfo(): PageInfo;
selectAllVisible(): void;
};
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增触发信号系统
table.subscribe(() => version(version() + 1));
const api = table as unknown as TableApi<T>; // 用到的两个取数方法在类型别名里声明
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增 → 派生统一重算
table.subscribe(() => version(version() + 1)); attachDataSource(table, props.data); // 信号数据源变化即换源(静态数组直接返回)
// —— 派生:全部由 version 单一来源推导,语义与内核一致 ——
const visible = computed(() => {
@@ -46,7 +47,6 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
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;
@@ -102,16 +102,31 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
},
);
const th = (col: (typeof props.columns)[number]) =>
h(
const th = (col: (typeof props.columns)[number]) => {
const toggle = () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }); // 鼠标与键盘共用的排序入口
const el = h(
'th',
{
class: `${col.sortable ? 'ua-sortable' : ''}${col.align ? ` ua-align-${col.align}` : ''}`.trim(),
onClick: () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }),
scope: 'col', // 表头单元格语义:读屏据此把列标题与数据格关联
tabindex: col.sortable ? '0' : null, // 可排序表头进 Tab 序,不可排序的不抢焦点
onClick: toggle,
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } // Enter / 空格 等价鼠标点击
},
},
col.header ?? col.key,
col.sortable ? sortMark(col.key) : null,
);
if (typeof col.width === 'number') attachResize(el, cols[props.columns.indexOf(col)], col.width, String(col.header ?? col.key)); // 定宽列可拖拽
if (col.sortable) {
effect(() => {
const rule = state().sorting.find((r) => r.key === col.key); // 与视觉箭头同源
el.setAttribute('aria-sort', rule ? (rule.dir === 'asc' ? 'ascending' : 'descending') : 'none');
});
}
return el;
};
/*** 骨架行:列宽与表头对齐,行数取当前每页条数(上限 6 行防过长) ***/
const skeletonRows = (): Node[] => {
@@ -125,9 +140,7 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
if (busy()) return skeletonRows(); // 加载态优先:不看数据,避免闪出旧行
const sel = state().selection; // 读入依赖:选择变化即整表重绘(行数 = pageSize,代价可控)
const rows = visible().rows;
if (!rows.length) {
return [h('tr', { class: 'ua-empty-row' }, h('td', { colspan }, '暂无匹配数据'))];
}
if (!rows.length) return [emptyRow(colspan, keyword, () => table.dispatch({ type: 'setFilter', key: filterKey, value: '' }), props.emptyText)]; // 空状态:筛选词与内核筛选一起清
return rows.map((row, i) => {
const key = visible().keys[i];
const selected = sel.includes(key);
@@ -137,25 +150,25 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
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;
const check = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement; // 受控:选中态由内核 selection 决定
check.checked = selected;
check.addEventListener('change', () => table.dispatch({ type: 'toggleSelect', key }));
cells.unshift(h('td', { class: 'ua-cell-check' }, check));
}
return h('tr', selected ? { class: 'ua-row-selected' } : null, ...cells);
const rowProps = props.onRowClick // 传了才把整行做成可聚焦按钮语义(Tab 进入、Enter / 空格触发)
? { tabindex: '0', class: `ua-row-clickable${selected ? ' ua-row-selected' : ''}`, onclick: () => props.onRowClick?.(row), onkeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row); } } }
: selected ? { class: 'ua-row-selected' } : null;
return h('tr', rowProps, ...cells);
});
});
// —— 列宽:数字落成 <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 cols = props.columns.map((col) => (typeof col.width === 'number' ? h('col', { style: `width:${col.width}px` }) : h('col'))); // 每个数据列一个 <col>,拖拽直接改它
const colgroup = h('colgroup', null, selectable ? h('col', { style: 'width:44px' }) : null, ...cols);
const filterInput = textField({
placeholder: `筛选 ${filterKey}…`,
prefix: iconSearch(),
value: keyword, // 受控信号:空状态里的「清除筛选」能同时清掉输入框内容
disabled: props.loading, // 加载中禁止筛选,避免与在途结果竞争
onInput: (value) => {
table.dispatch({ type: 'setFilter', key: filterKey, value });
@@ -171,14 +184,16 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
{ class: 'ua-table-toolbar' },
filterInput,
selectable ? h('span', { class: 'ua-sel-count' }, () => (selectedCount() ? `已选 ${selectedCount()} 行` : '')) : null,
props.exportable ? exportButton(table, props.columns, props.exportName ?? 'table.csv') : null, // 导出筛选+排序后的全量行
),
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', 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', disabled: props.loading, onClick: () => table.dispatch({ type: 'setPage', page: info().page + 1 }) }),
),
tablePager({
info,
total: () => visible().total,
loading: busy,
pageSizes: props.pageSizes,
onPage: (page) => table.dispatch({ type: 'setPage', page }),
onPageSize: (pageSize) => table.dispatch({ type: 'setPageSize', pageSize }),
}),
);
}
+30
View File
@@ -0,0 +1,30 @@
/*** 日期工具:datePicker 与 calendar 共用一套格式化与网格语义,避免两处各写一遍 ***/
export const pad = (n: number): string => String(n).padStart(2, '0'); // 两位补零
/*** 拼成 YYYY-MM-DD ***/
export const fmt = (y: number, m: number, d: number): string => `${y}-${pad(m + 1)}-${pad(d)}`;
/*** 解析 YYYY-MM-DD:非法串返回 null,调用方自行回落 ***/
export const parse = (s: string): [number, number, number] | null => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
return m ? [Number(m[1]), Number(m[2]) - 1, Number(m[3])] : null;
};
/*** 今天 ***/
export const today = (): [number, number, number] => {
const d = new Date();
return [d.getFullYear(), d.getMonth(), d.getDate()];
};
/*** 按天偏移:跨月 / 跨年自动进位,非法输入回落到今天 ***/
export const shiftDay = (key: string, delta: number): string => {
const p = parse(key) ?? today();
const d = new Date(p[0], p[1], p[2] + delta);
return fmt(d.getFullYear(), d.getMonth(), d.getDate());
};
/*** 单月网格元数据:月初留白格数与当月天数 ***/
export const monthGrid = (y: number, m: number): { lead: number; total: number } => ({
lead: new Date(y, m, 1).getDay(), // 1 号是周几 → 前面留几个空位
total: new Date(y, m + 1, 0).getDate(), // 下月第 0 天即本月最后一天
});
+175
View File
@@ -0,0 +1,175 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { DatePickerProps, DatePreset } from './types.js';
import { fmt, pad, parse, shiftDay, today } from './dateKit.js';
/*** 日期选择:单月网格 + 上下月翻页 + 今天快捷键,value 信号双向绑定,方向键/PageUp/PageDown 可导航 ***/
export function datePicker(props: DatePickerProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string> = props.value ?? signal(''); // 已选日期(区间模式下是起点)
const end: Signal<string> = props.end ?? signal(''); // 区间终点,非区间模式恒为空
const open = signal(false); // 弹层开合
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
const initial = parse(value()) ?? today(); // 初始视图:跟随已选,否则今天
const view = signal<[number, number]>([initial[0], initial[1]]); // 当前展示的年月
const focus = signal<string>(value() || fmt(...today())); // 键盘焦点日
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
const grid = h('div', { class: 'ua-datepicker-grid', role: 'grid' }) as HTMLElement;
const label = h('span', { class: 'ua-datepicker-label' }) as HTMLElement;
const head = h(
'div',
{ class: 'ua-datepicker-head' },
h('button', { type: 'button', class: 'ua-datepicker-nav', 'aria-label': '上一月', onClick: () => turn(-1) }, '‹'),
label,
h('button', { type: 'button', class: 'ua-datepicker-nav', 'aria-label': '下一月', onClick: () => turn(1) }, '›'),
);
const presetRow = props.range && props.presets?.length // 快捷区间只在区间模式有意义
? h('div', { class: 'ua-datepicker-presets' }, ...props.presets.map((p) => h('button', {
type: 'button', class: 'ua-datepicker-preset', onClick: () => applyPreset(p),
}, p.label)))
: null;
const pop = h(
'div',
{ class: () => `ua-datepicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` },
head,
...(presetRow ? [presetRow] : []),
h('div', { class: 'ua-datepicker-week' }, ...['日', '一', '二', '三', '四', '五', '六'].map((w) => h('span', {}, w))),
grid,
h('div', { class: 'ua-datepicker-foot' }, h('button', { type: 'button', class: 'ua-datepicker-today', onClick: () => commit(fmt(...today())) }, '今天')),
) as HTMLElement;
/*** 翻月:只改视图,不改已选值;跨年自动进位 ***/
function turn(delta: number): void {
const [y, m] = view();
const d = new Date(y, m + delta, 1);
view([d.getFullYear(), d.getMonth()]);
}
/*** 提交:单日模式写值即收起;区间模式首点定起点并留在弹层,次点补终点后收起 ***/
function commit(date: string): void {
if (!props.range) {
value(date);
props.onChange?.(date);
open(false);
trigger.focus();
return;
}
if (!value() || end()) { // 未选起点或上一段区间已完整:从这个日期重开一段
value(date);
end('');
render(); // 清掉上一段的区间底色
return;
}
const [start, stop] = date < value() ? [date, value()] : [value(), date]; // 点早了就自动交换
value(start);
end(stop);
props.onRangeChange?.(start, stop);
open(false);
trigger.focus();
}
/*** 应用快捷区间:days 优先(今天往前共 N 天),否则用绝对 from/to;按 min/max 钳制后写值并收起 ***/
function applyPreset(p: DatePreset): void {
const stop = p.days ? fmt(...today()) : (p.to || fmt(...today()));
const start = p.days ? shiftDay(stop, -(p.days - 1)) : (p.from || stop);
const [lo, hi] = start <= stop ? [start, stop] : [stop, start]; // 防御:from 晚于 to 时自动交换
const fit = (v: string): string => (props.min && v < props.min ? props.min : props.max && v > props.max ? props.max : v); // 钳进合法区间
value(fit(lo));
end(fit(hi));
props.onRangeChange?.(value(), end());
open(false);
trigger.focus();
}
/*** 是否越界:min / max 为闭区间,缺省视为不限制 ***/
const outOfRange = (date: string): boolean => (props.min ? date < props.min : false) || (props.max ? date > props.max : false);
/*** 重绘网格:前置空位对齐星期、今天/选中/焦点分别打标 ***/
function render(): void {
const [y, m] = view();
const lead = new Date(y, m, 1).getDay(); // 当月 1 号是周几
const total = new Date(y, m + 1, 0).getDate(); // 当月天数
const cells: HTMLElement[] = [];
for (let i = 0; i < lead; i++) cells.push(h('span', { class: 'ua-datepicker-blank' })); // 月初留白
for (let d = 1; d <= total; d++) {
const date = fmt(y, m, d);
const dead = outOfRange(date);
const edge = date === value() || Boolean(props.range && end() && date === end()); // 区间两端
const between = Boolean(props.range && value() && end() && date > value() && date < end()); // 区间内部
const cls = `ua-datepicker-day${edge ? ' is-selected' : ''}${between ? ' is-in-range' : ''}${date === fmt(...today()) ? ' is-today' : ''}${dead ? ' is-disabled' : ''}`;
cells.push(
h('button', {
type: 'button',
class: cls,
role: 'gridcell',
tabindex: date === focus() ? '0' : '-1', // 漫游 tabindex:只有焦点日可 Tab 进入
'aria-selected': String(edge),
disabled: dead,
'aria-label': date,
onClick: () => commit(date),
}, String(d)),
);
}
grid.replaceChildren(...cells);
label.textContent = `${y} 年 ${m + 1} 月`;
requestAnimationFrame(() => {
if (!open()) return; // 卫语句:收起时不抢焦点
(grid.querySelector('.ua-datepicker-day[tabindex="0"]') as HTMLElement | null)?.focus();
});
}
/*** 触发器文案:单日回显日期;区间回显「起 ~ 止」,只定了起点时后面跟省略号 ***/
const labelText = (): string => {
if (!props.range) return value() || props.placeholder || '请选择日期';
if (!value()) return props.placeholder || '请选择日期范围';
return end() ? `${value()} ~ ${end()}` : `${value()} ~ …`;
};
const trigger = h(
'button',
{
type: 'button',
class: 'ua-datepicker-trigger',
'aria-haspopup': 'dialog',
'aria-expanded': () => String(open()),
disabled: props.disabled,
onClick: () => {
if (!open()) {
const p = parse(value()) ?? today(); // 打开时视图对准已选日期(无选则今天)
view([p[0], p[1]]);
focus(value() || fmt(...p));
}
open(!open());
},
},
h('span', { class: () => `ua-datepicker-value${value() ? '' : ' is-empty'}` }, () => labelText()),
h('span', { class: 'ua-datepicker-caret', 'aria-hidden': 'true' }, '▤'),
);
const root = h('div', { class: () => `ua-datepicker${open() ? ' ua-open' : ''}` }, trigger, pop) as HTMLElement;
// 键盘:←→ 逐日、↑↓ 逐周、PageUp/PageDown 逐月、Enter 提交,Esc 交给弹层栈
pop.addEventListener('keydown', (e: KeyboardEvent) => {
const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
if (step[e.key]) {
e.preventDefault();
const next = shiftDay(focus(), step[e.key]);
const p = parse(next) ?? today();
focus(next);
if (p[0] !== view()[0] || p[1] !== view()[1]) view([p[0], p[1]]); // 跨月自动翻页
render();
return;
}
if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); turn(e.key === 'PageUp' ? -1 : 1); return; }
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!outOfRange(focus())) commit(focus()); }
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render); // 依赖 view / value / focus
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈逐层关闭
else { releaseEsc?.(); releaseEsc = null; }
});
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 空间不足自动翻转
return root;
}
+25
View File
@@ -0,0 +1,25 @@
import { h } from '@ui-arch/frame';
import type { DescriptionItem, DescriptionsProps } from './types.js';
/*** 单个描述项:字段名 + 字段值,span 控制跨列(超出列数时按整行处理) ***/
function cell(item: DescriptionItem, column: number): HTMLElement {
const cellEl = h('div', { class: 'ua-desc-item' },
h('div', { class: 'ua-desc-label' }, item.label),
h('div', { class: 'ua-desc-value' }, item.value));
cellEl.style.gridColumn = `span ${Math.min(item.span ?? 1, column)}`; // 跨列不越界
return cellEl;
}
/*** 描述列表:label:value 两栏网格,column 定桌面列数,窄屏由样式表降到单列;bordered 走表格形态,layout=vertical 把字段名挪到值上方 ***/
export function descriptions(props: DescriptionsProps): HTMLElement {
const column = props.column ?? 3;
const vertical = props.layout === 'vertical' ? ' is-vertical' : '';
const grid = h('div', { class: `ua-desc ua-desc-${props.size ?? 'md'}${props.bordered ? ' is-bordered' : ''}${vertical}` },
...props.items.map((item) => cell(item, column)));
grid.style.setProperty('--ua-desc-col', String(column)); // 列数交给 CSS 变量,媒体查询可在窄屏覆盖
if (!props.title) return grid;
const head = props.extra // 标题行:给了操作区就左右分栏,否则保持原来的单标题结构
? h('div', { class: 'ua-desc-head' }, h('div', { class: 'ua-desc-title' }, props.title), h('div', { class: 'ua-desc-extra' }, props.extra))
: h('div', { class: 'ua-desc-title' }, props.title);
return h('div', { class: 'ua-desc-wrap' }, head, grid);
}
+58
View File
@@ -0,0 +1,58 @@
/*** 行级差异纯逻辑:切行 / 归一键 / 中间段 LCS 对齐;不碰 DOM,可被冒烟脚本直接跑 ***/
/*** 一条差异:same 原样、add 只在右边、del 只在左边 ***/
export type DiffOp = { type: 'same' | 'add' | 'del'; text: string };
/*** 比较用的一行:text 是显示原文,key 是比对键(忽略空白模式下把行内空白压成一个空格) ***/
export type DiffRow = { text: string; key: string };
// LCS 计算上限:中间段行数乘积超过这个值就退化为「整段替换」,避免大文件把主线程卡住
export const DP_LIMIT = 400_000;
/*** 作用:比对键归一——行内连续空白压成一个空格并去首尾空白;显示仍用原文,缩进不丢 ***/
export function normalizeLine(line: string): string {
return line.replace(/\s+/g, ' ').trim();
}
/*** 作用:切行并算比对键;末尾换行不产生一行空内容(否则每个文件都多一条假差异) ***/
export function toRows(text: string, ignore: boolean): DiffRow[] {
const lines = text.split(/\r?\n/);
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); // 尾部换行忽略
return lines.map((line) => ({ text: line, key: ignore ? normalizeLine(line) : line }));
}
/*** 作用:中间段做 LCS 对齐,输出 del / add / same 序列;超限或一侧为空时退化为整段替换 ***/
export function alignMiddle(a: DiffRow[], b: DiffRow[]): DiffOp[] {
if (!a.length || !b.length) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))];
if (a.length * b.length > DP_LIMIT) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))]; // 超限:语义仍正确,只是不再逐行对齐
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0)); // dp[i][j]:a 前 i 行与 b 前 j 行的 LCS 长度
for (let i = a.length - 1; i >= 0; i -= 1) {
for (let j = b.length - 1; j >= 0; j -= 1) {
dp[i][j] = a[i].key === b[j].key ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i].key === b[j].key) { ops.push({ type: 'same', text: a[i].text }); i += 1; j += 1; continue; }
if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i].text }); i += 1; continue; }
ops.push({ type: 'add', text: b[j].text }); j += 1;
}
for (; i < a.length; i += 1) ops.push({ type: 'del', text: a[i].text });
for (; j < b.length; j += 1) ops.push({ type: 'add', text: b[j].text });
return ops;
}
/*** 作用:逐行差异——剪公共前后缀后只对中间段做对齐,日常改动只占几行 ***/
export function diffLines(before: string, after: string, ignore: boolean): DiffOp[] {
const a = toRows(before, ignore);
const b = toRows(after, ignore);
let head = 0;
while (head < a.length && head < b.length && a[head].key === b[head].key) head += 1; // 公共前缀
let tail = 0;
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail].key === b[b.length - 1 - tail].key) tail += 1; // 公共后缀
return [
...a.slice(0, head).map((r) => ({ type: 'same' as const, text: r.text })),
...alignMiddle(a.slice(head, a.length - tail), b.slice(head, b.length - tail)),
...a.slice(a.length - tail).map((r) => ({ type: 'same' as const, text: r.text })),
];
}
+41
View File
@@ -0,0 +1,41 @@
import { h } from '@ui-arch/frame';
import { diffLines } from './diffKit.js';
import type { DiffViewProps } from './types.js';
/*** 文本差异对比:行级对齐 + 增删着色 + 双行号列,超限时退化为整段替换(只影响精度不影响语义) ***/
export function diffView(props: DiffViewProps): HTMLElement {
const ignore = props.ignoreWhitespace === true; // 只在显式开启时归一行内空白
const ops = diffLines(props.before, props.after, ignore); // 纯逻辑在 diffKit:这里只负责把它画出来
const added = ops.filter((o) => o.type === 'add').length; // 新增行数:统计条与读屏共用
const removed = ops.filter((o) => o.type === 'del').length; // 删除行数
let oldNo = 0;
let newNo = 0;
const rows = ops.map((op) => {
if (op.type !== 'add') oldNo += 1; // same 与 del 占旧行号
if (op.type !== 'del') newNo += 1; // same 与 add 占新行号
const sign = op.type === 'add' ? '+' : op.type === 'del' ? '−' : ' ';
return h(
'div',
{ class: `ua-diff-row is-${op.type}` },
h('span', { class: 'ua-diff-no' }, op.type === 'add' ? '' : String(oldNo)),
h('span', { class: 'ua-diff-no' }, op.type === 'del' ? '' : String(newNo)),
h('span', { class: 'ua-diff-sign', 'aria-hidden': 'true' }, sign),
h('span', { class: 'ua-diff-text' }, op.text || ' '),
);
});
const stats = props.showStats === false
? null
: h(
'div',
{ class: 'ua-diff-stats' },
h('span', { class: 'ua-diff-add' }, `+${added}`),
h('span', { class: 'ua-diff-del' }, `−${removed}`),
ignore ? h('span', { class: 'ua-diff-note' }, '已忽略行内空白差异') : null, // 归一口径必须写在脸上,否则「为什么不报这几行」说不清
);
return h(
'div',
{ class: 'ua-diff', role: 'group', 'aria-label': props.ariaLabel ?? `文本差异:新增 ${added} 行,删除 ${removed} 行` },
stats,
h('div', { class: 'ua-diff-body' }, ...rows),
);
}
+11
View File
@@ -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;
}
+25 -24
View File
@@ -1,60 +1,61 @@
import { effect, h } from '@ui-arch/frame';
import type { DrawerProps } from './types.js';
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
let seq = 0; // 实例序号:给标题生成唯一 id,供 aria-labelledby 关联
/*** 抽屉:open 信号驱动侧滑面板,点遮罩 / × / Esc 关闭;Tab 焦点陷阱 + 关闭归还焦点,与 modal 的分工是不打断主视图、可从侧边保留上下文 ***/
export function drawer(props: DrawerProps, ...children: unknown[]): HTMLElement {
const titleId = `ua-drawer-title-${++seq}`; // 标题 id:抽屉的可访问名
const modal = props.modal !== false; // 是否模态:false 时无遮罩、不锁滚动、不困焦点,页面仍可操作
const close = (): void => {
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
props.open(false);
props.onClose?.();
};
const overlay = h('div', {
class: () => `ua-drawer-overlay${props.open() ? ' ua-open' : ''}`,
class: () => `ua-drawer-overlay${props.open() ? ' ua-open' : ''}${modal ? '' : ' is-plain'}`,
onClick: (e: Event) => {
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
if (!modal) return; // 卫语句:非模态没有遮罩,点外面不算关闭
if (e.target !== overlay) return; // 卫语句:只处理点在遮罩本身的
if (props.closeOnOverlay === false) return; // 显式禁止时防误关,× 与 Esc 仍可用
close();
},
});
const panel = h(
'aside',
{
class: `ua-drawer ua-drawer-${props.placement ?? 'right'}`,
style: `width:${props.width ?? 320}px`,
style: `${props.placement === 'top' || props.placement === 'bottom' ? 'height' : 'width'}:${props.width ?? 320}px`, // 上下滑出改高度
role: 'dialog',
'aria-labelledby': titleId,
'aria-hidden': () => String(!props.open()),
'aria-modal': modal ? 'true' : 'false',
},
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-head' }, h('h3', { class: 'ua-drawer-title', id: titleId }, 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')); // 面板内可聚焦元素(排除禁用)
let release: (() => void) | null = null; // Esc 栈注销函数,关闭时释放
let releaseLock: (() => void) | null = null; // 背景滚动锁解锁函数,全关才还原
effect(() => {
if (props.open()) {
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
release ??= pushOverlay(close); // 入 Esc 栈:多弹层时只关最上层
if (modal) releaseLock ??= lockScroll(); // 仅模态期锁背景滚动
(panel.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
} else if (lastFocus) {
lastFocus.focus(); // 关闭时归还焦点
} else {
release?.(); // 出 Esc 栈
release = null;
releaseLock?.(); // 解锁背景滚动
releaseLock = null;
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();
});
if (modal) trapFocus(panel, () => props.open()); // 模态才困焦点:非模态要让用户能继续操作背后页面
return overlay;
}
+14
View File
@@ -0,0 +1,14 @@
import { h } from '@ui-arch/frame';
import type { EmptyProps } from './types.js';
/*** 空状态:居中占位提示,title/description 可选,action 放操作区(常见为按钮),icon 可换成自家插图 ***/
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.icon ?? '⌀'), // 装饰符号默认 ⌀,读屏跳过
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;
}
+22
View File
@@ -0,0 +1,22 @@
import { h } from '@ui-arch/frame';
import type { FloatButtonProps } from './types.js';
/*** 悬浮操作按钮:固定停靠左下 / 右下角,label 缺省为纯图标圆形;返回 fixed 包裹层,挂 body 才不被父级 overflow 裁剪 ***/
export function floatButton(props: FloatButtonProps): HTMLElement {
const cls = ['ua-fab', props.label ? 'has-label' : ''].filter(Boolean).join(' ');
const btn = h('button', {
class: cls,
type: 'button',
'aria-label': props.tooltip ?? props.label ?? '快捷操作', // 纯图标时必须有可读名称
title: props.tooltip ?? null,
},
props.icon ? h('span', { class: 'ua-fab-icon', 'aria-hidden': 'true' }, props.icon) : null,
props.label ? h('span', { class: 'ua-fab-label' }, props.label) : null,
props.badge != null ? h('span', { class: 'ua-fab-badge' }, String(props.badge)) : null);
if (props.onClick) btn.addEventListener('click', props.onClick);
const offset = `${props.offset ?? 24}px`; // 缺省离边 24px,与常见浮动件手感一致
return h('div', {
class: `ua-fab-wrap is-${props.position ?? 'right-bottom'}`,
style: `--ua-fab-offset:${offset}`,
}, btn);
}
+97 -32
View File
@@ -1,30 +1,48 @@
import { h } from '@ui-arch/frame';
import { effect, h } from '@ui-arch/frame';
import { button } from './button.js';
import { textField } from './textField.js';
import { grid } from './grid.js';
import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js';
/*** 执行单条规则:null 通过,字符串为错误消息 ***/
function checkRule(value: string, rule: FormRule): string | null {
/*** 执行规则的同步部分(required / min / max / pattern);null 通过,字符串为错误消息 ***/
function syncPart(value: string, rule: FormRule): string | null {
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
if (!value) return null; // 非必填且为空:其余规则跳过
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
if (rule.validator) {
const r = rule.validator(value);
if (r !== true) return typeof r === 'string' ? r : rule.message ?? '校验未通过';
}
return null;
}
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄供 form 聚合校验 ***/
/*** 同步求值单条规则:validator 返回 Promise 时视为待定(交给异步版),null 通过 ***/
function checkRule(value: string, rule: FormRule): string | null {
const syncMsg = syncPart(value, rule);
if (syncMsg || !value || !rule.validator) return syncMsg;
const r = rule.validator(value);
if (typeof r === 'object' && r !== null) return null; // Promise:异步规则,此处不下结论
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
}
/*** 异步求值单条规则:await validator,同步返回值的规则结论与同步版一致 ***/
async function checkRuleAsync(value: string, rule: FormRule): Promise<string | null> {
const syncMsg = syncPart(value, rule);
if (syncMsg || !value || !rule.validator) return syncMsg;
const r = await rule.validator(value);
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
}
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄(同步 / 异步两种校验)供 form 聚合 ***/
export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle {
// —— 状态集中声明 ——
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])];
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])]; // 必填规则排最前
const error = h('div', { class: 'ua-form-error' });
const hint = props.hint ? h('div', { class: 'ua-form-hint' }, props.hint) : null; // 辅助说明:报错时隐藏,避免与错误挤在一起
const label = h('label', { class: 'ua-form-label' }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null);
const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, error));
const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, hint, error));
if (props.span) el.style.gridColumn = `span ${props.span}`; // 多列布局里跨列;单列容器下由 CSS 还原为整行
// 条件显示:隐藏时顺带清掉错误态,否则「看不见的红框」会拦住提交
if (props.visible) effect(() => { el.hidden = !props.visible?.(); if (el.hidden) paint(null); });
let seq = 0; // 校验轮次:异步结论回来时若已有更新的一轮,丢弃旧结果(防乱序覆盖)
/*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/
const marked = (): HTMLElement[] => [
@@ -32,57 +50,104 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
...Array.from(control.querySelectorAll<HTMLElement>('input, textarea, select, .ua-select-trigger')),
];
/*** 执行全部规则:返回首个错误消息,同时同步错误区、控件态与 aria-invalid ***/
const validate = () => {
/*** 落笔:错误文案 + 控件红框 + aria-invalid,视觉与读屏同源 ***/
const paint = (msg: string | null): void => {
error.textContent = msg ?? '';
if (hint) hint.hidden = !!msg; // 有错时让位:同一位置只留一条提示
control.classList.toggle('ua-invalid', !!msg);
for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg));
};
/*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/
const validate = (): string | null => {
if (el.hidden) return null; // 隐藏项不参与校验:没显示的字段不该拦住提交
const value = props.value();
let msg: string | null = null;
for (const rule of rules) {
msg = checkRule(value, rule);
if (msg) break; // 首个失败即停止
}
error.textContent = msg ?? '';
control.classList.toggle('ua-invalid', !!msg);
for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); // 读屏同步错误态
paint(msg);
return msg;
};
/*** 异步校验:按序 await 每条规则,结论按轮次取舍,过期结果不动 UI ***/
const validateAsync = async (): Promise<string | null> => {
if (el.hidden) return null; // 隐藏项不参与异步校验,与同步版同一口径
seq += 1;
const mine = seq; // 本轮编号
const value = props.value();
let msg: string | null = null;
for (const rule of rules) {
msg = await checkRuleAsync(value, rule);
if (msg) break;
}
if (mine === seq) paint(msg); // 期间又有新校验:交给最新一轮落笔
return msg;
};
control.addEventListener('input', () => {
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
if (mode === 'input' || error.textContent) void validateAsync(); // input 模式即时校验;其余模式仅清已报错误
});
control.addEventListener('change', () => {
if (mode === 'input') validate(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
if (mode === 'input') void validateAsync(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
});
control.addEventListener('focusout', () => {
if (mode !== 'submit') validate(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
if (mode !== 'submit') void validateAsync(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
});
return { el, validate };
return { el, validate, validateAsync };
}
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit) ***/
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit,异步规则会等待) ***/
export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
// —— 状态集中声明 ——
/*** 整体提交:逐项校验,聚焦首个出错项并中止 ***/
const submit = () => {
const submitBtn = button({ label: props.submitText ?? '提交', type: 'submit' }) as HTMLButtonElement; // 走原生 submit,避免 click + submit 双触发
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', type: 'button', onClick: props.onReset }) : null; // 重置不得提交表单
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
const summary = h('div', { class: 'ua-form-summary', role: 'alert' }) as HTMLElement; // 错误汇总:读屏能一次听到共几处要改
summary.hidden = true;
let running = false; // 提交中标记:异步校验期间屏蔽重复提交
/*** 整体提交:逐项 await 校验(全部跑完以显示所有错误),有错聚焦首个并中止 ***/
const submit = async (): Promise<void> => {
if (running) return; // 卫语句:上一轮异步校验还没结束
running = true;
submitBtn.disabled = true; // 校验期间禁用,避免连点重复提交
let firstBad: HTMLElement | null = null;
let bad = 0; // 不通过的字段数
for (const item of items) {
if (item.validate()) {
(item.el.querySelector('input, textarea, select') as HTMLElement | null)?.focus();
return; // 卫语句:有错误不提交
}
const msg = await item.validateAsync();
if (!msg) continue; // 卫语句:本项通过
bad += 1;
if (firstBad) continue; // 只把首个错误控件留作焦点目标
firstBad = item.el.querySelector('input, textarea, select') as HTMLElement | null;
}
submitBtn.disabled = false;
running = false;
summary.hidden = bad === 0;
summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : '';
if (firstBad) { firstBad.focus(); props.onError?.(bad); return; } // 卫语句:有错误不提交
props.onSubmit?.();
};
const submitBtn = button({ label: props.submitText ?? '提交', onClick: submit });
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', onClick: props.onReset }) : null;
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
const fields = items.map((i) => i.el); // 字段节点:单列直接铺,多列交给栅格
const board = props.columns && props.columns > 1 ? grid({ columns: props.columns, gutter: 'lg' }, ...fields) : fields; // 多列布局复用 grid
const el = h(
'form',
{ class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' },
...items.map((i) => i.el),
props.errorSummary === false ? null : summary, // 汇总条:关掉时连节点都不插
board, // 多列时是一层 grid,单列时是字段数组(h 自动扁平化)
actions,
);
el.addEventListener('submit', (e) => {
e.preventDefault(); // 拦截原生提交,走校验流程
submit();
void submit();
});
el.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.target instanceof HTMLTextAreaElement) return; // 多行输入框允许换行
if (!(e.target instanceof HTMLInputElement)) return; // 只接管单行输入框
e.preventDefault(); // 拦下浏览器隐式提交,统一走校验
void submit();
});
return el;
}
+21
View File
@@ -0,0 +1,21 @@
import { h } from '@ui-arch/frame';
import type { GridProps } from './types.js';
// 间距档位 → 令牌(数字档位不入表,直接拼像素)
const GUTTER: Record<string, string> = { sm: 'var(--ua-space-2)', md: 'var(--ua-space-3)', lg: 'var(--ua-space-4)' };
// 交叉轴对齐 → grid align-items 取值
const ALIGN: Record<string, string> = { start: 'start', center: 'center', end: 'end', stretch: 'stretch' };
/*** 栅格容器:columns 固定列数、min 走 auto-fit 自适应列宽,gutter 统一列间距;列模板写进 --ua-grid-cols,窄屏由样式表降为单列 ***/
export function grid(props: GridProps, ...children: unknown[]): HTMLElement {
const gutter = typeof props.gutter === 'number' ? `${props.gutter}px` : GUTTER[props.gutter ?? 'md']; // 数字档位按像素透传
const template = props.min
? `repeat(auto-fit, minmax(${props.min}, 1fr))` // 自适应:列数由可用宽度决定
: `repeat(${props.columns ?? 3}, minmax(0, 1fr))`; // 固定列数:minmax(0,1fr) 防止长内容撑破
const el = h('div', {
class: ['ua-grid', props.dense ? 'is-dense' : '', props.align ? `is-align-${props.align}` : ''].filter(Boolean).join(' '),
style: `gap:${gutter};align-items:${ALIGN[props.align ?? 'stretch']};`,
}, ...children);
el.style.setProperty('--ua-grid-cols', template); // 列模板交给 CSS 变量,媒体查询可覆盖
return el;
}
+46
View File
@@ -0,0 +1,46 @@
import { h } from '@ui-arch/frame';
import type { HeatmapProps } from './types.js';
// 缺省:最近 12 周、格子 11px、周一为首行——与国内日历习惯一致
const WEEKS = 12;
const CELL = 11;
const DAY = 86_400_000;
const LEVELS = 4; // 非零分四档,加上「零」共五档
/*** 补零与日期键:热力图的键必须与外部数据同一格式(YYYY-MM-DD) ***/
const pad = (n: number): string => String(n).padStart(2, '0');
const keyOf = (d: Date): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
/*** 日历热力图:按周列 × 星期行铺格子,颜色深浅表示当天数值;不画坐标轴,靠 title 看具体日期与数量 ***/
export function heatmap(props: HeatmapProps): HTMLElement {
const weeks = props.weeks ?? WEEKS;
const cell = props.cellSize ?? CELL;
const counts = new Map<string, number>();
for (const item of props.values) counts.set(item.date, (counts.get(item.date) ?? 0) + item.count); // 同一天多条求和,不覆盖
const max = Math.max(1, ...counts.values()); // 分母:至少 1,避免全零时除零
// 末列对齐到「本周」:先取今天,再回退到本周末,保证最后一列是完整 7 行
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = new Date(today.getTime() + (6 - ((today.getDay() + 6) % 7)) * DAY); // 周一起算的第 6 天(周日)
const start = new Date(end.getTime() - (weeks * 7 - 1) * DAY);
const grid = h('div', { class: 'ua-heat-grid', role: 'img', 'aria-label': props.ariaLabel ?? `最近 ${weeks} 周活跃度` });
grid.style.gridTemplateRows = `repeat(7, ${cell}px)`; // 七行对应周一到周日,行高与格子边长同源
for (let i = 0; i < weeks * 7; i += 1) {
const d = new Date(start.getTime() + i * DAY);
const n = counts.get(keyOf(d)) ?? 0;
const level = n === 0 ? 0 : Math.min(LEVELS, Math.ceil((n / max) * LEVELS)); // 归一成四档,最大那天必落在最高档
const box = h('span', { class: `ua-heat-cell is-${level}` });
box.style.width = `${cell}px`;
box.style.height = `${cell}px`;
box.title = props.formatValue ? props.formatValue(n, keyOf(d)) : `${keyOf(d)} · ${n}`; // 悬停看明细:热力图本身不标数字
grid.append(box);
}
const legend = h(
'div',
{ class: 'ua-heat-legend' },
h('span', { class: 'ua-heat-hint' }, props.legendText ?? '少'),
...[0, 1, 2, 3, 4].map((lv) => { const sw = h('span', { class: `ua-heat-cell is-${lv}` }); sw.style.width = `${cell}px`; sw.style.height = `${cell}px`; return sw; }), // 图例色块与网格同尺寸:两处各写一个尺寸会差出一号
h('span', { class: 'ua-heat-hint' }, '多'),
);
return h('div', { class: 'ua-heat' }, grid, legend);
}
+30
View File
@@ -0,0 +1,30 @@
import { effect, h, type Signal } from '@ui-arch/frame';
import type { HighlightProps } from './types.js';
/*** 作用:读信号或原始值(string | Signal<string> 统一取值) ***/
const read = (v: string | Signal<string>): string => (typeof v === 'function' ? v() : v);
/*** 关键词高亮:命中片段包进 mark(大小写不敏感、全部命中),text / keyword 均支持信号响应式刷新,空关键词原样展示 ***/
export function highlight(props: HighlightProps): HTMLSpanElement {
const root = h('span', { class: 'ua-highlight' }) as HTMLSpanElement;
/*** 作用:按当前 text / keyword 重画内容:命中段包 mark,未命中段原样拼接(effect 追踪两个信号,任一变化重画) ***/
const render = (): void => {
const text = read(props.text);
const kw = read(props.keyword).trim();
root.textContent = '';
if (!kw) { root.textContent = text; return; } // 空关键词不高亮
const lower = text.toLowerCase();
const k = kw.toLowerCase();
let cursor = 0;
for (let idx = lower.indexOf(k); idx !== -1; idx = lower.indexOf(k, cursor)) {
if (idx > cursor) root.append(text.slice(cursor, idx)); // 命中前缀原样
root.append(h('mark', { class: 'ua-highlight-mark' }, text.slice(idx, idx + k.length))); // 命中段包 mark
cursor = idx + k.length;
}
if (cursor < text.length) root.append(text.slice(cursor)); // 尾段原样
};
effect(render);
return root;
}
+30
View File
@@ -0,0 +1,30 @@
/*** 内联图标工厂:组件内部用小图标一律走 SVG,不用 emoji —— emoji 在 Windows / macOS 会渲染成彩色位图,尺寸、颜色、基线都不可控 ***/
const NS = 'http://www.w3.org/2000/svg'; // SVG 命名空间常量,createElementNS 必填
/*** 放大镜图标:搜索框的默认前缀。外层 span 承载布局,返回 HTMLElement 以兼容 prefix 入参类型 ***/
export function iconSearch(): HTMLElement {
const holder = document.createElement('span');
holder.style.display = 'inline-flex'; // 让图标与输入文本在同一基线居中
holder.setAttribute('aria-hidden', 'true'); // 纯装饰:语义由输入框 placeholder / label 承担
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('width', '14');
svg.setAttribute('height', '14');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor'); // 跟随主题文字色,暗色自动翻转
svg.setAttribute('stroke-width', '1.5');
svg.setAttribute('stroke-linecap', 'round');
const lens = document.createElementNS(NS, 'circle');
lens.setAttribute('cx', '7');
lens.setAttribute('cy', '7');
lens.setAttribute('r', '4.5');
const handle = document.createElementNS(NS, 'line');
handle.setAttribute('x1', '10.5');
handle.setAttribute('y1', '10.5');
handle.setAttribute('x2', '14');
handle.setAttribute('y2', '14');
svg.append(lens, handle);
holder.append(svg);
return holder;
}
+47
View File
@@ -0,0 +1,47 @@
import { h } from '@ui-arch/frame';
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
import type { ImageProps } from './types.js';
/*** 图片:缩略图展示,点击 / 回车打开遮罩大图预览(Esc 或点击遮罩关闭),加载失败自动落占位;样式只消费 --ua-* 令牌 ***/
export function image(props: ImageProps): HTMLElement {
const style = props.width ? `width:${props.width}px` : '';
const img = h('img', {
class: 'ua-image',
src: props.src,
alt: props.alt ?? '',
style,
tabindex: '0', // 可聚焦,键盘用户回车预览
role: 'button',
'aria-label': '预览图片',
onclick: () => openViewer(props.src, props.alt),
onkeydown: (e: KeyboardEvent) => e.key === 'Enter' && openViewer(props.src, props.alt),
});
img.addEventListener('error', () => {
const box = h('span', { class: 'ua-image-fallback', style, role: 'img', 'aria-label': props.fallback ?? '图片加载失败' }, props.fallback ?? '图片加载失败');
img.replaceWith(box); // 失败兜底:整块替换,避免残留破图图标
});
return img;
}
/*** 打开全屏遮罩预览层;关闭时自清理、解绑键盘并归还焦点 ***/
function openViewer(src: string, alt?: string): void {
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:预览层占一层,关闭即释放
const releaseLock = lockScroll(); // 预览期锁背景滚动
const prev = document.activeElement as HTMLElement | null; // 焦点归还目标
const mask = h(
'div',
{ class: 'ua-image-mask', role: 'dialog', 'aria-modal': 'true', 'aria-label': '图片预览', tabindex: '-1' },
h('img', { class: 'ua-image-full', src, alt: alt ?? '' }),
);
const close = () => {
mask.remove(); // 自清理:遮罩移除 + 出 Esc 栈 + 焦点归还
releaseEsc?.();
releaseLock();
prev?.focus?.();
};
mask.addEventListener('click', close);
releaseEsc = pushOverlay(close); // 入 Esc 栈:预览层压在最上面,Esc 先关它
trapFocus(mask, () => true); // 预览层常开:Tab 全程锁在遮罩内(层内无控件,等于整体拦截)
document.body.appendChild(mask);
mask.focus(); // 焦点移入预览层,读屏与键盘都不再落在背后的页面
}
+56
View File
@@ -5,8 +5,10 @@ export { inputNumber } from './inputNumber.js';
export { textarea } from './textarea.js';
export { slider } from './slider.js';
export { checkbox } from './checkbox.js';
export { checkboxGroup } from './checkboxGroup.js';
export { switchButton } from './switch.js';
export { select } from './select.js';
export { multiSelect } from './multiSelect.js';
export { menu } from './menu.js';
export { radioGroup } from './radioGroup.js';
export { accordion } from './accordion.js';
@@ -15,17 +17,71 @@ export { card } from './card.js';
export { badge } from './badge.js';
export { tag } from './tag.js';
export { avatar } from './avatar.js';
export { image } from './image.js';
export { alert } from './alert.js';
export { toast, showToast } from './toast.js';
export { notification } from './notification.js';
export { tooltip } from './tooltip.js';
export { popover } from './popover.js';
export { flip } from './position.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 { segmented } from './segmented.js';
export { stepper } from './stepper.js';
export { rate } from './rate.js';
export { empty } from './empty.js';
export { result } from './result.js';
export { divider } from './divider.js';
export { space } from './space.js';
export { list } from './list.js';
export { carousel } from './carousel.js';
export { tree } from './tree.js';
export { upload } from './upload.js';
export { descriptions } from './descriptions.js';
export { statistic } from './statistic.js';
export { timeline } from './timeline.js';
export { modal } from './modal.js';
export { drawer } from './drawer.js';
export { form, formItem } from './form.js';
export { dataTable } from './dataTable.js';
export { transfer } from './transfer.js';
export { cascader } from './cascader.js';
export { splitter } from './splitter.js';
export { autoComplete } from './autoComplete.js';
export { datePicker } from './datePicker.js';
export { backTop } from './backTop.js';
export { colorPicker } from './colorPicker.js';
export { tour } from './tour.js';
export { watermark } from './watermark.js';
export { affix } from './affix.js';
export { anchor } from './anchor.js';
export { popconfirm } from './popconfirm.js';
export { calendar } from './calendar.js';
export { typography } from './typography.js';
export { grid } from './grid.js';
export { floatButton } from './floatButton.js';
export { mentions } from './mentions.js';
export { treeSelect } from './treeSelect.js';
export { link } from './link.js';
export { avatarGroup } from './avatarGroup.js';
export { virtualList } from './virtualList.js';
export { commandPalette } from './commandPalette.js';
export { timePicker } from './timePicker.js';
export { pinInput } from './pinInput.js';
export { copyButton } from './copyButton.js';
export { countdown } from './countdown.js';
export { kbd } from './kbd.js';
export { highlight } from './highlight.js';
export { inputTag } from './inputTag.js';
export { layout } from './layout.js';
export { jsonViewer } from './jsonViewer.js';
export { diffView } from './diffView.js';
export { codeBlock } from './codeBlock.js';
export { signaturePad } from './signaturePad.js';
export { loading } from './loading.js';
export { relativeTime } from './relativeTime.js';
export { sparkline } from './sparkline.js';
export { heatmap } from './heatmap.js';
+19 -6
View File
@@ -1,16 +1,18 @@
import { effect, h } from '@ui-arch/frame';
import { button } from './button.js';
import { addonWrap } from './addons.js';
import type { InputNumberProps } from './types.js';
/*** 数字输入:−/+ 按钮 + 输入框三段式,value 信号双向绑定,min/max/step 超界自动夹取,键盘 ↑↓ 调节 ***/
export function inputNumber(props: InputNumberProps): HTMLDivElement {
/*** 数字输入:−/+ 按钮 + 输入框三段式,value 信号双向绑定,min/max/step 超界自动夹取,precision 小数位归一,键盘 ↑↓ 调节,长按连发步进 ***/
export function inputNumber(props: InputNumberProps): HTMLElement {
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; }; // 外部变化写回输入框
const quantize = (n: number): number => (props.precision === undefined ? n : Number(n.toFixed(props.precision))); // precision 归一(顺带消除步进浮点尾差)
const emit = (v: number): void => { const n = quantize(clamp(v)); props.value(n); if (props.onChange) props.onChange(n); }; // 写回信号并回调
const input = h('input', { class: `ua-input ua-input-number-field${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`, inputmode: 'decimal', disabled: props.disabled, 'aria-label': '数字输入' }) as HTMLInputElement;
const sync = (): void => { const text = props.precision === undefined ? String(props.value()) : props.value().toFixed(props.precision); if (input.value !== text) input.value = text; }; // 外部变化写回输入框(precision 定长显示)
effect(sync);
input.addEventListener('change', () => { const n = parseFloat(input.value); if (Number.isNaN(n)) sync(); else emit(n); }); // 非法输入回滚当前值
input.addEventListener('keydown', (e: KeyboardEvent) => {
@@ -22,5 +24,16 @@ export function inputNumber(props: InputNumberProps): HTMLDivElement {
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;
// —— 长按连发:400ms 延迟后每 100ms 连续步进,松开 / 移出即停(单击行为不受影响) ——
let holdTimer = 0; // 长按首步延迟计时器
let repeatTimer = 0; // 连发重复计时器
const stopHold = (): void => { window.clearTimeout(holdTimer); window.clearInterval(repeatTimer); holdTimer = 0; repeatTimer = 0; }; // 停止连发
const hold = (dir: number): void => { // dir:-1 减 / 1 加
stopHold(); // 重复按下先清旧计时
holdTimer = window.setTimeout(() => { repeatTimer = window.setInterval(() => emit(props.value() + dir * step), 100); }, 400);
};
dec.addEventListener('pointerdown', () => hold(-1));
inc.addEventListener('pointerdown', () => hold(1));
['pointerup', 'pointerleave', 'pointercancel'].forEach((ev) => { dec.addEventListener(ev, stopHold); inc.addEventListener(ev, stopHold); }); // 任一中止事件都停
return addonWrap(h('div', { class: 'ua-input-number', role: 'group' }, dec, input, inc) as HTMLDivElement, props.prefix, props.suffix);
}
+78
View File
@@ -0,0 +1,78 @@
import { effect, h, type Signal } from '@ui-arch/frame';
import type { InputTagProps } from './types.js';
/*** 作用:把布尔或信号口径的禁用态读成当前值(供渲染时统一判断,天然跟随信号) ***/
const off = (v: boolean | Signal<boolean> | undefined): boolean => (typeof v === 'function' ? v() : Boolean(v));
/*** 作用:标签输入——回车 / 逗号成标签、chip 逐个删、空框退格删末个、失焦自动入列;外框与 chip 复用现有类,零新增视觉字面量 ***/
export function inputTag(props: InputTagProps): HTMLElement {
const root = h('div', { class: 'ua-input-group ua-input-tag' }) as HTMLElement;
const hint = props.placeholder ?? '输入后回车添加'; // 未满时的占位文案
const input = h('input', { class: 'ua-input', placeholder: hint, 'aria-label': props.ariaLabel ?? '标签输入' }) as HTMLInputElement;
const limit = props.max ?? 0; // 0 = 不限条数
const maxLength = props.maxLength ?? 24; // 单个标签超长直接截断,防止一个标签撑爆整行
const allowDuplicate = props.allowDuplicate ?? false;
const SEP = /[,,\n\t]+/; // 多值粘贴的分隔符:半角 / 全角逗号、换行、制表符(从表格或列表里整段粘来最常见)
root.append(input);
/*** 作用:写回标签集合(单一出口:信号与 onChange 永远同源,避免两处各写一次) ***/
const put = (next: string[]): void => {
props.value(next);
props.onChange?.(next);
};
/*** 作用:追加一个标签:截断超长、丢弃重复与超限,并把输入框清空 ***/
const commit = (raw: string): void => {
const text = raw.trim().slice(0, maxLength);
input.value = '';
if (!text) return;
const list = props.value();
if (limit > 0 && list.length >= limit) return; // 已达上限:静默丢弃,输入框已清空
if (!allowDuplicate && list.includes(text)) return; // 重复标签:不重复入列
put([...list, text]);
};
/*** 作用:按信号重画 chip 与输入框状态(chip 全量重建,input 始终留在末尾) ***/
const render = (): void => {
const list = props.value();
root.querySelectorAll('.ua-tag').forEach((node) => node.remove()); // 先清后建:条数少,重建比 diff 稳
for (const tag of list) {
const close = h('button', {
type: 'button',
class: 'ua-tag-close',
'aria-label': `移除标签 ${tag}`,
onClick: () => {
put(props.value().filter((item) => item !== tag)); // 按值删,不用索引(外部可能已改序)
input.focus(); // 焦点归还:chip 一删按钮就消失,不接住焦点会掉回 body
},
}, '×');
root.insertBefore(h('span', { class: 'ua-tag' }, h('span', {}, tag), close), input);
}
const full = limit > 0 && list.length >= limit;
input.disabled = off(props.disabled) || full; // 满额即禁输:占位文案同时改成「已达上限」
input.placeholder = full ? `最多 ${limit} 个标签` : hint;
};
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
event.preventDefault(); // 回车不提交外层表单;逗号不留进输入值
commit(input.value);
return;
}
if (event.key === 'Backspace' && input.value === '') {
const list = props.value();
if (list.length) put(list.slice(0, -1)); // 空框退格:删掉最后一个 chip
}
});
input.addEventListener('paste', (event) => {
const text = event.clipboardData?.getData('text') ?? '';
if (!SEP.test(text)) return; // 单值粘贴交给浏览器默认行为,不打断普通输入
event.preventDefault();
text.split(SEP).forEach((piece) => commit(piece)); // 多值粘贴:拆成多个 chip,逐个过截断 / 去重 / 上限
});
input.addEventListener('blur', () => commit(input.value)); // 失焦即入列:手输后忘了回车也不会丢
root.addEventListener('click', () => input.focus()); // 点外框任意位置都聚焦输入框
effect(render);
return root;
}
+54
View File
@@ -0,0 +1,54 @@
import { h } from '@ui-arch/frame';
import type { JsonViewerProps } from './types.js';
// 值类型归类:数组 / 对象算分支,其余算叶子(null 单独一类,避免被 typeof 报成 object)
type JsonKind = 'array' | 'object' | 'string' | 'number' | 'boolean' | 'null';
const kindOf = (v: unknown): JsonKind => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'object' ? 'object' : (typeof v as JsonKind));
/*** 叶子值文案:字符串补引号,其余按字面;null 用灰字斜体在样式层区分 ***/
function leafText(v: unknown): string {
return typeof v === 'string' ? `"${v}"` : String(v);
}
/*** 分支摘要:收起时也要看得出里面有多少内容,否则无法判断值不值得展开 ***/
function summaryOf(v: unknown): string {
if (Array.isArray(v)) return `${v.length} 项`;
const n = Object.keys(v as Record<string, unknown>).length; // 对象分支:按自有键计数
return `${n} 个键`;
}
/*** JSON 树查看:按层折叠展示任意值,数组 / 对象可展开收起,叶子按类型着色;只读、不解析编辑 ***/
export function jsonViewer(props: JsonViewerProps): HTMLElement {
const rootLabel = props.rootLabel ?? 'root'; // 根标签:贴顶那一行的键名
const openDepth = props.depth ?? 2; // 默认展开层数:2 层能看出一级结构又不至于刷屏
const root = h('div', { class: 'ua-json', role: 'tree', 'aria-label': props.ariaLabel ?? 'JSON 数据' }) as HTMLDivElement;
if (props.maxHeight !== undefined) root.style.maxHeight = `${props.maxHeight}px`; // 只在给了上限时才限高,缺省随内容
/*** 单行渲染:分支行带开关与计数摘要,叶子行只带类型类名 ***/
const row = (key: string, value: unknown, level: number, path: string): HTMLElement => {
const kind = kindOf(value);
const branch = kind === 'array' || kind === 'object';
const label = h('span', { class: 'ua-json-key' }, key); // 键名:数组下标也用 key 位展示
if (!branch) {
return h('div', { class: 'ua-json-row is-leaf', role: 'treeitem' }, label, h('span', { class: 'ua-json-colon' }, ':'), h('span', { class: `ua-json-val ua-json-${kind}` }, leafText(value)));
}
const box = h('div', { class: 'ua-json-children', role: 'group' }); // 子节点容器:收起时整块隐藏,DOM 保留折叠态
const caret = h('span', { class: 'ua-json-caret', 'aria-hidden': 'true' }, '▸'); // 开关图标:方向由 aria-expanded 决定样式
const toggle = h('button', {
type: 'button',
class: 'ua-json-toggle',
'aria-expanded': () => String(!box.hidden),
'aria-label': () => `${box.hidden ? '展开' : '收起'} ${path}`,
onClick: () => { box.hidden = !box.hidden; }, // 只翻 hidden:样式按 :has 或 aria-expanded 跟随
}, caret, label, h('span', { class: 'ua-json-colon' }, ':'), h('span', { class: 'ua-json-sum' }, `${kind === 'array' ? '[' : '{'}${summaryOf(value)}${kind === 'array' ? ']' : '}'}`));
const entries: [string, unknown][] = kind === 'array'
? (value as unknown[]).map((v, i) => [String(i), v]) // 数组下标转字符串,走同一套行渲染
: Object.entries(value as Record<string, unknown>);
for (const [k, v] of entries) box.append(row(k, v, level + 1, `${path}.${k}`)); // 递归:路径用点号拼接,报给读屏时能定位
box.hidden = level >= openDepth; // 超出默认层数的一律先收起,用户按需展开
return h('div', { class: 'ua-json-branch', role: 'treeitem', 'aria-level': String(level + 1) }, toggle, box);
};
root.append(row(rootLabel, props.value, 0, rootLabel));
return root;
}
+10
View File
@@ -0,0 +1,10 @@
import { h } from '@ui-arch/frame';
import type { KbdProps } from './types.js';
/*** 键盘按键标记:键帽样式展示单个按键或组合键(多键以 + 连接),用于快捷键提示 ***/
export function kbd(props: KbdProps): HTMLElement {
const keys = Array.isArray(props.keys) ? props.keys : [props.keys]; // 统一成数组:单键 / 组合键同一渲染路径
const parts: (Node | null)[] = keys.map((k, i) => (i > 0 ? h('span', { class: 'ua-kbd-sep', 'aria-hidden': 'true' }, '+') : null)); // 分隔符占位
keys.forEach((k, i) => parts[i * 2 + 1] = h('kbd', { class: 'ua-kbd-key' }, k)); // 键帽交错插入:sep, key, sep, key…
return h('span', { class: 'ua-kbd' }, ...parts);
}
+32
View File
@@ -0,0 +1,32 @@
import { h, signal } from '@ui-arch/frame';
import type { LayoutProps } from './types.js';
// 侧栏展开态缺省宽度(px):够放下四个汉字的菜单项,再窄就要靠折叠态图标栏
const SIDER_DEFAULT = 220;
/*** 布局骨架:页头 / 侧栏 / 内容 / 页脚四区一次成型,侧栏可折叠(受控或自管);只管骨架与间距,导航内容交给 menu / anchor ***/
export function layout(props: LayoutProps, ...children: unknown[]): HTMLElement {
const collapsed = props.collapsed ?? signal(false); // 折叠态:外部给了信号就双向绑定,没给就内部自管
const width = props.siderWidth ?? SIDER_DEFAULT; // 侧栏宽度:折叠时由样式表收到 0,这里只给展开值
const toggle = (): void => { collapsed(!collapsed()); }; // 折叠开关:只翻信号,视觉由类名响应
const rootCls = ['ua-layout', props.sticky ? 'is-sticky' : ''].filter(Boolean).join(' '); // 根类名:吸顶页头是同一个容器上的修饰类
const header = props.header === undefined
? null // 没有页头就不渲染容器,避免多一层空盒子影响 :first-child 选择器
: h(
'header',
{ class: 'ua-layout-header' },
props.collapsible ? h('button', { class: 'ua-layout-toggle', type: 'button', 'aria-expanded': () => String(!collapsed()), 'aria-label': '折叠或展开侧栏', onClick: toggle }, '⟨⟩') : null,
props.header,
);
const sider = props.sider === undefined
? null // 没有侧栏时内容区独占整行,省掉一条 220px 的空白
: h('aside', {
class: () => `ua-layout-sider${collapsed() ? ' is-collapsed' : ''}`,
style: `width:${width}px`, // 展开宽度内联,折叠后的收拢宽度交给类名(0 + overflow hidden)
'aria-hidden': () => (collapsed() ? 'true' : null), // 折叠后视觉消失,读屏也不再报里面的菜单
}, props.sider);
const content = h('main', { class: 'ua-layout-content' }, ...children);
const body = sider ? h('div', { class: 'ua-layout-body' }, sider, content) : content; // 无侧栏时不套 body 层
const footer = props.footer === undefined ? null : h('footer', { class: 'ua-layout-footer' }, props.footer);
return h('div', { class: rootCls }, header, body, footer) as HTMLElement;
}
+25
View File
@@ -0,0 +1,25 @@
import { h } from '@ui-arch/frame';
import type { LinkProps } from './types.js';
/*** 链接:站内跳转与站外跳转统一入口;external 自动补 ↗ 与 rel,disabled 去掉 href 并拦点击 ***/
export function link(props: LinkProps): HTMLElement {
const external = Boolean(props.external) || props.target === '_blank'; // 新窗口一律按站外处理
const cls = ['ua-link', props.muted ? 'is-muted' : '', props.disabled ? 'is-disabled' : '', external ? 'is-external' : '']
.filter(Boolean).join(' ');
const a = h('a', {
class: cls,
href: props.disabled ? null : props.href, // 禁用态去掉 href:键盘也不再当链接用
target: external ? '_blank' : props.target ?? null,
rel: external ? 'noopener noreferrer' : null, // 新窗口必须断开 opener,防目标页反向操控
'aria-disabled': props.disabled ? 'true' : null,
onclick: (e: Event) => {
if (props.disabled) {
e.preventDefault(); // 保留可聚焦但不可跳转
return;
}
props.onClick?.(e);
},
}, props.label, external ? h('span', { class: 'ua-link-icon', 'aria-hidden': 'true' }, '↗') : null);
if (external) a.append(h('span', { class: 'ua-visually-hidden' }, '(在新窗口打开)')); // 读屏需知道开了新窗
return a;
}
+36
View File
@@ -0,0 +1,36 @@
import { h } from '@ui-arch/frame';
import type { ListProps } from './types.js';
import { empty } from './empty.js';
/*** 列表:header/footer 外壳 + 逐条 items(avatar / title / description / extra),传 onItemClick 时条目可点击,无数据回落空状态 ***/
export function list(props: ListProps): HTMLElement {
// —— 状态集中声明 ——
const clickable = typeof props.onItemClick === 'function'; // 仅提供回调时才加可点击样式
const rootClass = props.bordered ? 'ua-list ua-list-bordered' : 'ua-list'; // 外边框由 bordered 决定
const root = h('div', { class: rootClass }) as HTMLElement; // 列表根容器
if (props.header) root.append(h('div', { class: 'ua-list-header' }, props.header));
if (!props.items.length) {
root.append(empty({ title: props.emptyText ?? '暂无数据' })); // 空列表复用空状态组件
return root;
}
const body = h('div', { class: 'ua-list-body', role: 'list' }) as HTMLElement; // 条目容器,供读屏识别列表语义
props.items.forEach((item, index) => {
const row = h(
'div',
{ class: clickable ? 'ua-list-item ua-list-clickable' : 'ua-list-item', role: 'listitem' },
item.avatar ? h('div', { class: 'ua-list-avatar' }, item.avatar) : null, // 左侧头像/图标,缺省不留位
h(
'div',
{ class: 'ua-list-main' },
h('div', { class: 'ua-list-title' }, item.title),
item.description ? h('div', { class: 'ua-list-desc' }, item.description) : null,
),
item.extra ? h('div', { class: 'ua-list-extra' }, item.extra) : null, // 右侧附加(时间、标签)
);
if (clickable) row.addEventListener('click', () => props.onItemClick?.(item, index)); // 点击回传条目与下标
body.append(row);
});
root.append(body);
if (props.footer) root.append(h('div', { class: 'ua-list-footer' }, props.footer));
return root;
}
+39
View File
@@ -0,0 +1,39 @@
import { effect, h } from '@ui-arch/frame';
import { spinner } from './spinner.js';
import type { LoadingProps } from './types.js';
// 缺省延迟:120ms 内的请求不闪遮罩,否则「点了就闪一下」比不显示更闹
const DELAY = 120;
/*** 区域加载遮罩:包住一块内容,active 为真时盖上层遮罩 + 转圈 + 文案;带延迟出场,快请求不闪、慢请求必有反馈 ***/
export function loading(props: LoadingProps, ...children: unknown[]): HTMLElement {
const delay = props.delay ?? DELAY; // 延迟毫秒
let timer = 0; // 延迟计时器
const read = (): boolean => (typeof props.active === 'function' ? Boolean(props.active()) : props.active === true); // 布尔与信号同一口径
const mask = h(
'div',
{ class: 'ua-loading-mask' },
spinner({ size: props.spinnerSize ?? 20, label: props.text ?? '内容加载中' }), // 转圈自带 role=status,这里只用它的可访问名
props.text ? h('span', { class: 'ua-loading-text' }, props.text) : null,
);
const wrap = h(
'div',
{ class: 'ua-loading' },
h('div', { class: 'ua-loading-body' }, ...children), // 内容层:遮罩出现时整体降透明度,但仍留在原位不塌陷
mask,
);
if (props.minHeight !== undefined) wrap.style.minHeight = `${props.minHeight}px`; // 内容为空时也要撑出高度,遮罩才盖得住
/*** 状态落点:is-active 控制遮罩显隐,aria-busy 让读屏知道这块在等数据 ***/
const paint = (on: boolean): void => {
wrap.classList.toggle('is-active', on);
wrap.setAttribute('aria-busy', String(on));
};
effect(() => {
window.clearTimeout(timer);
if (!read()) { paint(false); return; } // 关得快:立刻收,不等延迟
timer = window.setTimeout(() => paint(true), delay); // 开得慢:先等一会儿,避免快速请求闪一下
});
paint(read()); // 首帧:初值为真时直接亮(延迟只管后续变化)
return wrap;
}
+114
View File
@@ -0,0 +1,114 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { MentionItem, MentionsProps } from './types.js';
// 每个实例的 listbox id 前缀,保证 aria-activedescendant 唯一
let seq = 0;
/*** 取光标前的 @ 触发词(不含 @ 本身):没有未闭合的 @ 片段时返回 null ***/
function tokenBefore(text: string, caret: number): string | null {
const match = /@([^\s@]*)$/.exec(text.slice(0, caret));
return match ? match[1] : null;
}
/*** 用选中的名字替换光标前的 @片段,返回替换后的全文与新光标位置 ***/
function applyPick(text: string, caret: number, name: string): { text: string; caret: number } {
const head = text.slice(0, caret).replace(/@[^\s@]*$/, `@${name} `);
return { text: head + text.slice(caret), caret: head.length };
}
/*** @提及输入:textarea 内键入 @ 弹候选,↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层;弹层贴输入框下沿,选中按光标替换 @片段 ***/
export function mentions(props: MentionsProps): HTMLElement {
const uid = `ua-mentions-${++seq}`;
const items = props.items;
const value = props.value ?? signal('');
const open = signal(false); // 弹层开合
const query = signal(''); // 当前 @ 后的过滤词
const active = signal(0); // 高亮项下标
const input = h('textarea', {
class: 'ua-mentions-input',
rows: String(props.rows ?? 3),
placeholder: props.placeholder ?? '',
'aria-autocomplete': 'list',
'aria-controls': uid,
'aria-expanded': 'false',
}) as HTMLTextAreaElement;
input.value = value();
const list = h('div', { class: 'ua-mentions-pop', role: 'listbox', id: uid });
const wrap = h('div', { class: 'ua-mentions' }, input, list);
// 候选:空过滤词给全量,否则按名字 / 关键词做包含匹配
const filtered = (): MentionItem[] => (query()
? items.filter((it) => it.name.includes(query()) || (it.keywords ?? []).some((k) => k.includes(query())))
: items);
/*** 重绘弹层:同步显隐、高亮项与 aria-activedescendant(无候选时不开层) ***/
const paint = (): void => {
const shown = filtered();
const visible = open() && shown.length > 0;
wrap.classList.toggle('is-open', visible);
input.setAttribute('aria-expanded', String(visible));
list.replaceChildren(...shown.map((item, i) => h('div', {
class: `ua-mentions-item${i === active() ? ' is-active' : ''}`,
role: 'option',
id: `${uid}-${i}`,
'aria-selected': i === active() ? 'true' : 'false',
onmousedown: (e: Event) => { e.preventDefault(); pick(item); }, // 阻止默认让 textarea 不失焦
}, h('span', { class: 'ua-mentions-name' }, item.label),
item.description ? h('span', { class: 'ua-mentions-desc' }, item.description) : null)));
if (visible) input.setAttribute('aria-activedescendant', `${uid}-${active()}`);
else input.removeAttribute('aria-activedescendant');
};
/*** 选中候选:替换光标前的 @片段、写回信号、光标落到插入文本之后 ***/
const pick = (item: MentionItem): void => {
const caret = input.selectionStart ?? input.value.length;
const next = applyPick(input.value, caret, item.name);
input.value = next.text;
value(next.text);
input.setSelectionRange(next.caret, next.caret);
input.focus();
open(false);
query('');
paint();
props.onSelect?.(item);
};
input.addEventListener('input', () => {
value(input.value);
props.onInput?.(input.value);
const token = tokenBefore(input.value, input.selectionStart ?? input.value.length);
query(token ?? '');
active(0); // 每次输入重新从第一项开始高亮
open(token !== null);
paint();
});
input.addEventListener('keydown', (e) => {
if (!open()) return; // 未开层时所有按键交给浏览器
const shown = filtered();
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation(); // 只收本层,不连带外层弹层
open(false);
paint();
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
active((active() + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length); // 环形移动
paint();
return;
}
if ((e.key === 'Enter' || e.key === 'Tab') && shown.length) {
e.preventDefault();
pick(shown[Math.min(active(), shown.length - 1)]);
}
});
input.addEventListener('blur', () => { open(false); paint(); }); // 失焦收起
effect(() => {
const outer = value();
if (outer !== input.value) input.value = outer; // 外部改信号时同步输入框
});
return wrap;
}
+35 -14
View File
@@ -1,11 +1,16 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { MenuItem, MenuProps } from './types.js';
import { flip } from './position.js';
import { bindOutside, pushOverlay } from './overlay.js';
/*** 下拉菜单:触发器 + 自绘弹层,项支持禁用 / 危险 / 分隔线;点项 / 点外部 / Esc 关闭,↑↓ 环形导航 + Home/End 跳转 ***/
/*** 下拉菜单:触发器 + 自绘弹层,项支持禁用 / 危险 / 分隔线;点项 / 点外部 / Esc 关闭,↑↓ 环形导航 + Home/End 跳转 + 键入快跳 ***/
export function menu(props: MenuProps): HTMLElement {
// —— 状态集中声明 ——
const open = signal(false); // 弹层开合
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
const place = signal<'top' | 'bottom'>('bottom'); // 实际生效方位:打开时按视口空间自动翻转
const focusables: HTMLButtonElement[] = []; // 可聚焦项(跳过分隔线与禁用项),供键盘导航
const labels: string[] = []; // 与 focusables 平行的条目文案,供键入快跳
const pick = (item: MenuItem): void => {
open(false); // 选中即收起
@@ -27,7 +32,7 @@ export function menu(props: MenuProps): HTMLElement {
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); // 禁用项不参与导航
if (!item.disabled) { focusables.push(btn); labels.push(item.label); } // 禁用项不参与导航
return btn;
});
@@ -47,7 +52,7 @@ export function menu(props: MenuProps): HTMLElement {
const pop = h(
'div',
{
class: () => `ua-menu-pop${open() ? ' ua-open' : ''}${props.align === 'end' ? ' is-end' : ''}`,
class: () => `ua-menu-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}${props.align === 'end' ? ' is-end' : ''}`,
role: 'menu',
},
...rows,
@@ -55,15 +60,26 @@ export function menu(props: MenuProps): HTMLElement {
const root = h('div', { class: () => `ua-menu${open() ? ' ua-open' : ''}` }, trigger, pop);
// —— 键入快跳缓冲:与 select 的行为保持一致 ——
let typed = ''; // 最近键入的字母串
let typedAt = 0; // 最近键入时间戳,超 1 秒重置
const jumpByTyped = (ch: string): void => {
const now = Date.now();
typed = now - typedAt > 1000 ? ch : typed + ch; // 超时重新起词,否则追加
typedAt = now;
const at = focusables.indexOf(document.activeElement as HTMLButtonElement); // 当前焦点序号
const order = focusables.map((_, i) => (at + 1 + i) % focusables.length); // 从下一项开始环形排列
const hit = order.find((i) => labels[i].toLowerCase().startsWith(typed.toLowerCase())); // 前缀匹配
if (hit != null) focusables[hit]?.focus(); // 命中即聚焦;无命中留在原地
};
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; // 卫语句:只接管上下键
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
if (open() && e.key.length === 1) jumpByTyped(e.key); // 可打印字符:键入快跳
return; // 卫语句:其余按键不接管
}
e.preventDefault();
if (!open()) {
open(true);
@@ -75,11 +91,16 @@ export function menu(props: MenuProps): HTMLElement {
focusables[(at + step + focusables.length) % focusables.length]?.focus(); // 环形移动
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false); // 点外部收起
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => open()); // 建立依赖,保证开合信号生效
effect(() => {
if (open()) {
releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
} else {
releaseEsc?.();
releaseEsc = null;
}
});
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 每次打开重算方位:空间不足自动翻转
return root;
}
+39 -24
View File
@@ -1,56 +1,71 @@
import { effect, h } from '@ui-arch/frame';
import type { ModalProps } from './types.js';
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
import { button } from './button.js';
let seq = 0; // 实例序号:给标题生成唯一 id,供 aria-labelledby 关联
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose;Tab 焦点陷阱 + 关闭归还焦点 ***/
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
const titleId = `ua-modal-title-${++seq}`; // 标题 id:对话框的可访问名
const close = () => {
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
props.open(false); // 写回信号触发隐藏
props.onClose?.();
};
/*** 内置页脚:取消直接关;确认若返回 Promise,则按钮转「处理中…」并禁用,成功才收窗、失败留在窗口重试 ***/
const builtinFoot = (): HTMLElement => {
const ok = button({ label: props.confirmText ?? '确定' }) as HTMLButtonElement;
const cancel = button({ label: props.cancelText ?? '取消', variant: 'ghost', onClick: close }) as HTMLButtonElement;
const idle = ok.textContent; // 记下常态文案,异步结束后还原
ok.addEventListener('click', () => {
const out = props.onConfirm?.();
if (!(out instanceof Promise)) { close(); return; } // 同步确认:直接收窗
ok.disabled = true;
ok.textContent = '处理中…';
out.then(() => { if (props.open()) close(); }) // 成功才关;onConfirm 里已手动关掉就不重复触发
.catch(() => { /* 失败什么都不做:保持窗口打开让用户重试,提示交给调用方的 toast */ })
.finally(() => { ok.disabled = false; ok.textContent = idle; });
});
return h('div', { class: 'ua-modal-actions' }, cancel, ok);
};
const foot = props.footer ? h('div', { class: 'ua-modal-foot' }, props.footer) : props.confirmText || props.onConfirm ? h('div', { class: 'ua-modal-foot' }, builtinFoot()) : null; // footer 优先,其次内置按钮组
const overlay = h('div', {
class: 'ua-modal-overlay',
onClick: (e: Event) => {
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
if (e.target !== overlay) return; // 卫语句:只处理点在遮罩本身的
if (props.closeOnOverlay === false) return; // 显式禁止时防误关,× 与 Esc 仍可用
close();
},
});
const box = h(
'div',
{ 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 }, '×')),
{ class: `ua-modal ua-modal-${props.size ?? 'md'}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId }, // 对话框语义 + 标题关联
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title', id: titleId }, 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, // 底部操作区:右对齐,仅传了才渲染
foot, // 底部操作区:自定义 footer 或内置按钮组,都没有则不渲染
);
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')); // 弹窗内可聚焦元素(排除禁用)
let release: (() => void) | null = null; // Esc 栈注销函数,关闭时释放
let releaseLock: (() => void) | null = null; // 背景滚动锁解锁函数,全关才还原
effect(() => {
const open = props.open();
overlay.classList.toggle('ua-open', open); // 显隐跟随信号
if (open) {
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
release ??= pushOverlay(close); // 入 Esc 栈:多弹层时只关最上层
releaseLock ??= lockScroll(); // 模态期锁背景滚动
(box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
} else if (lastFocus) {
lastFocus.focus(); // 关闭时归还焦点
} else {
release?.(); // 出 Esc 栈
release = null;
releaseLock?.(); // 解锁背景滚动
releaseLock = null;
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 关闭当前打开的弹窗
});
trapFocus(box, () => props.open()); // Tab 焦点陷阱:与 drawer 共用同一份实现
return overlay;
}
+124
View File
@@ -0,0 +1,124 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { MultiSelectProps } from './types.js';
/*** 多选下拉:标签式回显 + 可搜索弹层,value 数组信号双向绑定,到 max 未选项自动禁用 ***/
export function multiSelect(props: MultiSelectProps): HTMLElement {
// —— 状态集中声明 ——
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 选中值
const open = signal(false); // 弹层开合
const query = signal(''); // 搜索词
const active = signal(-1); // 键盘高亮下标
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
const searchable = props.searchable !== false; // 缺省可搜索
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
/*** 按搜索词过滤候选:禁用项仍展示(便于解释为何点不了) ***/
const visible = () => {
const q = query().trim().toLowerCase();
return q ? props.options.filter((o) => o.label.toLowerCase().includes(q)) : props.options;
};
/*** 提交变更:写信号 + 回调(上限由弹层禁用项兜住,这里不再拒绝) ***/
const update = (next: string[]): void => {
value(next);
props.onChange?.([...next]);
};
/*** 单项切换:已选则移除,未选且未到上限则加入 ***/
const toggle = (v: string): void => {
const cur = new Set(value());
if (cur.has(v)) cur.delete(v);
else if (props.max == null || cur.size < props.max) cur.add(v);
update([...cur]);
};
const tagWrap = h('span', { class: 'ua-multiselect-tags' }) as HTMLElement; // 标签区:每次重绘只换这里
const box = h('div', { class: 'ua-multiselect-box' }) as HTMLElement; // 标签 + 输入框容器
const input = h('input', {
type: 'text',
class: 'ua-multiselect-input',
placeholder: '',
'aria-label': props.placeholder ?? '多选下拉',
role: 'combobox',
'aria-expanded': () => String(open()),
disabled: props.disabled,
oninput: (e: Event) => {
query((e.target as HTMLInputElement).value);
active(-1);
open(true); // 输入即展开,便于边打边选
},
}) as HTMLInputElement;
box.append(tagWrap, input); // 标签区在前、输入框在后,横向排布
const list = h('ul', { class: 'ua-multiselect-list', role: 'listbox', 'aria-multiselectable': 'true' }) as HTMLElement;
const pop = h('div', { class: () => `ua-multiselect-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement;
const clearBtn = h('button', { type: 'button', class: 'ua-select-clear', 'aria-label': '清空所选', hidden: true, onClick: (e: Event) => { e.stopPropagation(); update([]); input.focus(); } }, '×') as HTMLElement; // 复用 select 清空钮样式;阻止冒泡免得清完又展开
const root = h('div', { class: () => `ua-multiselect${open() ? ' ua-open' : ''}` }, box, props.clearable ? clearBtn : null, pop) as HTMLElement;
effect(() => { clearBtn.hidden = !value().length; }); // 有值才显示(clearable 未开时按钮根本不挂载)
/*** 重绘标签区与候选:标签带删除钮,候选带勾选态与禁用态 ***/
const render = (): void => {
const cur = value();
const full = props.max != null && cur.length >= props.max; // 是否已达上限
const cut = props.maxTagCount != null && cur.length > props.maxTagCount ? cur.slice(0, props.maxTagCount) : cur; // 折叠后仍显示的标签
const labelOf = (v: string): string => props.options.find((o) => o.value === v)?.label ?? v;
const tags = cut.map((v) => {
const label = labelOf(v);
const del = h('button', { type: 'button', class: 'ua-multiselect-x', 'aria-label': `移除 ${label}`, onClick: () => toggle(v) }, '×');
return h('span', { class: 'ua-multiselect-tag' }, h('span', {}, label), del);
});
if (cut.length < cur.length) { // 溢出项折成一枚 +N,悬停给全量、点击展开弹层
const rest = cur.slice(cut.length).map(labelOf).join('、');
tags.push(h('span', { class: 'ua-multiselect-tag is-more', title: rest, 'aria-label': `另有 ${cur.length - cut.length} 项:${rest}`, onClick: () => open(true) }, `+${cur.length - cut.length}`));
}
tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容)
input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案
const rows: HTMLElement[] = []; // 候选行:分组小标题与选项混排(小标题不可聚焦,不占 active 下标)
let prevGroup = ''; // 上一个候选的分组名,只在组首插一次标题
visible().forEach((o, i) => {
if (o.group && o.group !== prevGroup) rows.push(h('li', { class: 'ua-multiselect-group', role: 'presentation' }, o.group));
prevGroup = o.group ?? ''; // 无分组的候选打断连续段,后续同名组会重新起标题
const on = cur.includes(o.value);
const dead = o.disabled === true || (full && !on); // 到上限:未选项不可再点
rows.push(h(
'li',
{
class: () => `ua-multiselect-opt${on ? ' is-selected' : ''}${active() === i ? ' is-active' : ''}${dead ? ' is-disabled' : ''}`,
role: 'option',
'aria-selected': String(on),
onmousedown: (e: Event) => { e.preventDefault(); if (!dead) toggle(o.value); }, // 阻止默认:保住输入焦点,弹层不收起
},
h('span', { class: 'ua-multiselect-check', 'aria-hidden': 'true' }, on ? '✓' : ''),
h('span', {}, o.label),
));
});
if (!rows.length) rows.push(h('li', { class: 'ua-multiselect-empty' }, '无匹配项'));
list.replaceChildren(...rows);
};
box.addEventListener('click', () => { if (!(props.disabled === true)) { open(true); input.focus(); } }); // 点空白处展开并聚焦输入
root.addEventListener('keydown', (e: KeyboardEvent) => {
const opts = visible();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open()) { open(true); active(0); return; }
const dir = e.key === 'ArrowDown' ? 1 : -1;
active((active() + dir + opts.length) % opts.length); // 环形移动
return;
}
if (e.key === 'Enter' && open() && active() >= 0) { e.preventDefault(); toggle(opts[active()].value); return; }
if (e.key === 'Backspace' && !input.value && value().length) { update(value().slice(0, -1)); return; } // 空输入时退格删末个标签
if (e.key === 'Escape') { open(false); return; }
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render);
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
else { releaseEsc?.(); releaseEsc = null; }
});
effect(() => { if (open()) place(flip(box, pop, 'bottom')); });
return root;
}
+94
View File
@@ -0,0 +1,94 @@
import { h } from '@ui-arch/frame';
import type { NotificationHandle, NotificationProps } from './types.js';
const hosts = new Map<string, HTMLElement>(); // 每个角落一个堆叠容器,首次用到该角落时懒创建
const byKey = new Map<string, NotificationHandle>(); // 同 key 只留一张卡,二次调用走原地更新
const MAX_STACK = 3; // 同一角落最多条数,超出挤掉最早一条
/*** 确保指定角落的堆叠容器存在:fixed 挂 body 末尾,与 toast 的右上角分区不打架 ***/
function ensureHost(place: string): HTMLElement {
const hit = hosts.get(place);
if (hit) return hit;
const el = h('div', { class: `ua-notice-host is-${place}` });
document.body.appendChild(el);
hosts.set(place, el);
return el;
}
/*** 退场并移除:摘 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
}
/*** 通知卡片:标题 + 描述 + 可选操作区,返回 { el, close, update } 句柄;duration 为 0 表示常驻,非 0 时悬停 / 聚焦自动暂停计时;带 key 时二次调用原地更新同一张卡 ***/
export function notification(props: NotificationProps): NotificationHandle {
const dup = props.key ? byKey.get(props.key) : undefined;
if (dup) { dup.update(props); return dup; } // 同 key:只更新内容,不新增一条
// —— 状态集中声明 ——
const duration = props.duration ?? 0; // 缺省常驻:通知通常需要用户读完
const closable = props.closable ?? true; // 缺省带关闭按钮
let timer = 0; // 自动关闭计时器句柄
let remain = duration; // 剩余毫秒:暂停时扣减,续计时接着走
let startedAt = 0; // 本轮计时起点,用于折算暂停前已流逝的时间
let paused = false; // 是否因悬停 / 聚焦而暂停
const closeBtn = h('button', { type: 'button', class: 'ua-notice-close', 'aria-label': '关闭通知' }, '×') as HTMLButtonElement; // 关闭按钮,回调在下方绑定
const titleEl = h('span', { class: 'ua-notice-title' }, props.title) as HTMLElement;
const actionEl = props.action ? h('div', { class: 'ua-notice-action' }, props.action) : null;
let descEl = props.description ? h('div', { class: 'ua-notice-desc' }, props.description) : null;
/*** 换语义色:整串重设 class,保住已挂上的入场态 ***/
const paint = (variant: string): void => {
el.className = `ua-notice ua-notice-${variant}${el.classList.contains('ua-open') ? ' ua-open' : ''}`;
};
const el = h(
'div',
{ class: `ua-notice ua-notice-${props.variant ?? 'info'}`, role: 'alert' },
h('div', { class: 'ua-notice-head' }, h('span', { class: 'ua-notice-dot', 'aria-hidden': 'true' }), titleEl, closable ? closeBtn : null),
descEl,
actionEl,
) as HTMLElement;
/*** 关闭:清计时器后走退场动画,顺手释放 key 登记 ***/
const close = () => {
window.clearTimeout(timer);
if (props.key && byKey.get(props.key) === handle) byKey.delete(props.key);
dismiss(el);
};
closeBtn.addEventListener('click', () => close());
/*** 原地更新:改标题 / 描述 / 语义色;描述按需插入到操作区之前 ***/
const update = (next: Partial<NotificationProps>): void => {
if (next.title != null) titleEl.textContent = next.title;
if (next.variant) paint(next.variant);
if (next.description != null) {
if (!descEl) { // 首次补描述:插在操作区之前,没有操作区就追加
descEl = h('div', { class: 'ua-notice-desc' });
if (actionEl) el.insertBefore(descEl, actionEl); else el.appendChild(descEl);
}
descEl.textContent = next.description;
}
};
/*** 起表:按剩余毫秒设定时器 ***/
const arm = () => { if (remain <= 0) return; startedAt = Date.now(); timer = window.setTimeout(() => dismiss(el), remain); };
/*** 暂停计时:扣掉已流逝部分,剩余时间留给续计 ***/
const pause = () => { if (paused || remain <= 0) return; paused = true; window.clearTimeout(timer); remain -= Date.now() - startedAt; };
/*** 续计:离开后按剩余毫秒重新起表 ***/
const resume = () => { if (!paused) return; paused = false; arm(); };
el.addEventListener('pointerenter', pause); // 悬停阅读时不倒计时
el.addEventListener('pointerleave', resume);
el.addEventListener('focusin', pause); // 键盘聚焦到关闭/操作按钮同样暂停
el.addEventListener('focusout', resume);
const stack = ensureHost(props.placement ?? 'bottom-right'); // 缺省右下,与右上角 toast 错开
if (stack.childElementCount >= MAX_STACK) stack.firstElementChild?.remove(); // 超出上限挤掉最早一条
stack.appendChild(el);
const handle: NotificationHandle = { el, close, update };
if (props.key) byKey.set(props.key, handle);
requestAnimationFrame(() => el.classList.add('ua-open')); // 下一帧入场,保证过渡生效
if (duration > 0) arm(); // 常驻(0)不设定时器
return handle;
}
+88
View File
@@ -0,0 +1,88 @@
import { effect } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
/*** 弹层键盘栈:维护打开顺序,Esc 只关最上层,避免多个弹层(含嵌套)被一次全关 ***/
// —— 状态集中声明 ——
const stack: { close: () => void }[] = []; // 打开中的弹层,后进先出
let bound = false; // 全局 keydown 监听只挂一次,栈空时摘除
/*** 全局 Esc 分发:只把事件交给栈顶弹层,其余保持不动 ***/
const onKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return; // 卫语句:只关心 Esc
const top = stack[stack.length - 1];
if (!top) return; // 卫语句:栈空无事可做
e.preventDefault();
top.close();
};
/*** 点外部关闭:仅在展开期间挂全局 click,收起即摘(避免逐实例常驻监听,组件卸载后不再残留) ***/
export function bindOutside(root: HTMLElement, isOpen: Signal<boolean>, close: () => void): void {
// —— 状态集中声明 ——
let release: (() => void) | null = null; // 当前监听的注销函数,未挂载时为 null
const handler = (e: Event): void => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
close();
};
effect(() => {
if (isOpen() && !release) {
document.addEventListener('click', handler);
release = () => document.removeEventListener('click', handler);
} else if (!isOpen() && release) {
release(); // 收起即摘
release = null;
}
});
}
/*** 注册一个打开的弹层,返回注销函数(弹层关闭时务必调用,否则栈不清理) ***/
export function pushOverlay(close: () => void): () => void {
if (!bound) {
document.addEventListener('keydown', onKeydown); // 首个弹层才挂全局监听
bound = true;
}
const entry = { close };
stack.push(entry);
return () => {
const i = stack.indexOf(entry);
if (i >= 0) stack.splice(i, 1); // 出栈:允许非栈顶关闭
if (!stack.length && bound) {
document.removeEventListener('keydown', onKeydown); // 栈空摘监听,避免泄漏
bound = false;
}
};
}
// —— 背景滚动锁:模态弹层(modal/drawer/image 预览)引用计数,全关才解锁 ——
let locks = 0; // 活跃模态层数
/*** 模态层打开时调用,返回解锁函数;嵌套叠加时只由最外层还原 body 内联样式 ***/
export function lockScroll(): () => void {
if (locks === 0) document.body.style.overflow = 'hidden'; // 第一层才锁,避免相互覆盖
locks++;
return () => {
locks--;
if (locks === 0) document.body.style.overflow = ''; // 全关解锁
};
}
/*** 焦点陷阱:Tab / Shift+Tab 在容器内首尾环形循环,容器内无可用焦点时整体拦截;焦点已在容器外则按方向拉回首尾 ***/
export function trapFocus(box: HTMLElement, isOpen: () => boolean): void {
box.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !isOpen()) return; // 卫语句:只处理打开中的 Tab
const els = Array.from(box.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter((el) => !el.hasAttribute('disabled')); // 容器内可聚焦元素(排除禁用)
if (!els.length) { e.preventDefault(); return; } // 卫语句:无可用焦点就整体拦截,防焦点逃逸
const first = els[0];
const last = els[els.length - 1];
const active = document.activeElement;
const outside = !box.contains(active); // 焦点落在容器外:按方向重新拉回,而不是让它继续往外跑
if (e.shiftKey && (active === first || outside)) {
e.preventDefault();
last.focus(); // Shift+Tab 到首位回绕到末尾
} else if (!e.shiftKey && (active === last || outside)) {
e.preventDefault();
first.focus(); // Tab 到末位回绕到开头
}
});
}
+51 -9
View File
@@ -1,5 +1,6 @@
import { h, renderList } from '@ui-arch/frame';
import { h, renderList, signal } from '@ui-arch/frame';
import type { PaginationProps } from './types.js';
import { select } from './select.js';
/*** 页码序列:≤7 页全展开;否则保留首尾与当前页 ±1 窗口,缺口折叠为省略号(null 占位) ***/
function pageSequence(page: number, totalPages: number): (number | null)[] {
@@ -15,27 +16,68 @@ function pageSequence(page: number, totalPages: number): (number | null)[] {
return seq;
}
/*** 独立分页器:受控组件,page 为双向绑定信号;页码过多折叠省略号,首末页禁用翻页,原生 button 保证键盘可达 ***/
/*** 独立分页器:受控组件,page 为双向绑定信号;页码过多折叠省略号,首末页禁用翻页,jumpable 可跳页,pageSizes 可切每页条数,simple 走极简、hideOnSinglePage 单页隐藏 ***/
export function pagination(props: PaginationProps): HTMLElement {
const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize)); // 总页数
// —— 状态集中声明 ——
const size = signal(props.pageSize); // 每页条数:可从 switcher 改动,故用信号持有
const totalPages = () => Math.max(1, Math.ceil(props.total / size())); // 总页数随时可重算
const pages = h('div', { class: 'ua-pagination-pages' }); // 页码区,随页码重绘
const tail = h('div', { class: 'ua-pagination-tail' }); // 附加区(条数切换 + 跳页),只创建一次
const go = (p: number) => {
const next = Math.min(totalPages, Math.max(1, p)); // 钳制到有效页码范围
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()));
// —— 每页条数切换器:传了候选才渲染,切换即回第 1 页 ——
if ((props.pageSizes ?? []).length > 1) {
const sizeValue = signal(String(size()));
tail.append(
select({
options: props.pageSizes!.map((n) => ({ value: String(n), label: `${n} 条/页` })),
value: sizeValue,
onChange: (v) => {
size(Number(v));
props.onPageSizeChange?.(Number(v));
go(1); // 条数变了旧页码无意义,统一回首页
},
}),
);
}
// —— 跳页输入:jumpable 且多页时渲染,回车跳转 ——
if (props.jumpable) {
const jump = h('input', { class: 'ua-page-jump', inputmode: 'numeric', 'aria-label': '跳转到指定页' }) as HTMLInputElement;
jump.addEventListener('change', () => {
const n = parseInt(jump.value, 10);
jump.value = ''; // 跳转后清空输入,避免残留旧页码
if (!Number.isNaN(n)) go(n); // 非数字忽略,由 go 钳制范围
});
tail.append(h('span', { class: 'ua-page-jump-wrap' }, jump, h('span', { class: 'ua-page-total' }, () => `/ ${totalPages()} 页`)));
}
const nav = h('nav', { class: 'ua-pagination', 'aria-label': '分页' }, pages, tail);
renderList(pages, () => {
const totalNow = totalPages();
const page = Math.min(totalNow, Math.max(1, props.page()));
nav.hidden = Boolean(props.hideOnSinglePage) && totalNow <= 1; // 单页时整块隐藏,工具栏不必为它留位
if (props.simple) { // 极简态:页码不给点,只留前后翻页与进度文字
return [
h('button', { class: 'ua-page-btn', disabled: page <= 1, 'aria-label': '上一页', onClick: () => go(page - 1) }, '‹'),
h('span', { class: 'ua-page-simple', 'aria-live': 'polite' }, `${page} / ${totalNow}`),
h('button', { class: 'ua-page-btn', disabled: page >= totalNow, 'aria-label': '下一页', onClick: () => go(page + 1) }, '›'),
];
}
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)) {
for (const it of pageSequence(page, totalNow)) {
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) }, '›'));
items.push(h('button', { class: 'ua-page-btn', disabled: page >= totalNow, 'aria-label': '下一页', onClick: () => go(page + 1) }, '›'));
return items;
});
return nav;
+62
View File
@@ -0,0 +1,62 @@
import { effect, h } from '@ui-arch/frame';
import type { PinInputProps } from './types.js';
/*** 验证码输入:N 格单字符数字输入,输入自动进格、退格回删、方向键移格、粘贴自动分发,value 信号双向绑定 ***/
export function pinInput(props: PinInputProps): HTMLDivElement {
const length = props.length ?? 6; // 格数缺省 6
const sanitize = (s: string): string => String(s).replace(/\D/g, '').slice(0, length); // 只留数字并截到格数
const inputs: HTMLInputElement[] = []; // 格子引用(格子建一次不重渲染,焦点天然不丢)
const focusAt = (i: number): void => inputs[Math.max(0, Math.min(length - 1, i))]?.focus(); // 目标格钳制到首尾
/*** 写回单一来源:拼接各格内容赋回外部信号(同值短路防 effect 环),并回调当前拼接串 ***/
const commit = (): void => {
const next = inputs.map((el) => el.value).join('');
if (props.value() !== next) props.value(next);
if (props.onChange) props.onChange(next);
};
/*** 单格构造:输入进格 / 退格回删 / 方向键移格 / 粘贴分发,非数字键一律拦下 ***/
const cell = (i: number): HTMLInputElement => h('input', {
class: 'ua-input ua-pin-cell',
type: props.mask ? 'password' : 'text',
inputmode: 'numeric',
autocomplete: props.mask ? 'one-time-code' : 'off',
maxlength: 1,
'aria-label': `第 ${i + 1} 位`,
onFocus: () => inputs[i].select(), // 聚焦全选:直接敲键即覆盖旧值
onInput: () => {
inputs[i].value = sanitize(inputs[i].value).slice(-1); // 单格只留最后敲入的一位
if (inputs[i].value && i < length - 1) focusAt(i + 1); // 敲完进下一格(末格不进)
commit();
},
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Backspace') { e.preventDefault(); if (inputs[i].value) { inputs[i].value = ''; } else if (i > 0) { inputs[i - 1].value = ''; focusAt(i - 1); } commit(); return; } // 本格有值删本格,空格回删上一格并左移
if (e.key === 'ArrowLeft') { e.preventDefault(); focusAt(i - 1); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); focusAt(i + 1); return; }
if (e.key.length === 1 && !/\d/.test(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault(); // 只放行数字与控制键(粘贴由 onPaste 接管)
},
onPaste: (e: ClipboardEvent) => {
const digits = sanitize(e.clipboardData?.getData('text') ?? '');
if (!digits) return;
e.preventDefault();
const from = digits.length >= length ? 0 : i; // 整段验证码从首格铺起,零散片段从当前格续
for (let k = 0; k < digits.length && from + k < length; k++) inputs[from + k].value = digits[k]; // 逐格分发
focusAt(Math.min(length - 1, from + digits.length)); // 落到末个空格,接着手输也顺手
commit();
},
}) as HTMLInputElement;
const root = h('div', { class: 'ua-pin-input', role: 'group' }, ...Array.from({ length }, (_, i) => cell(i))) as HTMLDivElement;
root.querySelectorAll('input').forEach((el) => inputs.push(el as HTMLInputElement));
effect(() => { // 外部信号 → 格子回写(同值不写避免打断输入法),禁用态支持布尔或信号
const v = sanitize(props.value());
const dis = typeof props.disabled === 'function' ? props.disabled() : !!props.disabled;
inputs.forEach((el, k) => {
const ch = v[k] ?? '';
if (el.value !== ch) el.value = ch;
el.disabled = dis;
});
});
return root;
}
+58
View File
@@ -0,0 +1,58 @@
import { effect, h, signal } from '@ui-arch/frame';
import { button } from './button.js';
import { popover } from './popover.js';
import type { PopconfirmProps } from './types.js';
/*** 二次确认气泡:危险操作就地确认,不打断页面;确定后收起再回调,取消 / 点外部 / Esc 都走 onCancel ***/
export function popconfirm(props: PopconfirmProps, trigger: HTMLElement): HTMLElement {
// —— 状态集中声明 ——
const open = props.open ?? signal(false); // 开合信号:自己持有一份,确认与取消都要能写回
let accepted = false; // 本次收起是否由「确定」触发:用来区分确认与取消
/*** 收起弹层:写回信号即触发 popover 的显隐与 Esc 栈清理 ***/
const close = (): void => { open(false); };
const idle = props.confirmText ?? '确定'; // 确认按钮常态文案,异步结束后还原
/*** 确认:同步回调先收起再执行(避免回调里改数据时弹层还开着);返回 Promise 时就地 loading,成功才收起、失败留在弹层重试 ***/
const accept = (): void => {
const out = props.onConfirm();
if (!(out instanceof Promise)) { accepted = true; close(); return; }
okBtn.disabled = true;
okBtn.textContent = '处理中…';
out.then(() => { accepted = true; close(); })
.catch(() => { /* 失败保持展开:提示交给调用方的 toast */ })
.finally(() => { okBtn.disabled = false; okBtn.textContent = idle; });
};
const okBtn = button({ label: idle, variant: props.variant === 'danger' ? 'danger' : 'primary', onClick: () => accept() }) as HTMLButtonElement;
const body = h(
'div',
{ class: 'ua-popconfirm' },
h('div', { class: 'ua-popconfirm-text' }, props.description ?? '该操作不可撤销,确定继续吗?'),
h(
'div',
{ class: 'ua-popconfirm-actions' },
button({ label: props.cancelText ?? '取消', variant: 'ghost', onClick: () => close() }),
okBtn,
),
);
const wrap = popover({
title: props.title,
content: body,
placement: props.placement ?? 'top', // 缺省压在上方:确认气泡不该盖住被操作的那一行
trigger: 'click',
open,
onOpenChange: (next) => {
if (next || accepted) { accepted = false; return; } // 展开、或刚由确认收起:不回调取消
props.onCancel?.(); // 点外部 / Esc 收起等同取消
},
}, trigger);
effect(() => {
if (!open()) return; // 卫语句:收起时不动焦点
requestAnimationFrame(() => (body.querySelector('button') as HTMLElement | null)?.focus()); // 展开即落在「取消」上,误按回车不会误伤
});
return wrap;
}
+14 -7
View File
@@ -1,12 +1,16 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { PopoverProps } from './types.js';
import { flip } from './position.js';
import { bindOutside, pushOverlay } from './overlay.js';
let seq = 0; // 全局自增序号:保证每个弹层 id 唯一,供 aria-controls 关联
/*** 气泡卡片:包裹触发器,默认点击开合;内容可交互,点外部 / Esc / 再次点触发器收起 ***/
export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement {
// —— 状态集中声明 ——
const open = signal(false); // 弹层显隐
const open = props.open ?? signal(false); // 弹层显隐:外部传了信号就交给它控制(实现受控开合)
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
const place = signal(props.placement ?? 'bottom'); // 实际生效方位:打开时按视口空间自动翻转
const id = `ua-pop-${++seq}`; // 弹层唯一 id
let timer: number | null = null; // hover 模式下的出入场计时器
@@ -24,7 +28,7 @@ export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement
'div',
{
id,
class: () => `ua-pop ua-pop-${props.placement ?? 'bottom'}${open() ? ' ua-open' : ''}`,
class: () => `ua-pop ua-pop-${place()}${open() ? ' ua-open' : ''}`,
role: 'dialog',
'aria-hidden': () => String(!open()),
onClick: (e: Event) => e.stopPropagation(), // 弹层内点击不触发外部关闭
@@ -49,12 +53,15 @@ export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement
toggle();
});
}
root.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape' || !open()) return; // 卫语句:未展开时 Esc 不处理
setOpen(false);
trigger.focus(); // 收起后焦点回触发器
bindOutside(root, open, () => setOpen(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
if (open()) {
releaseEsc ??= pushOverlay(() => { setOpen(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
} else {
releaseEsc?.();
releaseEsc = null;
}
});
document.addEventListener('click', () => setOpen(false)); // 点外部收起(弹层内已阻止冒泡)
return root;
}
+24
View File
@@ -0,0 +1,24 @@
/*** 浮层上下翻转判定:按触发器与面板的视口几何判断首选方位是否放得下,放不下且另一侧更宽敞则翻转;返回实际生效方位 'top' | 'bottom' ***/
export function flip(trigger: HTMLElement, panel: HTMLElement, preferred: 'top' | 'bottom'): 'top' | 'bottom' {
const GAP = 8; // 与 CSS 预留的浮层间距一致(popover 为 8px)
const MIN = 24; // 兜底:面板尚未量出高度时按最小可读高度估算
const t = trigger.getBoundingClientRect(); // 触发器视口矩形
const h = panel.offsetHeight || MIN; // 面板高度(visibility:hidden 仍占布局,可直接量)
const up = t.top; // 触发器上方可用空间
const down = window.innerHeight - t.bottom; // 触发器下方可用空间
if (preferred === 'top') return up >= h + GAP || up >= down ? 'top' : 'bottom'; // 首选上方:放得下、或不比下方更局促就保持
return down >= h + GAP || down >= up ? 'bottom' : 'top'; // 首选下方:对称判定
}
/*** 浮层方位判定(四向):水平方向按宽度判左右空间,垂直方向复用 flip 的上下一套逻辑 ***/
export function flipSide(trigger: HTMLElement, panel: HTMLElement, preferred: 'top' | 'bottom' | 'left' | 'right'): 'top' | 'bottom' | 'left' | 'right' {
const GAP = 8; // 与样式表预留的间距一致
const MIN = 24; // 兜底:面板尚未量出宽度时按最小可读宽度估算
if (preferred === 'top' || preferred === 'bottom') return flip(trigger, panel, preferred); // 垂直交回原判定
const t = trigger.getBoundingClientRect();
const w = panel.offsetWidth || MIN;
const left = t.left; // 触发器左侧可用空间
const right = window.innerWidth - t.right; // 触发器右侧可用空间
if (preferred === 'left') return left >= w + GAP || left >= right ? 'left' : 'right'; // 对称判定
return right >= w + GAP || right >= left ? 'right' : 'left';
}
+41 -4
View File
@@ -1,7 +1,8 @@
import { effect, h } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import type { ProgressProps } from './types.js';
/*** 进度条:value 信号驱动 0-100 定量态,缺省 value 则退化为不确定态(无限往复);满格切 success 色并停条纹 ***/
/*** 进度条:value 信号驱动 0-100 定量态(缺省则不确定态),type='circle' 且传 value 时出环形;满格切 success 色,format 可自定义文本 ***/
export function progress(props: ProgressProps = {}): HTMLElement {
// —— 状态集中声明 ——
const value = props.value; // 可选:不传即不确定态
@@ -9,22 +10,28 @@ export function progress(props: ProgressProps = {}): HTMLElement {
const track = h('div', { class: 'ua-progress-track' }, fill);
const text = h('span', { class: 'ua-progress-text' });
const withText = props.showText !== false && !!value; // 不确定态没有百分比可显示
const label = props.label; // 可访问名:读屏念「上传进度 50%」而不是干巴巴的百分比
const variant = props.variant ?? 'default'; // 语义色:default 之外都带类名,满格时仍以 variant 为准
const cls = `ua-progress${variant === 'default' ? '' : ` ua-progress-${variant}`}`;
const textOf = (v: number): string => (props.format ? props.format(v) : `${Math.round(v)}%`); // 进度文本统一出口:format 优先
if (!value) {
fill.classList.add('ua-indeterminate'); // 宽度与往复动画交给 CSS
return h('div', { class: 'ua-progress', role: 'progressbar', 'aria-label': '进行中' }, track);
return h('div', { class: cls, role: 'progressbar', 'aria-label': label ?? '进行中' }, track);
}
if (props.type === 'circle') return ring(value, label, variant, withText, textOf); // 环形:SVG 版
effect(() => {
const v = clamp(value()); // 依赖收集:信号变化自动写回
fill.style.width = `${v}%`;
fill.classList.toggle('ua-done', v >= 100); // 满格切语义色
text.textContent = `${Math.round(v)}%`;
text.textContent = textOf(v);
});
return h(
'div',
{
class: 'ua-progress',
class: cls,
role: 'progressbar',
'aria-label': label,
'aria-valuemin': '0',
'aria-valuemax': '100',
'aria-valuenow': () => String(Math.round(clamp(value()))),
@@ -39,3 +46,33 @@ function clamp(v: number): number {
if (Number.isNaN(v)) return 0; // 卫语句:非法值归零
return Math.min(100, Math.max(0, v));
}
/*** 环形进度:SVG 双圆用 dashoffset 表达弧长(必须 createElementNS,h() 造不出可渲染的 SVG) ***/
function ring(value: Signal<number>, label: string | undefined, variant: string, withText: boolean, textOf: (v: number) => string): HTMLElement {
const NS = 'http://www.w3.org/2000/svg'; // SVG 命名空间
const R = 26; // 半径:viewBox 64 两侧留出线宽
const C = 2 * Math.PI * R; // 周长:dasharray 的基准长度
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 64 64');
svg.setAttribute('class', `ua-progress-ring${variant === 'default' ? '' : ` is-${variant}`}`);
svg.setAttribute('role', 'img');
const base = { cx: '32', cy: '32', r: String(R), fill: 'none', 'stroke-width': '6' }; // 两圆共用几何
const track = document.createElementNS(NS, 'circle');
const arc = document.createElementNS(NS, 'circle');
for (const [k, v] of Object.entries({ ...base, class: 'ua-progress-ring-track' })) track.setAttribute(k, v);
for (const [k, v] of Object.entries({ ...base, class: 'ua-progress-ring-arc', 'stroke-dasharray': String(C), transform: 'rotate(-90 32 32)' })) arc.setAttribute(k, v); // 从 12 点起画
svg.append(track, arc);
const txt = document.createElementNS(NS, 'text');
txt.setAttribute('x', '32');
txt.setAttribute('y', '36');
txt.setAttribute('text-anchor', 'middle');
txt.setAttribute('class', 'ua-progress-ring-text');
if (withText) svg.append(txt);
effect(() => {
const v = clamp(value());
arc.setAttribute('stroke-dashoffset', String(C * (1 - v / 100))); // 弧长按比例收缩
txt.textContent = textOf(v);
svg.setAttribute('aria-label', `${label ?? '进度'} ${textOf(v)}`); // 环形没有原生 progressbar,名称自己拼
});
return h('div', { class: 'ua-progress ua-progress-circle' }, svg as unknown as HTMLElement) as HTMLElement;
}
+52
View File
@@ -0,0 +1,52 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { RateProps } from './types.js';
/*** 评分:星标点选(allowHalf 可记半星,allowClear 再点当前分清零),hover 预览移出还原,键盘 ←→/↑↓ 调节,value 信号双向绑定 ***/
export function rate(props: RateProps): HTMLDivElement {
const value = props.value;
const max = props.max ?? 5; // 星标总数缺省 5
const step = props.allowHalf ? 0.5 : 1; // 半星模式步进 0.5
const hover = signal(0); // 预览值(1 基,0 表示无预览)
const emit = (v: number, allowZero = false): void => { const n = Math.round(Math.min(max, Math.max(allowZero ? 0 : 1, v)) * 2) / 2; const q = props.allowHalf ? n : Math.round(n); value(q); if (props.onChange) props.onChange(q); }; // 写回信号并回调(半星按 0.5 量化;清零仅在 allowZero 时放行)
/*** 计算星标命中值:allowHalf 时左半区记 0.5 分 ***/
const hitOf = (e: MouseEvent, i: number): number => props.allowHalf && e.offsetX < (e.currentTarget as HTMLElement).offsetWidth / 2 ? i - 0.5 : i;
/*** 键盘调节:focus 在整组上,方向键按步进调节(重渲染后仍生效) ***/
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' ? step : -step)); // →/↑ 加一步,←/↓ 减一步
};
const render = (): HTMLElement => {
const shown = hover() || value(); // 预览优先,无预览显示当前值
const stars = [];
for (let i = 1; i <= max; i++) {
const frac = shown - i + 1; // 该星填充比例(1 满格 / 0.5 半格 / 0 灭)
stars.push(h('button', {
class: `ua-rate-star${frac >= 1 ? ' is-on' : frac > 0 ? ' is-half' : ''}`,
type: 'button',
disabled: props.disabled,
'aria-label': `${i} 星`,
onClick: (e: MouseEvent) => { const hit = hitOf(e, i); if (props.allowClear && hit === value()) emit(0, true); else emit(hit); }, // 再点当前分清零(allowClear)
onMouseMove: (e: MouseEvent) => hover(hitOf(e, i)), // 半星模式下按悬停位置细分预览
}, '★'));
}
const node = h('div', { class: 'ua-rate', role: 'slider', tabindex: '0', 'aria-label': '评分', 'aria-valuemin': props.allowClear ? 0 : 1, 'aria-valuemax': max, 'aria-valuenow': value(), onMouseLeave: () => hover(0) }, ...stars) as HTMLDivElement;
node.addEventListener('keydown', keyNav); // 每次渲染都挂:节点被替换后键盘仍可用
return node;
};
let el!: HTMLElement; // 当前渲染产物(effect 首跑赋值)
effect(() => {
const next = render();
if (el) {
const hadFocus = el.contains(document.activeElement); // 重渲染前焦点是否在本组内
el.replaceWith(next);
if (hadFocus) (next as HTMLElement).focus(); // 归还焦点,方向键可继续连按
}
el = next;
}); // value/hover 变化整条重渲染(星标少,开销可忽略)
return el as HTMLDivElement;
}
+58
View File
@@ -0,0 +1,58 @@
import { h } from '@ui-arch/frame';
import type { RelativeTimeProps } from './types.js';
// 缺省刷新间隔与切换绝对日期的阈值:30s 够用又不吵,7 天后再用「N 天前」已不如日期直观
const TICK = 30_000;
const MAX_AGO = 7 * 86_400;
/*** 目标时间归一成毫秒时间戳;非法输入给 NaN,调用方按「未知」处理 ***/
function toMs(value: string | number | Date): number {
if (value instanceof Date) return value.getTime();
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value; // 10 位按秒、13 位按毫秒
return new Date(value).getTime();
}
/*** 相对文案:刚刚 / N 分钟前 / N 小时前 / N 天前;未来时间反过来说「还有」 ***/
function phrase(diffSec: number): string {
const future = diffSec < 0;
const s = Math.abs(diffSec);
const unit = (n: number, name: string): string => (future ? `还有 ${n} ${name}` : `${n} ${name}前`);
if (s < 60) return future ? '即将' : '刚刚';
if (s < 3600) return unit(Math.floor(s / 60), '分钟');
if (s < 86400) return unit(Math.floor(s / 3600), '小时');
return unit(Math.floor(s / 86400), '天');
}
/*** 补零:绝对日期要定宽,列表里才不会一行一个宽度 ***/
const pad = (n: number): string => String(n).padStart(2, '0');
/*** 绝对日期:超出阈值后显示,同时作悬停提示(精确到分钟就够,秒是噪音) ***/
function absolute(ms: number): string {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/*** 相对时间:把时间戳渲染成「3 分钟前」并按间隔自动刷新;超阈值改显示绝对日期,title 始终给精确值 ***/
export function relativeTime(props: RelativeTimeProps): HTMLElement {
const ms = toMs(props.value); // 目标时间(毫秒)
const tick = props.refresh ?? TICK; // 自动刷新间隔;传 0 关闭
const maxAgo = props.threshold ?? MAX_AGO; // 超过多少秒改用绝对日期
const el = h('time', { class: 'ua-reltime', datetime: Number.isNaN(ms) ? '' : new Date(ms).toISOString() }) as HTMLTimeElement;
if (Number.isNaN(ms)) { el.textContent = props.fallback ?? '—'; return el; } // 非法时间:不猜,给占位(缺省破折号)
el.title = absolute(ms); // 悬停即精确时间:相对文案永远不需要「点开看」
if (props.ariaLabel) el.setAttribute('aria-label', props.ariaLabel);
/*** 一次重绘:算差值 → 超阈值用绝对日期,否则相对文案 ***/
const paint = (): void => {
const diffSec = Math.round((Date.now() - ms) / 1000);
el.textContent = Math.abs(diffSec) > maxAgo ? absolute(ms) : phrase(diffSec);
};
paint();
if (tick > 0) {
const timer = window.setInterval(() => {
if (!el.isConnected) { window.clearInterval(timer); return; } // 已卸载:自断,避免列表反复挂载累积计时器
paint();
}, tick);
}
return el;
}
+18
View File
@@ -0,0 +1,18 @@
import { h } from '@ui-arch/frame';
import type { ResultProps } from './types.js';
// 语义状态对应的符号(纯装饰,读屏跳过)
const GLYPH: Record<string, string> = { success: '✓', warning: '!', danger: '×', info: 'i' };
/*** 结果页:状态符号 + 标题 + 说明 + 操作区,用于提交完成 / 校验失败 / 无权限等终态页面 ***/
export function result(props: ResultProps): HTMLElement {
const status = props.status ?? 'info'; // 缺省中性信息
return h(
'div',
{ class: `ua-result ua-result-${status}`, role: 'status' }, // role=status 由读屏播报终态
h('div', { class: 'ua-result-icon', 'aria-hidden': 'true' }, GLYPH[status] ?? 'i'),
h('div', { class: 'ua-result-title' }, props.title),
props.description ? h('div', { class: 'ua-result-desc' }, props.description) : null,
props.extra ? h('div', { class: 'ua-result-extra' }, props.extra) : null, // 操作区:常见为主次按钮
) as HTMLElement;
}
+65
View File
@@ -0,0 +1,65 @@
import { effect, h } from '@ui-arch/frame';
import type { SegmentedProps } from './types.js';
import { trackSize } from './watch.js';
/*** 分段控制器:胶囊分段条 + 滑动滑块,value 信号双向绑定,←→/Home/End 环形导航,整组或单项可禁用 ***/
export function segmented(props: SegmentedProps): HTMLElement {
// —— 状态集中声明 ——
const buttons: HTMLButtonElement[] = []; // 分段按钮,顺序与 options 一致
const thumb = h('span', { class: 'ua-seg-thumb', 'aria-hidden': 'true' }); // 滑块:仅装饰,不参与可访问性树
const root = h('div', { class: `ua-segmented ua-segmented-${props.size ?? 'md'}`, role: 'radiogroup' }, thumb);
/*** 整组禁用判定:布尔直接取,信号在此读取以纳入 effect 依赖 ***/
const groupOff = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true);
/*** 同步选中态、禁用态与滑块几何(挂载前量不到宽度,由 rAF 二次测量兜底) ***/
const sync = (): void => {
const off = groupOff();
const box = root.getBoundingClientRect();
root.classList.toggle('is-disabled', off);
for (const btn of buttons) {
const on = btn.dataset.value === props.value();
const dead = off || btn.dataset.dead === '1'; // 整组禁用或单项禁用
btn.classList.toggle('is-active', on);
btn.setAttribute('aria-checked', String(on));
btn.setAttribute('aria-disabled', String(dead));
btn.disabled = dead;
btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅选中项可 Tab 聚焦
if (!on) continue; // 滑块只跟选中项走
const rect = btn.getBoundingClientRect();
thumb.style.width = `${rect.width}px`;
thumb.style.transform = `translateX(${rect.left - box.left - root.clientLeft}px)`; // 减去容器边框,滑块与按钮严丝合缝
}
};
for (const opt of props.options) {
const btn = h('button', { type: 'button', class: 'ua-seg-btn', role: 'radio' }, opt.label) as HTMLButtonElement;
btn.dataset.value = opt.value;
if (opt.disabled) btn.dataset.dead = '1'; // 单项禁用标记,交给 sync 统一置 disabled
btn.addEventListener('click', () => {
if (btn.disabled) return; // 卫语句:禁用项不响应
props.value(opt.value); // 用户切换写回信号
props.onChange?.(opt.value);
});
buttons.push(btn);
root.append(btn);
}
root.addEventListener('keydown', (e: KeyboardEvent) => {
const map: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }; // 左右移动,首尾环形
const steer = map[e.key];
if (!steer) return; // 卫语句:非方向键放行
const list = buttons.filter((b) => !b.disabled);
const i = list.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return; // 卫语句:焦点不在分段项上
e.preventDefault();
const next = list[(i + steer + list.length) % list.length]; // 环形取模
next.focus();
next.click();
});
effect(sync);
requestAnimationFrame(sync); // 挂载后再量一次布局,否则滑块宽度为 0
trackSize(root, sync); // 尺寸变化后重算滑块位置与宽度
return root;
}
+116 -18
View File
@@ -1,14 +1,22 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { SelectProps } from './types.js';
import { flip } from './position.js';
import { bindOutside, pushOverlay } from './overlay.js';
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;支持 ↑↓/Home/End 键盘导航,点选项 / 点外部 / Esc 关闭 ***/
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;searchable 时弹层顶部带搜索框,支持 ↑↓/Home/End 导航与 Esc 关闭 ***/
export function select(props: SelectProps): HTMLElement {
// —— 状态集中声明 ——
const open = signal(false); // 弹层开合
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
const open = signal(false); // 弹层开合
const place = signal<'top' | 'bottom'>('bottom'); // 实际生效方位:打开时按视口空间自动翻转
const query = signal(''); // 搜索关键字(仅 searchable 时使用)
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
const labels = props.options.map((o) => o.label); // 文案缓存,供搜索与键入快跳共用
const labelOf = (v: string) => props.options.find((o) => o.value === v)?.label ?? '';
const current = () => labelOf(value()); // 触发器显示文案(响应式)
const match = (i: number) => !query() || labels[i].toLowerCase().includes(query().toLowerCase()); // 关键字包含匹配
const visibleIdx = () => props.options.map((_, i) => i).filter(match); // 当前可见选项的下标序列
const pick = (v: string) => {
value(v);
@@ -16,14 +24,22 @@ export function select(props: SelectProps): HTMLElement {
props.onChange?.(v);
};
/*** 清空:可选筛选项回到「未选」态,信号与回调同源写回 ***/
const clear = () => {
if (!value()) return; // 卫语句:本来就没选,不重复派发回调
value('');
props.onChange?.('');
};
// —— 触发器 ——
const trigger = h(
'button',
{
type: 'button',
class: 'ua-select-trigger',
class: `ua-select-trigger${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`,
'aria-haspopup': 'listbox',
'aria-expanded': () => String(open()),
'aria-label': props.ariaLabel ?? null, // 无可见 label 时由调用方给名字,null 让 h() 不落属性
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传(原生 button 拦截点击)
onClick: () => open(!open()),
},
@@ -39,34 +55,116 @@ export function select(props: SelectProps): HTMLElement {
type: 'button',
class: () => `ua-option${opt.value === value() ? ' ua-option-selected' : ''}`,
role: 'option',
'aria-selected': () => String(opt.value === value()),
'aria-selected': () => String(value() === opt.value),
onClick: () => pick(opt.value),
},
opt.label,
),
);
const pop = h('div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}`, role: 'listbox' }, ...options);
// —— 分组:相邻同组选项合成一段,组内首项前插小标题;记下区间供过滤时整组显隐 ——
const nodes: HTMLElement[] = []; // 弹层内容序列(小标题与选项混排)
const runs: { el: HTMLElement; from: number; to: number }[] = []; // 每段标题及其首尾选项下标
props.options.forEach((opt, i) => {
let run = runs[runs.length - 1] ?? null;
if (opt.group && (!run || run.to !== i - 1)) { // 新起一段:标题插在本组首项之前
const el = h('div', { class: 'ua-option-group', role: 'presentation' }, opt.group);
run = { el, from: i, to: i };
runs.push(run);
nodes.push(el);
} else if (opt.group && run) run.to = i; // 同组续接:只拉长区间
nodes.push(options[i]);
});
const search = h('input', {
type: 'search',
class: 'ua-select-search',
placeholder: '搜索…',
'aria-label': '搜索选项',
oninput: (e: Event) => query((e.target as HTMLInputElement).value), // 输入即过滤
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); options[visibleIdx()[0] ?? 0]?.focus(); } // ↓ 从搜索框进选项
},
}) as HTMLInputElement;
const blank = h('div', { class: 'ua-select-blank' }, '无匹配选项'); // 过滤空结果提示
const pop = h(
'div',
{ class: () => `ua-select-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'listbox' },
props.searchable ? search : null,
...nodes,
blank,
);
const root = h('div', { class: () => `ua-select${open() ? ' ua-open' : ''}` }, trigger, pop); // 根带开合类,供箭头旋转
// —— 清空按钮:clearable 且有值时出现,绝对定位压在触发器右缘(与箭头错开)——
const clearBtn = h('button', {
type: 'button',
class: 'ua-select-clear',
'aria-label': '清空选择',
tabindex: '-1', // 不进 Tab 序:清空是鼠标 / 触屏便利,键盘用 Delete / Backspace
onClick: () => clear(),
}, '×');
effect(() => { clearBtn.hidden = !props.clearable || !value(); }); // 无值或未开启时藏起来(不进 Tab 序,隐藏即失效)
const root = h('div', { class: () => `ua-select${open() ? ' ua-open' : ''}` }, trigger, props.clearable ? clearBtn : null, pop); // 根带开合类,供箭头旋转
// —— 键入快跳缓冲(非 searchable 时生效,逻辑与 menu 一致)——
let typed = ''; // 最近键入的字母串,用于前缀匹配
let typedAt = 0; // 最近键入时间戳,超 1 秒重置缓冲
const jumpByTyped = (ch: string): void => {
const now = Date.now();
typed = now - typedAt > 1000 ? ch : typed + ch; // 超时重新起词,否则追加
typedAt = now;
const list = visibleIdx();
const at = list.findIndex((i) => options[i] === document.activeElement); // 当前焦点在可见集中的序号
const order = list.length ? list.map((_, k) => list[(at + 1 + k) % list.length]) : []; // 从下一项开始环形排列
const hit = order.find((i) => labels[i].toLowerCase().startsWith(typed.toLowerCase())); // 前缀匹配(忽略大小写)
if (hit != null) options[hit]?.focus(); // 命中即聚焦;无命中留在原地
};
root.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') { open(false); trigger.focus(); return; } // Esc 关闭并回焦触发器
const list = visibleIdx();
if (props.clearable && !open() && (e.key === 'Backspace' || e.key === 'Delete')) { e.preventDefault(); clear(); return; } // 键盘等价:收起态下 Delete / Backspace 清空
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); // 展开中:在选项间环形移动
if (!open()) { open(true); options[list[0] ?? 0]?.focus(); return; } // 未展开:展开并聚焦首个可见选项
const at = list.findIndex((i) => options[i] === document.activeElement); // 展开中:在可见选项间环形移动
const dir = e.key === 'ArrowDown' ? 1 : -1;
const next = (idx + dir + options.length) % options.length;
options[next]?.focus();
options[list[(at + dir + list.length) % list.length]]?.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 跳末项
if (e.key === 'Home' && open()) { e.preventDefault(); options[list[0]]?.focus(); return; } // Home 跳首个可见项
if (e.key === 'End' && open()) { e.preventDefault(); options[list[list.length - 1]]?.focus(); return; } // End 跳末个可见项
if (open() && !props.searchable && e.key.length === 1) jumpByTyped(e.key); // 可打印字符:键入快跳(搜索模式由输入框接管)
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false); // 点外部关闭
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
const q = query();
let shown = 0;
options.forEach((el, i) => {
const on = match(i);
el.hidden = !on; // 不匹配即折叠,键盘只在可见项之间移动
if (on) shown += 1;
});
for (const run of runs) { // 整组都无命中时连小标题一起藏,不留孤零零的分组名
let any = false;
for (let i = run.from; i <= run.to; i++) if (match(i)) { any = true; break; }
run.el.hidden = !any;
}
blank.hidden = shown > 0 || !q; // 仅「有关键字且无命中」时才提示
});
effect(() => open()); // 建立依赖,保证开合信号生效
effect(() => {
if (open() && props.searchable) {
query(''); // 每次打开重置关键字
search.value = '';
requestAnimationFrame(() => search.focus()); // 搜索模式:打开即聚焦输入框
}
});
effect(() => {
if (open()) {
releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
} else {
releaseEsc?.();
releaseEsc = null;
}
});
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 每次打开重算方位:空间不足自动翻转
return root;
}
+111
View File
@@ -0,0 +1,111 @@
import { effect, h, signal } from '@ui-arch/frame';
import { trackSize } from './watch.js';
import type { SignaturePadHandle, SignaturePadProps } from './types.js';
// 缺省画布高度与笔宽:160px 够签一个名字,2px 在 1x 屏上也不显细
const PAD_H = 160;
const PEN_W = 2;
/*** 签名板:指针手写,canvas 位图按 DPR 放大保证线条不糊,笔色读 canvas 的 color 走令牌;签字结果写回 value 信号 ***/
export function signaturePad(props: SignaturePadProps): SignaturePadHandle {
// —— 状态集中声明 ——
const height = props.height ?? PAD_H; // 画布 CSS 高度
const penW = props.penWidth ?? PEN_W; // 笔宽(CSS 像素)
const signed = signal(false); // 是否已落笔:决定清空按钮可用性与「未签」提示
const canvas = h('canvas', { class: 'ua-sign-canvas', role: 'img', 'aria-label': props.ariaLabel ?? '手写签名区' }) as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
let drawing = false; // 指针是否按住
let lastX = 0; // 上一落点 x(CSS 像素坐标系)
let lastY = 0; // 上一落点 y
let snap = ''; // 尺寸变化前的内容快照:重设位图会清空画布,先存后用
/*** 取得当前笔色:读 canvas 计算样式的 color,颜色令牌换了笔色自动跟随 ***/
const penColor = (): string => getComputedStyle(canvas).color || '#000';
/*** 位图尺寸同步:按 CSS 宽 × DPR 设置位图,再把坐标系缩回 CSS 像素,避免高分屏线条发虚 ***/
const resize = (): void => {
const cssW = canvas.clientWidth || canvas.parentElement?.clientWidth || 320; // 宽度跟随容器,取不到就退 320
const ratio = window.devicePixelRatio || 1;
const nextW = Math.round(cssW * ratio);
const nextH = Math.round(height * ratio);
if (canvas.width === nextW && canvas.height === nextH) return; // 尺寸没变:不动位图(也不清内容)
snap = signed() ? canvas.toDataURL() : ''; // 有内容才快照,空白不浪费一次编码
canvas.width = nextW;
canvas.height = nextH;
if (!ctx) return;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // 之后的坐标都按 CSS 像素算
ctx.lineCap = 'round'; // 圆头:手写笔迹不该是方角
ctx.lineJoin = 'round';
if (!snap) return;
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0, cssW, height); // 快照按 CSS 尺寸贴回,缩放后笔迹仍在
img.src = snap;
};
/*** 落笔点换算:指针坐标减画布位置,得到 CSS 像素坐标 ***/
const pointOf = (e: PointerEvent): [number, number] => {
const box = canvas.getBoundingClientRect();
return [e.clientX - box.left, e.clientY - box.top];
};
/*** 画到新点:从上一落点连线,单点(点一下)也要画出一个圆点 ***/
const stroke = (x: number, y: number): void => {
if (!ctx) return;
ctx.strokeStyle = penColor();
ctx.lineWidth = penW;
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(x, y);
ctx.stroke();
lastX = x;
lastY = y;
};
/*** 收笔:写回 dataURL,空画布不算签字 ***/
const commit = (): void => {
const url = canvas.toDataURL('image/png');
if (props.value) props.value(url);
if (props.onChange) props.onChange(url);
};
/*** 清空:复位位图 + 状态 + 信号,一次到位(三处不同源会立刻不同步) ***/
const clear = (): void => {
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
signed(false);
snap = '';
if (props.value) props.value('');
if (props.onChange) props.onChange('');
};
canvas.addEventListener('pointerdown', (e: PointerEvent) => {
if (props.disabled === true) return; // 卫语句:布尔禁用直接挡住
drawing = true;
canvas.setPointerCapture(e.pointerId); // 指针捕获:手滑出画布也继续跟笔
[lastX, lastY] = pointOf(e);
stroke(lastX, lastY); // 先落一个点,单击也能留下痕迹
signed(true);
});
canvas.addEventListener('pointermove', (e: PointerEvent) => {
if (!drawing) return; // 卫语句:没按住不画
const [x, y] = pointOf(e);
stroke(x, y);
});
const finish = (): void => {
if (!drawing) return; // 卫语句:未落笔不重复提交
drawing = false;
commit();
};
canvas.addEventListener('pointerup', finish);
canvas.addEventListener('pointercancel', finish);
canvas.style.height = `${height}px`; // 高度只在这一次写:resize 里读它,不重复推导
const clearBtn = h('button', { type: 'button', class: 'ua-sign-clear', onClick: clear }, '清空') as HTMLButtonElement;
effect(() => { clearBtn.disabled = !signed() || props.disabled === true || (typeof props.disabled === 'function' && Boolean(props.disabled())); }); // 空白或信号禁用时不给出无效操作
const el = h('div', { class: 'ua-sign' }, canvas, h('div', { class: 'ua-sign-bar' }, h('span', { class: 'ua-sign-hint' }, props.hint ?? '在上方区域手写签名'), clearBtn));
if (typeof props.disabled === 'function') effect(() => { el.classList.toggle('is-disabled', Boolean(props.disabled && typeof props.disabled === 'function' && props.disabled())); }); // 信号禁用:跟着变灰
if (props.disabled === true) el.classList.add('is-disabled');
trackSize(el, resize); // 容器宽度变化后重算位图,笔迹按快照贴回
requestAnimationFrame(resize); // 首帧:布局完成后再定位图尺寸,避免 clientWidth 为 0
return { el, clear, toDataURL: () => canvas.toDataURL('image/png'), isEmpty: () => !signed() };
}
+47 -22
View File
@@ -1,32 +1,57 @@
import { effect, h } from '@ui-arch/frame';
import { effect, h, signal } from '@ui-arch/frame';
import type { SliderProps } from './types.js';
/*** 滑块:原生 range 承载交互与键盘,value 信号双向绑定;轨道配色走 accent-color 令牌 ***/
/*** 滑块:原生 range 承载交互与键盘,value 信号双向绑定;range 模式叠两条原生轨取双滑块(值域夹取保证 low ≤ high),marks 在轨道下方标出刻度文案 ***/
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;
// —— 状态集中声明 ——
const value = props.value; // 必需信号:组件不持有数值,一律委托调用方
const min = props.min ?? 0; // 与 input 的缺省保持一致,供刻度换算百分比
const max = props.max ?? 100;
const step = props.step ?? 1;
const high = props.end ?? signal(max); // 区间上限:不传则由组件内部维护
const disabled = props.disabled; // 布尔或信号禁用,h() 直接透传
const pct = (v: number): number => ((v - min) / (max - min || 1)) * 100; // 分母兜底,避免 min=max 时除零
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; // 外部赋值同步滑块位置(等值不重写,避免打断拖动)
});
/*** 建一条原生 range:区间模式靠 CSS 叠在同一格,只有拇指可命中 ***/
const track = (sig: typeof value, onInput: (n: number) => void): HTMLInputElement => {
const input = h('input', { type: 'range', class: 'ua-slider-input', min, max, step, disabled }) as HTMLInputElement;
input.addEventListener('input', () => onInput(Number(input.value)));
effect(() => {
const next = String(sig());
if (input.value !== next) input.value = next; // 外部赋值同步位置(等值不重写,避免打断拖动)
});
return input;
};
// 刻度:按值算百分比定位,纯装饰不进读屏(数值本身由原生 input 播报)
const marks = props.marks?.length
? h('div', { class: 'ua-slider-marks', 'aria-hidden': 'true' },
...props.marks.map((mark) => h('span', { class: 'ua-slider-mark', style: `left:${pct(mark.value)}%` }, mark.label ?? String(mark.value))))
: null;
if (!props.range) { // —— 单值:一条原生轨,行为与旧版完全一致 ——
const input = track(value, (next) => { value(next); props.onChange?.(next); });
return h(
'div',
{ class: 'ua-slider' },
h('div', { class: 'ua-slider-track' }, input, marks),
props.showValue === false ? null : h('span', { class: 'ua-slider-value' }, () => String(value())),
);
}
// —— 区间:两条原生轨叠放,rail 打底、seg 画出两拇指之间的已选段 ——
const rail = h('div', { class: 'ua-slider-rail', 'aria-hidden': 'true' });
const seg = h('div', { class: 'ua-slider-seg', 'aria-hidden': 'true' }) as HTMLElement;
const lowIn = track(value, (next) => { value(Math.min(next, high())); props.onRangeChange?.(value(), high()); }); // 下限越过上限就压平
const highIn = track(high, (next) => { high(Math.max(next, value())); props.onRangeChange?.(value(), high()); }); // 上限低于下限就压平
lowIn.classList.add('ua-slider-input-low'); // 供 CSS 区分上下层与拇指命中区
highIn.classList.add('ua-slider-input-high');
effect(() => { seg.style.left = `${pct(value())}%`; seg.style.width = `${pct(high()) - pct(value())}%`; }); // 已选段随两端重算
return h(
'div',
{ class: 'ua-slider' },
input,
props.showValue === false ? null : h('span', { class: 'ua-slider-value' }, () => String(value())),
h('div', { class: 'ua-slider-track' }, h('div', { class: 'ua-slider-range' }, rail, seg, lowIn, highIn), marks),
props.showValue === false ? null : h('span', { class: 'ua-slider-value' }, () => `${value()} ~ ${high()}`),
);
}
+23
View File
@@ -0,0 +1,23 @@
import { h } from '@ui-arch/frame';
import type { SpaceProps, SpaceSize } from './types.js';
// 间距档位 → 令牌(数字档位不入表,直接拼像素)
const GAP: Record<Exclude<SpaceSize, number>, string> = { sm: 'var(--ua-space-2)', md: 'var(--ua-space-3)', lg: 'var(--ua-space-4)' };
// 交叉轴对齐 → flex align-items 取值
const ALIGN: Record<string, string> = { start: 'flex-start', center: 'center', end: 'flex-end', baseline: 'baseline' };
// 主轴分布 → flex justify-content 取值
const JUSTIFY: Record<string, string> = { start: 'flex-start', center: 'center', end: 'flex-end', between: 'space-between' };
/*** 间距容器:用 flex gap 统一一排子元素的间距,方向 / 档位 / 对齐 / 换行可调;split 在子元素之间插分隔符 ***/
export function space(props: SpaceProps, ...children: unknown[]): HTMLDivElement {
const size = props.size ?? 'md'; // 缺省中档,与多数组件内边距对齐
const gap = typeof size === 'number' ? `${size}px` : GAP[size]; // 数字档位按像素透传
const cls = ['ua-space', props.direction === 'vertical' ? 'is-vertical' : '', props.block ? 'is-block' : ''].filter(Boolean).join(' ');
// 内联只放几何(间距/对齐/换行),颜色与描边一律留给令牌驱动的类名
const geom = `gap:${gap};align-items:${ALIGN[props.align ?? 'center']};justify-content:${JUSTIFY[props.justify ?? 'start']};flex-wrap:${props.wrap ? 'wrap' : 'nowrap'}`;
// 分隔符:仅 2 个以上子元素时插入,aria-hidden 让读屏跳过装饰符号
const nodes = props.split && children.length > 1
? children.flatMap((child, i) => (i === 0 ? [child] : [h('span', { class: 'ua-space-split', 'aria-hidden': 'true' }, props.split), child]))
: children;
return h('div', { class: cls, style: geom }, ...nodes) as HTMLDivElement;
}
+41
View File
@@ -0,0 +1,41 @@
import { h } from '@ui-arch/frame';
import type { SparklineProps } from './types.js';
// SVG 命名空间:createElement 不认 svg 标签,必须走 createElementNS
const NS = 'http://www.w3.org/2000/svg';
// 缺省尺寸:120×32 塞得进统计卡片的右上角
const W = 120;
const H = 32;
/*** 建 SVG 元素并批量写属性:svg 的属性都得 setAttribute,写个小工具省掉每处重复 ***/
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
return el;
}
/*** 迷你趋势线:把一串数值压成一条折线(可选面积填充 + 末点标记),不画坐标轴、不带交互,只表达走向 ***/
export function sparkline(props: SparklineProps): SVGElement {
const values = props.values.filter((v) => Number.isFinite(v)); // 脏数据先剔掉,否则整条线会被一个 undefined 拉平
const w = props.width ?? W;
const hgt = props.height ?? H;
const pad = 2; // 内缩 2px:线宽与末点圆点不会被 viewBox 边缘裁掉
const svg = svgEl('svg', { viewBox: `0 0 ${w} ${hgt}`, width: w, height: hgt, class: 'ua-spark', role: 'img', 'aria-label': props.ariaLabel ?? '趋势图' });
if (values.length < 2) return svg; // 少于两点没有「趋势」可言:给空图,别画一根假横线
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1; // 全等值时除零保护:所有点落在中线
const x = (i: number): number => pad + (i * (w - pad * 2)) / (values.length - 1); // 首末点贴边,中间均分
const y = (v: number): number => pad + (1 - (v - min) / span) * (hgt - pad * 2); // 值越大越靠上(SVG y 轴向下)
const points = values.map((v, i) => `${x(i).toFixed(2)},${y(v).toFixed(2)}`).join(' ');
if (props.area !== false) {
// 面积:折线两端下探到底边闭合,填充走 currentColor 的低透明度(颜色由 CSS 的 color 决定)
svg.append(svgEl('polygon', { points: `${points} ${x(values.length - 1).toFixed(2)},${hgt} ${x(0).toFixed(2)},${hgt}`, class: 'ua-spark-area' }));
}
svg.append(svgEl('polyline', { points, class: 'ua-spark-line', 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
if (props.marker !== false) {
// 末点标记:一眼看出当前值落在哪,不用去数最后一段的斜率
svg.append(svgEl('circle', { cx: x(values.length - 1).toFixed(2), cy: y(values[values.length - 1]).toFixed(2), r: 2, class: 'ua-spark-dot' }));
}
return svg;
}
+1 -1
View File
@@ -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', role: 'status', 'aria-label': '加载中' }); // 读屏播报加载状态
const el = h('span', { class: 'ua-spinner', role: 'status', 'aria-label': props.label ?? '加载中' }); // 读屏播报加载状态,label 可写明在加载什么
el.style.width = size;
el.style.height = size;
return el;

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