diff --git a/.changeset/codeblock-header-focus-controls.md b/.changeset/codeblock-header-focus-controls.md new file mode 100644 index 000000000000..74d7528b44ce --- /dev/null +++ b/.changeset/codeblock-header-focus-controls.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] CodeBlock: complete the collapsible header's disclosure pattern. It now shows the standard accent focus ring on `:focus-visible` — previously it defined no focus style and fell back to the browser's default outline, unlike the system's other disclosure controls (Collapsible, TabMenu) — and links to the code region it shows/hides via `aria-controls` (the header already exposed `aria-expanded`). The region stays mounted when collapsed (CSS grid animation), so `aria-controls` is an always-resolvable reference. (#3723) +@bhamodi diff --git a/packages/core/src/CodeBlock/CodeBlock.test.tsx b/packages/core/src/CodeBlock/CodeBlock.test.tsx index 6a0db662a7b3..6e1dada5ab58 100644 --- a/packages/core/src/CodeBlock/CodeBlock.test.tsx +++ b/packages/core/src/CodeBlock/CodeBlock.test.tsx @@ -114,6 +114,49 @@ describe('CodeBlock', () => { expect(header).toHaveAttribute('aria-expanded', 'false'); }); + it('links the collapsible header to its code region via aria-controls', () => { + render( + , + ); + const header = screen + .getAllByRole('button') + .find(el => el.hasAttribute('aria-expanded'))!; + const controlsId = header.getAttribute('aria-controls'); + // aria-controls must be present and point at the real code region. + expect(controlsId).toBeTruthy(); + const region = document.getElementById(controlsId as string); + expect(region).not.toBeNull(); + // The region contains the scrollable code body (role="group"). + expect(region).toContainElement(screen.getByRole('group')); + }); + + it('keeps aria-controls resolvable when collapsed (region stays mounted)', () => { + render( + , + ); + const header = screen + .getAllByRole('button') + .find(el => el.hasAttribute('aria-expanded'))!; + fireEvent.click(header); + expect(header).toHaveAttribute('aria-expanded', 'false'); + // The code region uses a CSS grid animation to collapse, so it stays in + // the DOM — aria-controls stays a valid, resolvable reference (unlike a + // conditionally-mounted region, which would need a conditional attribute). + const controlsId = header.getAttribute('aria-controls'); + expect(controlsId).toBeTruthy(); + expect(document.getElementById(controlsId as string)).not.toBeNull(); + }); + it('applies a per-instance syntax theme via the syntaxTheme prop', () => { const {container} = render( = collapsibleThreshold; const [isCollapsed, setIsCollapsed] = useState(false); + // Links the collapsible header to the code region it shows/hides so assistive + // tech can move from the button to its controlled content (disclosure + // pattern). The region stays mounted when collapsed (CSS grid animation), so + // this is always a resolvable reference — aria-controls can be unconditional. + const regionId = useId(); const scrollContainerRef = useRef(null); const scrollStyle: CSSProperties | undefined = maxHeight @@ -727,6 +744,7 @@ export function CodeBlock({ role={canCollapse ? 'button' : undefined} tabIndex={canCollapse ? 0 : undefined} aria-expanded={canCollapse ? !isCollapsed : undefined} + aria-controls={canCollapse ? regionId : undefined} onClick={canCollapse ? () => setIsCollapsed(prev => !prev) : undefined} onKeyDown={ canCollapse @@ -830,6 +848,7 @@ export function CodeBlock({ {headerEl} {canCollapse ? (