Skip to content

Commit 5dab829

Browse files
committed
fix(ExpandableSection): allow toggles to have proper dom structured headings
Signed-off-by: gitdallas <5322142+gitdallas@users.noreply.github.com>
1 parent ce02202 commit 5dab829

File tree

7 files changed

+247
-54
lines changed

7 files changed

+247
-54
lines changed

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

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

7378
interface ExpandableSectionState {
@@ -208,6 +213,7 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
208213
// eslint-disable-next-line @typescript-eslint/no-unused-vars
209214
truncateMaxLines,
210215
direction,
216+
toggleWrapper = 'div',
211217
...props
212218
} = this.props;
213219

@@ -238,8 +244,10 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
238244
propOrStateIsExpanded
239245
);
240246

247+
const ToggleWrapper = toggleWrapper as any;
248+
241249
const expandableToggle = !isDetached && (
242-
<div className={`${styles.expandableSection}__toggle`}>
250+
<ToggleWrapper className={`${styles.expandableSection}__toggle`}>
243251
<Button
244252
variant="link"
245253
{...(variant === ExpandableSectionVariant.truncate && { isInline: true })}
@@ -257,7 +265,7 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
257265
>
258266
{toggleContent || computedToggleText}
259267
</Button>
260-
</div>
268+
</ToggleWrapper>
261269
);
262270

263271
return (

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

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export interface ExpandableSectionToggleProps extends Omit<React.HTMLProps<HTMLD
3030
onToggle?: (isExpanded: boolean) => void;
3131
/** Flag indicating that the expandable section and expandable toggle are detached from one another. */
3232
isDetached?: boolean;
33+
/** The HTML element to use for the toggle wrapper. Can be 'div' (default) or any heading level.
34+
* When using heading elements, the button will be rendered inside the heading for proper semantics.
35+
* This is useful when the toggle text should function as a heading in the document structure.
36+
*/
37+
toggleWrapper?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
3338
}
3439

3540
export const ExpandableSectionToggle: React.FunctionComponent<ExpandableSectionToggleProps> = ({
@@ -42,43 +47,48 @@ export const ExpandableSectionToggle: React.FunctionComponent<ExpandableSectionT
4247
direction = 'down',
4348
hasTruncatedContent = false,
4449
isDetached,
50+
toggleWrapper = 'div',
4551
...props
46-
}: ExpandableSectionToggleProps) => (
47-
<div
48-
className={css(
49-
styles.expandableSection,
50-
isExpanded && styles.modifiers.expanded,
51-
hasTruncatedContent && styles.modifiers.truncate,
52-
isDetached && 'pf-m-detached',
53-
className
54-
)}
55-
{...props}
56-
>
57-
<div className={`${styles.expandableSection}__toggle`}>
58-
<Button
59-
variant="link"
60-
{...(hasTruncatedContent && { isInline: true })}
61-
aria-expanded={isExpanded}
62-
aria-controls={contentId}
63-
onClick={() => onToggle(!isExpanded)}
64-
id={toggleId}
65-
{...(!hasTruncatedContent && {
66-
icon: (
67-
<span
68-
className={css(
69-
styles.expandableSectionToggleIcon,
70-
isExpanded && direction === 'up' && styles.modifiers.expandTop // TODO: next breaking change move this class to the outer styles.expandableSection wrapper
71-
)}
72-
>
73-
<AngleRightIcon />
74-
</span>
75-
)
76-
})}
77-
>
78-
{children}
79-
</Button>
52+
}: ExpandableSectionToggleProps) => {
53+
const ToggleWrapper = toggleWrapper as any;
54+
55+
return (
56+
<div
57+
className={css(
58+
styles.expandableSection,
59+
isExpanded && styles.modifiers.expanded,
60+
hasTruncatedContent && styles.modifiers.truncate,
61+
isDetached && 'pf-m-detached',
62+
className
63+
)}
64+
{...props}
65+
>
66+
<ToggleWrapper className={`${styles.expandableSection}__toggle`}>
67+
<Button
68+
variant="link"
69+
{...(hasTruncatedContent && { isInline: true })}
70+
aria-expanded={isExpanded}
71+
aria-controls={contentId}
72+
onClick={() => onToggle(!isExpanded)}
73+
id={toggleId}
74+
{...(!hasTruncatedContent && {
75+
icon: (
76+
<span
77+
className={css(
78+
styles.expandableSectionToggleIcon,
79+
isExpanded && direction === 'up' && styles.modifiers.expandTop // TODO: next breaking change move this class to the outer styles.expandableSection wrapper
80+
)}
81+
>
82+
<AngleRightIcon />
83+
</span>
84+
)
85+
})}
86+
>
87+
{children}
88+
</Button>
89+
</ToggleWrapper>
8090
</div>
81-
</div>
82-
);
91+
);
92+
};
8393

8494
ExpandableSectionToggle.displayName = 'ExpandableSectionToggle';

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,35 @@ test('Renders with class pf-m-detached when isDetached is true and direction is
191191

192192
expect(screen.getByText('Test content').parentElement).toHaveClass('pf-m-detached');
193193
});
194+
195+
test('Renders with default div wrapper when toggleWrapper is not specified', () => {
196+
render(<ExpandableSection data-testid="test-id">Test content</ExpandableSection>);
197+
198+
const wrapper = screen.getByTestId('test-id');
199+
const toggle = wrapper.querySelector('.pf-v6-c-expandable-section__toggle');
200+
expect(toggle?.tagName).toBe('DIV');
201+
});
202+
203+
test('Renders with h2 wrapper when toggleWrapper="h2"', () => {
204+
render(
205+
<ExpandableSection data-testid="test-id" toggleWrapper="h2">
206+
Test content
207+
</ExpandableSection>
208+
);
209+
210+
const wrapper = screen.getByTestId('test-id');
211+
const toggle = wrapper.querySelector('.pf-v6-c-expandable-section__toggle');
212+
expect(toggle?.tagName).toBe('H2');
213+
});
214+
215+
test('Renders with div wrapper when toggleWrapper="div"', () => {
216+
render(
217+
<ExpandableSection data-testid="test-id" toggleWrapper="div">
218+
Test content
219+
</ExpandableSection>
220+
);
221+
222+
const wrapper = screen.getByTestId('test-id');
223+
const toggle = wrapper.querySelector('.pf-v6-c-expandable-section__toggle');
224+
expect(toggle?.tagName).toBe('DIV');
225+
});

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,34 @@ test('Renders with class pf-m-detached when isDetached is true', () => {
3030

3131
expect(screen.getByTestId('test-id')).toHaveClass('pf-m-detached');
3232
});
33+
34+
test('Renders with default div wrapper when toggleWrapper is not specified', () => {
35+
render(<ExpandableSectionToggle data-testid="test-id">Toggle test</ExpandableSectionToggle>);
36+
37+
const wrapper = screen.getByTestId('test-id');
38+
const toggle = wrapper.querySelector('.pf-v6-c-expandable-section__toggle');
39+
expect(toggle?.tagName).toBe('DIV');
40+
});
41+
42+
test('Renders with h2 wrapper when toggleWrapper="h2"', () => {
43+
render(
44+
<ExpandableSectionToggle data-testid="test-id" toggleWrapper="h2">
45+
Toggle test
46+
</ExpandableSectionToggle>
47+
);
48+
49+
const wrapper = screen.getByTestId('test-id');
50+
const toggle = wrapper.querySelector('.pf-v6-c-expandable-section__toggle');
51+
expect(toggle?.tagName).toBe('H2');
52+
});
53+
test('Renders with div wrapper when toggleWrapper="div"', () => {
54+
render(
55+
<ExpandableSectionToggle data-testid="test-id" toggleWrapper="div">
56+
Toggle test
57+
</ExpandableSectionToggle>
58+
);
59+
60+
const wrapper = screen.getByTestId('test-id');
61+
const toggle = wrapper.querySelector('.pf-v6-c-expandable-section__toggle');
62+
expect(toggle?.tagName).toBe('DIV');
63+
});

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

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

6363
```
6464

65+
### With heading semantics
66+
67+
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.
68+
69+
```ts file="ExpandableSectionWithHeading.tsx"
70+
71+
```
72+
6573
### Truncate expansion
6674

6775
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 [isExpandedH2, setIsExpandedH2] = useState(false);
7+
const [isExpandedH3, setIsExpandedH3] = useState(false);
8+
const [isExpandedDetachedH4, setIsExpandedDetachedH4] = useState(false);
9+
10+
const onToggleH2 = (_event: MouseEvent, isExpanded: boolean) => {
11+
setIsExpandedH2(isExpanded);
12+
};
13+
14+
const onToggleH3 = (_event: MouseEvent, isExpanded: boolean) => {
15+
setIsExpandedH3(isExpanded);
16+
};
17+
18+
const onToggleDetachedH4 = (isExpanded: boolean) => {
19+
setIsExpandedDetachedH4(isExpanded);
20+
};
21+
22+
return (
23+
<Stack hasGutter>
24+
<StackItem>
25+
<h1>Document with Expandable Sections</h1>
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="h2"
31+
toggleText="Important Information"
32+
onToggle={onToggleH2}
33+
isExpanded={isExpandedH2}
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+
<h2>Detached Variant with Heading</h2>
48+
<p>You can also use the detached variant with heading semantics:</p>
49+
50+
<ExpandableSectionToggle
51+
toggleWrapper="h3"
52+
toggleId="detached-heading-toggle"
53+
contentId="detached-heading-content"
54+
isExpanded={isExpandedDetachedH4}
55+
onToggle={onToggleDetachedH4}
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={isExpandedDetachedH4}
65+
>
66+
<p>This is detached content that can be positioned anywhere in the DOM.</p>
67+
</ExpandableSection>
68+
</StackItem>
69+
70+
<StackItem>
71+
<h2>Custom Content with Heading</h2>
72+
<p>You can also use custom content within heading wrappers:</p>
73+
74+
<ExpandableSection
75+
toggleWrapper="h3"
76+
toggleContent={
77+
<span>
78+
<CheckCircleIcon /> Custom Content with Icon
79+
</span>
80+
}
81+
onToggle={onToggleH3}
82+
isExpanded={isExpandedH3}
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)