Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions apps/site/app/components/SchemaCatalogIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button
type="button"
onClick={() => onOpen(entry)}
className="group flex flex-col gap-2 rounded-lg border border-fd-border bg-fd-card p-3 text-left transition hover:border-fd-primary hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring"
>
<div className="group relative flex flex-col gap-2 rounded-lg border border-fd-border bg-fd-card p-3 text-left transition hover:border-fd-primary hover:shadow-sm">
<SchemaThumbnail schema={entry.schema as SchemaNode} />
<div className="flex flex-col gap-0.5">
<div className="line-clamp-1 text-sm font-medium text-fd-foreground">
Expand All @@ -122,7 +132,20 @@ const GalleryCard = React.memo(function GalleryCard({
{entry.id}
</code>
</div>
</button>
{/*
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.
*/}
<button
type="button"
onClick={() => onOpen(entry)}
aria-label={`Open ${entry.meta.title} (${entry.id})`}
className="absolute inset-0 cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring"
/>
</div>
);
});

Expand Down
11 changes: 11 additions & 0 deletions apps/site/app/components/SchemaThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ export function SchemaThumbnail({
}
>
<div
// `inert`, not just `pointer-events: none`: the preview renders the
// example's own controls, and this frame is `aria-hidden`. A
// focusable node inside an `aria-hidden` subtree is itself a
// violation (axe `aria-hidden-focus`), and because the preview
// precedes the card's open button in DOM order, a keyboard user
// otherwise tabs through every example's internal buttons before
// reaching the card (measured: Tab from card 1 stopped on Submit,
// Save Draft, Delete, Cancel — objectui#3903). `inert` removes the
// subtree from focus order and hit-testing, which is what makes the
// "non-interactive preview" in this component's contract true.
inert
className="pointer-events-none absolute left-0 top-0 origin-top-left select-none"
style={{
width: `${viewportWidth}px`,
Expand Down
Loading
Loading