Skip to content

Commit cb20a43

Browse files
ciampomirkajasmussen
authored
CollapsibleCard: Support multiple header descriptions (#81227)
* CollapsibleCard: Support multiple header descriptions * CollapsibleCard: Add changelog entry * CollapsibleCard: Expand header description story * CollapsibleCard: Preserve explicit header descriptions * CollapsibleCard: Test header descriptions by behavior * CollapsibleCard: Test descriptions through accessibility API * CollapsibleCard: Style header descriptions * CollapsibleCard: Keep description story uncontrolled * CollapsibleCard: Refresh package metadata * UI: Use Text in card stories * CollapsibleCard: Use medium description text * CollapsibleCard: Show single description layouts * CollapsibleCard: Simplify description guidance * CollapsibleCard: Simplify prop definitions * CollapsibleCard: Refine header description layout * CollapsibleCard: Address review feedback * CollapsibleCard: Restore title-aligned chevron * CollapsibleCard: Target registered header descriptions * CollapsibleCard: Remove header description marker * CollapsibleCard: Address latest review feedback --- Unlinked contributors: lucyneb. Co-authored-by: ciampo <mciampini@git.wordpress.org> Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: jasmussen <joen@git.wordpress.org>
1 parent 094b1b0 commit cb20a43

8 files changed

Lines changed: 327 additions & 163 deletions

File tree

‎packages/ui/CHANGELOG.md‎

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

2020
### Enhancements
2121

22+
- `CollapsibleCard`: Support multiple `CollapsibleCard.HeaderDescription` components ([#81227](https://github.com/WordPress/gutenberg/pull/81227)).
2223
- Add `TextareaControl` component ([#81359](https://github.com/WordPress/gutenberg/pull/81359)).
2324
- Export shared keyboard shortcut presentation and accessibility utilities. ([#81826](https://github.com/WordPress/gutenberg/pull/81826))
2425
- `Menu`: Add shortcut display and accessibility metadata support. ([#79560](https://github.com/WordPress/gutenberg/pull/79560))

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

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,8 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
2-
import { Stack } from '@wordpress/ui';
2+
import { Stack } from '../../stack';
3+
import { Text } from '../../text';
34
import * as Card from '../index';
45

5-
/**
6-
* Temporary text component for story examples. This will be replaced by an
7-
* official DS `<Text />` component once it's available.
8-
*/
9-
function Text( { children }: { children: React.ReactNode } ) {
10-
return (
11-
<p
12-
style={ {
13-
margin: 0,
14-
fontFamily: 'var(--wpds-typography-font-family-body)',
15-
fontSize: 'var(--wpds-typography-font-size-md)',
16-
fontWeight: 'var(--wpds-typography-font-weight-default)',
17-
lineHeight: 'var(--wpds-typography-line-height-sm)',
18-
textWrap: 'pretty',
19-
color: 'var(--wpds-color-foreground-content-neutral-weak)',
20-
} }
21-
>
22-
{ children }
23-
</p>
24-
);
25-
}
26-
276
const meta: Meta< typeof Card.Root > = {
287
tags: [ 'manifest' ],
298
title: 'Design System/Components/Card',
@@ -52,15 +31,15 @@ export const Default: Story = {
5231
<Card.Title>Card title</Card.Title>
5332
</Card.Header>,
5433
<Card.Content key="content">
55-
<Text>
34+
<Text render={ <p /> }>
5635
This is the main content area. It can contain any elements.
5736
This is the main content area. It can contain any elements.
5837
This is the main content area. It can contain any elements.
5938
This is the main content area. It can contain any elements.
6039
This is the main content area. It can contain any elements.
6140
This is the main content area. It can contain any elements.
6241
</Text>
63-
<Text>
42+
<Text render={ <p /> }>
6443
This is the main content area. It can contain any elements.
6544
</Text>
6645
</Card.Content>,
@@ -139,7 +118,7 @@ export const WithFullBleed: Story = {
139118
} }
140119
/>
141120
</Card.FullBleed>
142-
<Text>Content below the full-bleed area.</Text>
121+
<Text render={ <p /> }>Content below the full-bleed area.</Text>
143122
</Card.Content>,
144123
],
145124
},
@@ -182,7 +161,7 @@ export const FullBleedHeroWithTitle: Story = {
182161
<Card.Title>Hero image card</Card.Title>
183162
</Card.Header>,
184163
<Card.Content key="content">
185-
<Text>
164+
<Text render={ <p /> }>
186165
The image above bleeds to the card&apos;s top and side
187166
edges.
188167
</Text>
@@ -211,7 +190,7 @@ export const FullBleedHeroOnly: Story = {
211190
</Card.FullBleed>
212191
</Card.Header>,
213192
<Card.Content key="content">
214-
<Text>
193+
<Text render={ <p /> }>
215194
The image above bleeds to the card&apos;s top and side
216195
edges.
217196
</Text>
@@ -233,7 +212,9 @@ export const CustomSemantics: Story = {
233212
<Card.Title render={ <h2 /> }>Section heading</Card.Title>
234213
</Card.Header>,
235214
<Card.Content key="content">
236-
<Text>Semantically meaningful card content.</Text>
215+
<Text render={ <p /> }>
216+
Semantically meaningful card content.
217+
</Text>
237218
</Card.Content>,
238219
],
239220
},
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createContext } from '@wordpress/element';
22

3-
export const HeaderDescriptionIdContext = createContext< {
4-
setDescriptionId: ( id: string | undefined ) => void;
5-
} >( {
6-
setDescriptionId: () => {},
7-
} );
3+
type HeaderDescriptionIdContextValue = {
4+
registerDescriptionId: ( id: string ) => () => void;
5+
};
6+
7+
export const HeaderDescriptionIdContext =
8+
createContext< HeaderDescriptionIdContextValue | null >( null );
Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1+
import clsx from 'clsx';
12
import { forwardRef, useContext, useEffect, useId } from '@wordpress/element';
23
import { HeaderDescriptionIdContext } from './context';
34
import type { HeaderDescriptionProps } from './types';
5+
import { Text } from '../text';
6+
import styles from './style.module.css';
7+
8+
const DEFAULT_TAG = <div />;
49

510
/**
611
* Secondary content placed in the collapsible card header that describes
712
* the trigger button via `aria-describedby`. Use it for supplementary
813
* information such as status badges or summary values.
914
*
15+
* Must be rendered inside `CollapsibleCard.Header`.
16+
*
1017
* The content is visually rendered but marked `aria-hidden` so that
1118
* assistive technologies consume it only through the `aria-describedby`
1219
* relationship on the trigger, avoiding double announcements.
@@ -18,26 +25,35 @@ export const HeaderDescription = forwardRef<
1825
HTMLDivElement,
1926
HeaderDescriptionProps
2027
>( function CollapsibleCardHeaderDescription(
21-
{ children, className, ...restProps },
28+
{ children, className, id: idProp, render = DEFAULT_TAG, ...restProps },
2229
ref
2330
) {
24-
const descriptionId = useId();
25-
const { setDescriptionId } = useContext( HeaderDescriptionIdContext );
31+
const generatedId = useId();
32+
const descriptionId = idProp ?? generatedId;
33+
const context = useContext( HeaderDescriptionIdContext );
34+
35+
if ( process.env.NODE_ENV !== 'production' && ! context ) {
36+
throw new Error(
37+
'CollapsibleCard.HeaderDescription: Missing parent <CollapsibleCard.Header>. ' +
38+
'Render <CollapsibleCard.HeaderDescription> inside <CollapsibleCard.Header>.'
39+
);
40+
}
2641

2742
useEffect( () => {
28-
setDescriptionId( descriptionId );
29-
return () => setDescriptionId( undefined );
30-
}, [ descriptionId, setDescriptionId ] );
43+
return context?.registerDescriptionId( descriptionId );
44+
}, [ context, descriptionId ] );
3145

3246
return (
33-
<div
47+
<Text
3448
ref={ ref }
49+
variant="body-md"
50+
render={ render }
3551
id={ descriptionId }
3652
aria-hidden="true"
37-
className={ className }
53+
className={ clsx( styles[ 'header-description' ], className ) }
3854
{ ...restProps }
3955
>
4056
{ children }
41-
</div>
57+
</Text>
4258
);
4359
} );

‎packages/ui/src/collapsible-card/header.tsx‎

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { mergeProps, useRender } from '@base-ui/react';
22
import clsx from 'clsx';
3-
import { forwardRef, useMemo, useState } from '@wordpress/element';
3+
import { useIsomorphicLayoutEffect } from '@wordpress/compose';
4+
import {
5+
forwardRef,
6+
useCallback,
7+
useMemo,
8+
useRef,
9+
useState,
10+
} from '@wordpress/element';
411
import { chevronDown } from '@wordpress/icons';
512
import * as Card from '../card';
613
import * as Collapsible from '../collapsible';
@@ -30,16 +37,79 @@ import type { HeaderProps } from './types';
3037
*/
3138
export const Header = forwardRef< HTMLDivElement, HeaderProps >(
3239
function CollapsibleCardHeader(
33-
{ children, className, render, ...restProps },
40+
{
41+
children,
42+
className,
43+
render,
44+
'aria-describedby': ariaDescribedByProp,
45+
...restProps
46+
},
3447
ref
3548
) {
36-
const [ descriptionId, setDescriptionId ] = useState< string >();
49+
const [ descriptionIds, setDescriptionIds ] = useState< string[] >(
50+
[]
51+
);
52+
const [ orderedDescriptionIds, setOrderedDescriptionIds ] = useState<
53+
string[]
54+
>( [] );
55+
const headerContentRef = useRef< HTMLDivElement >( null );
56+
57+
const registerDescriptionId = useCallback( ( id: string ) => {
58+
setDescriptionIds( ( currentDescriptionIds ) => {
59+
if ( currentDescriptionIds.includes( id ) ) {
60+
return currentDescriptionIds;
61+
}
62+
63+
return [ ...currentDescriptionIds, id ];
64+
} );
65+
66+
return () => {
67+
setDescriptionIds( ( currentDescriptionIds ) =>
68+
currentDescriptionIds.filter(
69+
( descriptionId ) => descriptionId !== id
70+
)
71+
);
72+
};
73+
}, [] );
3774

3875
const contextValue = useMemo(
39-
() => ( { setDescriptionId } ),
40-
[ setDescriptionId ]
76+
() => ( { registerDescriptionId } ),
77+
[ registerDescriptionId ]
4178
);
4279

80+
useIsomorphicLayoutEffect( () => {
81+
const registeredDescriptionIds = new Set( descriptionIds );
82+
const nextDescriptionIds = Array.from(
83+
headerContentRef.current?.querySelectorAll( '[id]' ) ?? []
84+
)
85+
.map( ( element ) => element.id )
86+
.filter( ( id ) => registeredDescriptionIds.has( id ) );
87+
88+
setOrderedDescriptionIds( ( currentDescriptionIds ) => {
89+
if (
90+
currentDescriptionIds.length ===
91+
nextDescriptionIds.length &&
92+
currentDescriptionIds.every(
93+
( id, index ) => id === nextDescriptionIds[ index ]
94+
)
95+
) {
96+
return currentDescriptionIds;
97+
}
98+
99+
return nextDescriptionIds;
100+
} );
101+
} );
102+
103+
const ariaDescribedBy =
104+
Array.from(
105+
new Set( [
106+
...( ariaDescribedByProp
107+
?.split( /\s+/ )
108+
.filter( Boolean ) ?? [] ),
109+
...orderedDescriptionIds,
110+
] )
111+
).join( ' ' ) || undefined;
112+
43113
return useRender( {
44114
defaultTagName: 'div',
45115
render,
@@ -56,9 +126,12 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >(
56126
className={ styles.header }
57127
render={ <Card.Header /> }
58128
nativeButton={ false }
59-
aria-describedby={ descriptionId }
129+
aria-describedby={ ariaDescribedBy }
60130
>
61-
<div className={ styles[ 'header-content' ] }>
131+
<div
132+
ref={ headerContentRef }
133+
className={ styles[ 'header-content' ] }
134+
>
62135
{ children }
63136
</div>
64137
<div

0 commit comments

Comments
 (0)