Skip to content

fix(core): visible focus ring and aria-controls on CodeBlock collapse header#3723

Merged
cixzhang merged 1 commit into
facebook:mainfrom
bhamodi:a11y/codeblock-header-focus-controls
Jul 10, 2026
Merged

fix(core): visible focus ring and aria-controls on CodeBlock collapse header#3723
cixzhang merged 1 commit into
facebook:mainfrom
bhamodi:a11y/codeblock-header-focus-controls

Conversation

@bhamodi

@bhamodi bhamodi commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The CodeBlock collapsible header (packages/core/src/CodeBlock/CodeBlock.tsx) is a <div role="button" tabIndex={0}> that already exposes aria-expanded, but its disclosure pattern was incomplete in two ways:

  • No explicit focus ring. styles.header resets the control (padding, border: none, background, color, font, textAlign) but never defines a :focus-visible outline. Unlike Collapsible's trigger (fix(core): restore visible keyboard focus ring on Collapsible trigger #3722), it does not use all: unset, and the global reset.css doesn't strip outlines from [role="button"] -- so the header still renders the browser's default UA outline on keyboard focus. That's technically visible, but inconsistent with the rest of the system: every other disclosure control (Collapsible, TabMenu) restores the design system's accent ring explicitly. This adds the same ring so keyboard focus is consistent and theme-aware.
  • No aria-controls. The header exposed aria-expanded but nothing linked it to the region it shows/hides, so assistive tech couldn't move from the button to its controlled content.

Changes

  • CodeBlock/CodeBlock.tsx:
    • Add outline / outlineOffset :focus-visible rules to styles.headerCollapsible (scoped to the collapsible header -- the only state in which it's focusable), using the established convention: outline: 2px solid var(--color-accent), outlineOffset: 2px, both gated on :focus-visible. Mirrors TabMenu (TabList/TabMenu.tsx:93-100) and Collapsible (fix(core): restore visible keyboard focus ring on Collapsible trigger #3722).
    • Add a useId()-based id on the collapsible code region and aria-controls on the header.
  • CodeBlock/CodeBlock.test.tsx: add two tests (below).

aria-controls: unconditional (region stays mounted)

The code region collapses via a CSS grid animation (grid-template-rows: 1fr -> 0fr), not by unmounting -- the region is always in the DOM whenever the header is a button. So aria-controls is set unconditionally (like Collapsible #3707) and never points at a missing element. This differs from Banner #3719, where the region is conditionally rendered and aria-controls must be gated on mount.

Test plan

  • Added links the collapsible header to its code region via aria-controls -- asserts aria-controls resolves via document.getElementById to the region containing the code (role="group"). Written first; confirmed failing before the fix (aria-controls was null), passing after.
  • Added keeps aria-controls resolvable when collapsed (region stays mounted) -- collapses the header and asserts the reference still resolves, documenting the unconditional choice.
  • Focus ring: StyleX compiles styles to atomic class names, so jsdom cannot assert the computed outline value. Consistent with fix(core): restore visible keyboard focus ring on Collapsible trigger #3722, I did not add a brittle class-name-snapshot test. Manual verification: Tabbing to the header in Storybook shows the accent ring (matching TabMenu); I could not run a browser in this environment, so this step is un-run -- the values are copied verbatim from already-shipping components that render the ring correctly.

Commands:

  • node_modules/.bin/vitest run --root . packages/core/src/CodeBlock -- 26 pass (24 existing + 2 new).
  • node_modules/.bin/tsc --project packages/core/tsconfig.json --noEmit -- clean.
  • node_modules/.bin/eslint packages/core/src/CodeBlock/CodeBlock.tsx packages/core/src/CodeBlock/CodeBlock.test.tsx -- clean.

Notes

Found during a broader a11y audit of core components. Scoped to the header/region only -- the copy button and its live region (covered separately by #3709) are untouched. One correction to the audit's framing: for CodeBlock the header's reset does not remove the browser outline (no all: unset), so this is a focus-ring consistency fix, not an invisible-focus rescue like #3722.

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

@bhamodi is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 9, 2026
@bhamodi bhamodi force-pushed the a11y/codeblock-header-focus-controls branch from 851bc83 to 3ead828 Compare July 9, 2026 06:22

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @bhamodi — focus ring + aria-controls both verified; region id and control gated on the same condition so it never dangles. 🙏

@cixzhang cixzhang merged commit d7bde21 into facebook:main Jul 10, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants