Skip to content

Commit fe262e6

Browse files
committed
CollapsibleCard: Add HeaderDescription subcomponent
Add a `CollapsibleCard.HeaderDescription` subcomponent that provides an `aria-describedby` relationship between supplementary header content (e.g. status badges, summaries) and the collapsible trigger button. The component uses React context to communicate its auto-generated ID to the parent `Header`, which sets `aria-describedby` on the trigger. Content is rendered with `aria-hidden="true"` so assistive technologies consume it only through the description relationship, avoiding double announcements. Also refactors the DataForm card layout to use `HeaderDescription` instead of the previous manual `aria-describedby` + `useId()` approach. Made-with: Cursor
1 parent c909bc4 commit fe262e6

10 files changed

Lines changed: 249 additions & 39 deletions

File tree

‎packages/dataviews/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Enhancements
1111

1212
- DataViews: Field's description can accept ReactElements. [#76829](https://github.com/WordPress/gutenberg/pull/76829)
13+
- DataForm: Use `CollapsibleCard.HeaderDescription` for card layout header descriptions instead of manual `aria-describedby`.
1314

1415
## 13.1.0 (2026-03-18)
1516

‎packages/dataviews/src/components/dataform-layouts/card/index.tsx‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
useCallback,
66
useContext,
77
useEffect,
8-
useId,
98
useMemo,
109
useRef,
1110
useState,
@@ -87,7 +86,6 @@ function isSummaryFieldVisible< Item >(
8786
function HeaderContent< Item >( {
8887
data,
8988
fields,
90-
descriptionId,
9189
label,
9290
layout,
9391
isOpen,
@@ -96,7 +94,6 @@ function HeaderContent< Item >( {
9694
}: {
9795
data: Item;
9896
fields: NormalizedField< Item >[];
99-
descriptionId: string;
10097
label: string | undefined;
10198
layout: NormalizedCardLayout;
10299
isOpen: boolean;
@@ -120,11 +117,7 @@ function HeaderContent< Item >( {
120117
>
121118
<Card.Title>{ label }</Card.Title>
122119
{ ( hasBadge || hasSummary ) && (
123-
<div
124-
id={ descriptionId }
125-
aria-hidden="true"
126-
className="dataforms-layouts-card__field-header-content-description"
127-
>
120+
<CollapsibleCard.HeaderDescription className="dataforms-layouts-card__field-header-content-description">
128121
{ hasBadge && <ValidationBadge validity={ validity } /> }
129122
{ hasSummary && (
130123
<div className="dataforms-layouts-card__field-summary">
@@ -137,7 +130,7 @@ function HeaderContent< Item >( {
137130
) ) }
138131
</div>
139132
) }
140-
</div>
133+
</CollapsibleCard.HeaderDescription>
141134
) }
142135
</Stack>
143136
);
@@ -208,7 +201,6 @@ export default function FormCardField< Item >( {
208201
const { fields } = useContext( DataFormContext );
209202
const layout = field.layout as NormalizedCardLayout;
210203
const contentRef = useRef< HTMLDivElement >( null );
211-
const descriptionId = useId();
212204

213205
const form: NormalizedForm = useMemo(
214206
() => ( {
@@ -284,7 +276,6 @@ export default function FormCardField< Item >( {
284276
<HeaderContent
285277
data={ data }
286278
fields={ fields }
287-
descriptionId={ descriptionId }
288279
label={ label }
289280
layout={ layout }
290281
isOpen={ isCollapsible ? !! isOpen : true }
@@ -300,7 +291,7 @@ export default function FormCardField< Item >( {
300291
open={ isOpen }
301292
onOpenChange={ handleOpenChange }
302293
>
303-
<CollapsibleCard.Header aria-describedby={ descriptionId }>
294+
<CollapsibleCard.Header>
304295
{ headerContent }
305296
</CollapsibleCard.Header>
306297
<CollapsibleCard.Content

‎packages/ui/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Add `InputControl` component ([#76653](https://github.com/WordPress/gutenberg/pull/76653)).
88
- `Dialog`: Expose `initialFocus` and `finalFocus` props on `Dialog.Popup` for custom focus management ([#76860](https://github.com/WordPress/gutenberg/pull/76860)).
9+
- `CollapsibleCard`: Add `HeaderDescription` subcomponent for supplementary header content with `aria-describedby` relationship.
910

1011
### Bug Fixes
1112

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createContext } from '@wordpress/element';
2+
3+
export const HeaderDescriptionIdContext = createContext< {
4+
setDescriptionId: ( id: string | undefined ) => void;
5+
} >( {
6+
setDescriptionId: () => {},
7+
} );
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { forwardRef, useContext, useEffect, useId } from '@wordpress/element';
2+
import { HeaderDescriptionIdContext } from './context';
3+
import type { HeaderDescriptionProps } from './types';
4+
5+
/**
6+
* Secondary content placed in the collapsible card header that describes
7+
* the trigger button via `aria-describedby`. Use it for supplementary
8+
* information such as status badges or summary values.
9+
*
10+
* The content is visually rendered but marked `aria-hidden` so that
11+
* assistive technologies consume it only through the `aria-describedby`
12+
* relationship on the trigger, avoiding double announcements.
13+
*
14+
* Avoid interactive elements (buttons, links, inputs) inside this
15+
* component — the entire header is the toggle trigger.
16+
*/
17+
export const HeaderDescription = forwardRef<
18+
HTMLDivElement,
19+
HeaderDescriptionProps
20+
>( function CollapsibleCardHeaderDescription(
21+
{ children, className, ...restProps },
22+
ref
23+
) {
24+
const descriptionId = useId();
25+
const { setDescriptionId } = useContext( HeaderDescriptionIdContext );
26+
27+
useEffect( () => {
28+
setDescriptionId( descriptionId );
29+
return () => setDescriptionId( undefined );
30+
}, [ descriptionId, setDescriptionId ] );
31+
32+
return (
33+
<div
34+
ref={ ref }
35+
id={ descriptionId }
36+
aria-hidden="true"
37+
className={ className }
38+
{ ...restProps }
39+
>
40+
{ children }
41+
</div>
42+
);
43+
} );
Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import clsx from 'clsx';
2-
import { forwardRef } from '@wordpress/element';
2+
import { forwardRef, useMemo, useState } from '@wordpress/element';
33
import { chevronDown } from '@wordpress/icons';
44
import * as Card from '../card';
55
import * as Collapsible from '../collapsible';
66
import { Icon } from '../icon';
77
import styles from './style.module.css';
88
import focusStyles from '../utils/css/focus.module.css';
9+
import { HeaderDescriptionIdContext } from './context';
910
import type { HeaderProps } from './types';
1011

1112
/**
@@ -22,38 +23,54 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >(
2223
{ children, className, render, ...restProps },
2324
ref
2425
) {
26+
const [ descriptionId, setDescriptionId ] = useState< string >();
27+
28+
const contextValue = useMemo(
29+
() => ( { setDescriptionId } ),
30+
[ setDescriptionId ]
31+
);
32+
2533
return (
26-
<Collapsible.Trigger
27-
className={ clsx( styles.header, className ) }
28-
render={
29-
<Card.Header
30-
ref={ ref }
31-
render={ render }
32-
{ ...restProps }
33-
/>
34-
}
35-
nativeButton={ false }
36-
>
37-
<div className={ styles[ 'header-content' ] }>{ children }</div>
38-
<div
39-
className={ clsx( styles[ 'header-trigger-positioner' ] ) }
34+
<HeaderDescriptionIdContext.Provider value={ contextValue }>
35+
<Collapsible.Trigger
36+
className={ clsx( styles.header, className ) }
37+
render={
38+
<Card.Header
39+
ref={ ref }
40+
render={ render }
41+
{ ...restProps }
42+
/>
43+
}
44+
nativeButton={ false }
45+
aria-describedby={ descriptionId }
4046
>
47+
<div className={ styles[ 'header-content' ] }>
48+
{ children }
49+
</div>
4150
<div
4251
className={ clsx(
43-
styles[ 'header-trigger-wrapper' ],
44-
// While the interactive trigger element is the whole header,
45-
// the focus ring will be displayed only on the icon to visually
46-
// emulate it being the button.
47-
focusStyles[ 'outset-ring--focus-parent-visible' ]
52+
styles[ 'header-trigger-positioner' ]
4853
) }
4954
>
50-
<Icon
51-
icon={ chevronDown }
52-
className={ styles[ 'header-trigger' ] }
53-
/>
55+
<div
56+
className={ clsx(
57+
styles[ 'header-trigger-wrapper' ],
58+
// While the interactive trigger element is the whole header,
59+
// the focus ring will be displayed only on the icon to visually
60+
// emulate it being the button.
61+
focusStyles[
62+
'outset-ring--focus-parent-visible'
63+
]
64+
) }
65+
>
66+
<Icon
67+
icon={ chevronDown }
68+
className={ styles[ 'header-trigger' ] }
69+
/>
70+
</div>
5471
</div>
55-
</div>
56-
</Collapsible.Trigger>
72+
</Collapsible.Trigger>
73+
</HeaderDescriptionIdContext.Provider>
5774
);
5875
}
5976
);
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Root } from './root';
22
import { Header } from './header';
3+
import { HeaderDescription } from './header-description';
34
import { Content } from './content';
45

5-
export { Root, Header, Content };
6+
export { Root, Header, HeaderDescription, Content };

‎packages/ui/src/collapsible-card/stories/index.story.tsx‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const meta: Meta< typeof CollapsibleCard.Root > = {
2929
component: CollapsibleCard.Root,
3030
subcomponents: {
3131
'CollapsibleCard.Header': CollapsibleCard.Header,
32+
'CollapsibleCard.HeaderDescription': CollapsibleCard.HeaderDescription,
3233
'CollapsibleCard.Content': CollapsibleCard.Content,
3334
},
3435
};
@@ -156,6 +157,49 @@ export const Stacked: Story = {
156157
),
157158
};
158159

160+
/**
161+
* A collapsible card with a `HeaderDescription` that provides supplementary
162+
* information (e.g. status, summary) as an `aria-describedby` relationship.
163+
*/
164+
export const WithHeaderDescription: Story = {
165+
// `defaultOpen` (uncontrolled) and `open` (controlled) should not be
166+
// used together — disable the `open` control to avoid confusion.
167+
argTypes: { open: { control: false } },
168+
args: {
169+
defaultOpen: true,
170+
},
171+
render: ( { open, defaultOpen, onOpenChange, disabled, ...restArgs } ) => (
172+
<CollapsibleCard.Root
173+
open={ open }
174+
defaultOpen={ defaultOpen }
175+
onOpenChange={ onOpenChange }
176+
disabled={ disabled }
177+
{ ...restArgs }
178+
>
179+
<CollapsibleCard.Header>
180+
<Card.Title>Settings</Card.Title>
181+
<CollapsibleCard.HeaderDescription>
182+
<span
183+
style={ {
184+
fontSize: 'var(--wpds-font-size-sm)',
185+
color: 'var(--wpds-color-fg-content-neutral-weak)',
186+
} }
187+
>
188+
3 items configured
189+
</span>
190+
</CollapsibleCard.HeaderDescription>
191+
</CollapsibleCard.Header>
192+
<CollapsibleCard.Content>
193+
<Text>
194+
The header description provides supplementary context to the
195+
trigger button. Assistive technologies will announce the
196+
description alongside the button label.
197+
</Text>
198+
</CollapsibleCard.Content>
199+
</CollapsibleCard.Root>
200+
),
201+
};
202+
159203
/**
160204
* Visual comparison: a `CollapsibleCard` (open) next to a regular `Card`
161205
* to verify identical spacing and layout.

‎packages/ui/src/collapsible-card/test/index.test.tsx‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,97 @@ describe( 'CollapsibleCard', () => {
178178
).toBeVisible();
179179
} );
180180
} );
181+
182+
describe( 'HeaderDescription', () => {
183+
it( 'sets aria-describedby on the trigger pointing to the description', () => {
184+
render(
185+
<CollapsibleCard.Root>
186+
<CollapsibleCard.Header>
187+
<Card.Title>Settings</Card.Title>
188+
<CollapsibleCard.HeaderDescription data-testid="desc">
189+
3 errors
190+
</CollapsibleCard.HeaderDescription>
191+
</CollapsibleCard.Header>
192+
<CollapsibleCard.Content>
193+
<p>Content</p>
194+
</CollapsibleCard.Content>
195+
</CollapsibleCard.Root>
196+
);
197+
198+
const trigger = screen.getByRole( 'button', {
199+
name: 'Settings',
200+
} );
201+
const descriptionElement = screen.getByTestId( 'desc' );
202+
203+
expect( descriptionElement ).toHaveAttribute( 'id' );
204+
expect( trigger ).toHaveAttribute(
205+
'aria-describedby',
206+
descriptionElement.id
207+
);
208+
} );
209+
210+
it( 'marks the description content as aria-hidden', () => {
211+
render(
212+
<CollapsibleCard.Root>
213+
<CollapsibleCard.Header>
214+
<Card.Title>Settings</Card.Title>
215+
<CollapsibleCard.HeaderDescription data-testid="desc">
216+
<span>Status: OK</span>
217+
</CollapsibleCard.HeaderDescription>
218+
</CollapsibleCard.Header>
219+
</CollapsibleCard.Root>
220+
);
221+
222+
const descriptionWrapper = screen.getByTestId( 'desc' );
223+
expect( descriptionWrapper ).toHaveAttribute(
224+
'aria-hidden',
225+
'true'
226+
);
227+
} );
228+
229+
it( 'does not set aria-describedby when HeaderDescription is absent', () => {
230+
render(
231+
<CollapsibleCard.Root>
232+
<CollapsibleCard.Header>
233+
<Card.Title>Title</Card.Title>
234+
</CollapsibleCard.Header>
235+
</CollapsibleCard.Root>
236+
);
237+
238+
const trigger = screen.getByRole( 'button', { name: 'Title' } );
239+
expect( trigger ).not.toHaveAttribute( 'aria-describedby' );
240+
} );
241+
242+
it( 'forwards ref on HeaderDescription', () => {
243+
const descRef = createRef< HTMLDivElement >();
244+
245+
render(
246+
<CollapsibleCard.Root>
247+
<CollapsibleCard.Header>
248+
<Card.Title>Title</Card.Title>
249+
<CollapsibleCard.HeaderDescription ref={ descRef }>
250+
Description
251+
</CollapsibleCard.HeaderDescription>
252+
</CollapsibleCard.Header>
253+
</CollapsibleCard.Root>
254+
);
255+
256+
expect( descRef.current ).toBeInstanceOf( HTMLDivElement );
257+
} );
258+
259+
it( 'renders description content visually', () => {
260+
render(
261+
<CollapsibleCard.Root>
262+
<CollapsibleCard.Header>
263+
<Card.Title>Title</Card.Title>
264+
<CollapsibleCard.HeaderDescription>
265+
Badge content
266+
</CollapsibleCard.HeaderDescription>
267+
</CollapsibleCard.Header>
268+
</CollapsibleCard.Root>
269+
);
270+
271+
expect( screen.getByText( 'Badge content' ) ).toBeInTheDocument();
272+
} );
273+
} );
181274
} );

0 commit comments

Comments
 (0)