Compare commits

1 Commits

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