diff --git a/.ua/backlog.json b/.ua/backlog.json index 70768ed..53a5d8c 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3050,6 +3050,15 @@ "motivation": "对比度门禁只管「文字 vs 底色」,管不到「底色 vs 底色」;实测表面→二阶表面亮度比只有 1.008(暗色 1.006),即悬停底与表面几乎同色,6 处 surface-2 与新建的列面板悬停都白改。", "acceptance": "① ua audit:surface 按四套主题逐档验阶梯(页面底→表面→悬停底→按下底 ≥1.04,凹陷底 ≥1.02),不达标退出码 1,负向自检确认会红;② surface-2/3 改为从 surface 派生并拉开档距;③ audit:surface 进 check 链、package.json 脚本、usage 串与 doctor 的 check 链检查;④ 列面板悬停改用 surface-2(原先撞 .ua-pop 同底色);⑤ audit:contrast 与 audit:visual 仍全绿。", "notes": "新 lib/surface-audit.mjs:四套主题 × 四段阶梯算亮度比(1.04 / 凹陷 1.02),--json/--list 双出口,进 check 链与 doctor;tokens surface-2/3 改为从 surface 派生(亮 6%/14%,暗 7%/17%),阶梯由 1.008→1.123(暗 1.006→1.193);列面板悬停改用 surface-2(原与 .ua-pop 同底色等于没反馈);contrast-audit 导出 resolve/ratio 供复用" + }, + { + "id": "g296", + "domain": "tune", + "status": "open", + "title": "修 form 校验失败仍提交:自绘控件字段没有原生 input 时守卫失效", + "motivation": "提交守卫写成「找到焦点目标才中止」,而焦点目标只查 input/textarea/select;非搜索式下拉、评分、开关容器里没有原生 input,firstBad 为 null 时校验有错也照样调 onSubmit(demo 的「城市」必填下拉即可复现)。", + "acceptance": "① 中止判据改为错误条数 bad > 0;② 焦点落点按 控件 → 触发器(button/[tabindex]) → 字段容器(tabIndex=-1) 三级退让;③ manifest 的 form 步骤与 demo 描述同步说明;④ npm run check 全绿。", + "notes": "" } ] } \ No newline at end of file diff --git a/.ua/index.json b/.ua/index.json index 32f0e3d..80dceb5 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T15:31:57.268Z", + "generatedAt": "2026-10-26T15:36:58.959Z", "repoRoot": ".", "packages": [ { @@ -2034,7 +2034,8 @@ "需要给字段加格式说明就传 hint:平时显示在控件下方,校验失败时自动让位给错误文案,同一位置不堆两行", "字段多的编辑页传 columns: 2 或 3 走多列栅格:列间距与窄屏堆叠沿用 grid,按钮区始终整行;单列表单不要传(默认 1)", "多列表单里想整宽就传 span:formItem({ span: 2 }, textarea(...)) 会占满两列,窄屏单列时自动忽略跨列(否则会撑出横向滚动)", - "字段要按另一个字段的值决定是否出现(选了「其他」才出现输入框),给 formItem 传 visible 信号:隐藏项自动清掉错误态并退出校验,不用自己在 items 数组里增删" + "字段要按另一个字段的值决定是否出现(选了「其他」才出现输入框),给 formItem 传 visible 信号:隐藏项自动清掉错误态并退出校验,不用自己在 items 数组里增删", + "校验失败必定中止提交:判据是错误条数而不是「有没有找到焦点目标」,所以只有自绘控件(非搜索式下拉 / 评分 / 开关,容器里没有原生 input)的字段也能拦住;焦点定位顺序是控件 → 触发器 → 字段容器(后者临时 tabindex=-1,只接受程序聚焦,不污染 Tab 序)" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 9b506e9..7e17d0c 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -579,7 +579,7 @@ const content = h( section( 'sec-formlab', '表单校验', - 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处,onError 拿到不通字段数。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。', + 'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处——自绘控件(下拉 / 评分 / 开关)没有原生 input,焦点退到它的触发器,整块不可聚焦时落在字段容器上;onError 拿到不通字段数。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。', form( { columns: 2, submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) }, [ diff --git a/llms-full.txt b/llms-full.txt index 3fba37c..d15526d 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T15:31:57.268Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T15:36:58.959Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1010,6 +1010,7 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: 13. 字段多的编辑页传 columns: 2 或 3 走多列栅格:列间距与窄屏堆叠沿用 grid,按钮区始终整行;单列表单不要传(默认 1) 14. 多列表单里想整宽就传 span:formItem({ span: 2 }, textarea(...)) 会占满两列,窄屏单列时自动忽略跨列(否则会撑出横向滚动) 15. 字段要按另一个字段的值决定是否出现(选了「其他」才出现输入框),给 formItem 传 visible 信号:隐藏项自动清掉错误态并退出校验,不用自己在 items 数组里增删 +16. 校验失败必定中止提交:判据是错误条数而不是「有没有找到焦点目标」,所以只有自绘控件(非搜索式下拉 / 评分 / 开关,容器里没有原生 input)的字段也能拦住;焦点定位顺序是控件 → 触发器 → 字段容器(后者临时 tabindex=-1,只接受程序聚焦,不污染 Tab 序) 现场演示:apps/docs/demo.html#sec-formlab diff --git a/packages/skin/ui/src/form.ts b/packages/skin/ui/src/form.ts index 73d735e..cee2e3b 100644 --- a/packages/skin/ui/src/form.ts +++ b/packages/skin/ui/src/form.ts @@ -30,6 +30,10 @@ async function checkRuleAsync(value: string, rule: FormRule): Promise + box.querySelector('input:not([type="hidden"]), textarea, select, button, [tabindex]:not([tabindex="-1"])'); + /*** 表单字段:label + 控件插槽 + 错误区;返回句柄(同步 / 异步两种校验)供 form 聚合 ***/ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle { // —— 状态集中声明 —— @@ -120,13 +124,15 @@ export function form(props: FormProps, items: FormItemHandle[]): HTMLElement { if (!msg) continue; // 卫语句:本项通过 bad += 1; if (firstBad) continue; // 只把首个错误控件留作焦点目标 - firstBad = item.el.querySelector('input, textarea, select') as HTMLElement | null; + const target = focusableOf(item.el); + if (!target) item.el.tabIndex = -1; // 整块无可聚焦控件时把字段容器本身设为焦点落点(-1 不进 Tab 序,只接受程序聚焦) + firstBad = target ?? item.el; } submitBtn.disabled = false; running = false; summary.hidden = bad === 0; summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : ''; - if (firstBad) { firstBad.focus(); props.onError?.(bad); return; } // 卫语句:有错误不提交 + if (bad > 0) { firstBad?.focus(); props.onError?.(bad); return; } // 有错即中止:判据用错误数而非焦点目标,否则自绘控件字段会漏过去直接提交 props.onSubmit?.(); }; diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index cc03edb..5d4d6b7 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -1905,7 +1905,8 @@ "需要给字段加格式说明就传 hint:平时显示在控件下方,校验失败时自动让位给错误文案,同一位置不堆两行", "字段多的编辑页传 columns: 2 或 3 走多列栅格:列间距与窄屏堆叠沿用 grid,按钮区始终整行;单列表单不要传(默认 1)", "多列表单里想整宽就传 span:formItem({ span: 2 }, textarea(...)) 会占满两列,窄屏单列时自动忽略跨列(否则会撑出横向滚动)", - "字段要按另一个字段的值决定是否出现(选了「其他」才出现输入框),给 formItem 传 visible 信号:隐藏项自动清掉错误态并退出校验,不用自己在 items 数组里增删" + "字段要按另一个字段的值决定是否出现(选了「其他」才出现输入框),给 formItem 传 visible 信号:隐藏项自动清掉错误态并退出校验,不用自己在 items 数组里增删", + "校验失败必定中止提交:判据是错误条数而不是「有没有找到焦点目标」,所以只有自绘控件(非搜索式下拉 / 评分 / 开关,容器里没有原生 input)的字段也能拦住;焦点定位顺序是控件 → 触发器 → 字段容器(后者临时 tabindex=-1,只接受程序聚焦,不污染 Tab 序)" ] }, {