Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export interface ExpandableSectionProps extends Omit<React.HTMLProps<HTMLDivElem
* animation will not occur.
*/
direction?: 'up' | 'down';
/** The HTML element to use for the toggle wrapper. Can be 'div' (default) or any heading level.
* When using heading elements, the button will be rendered inside the heading for proper semantics.
* This is useful when the toggle text should function as a heading in the document structure.
*/
toggleWrapper?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
}

interface ExpandableSectionState {
Expand Down Expand Up @@ -208,6 +213,7 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
// eslint-disable-next-line @typescript-eslint/no-unused-vars
truncateMaxLines,
direction,
toggleWrapper = 'div',
...props
} = this.props;

Expand Down Expand Up @@ -238,8 +244,10 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
propOrStateIsExpanded
);

const ToggleWrapper = toggleWrapper as any;

const expandableToggle = !isDetached && (
<div className={`${styles.expandableSection}__toggle`}>
<ToggleWrapper className={`${styles.expandableSection}__toggle`}>
<Button
variant="link"
{...(variant === ExpandableSectionVariant.truncate && { isInline: true })}
Expand All @@ -257,7 +265,7 @@ class ExpandableSection extends Component<ExpandableSectionProps, ExpandableSect
>
{toggleContent || computedToggleText}
</Button>
</div>
</ToggleWrapper>
);

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

export const ExpandableSectionToggle: React.FunctionComponent<ExpandableSectionToggleProps> = ({
Expand All @@ -42,43 +47,48 @@ export const ExpandableSectionToggle: React.FunctionComponent<ExpandableSectionT
direction = 'down',
hasTruncatedContent = false,
isDetached,
toggleWrapper = 'div',
...props
}: ExpandableSectionToggleProps) => (
<div
className={css(
styles.expandableSection,
isExpanded && styles.modifiers.expanded,
hasTruncatedContent && styles.modifiers.truncate,
isDetached && 'pf-m-detached',
className
)}
{...props}
>
<div className={`${styles.expandableSection}__toggle`}>
<Button
variant="link"
{...(hasTruncatedContent && { isInline: true })}
aria-expanded={isExpanded}
aria-controls={contentId}
onClick={() => onToggle(!isExpanded)}
id={toggleId}
{...(!hasTruncatedContent && {
icon: (
<span
className={css(
styles.expandableSectionToggleIcon,
isExpanded && direction === 'up' && styles.modifiers.expandTop // TODO: next breaking change move this class to the outer styles.expandableSection wrapper
)}
>
<AngleRightIcon />
</span>
)
})}
>
{children}
</Button>
}: ExpandableSectionToggleProps) => {
const ToggleWrapper = toggleWrapper as any;

return (
<div
className={css(
styles.expandableSection,
isExpanded && styles.modifiers.expanded,
hasTruncatedContent && styles.modifiers.truncate,
isDetached && 'pf-m-detached',
className
)}
{...props}
>
<ToggleWrapper className={`${styles.expandableSection}__toggle`}>
<Button
variant="link"
{...(hasTruncatedContent && { isInline: true })}
aria-expanded={isExpanded}
aria-controls={contentId}
onClick={() => onToggle(!isExpanded)}
id={toggleId}
{...(!hasTruncatedContent && {
icon: (
<span
className={css(
styles.expandableSectionToggleIcon,
isExpanded && direction === 'up' && styles.modifiers.expandTop // TODO: next breaking change move this class to the outer styles.expandableSection wrapper
)}
>
<AngleRightIcon />
</span>
)
})}
>
{children}
</Button>
</ToggleWrapper>
</div>
</div>
);
);
};

ExpandableSectionToggle.displayName = 'ExpandableSectionToggle';
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,31 @@ test('Renders with class pf-m-detached when isDetached is true and direction is

expect(screen.getByText('Test content').parentElement).toHaveClass('pf-m-detached');
});

test('Renders with default div wrapper when toggleWrapper is not specified', () => {
render(<ExpandableSection data-testid="test-id">Test content</ExpandableSection>);

const toggle = screen.getByRole('button').parentElement;
expect(toggle?.tagName).toBe('DIV');
});

test('Renders with h2 wrapper when toggleWrapper="h2"', () => {
render(
<ExpandableSection data-testid="test-id" toggleWrapper="h2">
Test content
</ExpandableSection>
);

expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});

test('Renders with div wrapper when toggleWrapper="div"', () => {
render(
<ExpandableSection data-testid="test-id" toggleWrapper="div">
Test content
</ExpandableSection>
);

const toggle = screen.getByRole('button').parentElement;
expect(toggle?.tagName).toBe('DIV');
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,30 @@ test('Renders with class pf-m-detached when isDetached is true', () => {

expect(screen.getByTestId('test-id')).toHaveClass('pf-m-detached');
});

test('Renders with default div wrapper when toggleWrapper is not specified', () => {
render(<ExpandableSectionToggle data-testid="test-id">Toggle test</ExpandableSectionToggle>);

const toggle = screen.getByRole('button').parentElement;
expect(toggle?.tagName).toBe('DIV');
});

test('Renders with h2 wrapper when toggleWrapper="h2"', () => {
render(
<ExpandableSectionToggle data-testid="test-id" toggleWrapper="h2">
Toggle test
</ExpandableSectionToggle>
);

expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
test('Renders with div wrapper when toggleWrapper="div"', () => {
render(
<ExpandableSectionToggle data-testid="test-id" toggleWrapper="div">
Toggle test
</ExpandableSectionToggle>
);

const toggle = screen.getByRole('button').parentElement;
expect(toggle?.tagName).toBe('DIV');
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ By using the `toggleContent` prop, you can pass in content other than a simple s

```

### With heading semantics

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.

```ts file="ExpandableSectionWithHeading.tsx"

```

### Truncate expansion

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ExpandableSection, Badge } from '@patternfly/react-core';
import { ExpandableSection, Badge, Stack, StackItem } from '@patternfly/react-core';
import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon';

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

return (
<ExpandableSection
toggleContent={
<div>
<span>You can also use icons </span>
<CheckCircleIcon />
<span> or badges </span>
<Badge isRead={true}>4</Badge>
<span> !</span>
</div>
}
onToggle={onToggle}
isExpanded={isExpanded}
>
This content is visible only when the component is expanded.
</ExpandableSection>
<Stack hasGutter>
<StackItem>
<h3>Custom Toggle Content</h3>
<p>You can use custom content such as icons and badges in the toggle:</p>
<ExpandableSection
toggleContent={
<div>
<span>You can also use icons </span>
<CheckCircleIcon />
<span> or badges </span>
<Badge isRead={true}>4</Badge>
<span> !</span>
</div>
}
onToggle={onToggle}
isExpanded={isExpanded}
>
This content is visible only when the component is expanded.
</ExpandableSection>
</StackItem>

<StackItem>
<h3>Accessibility Note</h3>
<p>
<strong>Important:</strong> If you need the toggle text to function as a heading in the document structure, do
NOT put heading elements (h1-h6) inside the <code>toggleContent</code> prop, as this creates invalid HTML
structure. Instead, use the <code>toggleWrapper</code> prop.
</p>
</StackItem>
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState, MouseEvent } from 'react';
import { ExpandableSection, ExpandableSectionToggle, Stack, StackItem } from '@patternfly/react-core';
import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon';

export const ExpandableSectionWithHeading = () => {
const [isExpanded1, setIsExpanded1] = useState(false);
const [isExpanded2, setIsExpanded2] = useState(false);
const [isExpandedDetached, setIsExpandedDetached] = useState(false);

const onToggle1 = (_event: MouseEvent, isExpanded: boolean) => {
setIsExpanded1(isExpanded);
};

const onToggle2 = (_event: MouseEvent, isExpanded: boolean) => {
setIsExpanded2(isExpanded);
};

const onToggleDetached = (isExpanded: boolean) => {
setIsExpandedDetached(isExpanded);
};

return (
<Stack hasGutter>
<StackItem>
<h4>Document with Expandable Sections</h4>
<p>This demonstrates how to use expandable sections with proper heading semantics.</p>

{/* Using toggleWrapper prop for proper heading semantics */}
<ExpandableSection
toggleWrapper="h5"
toggleText="Toggle as a heading"
onToggle={onToggle1}
isExpanded={isExpanded1}
>
<p>
This content is visible only when the component is expanded. The toggle text above functions as a proper
heading in the document structure, which is important for screen readers and other assistive technologies.
</p>
<p>
When using the <code>toggleWrapper</code> prop with heading elements (h1-h6), the button is rendered inside
the heading element, maintaining proper semantic structure.
</p>
</ExpandableSection>
</StackItem>

<StackItem>
<h4>Detached Variant with Heading</h4>
<p>You can also use the detached variant with heading semantics:</p>

<ExpandableSectionToggle
toggleWrapper="h5"
toggleId="detached-heading-toggle"
contentId="detached-heading-content"
isExpanded={isExpandedDetached}
onToggle={onToggleDetached}
>
Detached Toggle with Heading
</ExpandableSectionToggle>

<ExpandableSection
isDetached
toggleId="detached-heading-toggle"
contentId="detached-heading-content"
isExpanded={isExpandedDetached}
>
<p>This is detached content that can be positioned anywhere in the DOM.</p>
</ExpandableSection>
</StackItem>

<StackItem>
<h4>Custom Content with Heading</h4>
<p>You can also use custom content within heading wrappers:</p>

<ExpandableSection
toggleWrapper="h5"
toggleContent={
<span>
<CheckCircleIcon /> Custom Heading Content with Icon
</span>
}
onToggle={onToggle2}
isExpanded={isExpanded2}
>
<p>This expandable section uses custom content with an icon inside a heading wrapper.</p>
</ExpandableSection>
</StackItem>
</Stack>
);
};
Loading