From 8afbbcc6a098374261fc24789de067d45f75a1c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 17:42:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(site):=20Schema=20Catalog=20=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=A4=96=E5=A3=B3=E5=8E=BB=20button=20=E5=8C=96,?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E9=A2=84=E8=A7=88=E4=B8=8D=E5=86=8D=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E6=8C=89=E9=92=AE=20(#3903)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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"` 的子树里,并自测检测器对四种合成样本的判定,避免空转绿。 --- .../app/components/SchemaCatalogIndex.tsx | 35 +- apps/site/app/components/SchemaThumbnail.tsx | 11 + ...alog-card-interactive-nesting-3903.test.ts | 361 ++++++++++++++++++ 3 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 scripts/__tests__/site-catalog-card-interactive-nesting-3903.test.ts diff --git a/apps/site/app/components/SchemaCatalogIndex.tsx b/apps/site/app/components/SchemaCatalogIndex.tsx index 37f169add5..bedda33afe 100644 --- a/apps/site/app/components/SchemaCatalogIndex.tsx +++ b/apps/site/app/components/SchemaCatalogIndex.tsx @@ -103,16 +103,26 @@ interface CardProps { onOpen: (e: Example) => void; } +/** + * One gallery card. + * + * The shell is deliberately NON-interactive and the click target is an overlay + * sibling, never an ancestor of the thumbnail (objectui#3903). `SchemaThumbnail` + * renders the example itself through a real `SchemaRenderer`, and 85 of the 423 + * catalog examples contain `"type": "button"` nodes — with a `button` shell those + * cards shipped `button` inside `button`, which React classifies as a hydration + * error (the HTML parser hoists the inner button out of the outer one, so the + * server HTML and the client tree disagree) on top of being an accessibility + * defect in its own right. A `div role="button"` shell would keep both problems; + * an absolutely positioned overlay removes them without giving up a real, + * natively focusable control. + */ const GalleryCard = React.memo(function GalleryCard({ entry, onOpen, }: CardProps) { return ( - + {/* + The card's only focusable node: it covers the whole card (`inset-0` + resolves against the shell's padding box), so the focus ring still reads + as "the card is focused", and its accessible name carries both labels the + card shows — title and id — which keeps WCAG 2.5.3 (label in name) + satisfied. The `button` role itself announces what activation does. + */} + ', + ');', + ].join('\n') + ); + + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ child: 'SchemaThumbnail', control: 'button', line: 3 }); + }); + + it('flags a literal button inside a button', () => { + const violations = findViolations( + 'sample-nested-button.tsx', + ['export const X = () => (', ' ', ' ', ');'].join( + '\n' + ) + ); + + expect(violations.map((v) => v.child)).toEqual(['button']); + }); + + it('flags the div role="button" rewrite as well, which the issue warns against', () => { + const violations = findViolations( + 'sample-role-button.tsx', + [ + 'export const X = () => (', + '
', + ' ', + '
', + ');', + ].join('\n') + ); + + expect(violations.map((v) => v.child)).toEqual(['SchemaThumbnail']); + }); + + it('accepts the overlay shape: preview and click target as SIBLINGS', () => { + const violations = findViolations( + 'sample-overlay.tsx', + [ + 'export const Card = () => (', + '
', + ' ', + '
{entry.meta.title}
', + '
', + ');', + ].join('\n') + ); + + expect(violations).toEqual([]); + }); +}); + +describe('objectui#3903 — no apps/site schema preview sits inside an interactive control', () => { + const files = siteTsxFiles(); + + it('discovers the site tree at all (a zero-hit scan would be vacuously green)', () => { + expect(files.length).toBeGreaterThanOrEqual(10); + expect(files).toContain(CARD_FILE); + }); + + it('the catalog index really does render a preview AND a button, so it is in scope', () => { + // The other half of the empty-fixture guard: if the card ever stops + // embedding a preview host, or stops having a click target, the sweep below + // would go green over a file that no longer exercises the rule — and the + // page would be broken in a different way. + const source = fs.readFileSync(path.join(repoRoot, CARD_FILE), 'utf8'); + const sf = parse(CARD_FILE, source); + const tags: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + tags.push(tagNameOf(node as JsxNode, sf)); + } + ts.forEachChild(node, visit); + }; + visit(sf); + + expect(tags).toContain('SchemaThumbnail'); + expect(tags).toContain('button'); + }); + + it('finds no schema preview nested inside an interactive control', () => { + const violations = files.flatMap((file) => + findViolations(file, fs.readFileSync(path.join(repoRoot, file), 'utf8')) + ); + + expect( + violations.map((v) => `${v.file}:${v.line} ${v.child} inside <${v.control} > at line ${v.controlLine}`), + 'A schema preview renders whatever the example JSON says, and 85 catalog examples contain ' + + 'button nodes — inside a control that is a React hydration error plus an accessibility ' + + 'defect (objectui#3903). Keep the shell non-interactive and make the click target an ' + + 'absolutely positioned button SIBLING with an aria-label naming the example.' + ).toEqual([]); + }); +}); + +describe('objectui#3903 — the thumbnail preview is inert, not merely unclickable', () => { + /** + * The overlay fix alone leaves the preview's own controls FOCUSABLE, and the + * thumbnail frame is `aria-hidden` — a focusable node inside `aria-hidden` is + * itself a violation (axe `aria-hidden-focus`), and because the preview + * precedes the overlay in DOM order, a keyboard user would tab through every + * example's internal buttons before reaching the card's own open button. + * `inert` is what makes SchemaThumbnail's documented contract ("scaled, + * non-interactive preview") true; `pointer-events: none` only ever covered + * the mouse. + */ + const source = fs.readFileSync(path.join(repoRoot, THUMBNAIL_FILE), 'utf8'); + const sf = parse(THUMBNAIL_FILE, source); + + function ancestorsOfRenderer(): JsxNode[][] { + const chains: JsxNode[][] = []; + const stack: JsxNode[] = []; + const visit = (node: ts.Node): void => { + const jsx = + ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) ? (node as JsxNode) : null; + if (jsx) { + if (tagNameOf(jsx, sf) === 'SchemaRenderer') chains.push([...stack]); + stack.push(jsx); + } + ts.forEachChild(node, visit); + if (jsx) stack.pop(); + }; + visit(sf); + return chains; + } + + const chains = ancestorsOfRenderer(); + + it('renders a SchemaRenderer at all', () => { + expect(chains.length).toBeGreaterThan(0); + }); + + chains.forEach((chain, i) => { + it(`SchemaRenderer #${i} is wrapped in an inert, pointer-events-none subtree`, () => { + const inert = chain.filter((n) => hasAttr(n, 'inert')); + expect( + inert.length, + `no ancestor of the SchemaRenderer in ${THUMBNAIL_FILE} carries inert, so the preview's own ` + + 'controls stay focusable inside an aria-hidden frame and sit ahead of the card button in ' + + 'tab order (objectui#3903)' + ).toBeGreaterThan(0); + + const unclickable = chain.filter((n) => + (stringAttr(n, 'className') ?? '').includes('pointer-events-none') + ); + expect( + unclickable.length, + `no ancestor of the SchemaRenderer in ${THUMBNAIL_FILE} carries pointer-events-none` + ).toBeGreaterThan(0); + }); + }); +});