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); + }); + }); +});