Skip to content

fix(site): Schema Catalog 卡片外壳去 button 化,示例预览不再嵌套按钮 (#3903) - #3964

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3903-catalog-index-nested-button
Aug 9, 2026
Merged

fix(site): Schema Catalog 卡片外壳去 button 化,示例预览不再嵌套按钮 (#3903)#3964
yinlianghui merged 1 commit into
mainfrom
claude/issue-3903-catalog-index-nested-button

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3903

问题

SchemaCatalogIndex 把每张卡片渲染成 button(修前 :111),而卡片里嵌的 SchemaThumbnail(:116)用真实 SchemaRenderer 渲染示例本身。目录 423 个示例里 85 个含 "type": "button" 节点,于是这些卡片在 /docs/guide/schema-catalog 上落成「按钮套按钮」。

React 把它判为 hydration error 而非样式瑕疵:HTML 解析器会把内层按钮提出外层,server HTML 与 client 树因此不一致。它同时本身就是可访问性缺陷 —— 交互控件套交互控件,键盘与读屏行为未定义。

前提已在 origin/main @ 69becd2d1 上复核:AST 扫描 apps/site 全部 17 个 tsx,命中恰好一处 —— SchemaCatalogIndex.tsx:116 SchemaThumbnail inside button@111,与 issue 正文与分诊评论完全一致。

改法

1. 卡片外壳去 button 化。 外壳改为非交互 div(加 relative,边框 / hover / 间距类不变),点击目标改为绝对定位的覆盖层 button 兄弟节点(absolute inset-0)。缩略图与文字都不再位于任何交互控件的子树内。正文提到的 div role="button" 写法会把 hydration 与 a11y 两个问题都留下,因此没有采用(结构钉也把它一并禁掉)。

2. SchemaThumbnail 的缩放预览层加 inert 仅有覆盖层不够 —— 见下方焦点顺序实测。

a11y 论证

  • 焦点顺序:覆盖层 button 是卡片内唯一可聚焦节点,焦点顺序 = 卡片视觉顺序,一卡一停。修前 DOM 顺序上缩略图在前,从第一张卡 Tab 出去依次落在示例内部的 Submit / Save Draft / Delete / Cancel 才到第二张卡(实测轨迹见下)。inert 同时移除聚焦与命中测试,这才让该组件文档里写的「scaled, non-interactive preview」成立;原来的 pointer-events: none 只管住了鼠标。另外缩略图框本身带 aria-hidden,子树里存在可聚焦节点本身就是 axe aria-hidden-focus 违规。
  • 焦点环:focus-visible:ring-2 画在覆盖层上,而覆盖层 inset-0 解析到外壳的 padding box,因此视觉上仍是「整卡获得焦点」(修后截图里 Simple Login Form 那张卡的环即是)。
  • 可访问名:aria-labelOpen {title} ({id}),包含卡片上可见的标题与 id,满足 WCAG 2.5.3 label-in-name;role 由 button 元素本身提供。
  • 鼠标:hover 仍作用在外壳(hover 沿祖先链传播),边框高亮行为不变;覆盖层带 cursor-pointer(Tailwind v4 preflight 不再给 button 设 pointer)。

浏览器实证

Chromium(/opt/pw-browsers/chromium)+ next dev,同一 dev server 上先复现后修复;缩略图是 IntersectionObserver 懒挂载,因此滚动加载后再统计。

读数 修前 修后
document.querySelectorAll('button button').length 136 0
cannot be a descendant of / cannot contain a nested 控制台报错 2 0
其中 This will cause a hydration error 1 0
aria-hidden 预览内可聚焦节点 180 0
[inert] 预览层 0 115(已挂载的)
卡片外壳是 button 423 0
覆盖层 button 0 423
Next dev 指示器 Issues 计数 12 10

修前报错样本(祖先栈直接点名 GalleryCard 的外壳 button 套住 ButtonRenderer 的 button):

[error] In HTML, %s cannot be a descendant of  %s .
This will cause a hydration error.%s  button   button
  ...
    GalleryCard entry={{id:"action...", ...}}
>     button
>       type="button"
>       onClick={function onClick}
>       className="group flex flex-col gap-2 rounded-lg border border-fd-border bg-fd-card p-3..."
>     
        ...
          SchemaErrorBoundary componentType="button"
            ButtonRenderer schema={{type:"button", ...}} label="Submit"
              Button ref={null} type="button" variant="default"
>               button
>                 className={"inline-flex items-center justify-center gap-2 whitespace-nowrap..."}

点击导航实测(抽 3 张卡实点,dialog 的 aria-label 与卡片标题一致,Esc 关闭):

Open Action Button Variants (actions/action-button-variants) -> dialog "Action Button Variants"
Open Action Toolbar (actions/action-toolbar)                 -> dialog "Action Toolbar"
Open Confirmation Dialog (actions/confirmation-dialog)       -> dialog "Confirmation Dialog"

焦点顺序实测(聚焦第一张卡后连按 Tab):

修前: 卡1外壳 -> Submit -> Save Draft -> Delete -> Cancel -> 卡2外壳 -> Reject
修后: 卡1覆盖层 -> 卡2 -> 卡3 -> 卡4 -> 卡5 -> 卡6 -> 卡7

截图与完整控制台日志留在 scratchpad(3903-before.png / 3903-after.png / 3903-*-console.log)。

测试

apps/site 没有 vitest 面(根 vitest.config.mtssharedExcludeapps/**,projects 只额外注册了 apps/console),而这个缺陷形态是静态可判的,所以结构钉放在 scripts/__tests__/ —— 与 site-playground-layout-registration-3904.test.ts 同一高度、同一理由,且它在 CI 的根 vitest 分片里真的会跑(不是只能靠一个默认被 skip 的 docs-smoke e2e)。

钉子用 TS AST 遍历 apps/site 全部 tsx:任何 schema 预览宿主(SchemaRenderer / SchemaThumbnail / InteractiveDemo / LiveSplitDemo / SchemaExample)与任何 button,都不得落在 buttonrole="button" 的子树里 —— 按发现而非硬编码文件清单,明天新加的第二个宿主会被同样抓到。防空转绿三重:检测器先对四份合成样本自测(修前形状、button 套 button、div role=button 改写、覆盖层形状),再断言扫描确实覆盖到 ≥10 个文件且含卡片文件,并断言卡片文件里确实同时存在预览宿主与 button。最后一条 suite 钉 inert + pointer-events-none

反向验证(方向在跑之前就已预判,两条都是朴素红):

修前(pristine main):  Tests  2 failed | 7 passed (9)
  × finds no schema preview nested inside an interactive control
      + [ "apps/site/app/components/SchemaCatalogIndex.tsx:116 SchemaThumbnail inside  button  at line 111" ]
  × SchemaRenderer #0 is wrapped in an inert, pointer-events-none subtree
      no ancestor ... carries inert: expected 0 to be greater than 0
修后:                 Test Files  1 passed (1) / Tests  9 passed (9)

其余门(仓根,全部在共享 verify 锁内串行):

pnpm exec vitest run scripts/__tests__/site-catalog-card-interactive-nesting-3903.test.ts --maxWorkers=2   PIN_EXIT=0
pnpm run type-check:scripts                                                                                SCRIPTS_TC_EXIT=0
pnpm --filter @object-ui/site types:check                                                                  SITE_TC_EXIT=0
pnpm exec turbo run type-check --concurrency=2                        Tasks: 78 successful, 78 total       TURBO_TC_EXIT=0
node scripts/check-control-bytes.mjs        OK (3852 tracked text files)
pnpm exec eslint (三个改动文件)              0

changeset

无需 changeset:.changeset/config.json@object-ui/site 列在 ignore,且 scripts/check-changeset-presence.mjs 只守 fixed 组里每个包自己的 src 目录(即 包名 + /src/**);本 PR 改的是 apps/site/app/**scripts/__tests__/**,两者都不在守护面内。

范围之外

  • content/**/*.mdx 里的 SchemaExample 用法不在这条 AST 规则的扫描面(只扫 tsx),跨组件边界的交互性分析也不在 —— 缺陷本身与回归都会是同一文件内的 JSX 形状,更大的全图分析是另一件工具。
  • 修后 button [aria-hidden] 仍有 91 处:那是页面上其它 button(站点 chrome 与预览内部按钮)里的 aria-hidden 图标节点,与本单无关,非残留。

`SchemaCatalogIndex` 把每张卡片渲染成 `button`,而卡片里嵌的 `SchemaThumbnail`
用真实 `SchemaRenderer` 渲染示例本身 —— 目录 423 个示例里有 85 个含
`"type": "button"` 节点,于是这些卡片在 `/docs/guide/schema-catalog` 上落成
「按钮套按钮」。React 把它判为 hydration error 而非样式瑕疵:HTML 解析器会把内层
按钮提出外层,server HTML 与 client 树因此不一致;它同时本身就是可访问性缺陷。

改法两条:

1. 卡片外壳改为非交互 `div`(加 `relative`,边框/hover/间距类不变),点击目标改为
   绝对定位的覆盖层 `button` 兄弟节点(`absolute inset-0`),`aria-label` 给出示例
   标题与 id。缩略图与文字都不再位于任何交互控件的子树内。正文提到的
   `div role="button"` 写法会把两个问题都留下,因此没有采用。

2. `SchemaThumbnail` 的缩放预览层加 `inert`。仅有覆盖层还不够:预览内部的控件依然
   可聚焦,而缩略图框本身是 `aria-hidden`(`aria-hidden` 子树里存在可聚焦节点本身
   就是 axe `aria-hidden-focus` 违规),且 DOM 顺序上预览在覆盖层之前 —— 实测修前
   从第一张卡 Tab 出去依次落在 Submit / Save Draft / Delete / Cancel,才到第二张卡。
   `inert` 同时移除聚焦与命中测试,这才让该组件文档里的「scaled, non-interactive
   preview」成立;原来的 `pointer-events: none` 只管住了鼠标。

浏览器实证(Chromium + next dev,滚动加载缩略图后统计):`button button` 节点
136 → 0,React 嵌套按钮控制台报错 2 → 0(hydration error 1 → 0),aria-hidden 预览
内可聚焦节点 180 → 0;抽 3 张卡实点仍正常打开对应 dialog,Tab 现在一卡一停。

结构钉放在 `scripts/__tests__/`:`apps/site` 没有 vitest 面(根配置 exclude
`apps/**`,只注册了 `apps/console`),而缺陷形态是静态可判的 —— 用 TS AST 遍历
apps/site 全部 tsx,断言没有任何 schema 预览宿主落在 `button` 或 `role="button"`
的子树里,并自测检测器对四种合成样本的判定,避免空转绿。
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 9, 2026 5:47pm

Request Review

Copy link
Copy Markdown
Collaborator Author

PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3903 落地。

核验记录(head 8afbbcc6a,基 69becd2d1,实物核验 + CI 亲读):

  1. 方案按正文方向落地:外壳 button → 非交互 div(样式类保留),点击目标为覆盖层 button(absolute inset-0 + aria-label 含可见标题与 id,WCAG 2.5.3 达标),缩略图预览层加 inert(实物 grep 确认)。inert 的必要性有实测支撑:仅覆盖层时预览内控件仍可聚焦且 DOM 序在前 —— 修前 Tab 轨迹(卡 1 → 预览内 Submit/Save Draft/Delete/Cancel → 卡 2)为证,修后一卡一停。div role="button" 反方案被结构钉一并禁掉 —— 正确。
  2. 浏览器实证(修前后同一 dev server):button 套 button 136 → 0;嵌套/hydration 控制台报错 2 → 0;aria-hidden 预览内可聚焦节点 180 → 0;点击导航实测 3 卡开合正常;pageerror 0;进程按 PID 收净。
  3. 结构钉是 CI 会真跑的:放 scripts/__tests__(与 apps/site: Playground 未注册 layout 组件(输入 page-header 得红色错误面板);transpilePackages 还列着两个非依赖包 #3904 钉同一先例与理由),TS AST 按发现遍历 apps/site 全部 tsx 而非硬编码清单;防空转绿三重(4 份合成样本自测 + 覆盖文件数下限 + 卡片文件双元素在场断言)。反向验证预判两条朴素红,实跑命中(2 failed | 7 passed → 9 passed)。
  4. 门与规程:type-check 78/78 + scripts 与 site 两腿单独跑过;site 生产 build 由 CI Build Docs 的 Build Site 步骤覆盖(dev 已披露本地未跑及原因);控制字节门 + 盲区自查零命中;changeset 免除依据成立(site 在 ignore、改动不在 presence 门守护面,CI 门绿为证);提交文案控制词 0;⛔ releases/ 未触碰。
  5. CI 亲读终态:18/18 全 completed、0 失败(Test shard×4 至 17:53:27Z、Type Check 17:51:28Z、Lint 17:51:15Z;coverage/dependabot skipped 计绿;本 run 检查集由 path filter 决定)。
  6. concern 中 button [aria-hidden] 残余 91 的解释已核(站点 chrome 与预览内部图标,非本单残留);inert 老浏览器降级行为已在报告写明(嵌套修复不受影响)。

out-of-scope #3965(48 个示例用废弃 div 类型 + 无去重 deprecation warn 刷屏)已按 finding 纪律立单持有,查重在案 —— 处置得当,进分诊轮存量。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 9, 2026 17:54
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 53b7b88 Aug 9, 2026
19 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3903-catalog-index-nested-button branch August 9, 2026 17:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

apps/site: Schema Catalog 索引页把每个缩略图包在 button 里,85 个含 button 节点的示例都触发 React 嵌套按钮 hydration 报错

2 participants