Skip to content

Commit 41f2563

Browse files
fix(ExpandableSection): allow toggles proper dom structured headings (#12037)
* fix(ExpandableSection): allow toggles to have proper dom structured headings Signed-off-by: gitdallas <[email protected]> * pr review fixes Signed-off-by: gitdallas <[email protected]> * Update packages/react-core/src/components/ExpandableSection/examples/ExpandableSectionWithHeading.tsx Co-authored-by: Eric Olkowski <[email protected]> * Update packages/react-core/src/components/ExpandableSection/examples/ExpandableSectionWithHeading.tsx Co-authored-by: Eric Olkowski <[email protected]> * Update packages/react-core/src/components/ExpandableSection/examples/ExpandableSectionWithHeading.tsx Co-authored-by: Eric Olkowski <[email protected]> * Update packages/react-core/src/components/ExpandableSection/examples/ExpandableSectionWithHeading.tsx Co-authored-by: Eric Olkowski <[email protected]> * Update packages/react-core/src/components/ExpandableSection/examples/ExpandableSectionWithHeading.tsx Co-authored-by: Eric Olkowski <[email protected]> * Update packages/react-core/src/components/ExpandableSection/examples/ExpandableSectionWithHeading.tsx Co-authored-by: Eric Olkowski <[email protected]> --------- Signed-off-by: gitdallas <[email protected]> Co-authored-by: Eric Olkowski <[email protected]>
1 parent 40a5015 commit 41f2563

File tree

7 files changed

+242
-56
lines changed

7 files changed

+242
-56
lines changed

packages/react-core/src/components/ExpandableSection/ExpandableSection.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ export interface ExpandableSectionProps extends Omit<React.HTMLProps<HTMLDivElem
7474
* animation will not occur.
7575
*/
7676
direction?: 'up' | 'down';
77+
/** The HTML element to use for the toggle wrapper. Can be 'div' (default) or any heading level.
78+
* When using heading elements, the button will be rendered inside the heading for proper semantics.
79+
* This is useful when the toggle text should function as a heading in the document structure.
80+
*/
81+
toggleWrapper?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
7782
}
7883

7984
interface ExpandableSectionState {
@@ -218,6 +223,7 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
218223
// eslint-disable-next-line @typescript-eslint/no-unused-vars
219224
truncateMaxLines,
220225
direction,
226+
toggleWrapper = 'div',
221227
...props
222228
} = this.props;
223229

@@ -250,9 +256,10 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
250256

251257
const computedToggleContent =
252258
typeof toggleContent === 'function' ? toggleContent(propOrStateIsExpanded) : toggleContent;
259+
const ToggleWrapper = toggleWrapper as any;
253260

254261
const expandableToggle = !isDetached && (
255-
<div className={`${styles.expandableSection}__toggle`}>
262+
<ToggleWrapper className={`${styles.expandableSection}__toggle`}>
256263
<Button
257264
variant="link"
258265
{...(variant === ExpandableSectionVariant.truncate && { isInline: true })}
@@ -272,7 +279,7 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
272279
>
273280
{computedToggleContent || computedToggleText}
274281
</Button>
275-
</div>
282+
</ToggleWrapper>
276283
);
277284

278285
return (

packages/react-core/src/components/ExpandableSection/ExpandableSectionToggle.tsx

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export interface ExpandableSectionToggleProps extends Omit<React.HTMLProps<HTMLD
3434
toggleAriaLabel?: string;
3535
/** Accessible name via space delimtted list of IDs for the expandable section toggle. */
3636
toggleAriaLabelledBy?: string;
37+
/** The HTML element to use for the toggle wrapper. Can be 'div' (default) or any heading level.
38+
* When using heading elements, the button will be rendered inside the heading for proper semantics.
39+
* This is useful when the toggle text should function as a heading in the document structure.
40+
*/
41+
toggleWrapper?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
3742
}
3843

3944
export const ExpandableSectionToggle: React.FunctionComponent<ExpandableSectionToggleProps> = ({
@@ -48,45 +53,50 @@ export const ExpandableSectionToggle: React.FunctionComponent<ExpandableSectionT
4853
isDetached,
4954
toggleAriaLabel,
5055
toggleAriaLabelledBy,
56+
toggleWrapper = 'div',
5157
...props
52-
}: ExpandableSectionToggleProps) => (
53-
<div
54-
className={css(
55-
styles.expandableSection,
56-
isExpanded && styles.modifiers.expanded,
57-
hasTruncatedContent && styles.modifiers.truncate,
58-
isDetached && 'pf-m-detached',
59-
className
60-
)}
61-
{...props}
62-
>
63-
<div className={`${styles.expandableSection}__toggle`}>
64-
<Button
65-
variant="link"
66-
{...(hasTruncatedContent && { isInline: true })}
67-
aria-expanded={isExpanded}
68-
aria-controls={contentId}
69-
onClick={() => onToggle(!isExpanded)}
70-
id={toggleId}
71-
{...(!hasTruncatedContent && {
72-
icon: (
73-
<span
74-
className={css(
75-
styles.expandableSectionToggleIcon,
76-
isExpanded && direction === 'up' && styles.modifiers.expandTop // TODO: next breaking change move this class to the outer styles.expandableSection wrapper
77-
)}
78-
>
79-
<AngleRightIcon />
80-
</span>
81-
)
82-
})}
83-
aria-label={toggleAriaLabel}
84-
aria-labelledby={toggleAriaLabelledBy}
85-
>
86-
{children}
87-
</Button>
58+
}: ExpandableSectionToggleProps) => {
59+
const ToggleWrapper = toggleWrapper as any;
60+
61+
return (
62+
<div
63+
className={css(
64+
styles.expandableSection,
65+
isExpanded && styles.modifiers.expanded,
66+
hasTruncatedContent && styles.modifiers.truncate,
67+
isDetached && 'pf-m-detached',
68+
className
69+
)}
70+
{...props}
71+
>
72+
<ToggleWrapper className={`${styles.expandableSection}__toggle`}>
73+
<Button
74+
variant="link"
75+
{...(hasTruncatedContent && { isInline: true })}
76+
aria-expanded={isExpanded}
77+
aria-controls={contentId}
78+
onClick={() => onToggle(!isExpanded)}
79+
id={toggleId}
80+
{...(!hasTruncatedContent && {
81+
icon: (
82+
<span
83+
className={css(
84+
styles.expandableSectionToggleIcon,
85+
isExpanded && direction === 'up' && styles.modifiers.expandTop // TODO: next breaking change move this class to the outer styles.expandableSection wrapper
86+
)}
87+
>
88+
<AngleRightIcon />
89+
</span>
90+
)
91+
})}
92+
aria-label={toggleAriaLabel}
93+
aria-labelledby={toggleAriaLabelledBy}
94+
>
95+
{children}
96+
</Button>
97+
</ToggleWrapper>
8898
</div>
89-
</div>
90-
);
99+
);
100+
};
91101

92102
ExpandableSectionToggle.displayName = 'ExpandableSectionToggle';

packages/react-core/src/components/ExpandableSection/__tests__/ExpandableSection.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ test('Renders with aria-labelledby when toggleAriaLabelledBy is passed', () => {
208208

209209
expect(screen.getByRole('button')).toHaveAccessibleName('Test label');
210210
});
211+
211212
test('Renders toggleContent as a function in uncontrolled mode (collapsed)', () => {
212213
render(
213214
<ExpandableSection toggleContent={(isExpanded) => (isExpanded ? 'Hide details' : 'Show details')}>
@@ -242,3 +243,31 @@ test('Renders toggleContent as a function in controlled mode', () => {
242243

243244
expect(screen.getByRole('button', { name: 'Collapse' })).toBeInTheDocument();
244245
});
246+
247+
test('Renders with default div wrapper when toggleWrapper is not specified', () => {
248+
render(<ExpandableSection data-testid="test-id">Test content</ExpandableSection>);
249+
250+
const toggle = screen.getByRole('button').parentElement;
251+
expect(toggle?.tagName).toBe('DIV');
252+
});
253+
254+
test('Renders with h2 wrapper when toggleWrapper="h2"', () => {
255+
render(
256+
<ExpandableSection data-testid="test-id" toggleWrapper="h2">
257+
Test content
258+
</ExpandableSection>
259+
);
260+
261+
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
262+
});
263+
264+
test('Renders with div wrapper when toggleWrapper="div"', () => {
265+
render(
266+
<ExpandableSection data-testid="test-id" toggleWrapper="div">
267+
Test content
268+
</ExpandableSection>
269+
);
270+
271+
const toggle = screen.getByRole('button').parentElement;
272+
expect(toggle?.tagName).toBe('DIV');
273+
});

packages/react-core/src/components/ExpandableSection/__tests__/ExpandableSectionToggle.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,31 @@ test('Renders with aria-labelledby when toggleAriaLabelledBy is passed', () => {
4747

4848
expect(screen.getByRole('button')).toHaveAccessibleName('Test label');
4949
});
50+
51+
test('Renders with default div wrapper when toggleWrapper is not specified', () => {
52+
render(<ExpandableSectionToggle data-testid="test-id">Toggle test</ExpandableSectionToggle>);
53+
54+
const toggle = screen.getByRole('button').parentElement;
55+
expect(toggle?.tagName).toBe('DIV');
56+
});
57+
58+
test('Renders with h2 wrapper when toggleWrapper="h2"', () => {
59+
render(
60+
<ExpandableSectionToggle data-testid="test-id" toggleWrapper="h2">
61+
Toggle test
62+
</ExpandableSectionToggle>
63+
);
64+
65+
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
66+
});
67+
68+
test('Renders with div wrapper when toggleWrapper="div"', () => {
69+
render(
70+
<ExpandableSectionToggle data-testid="test-id" toggleWrapper="div">
71+
Toggle test
72+
</ExpandableSectionToggle>
73+
);
74+
75+
const toggle = screen.getByRole('button').parentElement;
76+
expect(toggle?.tagName).toBe('DIV');
77+
});

packages/react-core/src/components/ExpandableSection/examples/ExpandableSection.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ By using the `toggleContent` prop, you can pass in content other than a simple s
7070

7171
```
7272

73+
### With heading semantics
74+
75+
When the toggle text should function as a heading in the document structure, use the `toggleWrapper` prop to specify a heading element (h1-h6). This ensures proper semantic structure for screen readers and other assistive technologies. The component automatically uses a native button element when heading wrappers are used, allowing the heading styles to display properly.
76+
77+
```ts file="ExpandableSectionWithHeading.tsx"
78+
79+
```
80+
7381
### Truncate expansion
7482

7583
By passing in `variant="truncate"`, the expandable content will be visible up to a maximum number of lines before being truncated, with the toggle revealing or hiding the truncated content. By default the expandable content will truncate after 3 lines, and this can be customized by also passing in the `truncateMaxLines` prop.
Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react';
2-
import { ExpandableSection, Badge } from '@patternfly/react-core';
2+
import { ExpandableSection, Badge, Stack, StackItem } from '@patternfly/react-core';
33
import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon';
44

55
export const ExpandableSectionCustomToggle: React.FunctionComponent = () => {
@@ -10,20 +10,35 @@ export const ExpandableSectionCustomToggle: React.FunctionComponent = () => {
1010
};
1111

1212
return (
13-
<ExpandableSection
14-
toggleContent={
15-
<div>
16-
<span>You can also use icons </span>
17-
<CheckCircleIcon />
18-
<span> or badges </span>
19-
<Badge isRead={true}>4</Badge>
20-
<span> !</span>
21-
</div>
22-
}
23-
onToggle={onToggle}
24-
isExpanded={isExpanded}
25-
>
26-
This content is visible only when the component is expanded.
27-
</ExpandableSection>
13+
<Stack hasGutter>
14+
<StackItem>
15+
<h3>Custom Toggle Content</h3>
16+
<p>You can use custom content such as icons and badges in the toggle:</p>
17+
<ExpandableSection
18+
toggleContent={
19+
<div>
20+
<span>You can also use icons </span>
21+
<CheckCircleIcon />
22+
<span> or badges </span>
23+
<Badge isRead={true}>4</Badge>
24+
<span> !</span>
25+
</div>
26+
}
27+
onToggle={onToggle}
28+
isExpanded={isExpanded}
29+
>
30+
This content is visible only when the component is expanded.
31+
</ExpandableSection>
32+
</StackItem>
33+
34+
<StackItem>
35+
<h3>Accessibility Note</h3>
36+
<p>
37+
<strong>Important:</strong> If you need the toggle text to function as a heading in the document structure, do
38+
NOT put heading elements (h1-h6) inside the <code>toggleContent</code> prop, as this creates invalid HTML
39+
structure. Instead, use the <code>toggleWrapper</code> prop.
40+
</p>
41+
</StackItem>
42+
</Stack>
2843
);
2944
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState, MouseEvent } from 'react';
2+
import { ExpandableSection, ExpandableSectionToggle, Stack, StackItem } from '@patternfly/react-core';
3+
import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon';
4+
5+
export const ExpandableSectionWithHeading = () => {
6+
const [isExpanded1, setIsExpanded1] = useState(false);
7+
const [isExpanded2, setIsExpanded2] = useState(false);
8+
const [isExpandedDetached, setIsExpandedDetached] = useState(false);
9+
10+
const onToggle1 = (_event: MouseEvent, isExpanded: boolean) => {
11+
setIsExpanded1(isExpanded);
12+
};
13+
14+
const onToggle2 = (_event: MouseEvent, isExpanded: boolean) => {
15+
setIsExpanded2(isExpanded);
16+
};
17+
18+
const onToggleDetached = (isExpanded: boolean) => {
19+
setIsExpandedDetached(isExpanded);
20+
};
21+
22+
return (
23+
<Stack hasGutter>
24+
<StackItem>
25+
<h4>Document with Expandable Sections</h4>
26+
<p>This demonstrates how to use expandable sections with proper heading semantics.</p>
27+
28+
{/* Using toggleWrapper prop for proper heading semantics */}
29+
<ExpandableSection
30+
toggleWrapper="h5"
31+
toggleText="Toggle as a heading"
32+
onToggle={onToggle1}
33+
isExpanded={isExpanded1}
34+
>
35+
<p>
36+
This content is visible only when the component is expanded. The toggle text above functions as a proper
37+
heading in the document structure, which is important for screen readers and other assistive technologies.
38+
</p>
39+
<p>
40+
When using the <code>toggleWrapper</code> prop with heading elements (h1-h6), the button is rendered inside
41+
the heading element, maintaining proper semantic structure.
42+
</p>
43+
</ExpandableSection>
44+
</StackItem>
45+
46+
<StackItem>
47+
<h4>Detached Variant with Heading</h4>
48+
<p>You can also use the detached variant with heading semantics:</p>
49+
50+
<ExpandableSectionToggle
51+
toggleWrapper="h5"
52+
toggleId="detached-heading-toggle"
53+
contentId="detached-heading-content"
54+
isExpanded={isExpandedDetached}
55+
onToggle={onToggleDetached}
56+
>
57+
Detached Toggle with Heading
58+
</ExpandableSectionToggle>
59+
60+
<ExpandableSection
61+
isDetached
62+
toggleId="detached-heading-toggle"
63+
contentId="detached-heading-content"
64+
isExpanded={isExpandedDetached}
65+
>
66+
<p>This is detached content that can be positioned anywhere in the DOM.</p>
67+
</ExpandableSection>
68+
</StackItem>
69+
70+
<StackItem>
71+
<h4>Custom Content with Heading</h4>
72+
<p>You can also use custom content within heading wrappers:</p>
73+
74+
<ExpandableSection
75+
toggleWrapper="h5"
76+
toggleContent={
77+
<span>
78+
<CheckCircleIcon /> Custom Heading Content with Icon
79+
</span>
80+
}
81+
onToggle={onToggle2}
82+
isExpanded={isExpanded2}
83+
>
84+
<p>This expandable section uses custom content with an icon inside a heading wrapper.</p>
85+
</ExpandableSection>
86+
</StackItem>
87+
</Stack>
88+
);
89+
};

0 commit comments

Comments
 (0)