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
6 changes: 6 additions & 0 deletions .changeset/codeblock-header-focus-controls.md
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions packages/core/src/CodeBlock/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CodeBlock
code={LONG_CODE}
language="javascript"
title="example"
isCollapsible
/>,
);
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(
<CodeBlock
code={LONG_CODE}
language="javascript"
title="example"
isCollapsible
/>,
);
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(
<CodeBlock
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/CodeBlock/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import {
useInsertionEffect,
useEffect,
useId,
useRef,
useState,
useCallback,
Expand Down Expand Up @@ -174,6 +175,17 @@ const styles = stylex.create({
headerCollapsible: {
cursor: 'pointer',
userSelect: 'none',
// Restore a keyboard-only focus ring with the standard token/offset so this
// disclosure control matches the rest of the system (Collapsible, TabMenu);
// otherwise it falls back to the inconsistent UA default outline.
outline: {
default: null,
':focus-visible': `2px solid ${colorVars['--color-accent']}`,
},
outlineOffset: {
default: '0',
':focus-visible': '2px',
},
},
gutter: {
flexShrink: 0,
Expand Down Expand Up @@ -690,6 +702,11 @@ export function CodeBlock({

const canCollapse = isCollapsible && lines.length >= 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<HTMLDivElement>(null);
const scrollStyle: CSSProperties | undefined = maxHeight
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -830,6 +848,7 @@ export function CodeBlock({
{headerEl}
{canCollapse ? (
<div
id={regionId}
{...stylex.props(
styles.collapseGrid,
isCollapsed && styles.collapseGridCollapsed,
Expand Down
Loading