fix(fields): 接通 slider 的三条 a11y 通道、命名 signature、把 aria-invalid 账本拆成两个语义位 (#3318) - #4933
Conversation
…e aria-invalid ledger (#3318) The registry sweep's NOT_YET_DELIVERED ledger held nine rows. Measured against `origin/main` before touching anything — a real form, one required field per row, freshly failed validation: formula ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 summary ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 auto_number ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 vector ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 signature ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 filter-condition ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 recipient-picker ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 grid ariaInvalidTrue=[] focusable=1 [button] slider ariaInvalidTrue=[] focusable=2 [span[slider],input] text (control) ariaInvalidTrue=[input] focusable=1 labelFor -> input desc=1 Three dispositions, and the measurement is what separates them. DELIVERED — slider. Its focusable control is Radix's span[role="slider"] thumb, rendered inside the synced `ui/slider.tsx` and not exported, so unlike #3306's SelectTrigger a widget has no handle on it. The primitive grew a declared `thumbProps`, routed by `lib/slider-thumb.ts` and applied to the no-touch file through the repo's declared sync-patch registry so it survives regeneration. The root half of that patch is load-bearing, not bookkeeping: without it the object stringifies onto the wrapper and the host id lands on two elements at once. `SliderField` then splits exactly as `SelectField` does — `name` and `disabled` stay on Root, everything else goes to the thumb, `aria-invalid` after the spread. One fix, three channels: state, name and description. NOT APPLICABLE — a second ledger, for the seven rows whose widget renders no focusable control at all. `aria-invalid` is control-channel state; a row with nothing to focus has no element to carry it, and marking its text span is the wrapper move this sweep exists to forbid. Each row states its ground and the sweep MEASURES that ground, so a reclassification is falsifiable: `grid` cannot be parked there to empty the first ledger, because it renders a button. Left in NOT_YET_DELIVERED: `grid` alone, where driving a form-level failure into a per-cell editor needs a design rather than a spread. Signature's verdict is split, not blanket. The control state does not apply, but the NAME always did: `<canvas>` is not a labelable element, so its host label could only dangle. It and `slider` are now declared `labelling: 'group'` — `file`'s shape, one unlabelable control, not a composite — and named by IDREF. Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
The comment above the destructure opened on "withheld alongside them" with the antecedent 200 lines up in the doc block, which reads as a dangling reference at the one place a reader needs the split spelled out. Comment only. Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
|
PM 验收:ACCEPT(Part of #3318,批次 18,PM 会话 分流裁定执行(逐条对账):账本 9→1 —— slider 交付(三通道: 账本结构:两个互斥、只收缩的集合,DELIVERING 由全集减法派生 —— 三集合按构造划分注册表,逃账结构上不可能(M2/M4 变异各自证实)。 实物核验(已过):11 文件 +922/−55 对账;标识 0;releases 0; 反向验证(4+1 变异):M1 预判 6 红逐例命中(附一条「空缺断言空真」的诚实排除);M2 双删证防逃账;M3 预判红实测绿 —— dev 自查定位为变异放错分支(EmptyValue 早退路径不可达),以 M3b 在真渲染分支补证转红,教训写进账本文档 —— 预判失败的正确处置形态;M4 headline 反逃账证实。 有据偏离(全部接受):①基线自主改用 69dbea2 —— 派发后 16 秒 main 上 e7747f1 恰好改了目标账本文件,旧基线意味着在 main 已越过的版本上重写账本,判断正确且同为钉死 sha;②范围延伸 components+scripts —— 账本注释本身预诊断了需要组件级改动,且走了仓自己的声明机制;③amend 后为避免 force-push 改用 soft-reset + 快进提交 —— ⛔ 红线意识正确。 协作产出:#4857 上的修正评论(其 option 3 对 slider/signature 不充分 —— 悬空 CI(亲读终态):20 项全 completed,零失败。 → undraft + auto-merge (SQUASH)。#3318 保持 open(余 grid 一行)。 Generated by Claude Code |
Part of #3318
按 2026-08-17 的 PM 分流裁定处理账本残留 9 条。账本未清零(
grid仍留一条,见下),所以正文用Part of而非关闭关键字。一、先量后改:
origin/main上 9 条的实测读数真表单 + 真 react-hook-form,每行一个
required字段、null值(presence 语义),提交后先自证 "is required" 已渲染再读 aria。text是对照组。aria-invalid=trueforbuttonspan[role=slider]+ 隐藏inputinputinputinput这张表就是分流线本身:「可聚焦控件 = 0」是「不适用」的事实依据,不是说法。
grid与slider各有控件,所以它们不在那条线上。另外,9 行全部
ids=[]—— 不是 id 被覆写(#3952 在boolean上的形态),而是整行 DOM 里没有任何元素带 id。这正是 08-09 / 08-16 三通道实测的结论:一处缺失同时掉了三样东西。二、逐条处置
slidersignaturearia-invalid判不适用;label 关联已交付file范式formulaNOT_APPLICABLEsummaryNOT_APPLICABLEauto_numberNOT_APPLICABLEvectorNOT_APPLICABLEfilter-conditionNOT_APPLICABLEobject_name)只有提示段落;可编辑态已交付recipient-pickerNOT_APPLICABLErecipient_type)只有提示段落;可编辑态已交付gridNOT_YET_DELIVERED,不修NOT_YET_DELIVERED现在只剩grid一条。按裁定这不卡 issue 关闭决策,交 PM 定。三、
slider:为什么需要一次 components 级改动aria-invalid必须落在role="slider"的 thumb 上 —— 那才是键盘用户落点、辅助技术读状态的元素。Radix 的 Root 渲染一个无 role 的包裹span并吞掉所有它不认识的 prop,所以 host 下发的一切都落在辅助技术永不访问的包裹层上。这与 #3306 的
select同形态但难一档:那里可聚焦控件是SelectTrigger,primitive 导出了它,widget 能直接寻址;ui/slider.tsx的 thumb 是内部渲染、只导出Slider,widget 对那个唯一必须承载状态的元素没有任何把手。走仓库既有的声明式 sync patch 机制(
scripts/shadcn-local-patches.mjs)—— 这正是它存在的用途:「必须在 regeneration 后存活的src/ui/**编辑,以数据形式声明,由 sync 自己重放」。payload 依该文件自身的指引留在src/ui/之外(packages/components/src/lib/slider-thumb.ts),ui/slider.tsx只收四行有锚点的一行改。四条 patch 里 root 那条不是记账:若只加 thumb 交付而不从 Root 的 spread 里摘掉
thumbProps,代码照样编译照样渲染 —— 对象会被String()成thumbprops="[object Object]"贴到包裹 span(#3291 扫的那类泄漏),并且 host id 会同时落在两个元素上,getElementById答出包裹层,于是 label 的for依旧指向不可聚焦的 span,bug 穿着自己的修复活下来。这条已在scripts/__tests__/shadcn-local-patches.test.ts里单独钉住并写明原因。SliderField的分流与SelectField逐键相同:name(Root 拥有参与提交的隐藏 input)和disabled(Root 是唯一权威)留在 Root,其余走 thumb,aria-invalid={!!error}写在 spread 之后。className也扣住 —— primitive 把thumbPropsspread 在 thumb 自身 class 之后,透过去会把 thumb 的形状整个换掉。四、三通道读数(修后)
slider一处修改同时接通三通道,三面都钉在slider-signature-host-channels-e2e.test.tsx:aria-invalidspan[role=slider]上,整行恰好 1 个for、发aria-labelledby;getByRole('slider', {name:/Confidence/})命中 thumbaria-describedby外加两条:host id 在 thumb 上且全行只有一个元素答应该 id;thumb 上没有
name/thumbprops泄漏、且保留自身rounded-full形状。label 通道为何单靠 spread 不够:
for能 resolve 不等于 label 能 工作。label for只能寻址 HTML 的 labelable 元素(button / input / meter / output / progress / select / textarea)。slider 的控件是span[role=slider]、signature 的是 canvas,直接把 host id 给它们只是把「悬空的for」升级成「无效的for」。所以两者都声明labelling: 'group'(#3961 的路子,file的形态:一个不可 label 的控件,而非复合体),改由 IDREF 命名。这条连带更新了group-labelling-declaration.test.ts的精确集合断言。signature则拿到名字与描述(role="group"容器,instead-of-the-inputs),不拿控件态:Clear 按钮是作用于值的辅助按钮,不是值的控件 —— 与geolocation只读行里那个可聚焦的「View on map」链接同一读法。五、棘轮结构:为什么「借改判逃账」结构上不可能
DELIVERING是推导出来的:ALL_TYPES减去两个账本。三个集合按构造划分注册表。三重防线:NOT_APPLICABLE自带守卫 —— sweep 实测该 widget 的行确实不提供可聚焦控件。grid无法被挪进来凑数(它渲染一个 button);将来谁给这些 widget 加了真控件而账本行没动,同一条守卫也红。六、反向验证(先书面预判,后跑;变异前已 commit,还原用
git checkout,未用 stash)四次变异,预判与实测的一致/不一致都照实报:
field:slider正向断言 + channels 的 5 条formula从两个账本都删掉field:formula正向 sweepformula塞一个可聚焦控件(假阳测试)[ 'span' ]grid从NOT_YET_DELIVERED挪进NOT_APPLICABLE[ 'button' ];互斥/存在/划分三条仍绿M1 的一个诚实例外:
leaks no renderer plumbing onto the thumb那条在 M1 下保持绿色,而且这是预判里就写明的 —— 它整条都是「不存在」断言(not.toHaveAttribute('name')/'thumbprops')加一个 className 检查,而改动前的 widget 什么都不转发,因此空洞地满足它。它守的是修复的回归,不是修复的缺席;在这里它两个方向都不构成证据,不当成证据用。M3 为什么预判错,以及它教了什么:我把
tabIndex加在FormulaField的有值分支上,而 sweep 渲染的是value == null的EmptyValue早返回 —— 变异落在一条这个 fixture 走不到的分支上,红不了是机械原因,不是守卫弱。M3b 改到真正渲染的分支即按预判变红。这件事本身是条真事实,已写进账本的文档注释:不适用守卫读的是 sweep 渲染的那个状态(fresh / empty / required / 刚失败),而那恰好是该判的状态 ——
required是 presence 检查,属性需要被播报的时刻正是值缺失的时刻。四个只读类型在这里渲染的是空值分支、signature的 Clear 按钮要画过才可聚焦,两者都已在注释里点名,是披露而非藏起来。七、验证
pnpm exec vitest run packages/fields/→ 103 files / 1709 tests passedpnpm exec vitest run packages/components/ scripts/→ 195 files / 2464 tests passedplugin-form+plugin-detail+plugin-designer+plugin-grid→ 209 / 1964 passed;app-shell→ 414 / 3919 passed(1 skipped)pnpm exec turbo run type-check --concurrency=2→ 81/81 successfulnode scripts/check-control-bytes.mjs→ OK(4413 files);另对本 PR 触及文件做了越过该门的自查(grep -naP控制字符类)→ cleanchangeset:
@object-ui/components+@object-ui/fields各 patch(⛔ 无 major)。未触碰content/docs/releases/**,未 force-push。八、偏差一条
派发单钉的基线是
40d3a3318,实际从origin/main(69dbea20a)开分支。原因:派发后 main 又进了两个 commit,其中e7747f19a(#4814 retireowneralias,落地时间比分流评论晚 16 秒)改的正是本单目标账本文件 —— 它把owner: UserField从WIDGETS里摘走换成一段注释。基于旧基线会让我在一个 main 已经走过的文件版本上重写账本。较新基线同样是钉死的 SHA。Generated by Claude Code