Skip to content

Commit ff5c8b4

Browse files
authored
feat: Add CollapsibleInfoSection component (#53243)
- Add component for collapsible 'show more/show less' info sections, intended for use in guided flows where extended instructions may be desirable to hide initially.
1 parent fba66e0 commit ff5c8b4

File tree

4 files changed

+387
-0
lines changed

4 files changed

+387
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import { action } from '@storybook/addon-actions';
20+
import { Meta, StoryFn, type StoryObj } from '@storybook/react';
21+
import styled from 'styled-components';
22+
23+
import { Flex, H2, Text } from 'design';
24+
25+
import { CollapsibleInfoSection as CollapsibleInfoSectionComponent } from './';
26+
27+
type Story = StoryObj<typeof CollapsibleInfoSectionComponent>;
28+
type StoryMeta = Meta<typeof CollapsibleInfoSectionComponent>;
29+
30+
const defaultArgs = {
31+
size: 'large',
32+
defaultOpen: false,
33+
openLabel: 'More info',
34+
closeLabel: 'Less info',
35+
onClick: action('onClick'),
36+
} satisfies StoryMeta['args'];
37+
38+
export default {
39+
title: 'Design/CollapsibleInfoSection',
40+
component: CollapsibleInfoSectionComponent,
41+
argTypes: {
42+
size: {
43+
control: { type: 'radio' },
44+
options: ['small', 'large'],
45+
description: 'Size of the toggle button',
46+
table: { defaultValue: { summary: defaultArgs.size } },
47+
},
48+
defaultOpen: {
49+
control: { type: 'boolean' },
50+
description: 'Whether the section is open or closed initially',
51+
table: {
52+
defaultValue: { summary: defaultArgs.defaultOpen.toString() },
53+
},
54+
},
55+
openLabel: {
56+
control: { type: 'text' },
57+
description: 'Label for the closed state',
58+
table: { defaultValue: { summary: defaultArgs.openLabel } },
59+
},
60+
closeLabel: {
61+
control: { type: 'text' },
62+
description: 'Label for the opened state',
63+
table: { defaultValue: { summary: defaultArgs.closeLabel } },
64+
},
65+
onClick: {
66+
control: false,
67+
description: 'Callback for when the toggle is clicked',
68+
table: { type: { summary: '(isOpen: boolean) => void' } },
69+
},
70+
},
71+
args: defaultArgs,
72+
render: (args => (
73+
<Container maxWidth="750px">
74+
<H2 ml={1}>Collapsible Info Section</H2>
75+
<Text ml={1} mb={1}>
76+
This is an example of a collapsible section that shows more information
77+
when expanded. It is useful for hiding less important – or more detailed
78+
– information by default.
79+
</Text>
80+
<CollapsibleInfoSectionComponent {...args} maxWidth="500px">
81+
<DummyContent />
82+
</CollapsibleInfoSectionComponent>
83+
</Container>
84+
)) satisfies StoryFn<typeof CollapsibleInfoSectionComponent>,
85+
} satisfies StoryMeta;
86+
87+
const Container = styled(Flex).attrs({
88+
flexDirection: 'column',
89+
gap: 2,
90+
})`
91+
background-color: ${p => p.theme.colors.spotBackground[0]};
92+
border-radius: ${p => `${p.theme.radii[3]}px`};
93+
padding: ${p => p.theme.space[3]}px;
94+
`;
95+
96+
const DummyContent = () => (
97+
<Flex flexDirection="column" gap={2}>
98+
<Text bold>What this does:</Text>
99+
<Text>
100+
This is a dummy section that shows some information when the section is
101+
expanded. It can contain anything you want.
102+
</Text>
103+
<Text>
104+
There is no limit to the amount of content you can put in here, but it is
105+
recommended to keep it concise.
106+
</Text>
107+
</Flex>
108+
);
109+
110+
export const CollapsibleInfoSection: Story = { args: defaultArgs };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import { fireEvent, render, screen, userEvent } from 'design/utils/testing';
20+
21+
import { CollapsibleInfoSection } from './CollapsibleInfoSection';
22+
23+
describe('CollapsibleInfoSection', () => {
24+
test('renders with default props, closed by default', () => {
25+
render(<CollapsibleInfoSection>Some content</CollapsibleInfoSection>);
26+
27+
expect(screen.getByText('More info')).toBeInTheDocument();
28+
expect(screen.queryByText('Some content')).not.toBeInTheDocument();
29+
});
30+
31+
test('opens when the toggle is clicked, and closes again when clicked again', async () => {
32+
render(<CollapsibleInfoSection>Some content</CollapsibleInfoSection>);
33+
34+
await userEvent.click(screen.getByRole('button', { name: /More info/i }));
35+
expect(screen.getByText('Less info')).toBeInTheDocument();
36+
expect(screen.getByText('Some content')).toBeInTheDocument();
37+
38+
const button = screen.getByRole('button', { name: /Less info/i });
39+
await userEvent.click(button);
40+
41+
// In a real browser, `onTransitionEnd` would eventually be fired, hiding the content.
42+
// Since we're testing w/ JSDOM, let's simulate:
43+
fireEvent.transitionEnd(button.nextElementSibling!);
44+
45+
expect(screen.getByText('More info')).toBeInTheDocument();
46+
expect(screen.queryByText('Some content')).not.toBeInTheDocument();
47+
});
48+
49+
test('can be open by default using defaultOpen prop', () => {
50+
render(
51+
<CollapsibleInfoSection defaultOpen>
52+
This starts out visible
53+
</CollapsibleInfoSection>
54+
);
55+
56+
expect(screen.getByText('Less info')).toBeInTheDocument();
57+
expect(screen.getByText('This starts out visible')).toBeInTheDocument();
58+
});
59+
60+
test('allows custom open and close labels', async () => {
61+
render(
62+
<CollapsibleInfoSection
63+
openLabel="Show details"
64+
closeLabel="Hide details"
65+
>
66+
Custom labels
67+
</CollapsibleInfoSection>
68+
);
69+
70+
expect(screen.getByText('Show details')).toBeInTheDocument();
71+
await userEvent.click(
72+
screen.getByRole('button', { name: /Show details/i })
73+
);
74+
expect(screen.getByText('Hide details')).toBeInTheDocument();
75+
expect(screen.getByText('Custom labels')).toBeInTheDocument();
76+
});
77+
78+
test('calls onClick callback with isOpen toggle state', async () => {
79+
const handleClick = jest.fn();
80+
render(
81+
<CollapsibleInfoSection onClick={handleClick}>
82+
Content
83+
</CollapsibleInfoSection>
84+
);
85+
86+
await userEvent.click(screen.getByRole('button', { name: /More info/i }));
87+
expect(handleClick).toHaveBeenCalledWith(true);
88+
89+
await userEvent.click(screen.getByRole('button', { name: /Less info/i }));
90+
expect(handleClick).toHaveBeenCalledWith(false);
91+
});
92+
93+
test('does not toggle when disabled', async () => {
94+
render(
95+
<CollapsibleInfoSection disabled>Disabled content</CollapsibleInfoSection>
96+
);
97+
98+
await userEvent.click(screen.getByRole('button', { name: /More info/i }));
99+
100+
expect(screen.getByText('More info')).toBeInTheDocument();
101+
expect(screen.queryByText('Disabled content')).not.toBeInTheDocument();
102+
});
103+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import {
20+
PropsWithChildren,
21+
useId,
22+
useLayoutEffect,
23+
useRef,
24+
useState,
25+
} from 'react';
26+
import styled from 'styled-components';
27+
28+
import { Box, Button, Text } from 'design';
29+
import { BoxProps } from 'design/Box';
30+
import { Minus, Plus } from 'design/Icon';
31+
32+
type CollapsibleInfoSectionProps = {
33+
/* defaultOpen is optional and determines whether the section is open or closed initially */
34+
defaultOpen?: boolean;
35+
/* size is an optional prop to set the size of the toggle button */
36+
size?: 'small' | 'large';
37+
/* onClick is an optional callback for when the toggle is clicked */
38+
onClick?: (isOpen: boolean) => void;
39+
/* openLabel is an optional label for the closed state */
40+
openLabel?: string;
41+
/* closeLabel is an optional label for the opened state */
42+
closeLabel?: string;
43+
/* disabled is an optional flag to disable the toggle */
44+
disabled?: boolean;
45+
} & BoxProps;
46+
47+
/**
48+
* CollapsibleInfoSection is a collapsible section that shows more information when expanded.
49+
* It is useful for hiding less important – or more detailed – information by default.
50+
*/
51+
export const CollapsibleInfoSection = ({
52+
defaultOpen = false,
53+
openLabel = 'More info',
54+
closeLabel = 'Less info',
55+
size = 'large',
56+
onClick,
57+
disabled = false,
58+
children,
59+
...boxProps
60+
}: PropsWithChildren<CollapsibleInfoSectionProps>) => {
61+
const contentId = useId();
62+
const [isOpen, setIsOpen] = useState(defaultOpen);
63+
const [contentHeight, setContentHeight] = useState(0);
64+
const [shouldRenderContent, setShouldRenderContent] = useState(defaultOpen);
65+
const contentRef = useRef<HTMLDivElement>(null);
66+
67+
useLayoutEffect(() => {
68+
if (!contentRef.current || !shouldRenderContent) {
69+
return;
70+
}
71+
const ro = new ResizeObserver(entries => {
72+
for (const entry of entries) {
73+
if (entry.target === contentRef.current) {
74+
setContentHeight(entry.contentRect.height);
75+
}
76+
}
77+
});
78+
ro.observe(contentRef.current);
79+
return () => ro.disconnect();
80+
}, [contentRef, shouldRenderContent]);
81+
82+
return (
83+
<Box {...boxProps}>
84+
<ToggleButton
85+
size={size}
86+
onClick={() => {
87+
!isOpen && setShouldRenderContent(true);
88+
setIsOpen(!isOpen);
89+
onClick?.(!isOpen);
90+
}}
91+
disabled={disabled}
92+
aria-expanded={isOpen}
93+
aria-controls={contentId}
94+
>
95+
{isOpen ? <Minus size="small" /> : <Plus size="small" />}
96+
<Text>{isOpen ? closeLabel : openLabel}</Text>
97+
</ToggleButton>
98+
<ContentWrapper
99+
id={contentId}
100+
role="region"
101+
aria-hidden={!isOpen}
102+
$isOpen={isOpen}
103+
$contentHeight={contentHeight}
104+
onTransitionEnd={() => !isOpen && setShouldRenderContent(false)}
105+
>
106+
{shouldRenderContent && (
107+
<Box
108+
ml={size === 'small' ? 3 : 4}
109+
style={{ position: 'relative' }}
110+
ref={contentRef}
111+
>
112+
<Bar />
113+
<Box
114+
ml={3}
115+
pt={size === 'small' ? 2 : 3}
116+
pb={size === 'small' ? 1 : 2}
117+
>
118+
{children}
119+
</Box>
120+
</Box>
121+
)}
122+
</ContentWrapper>
123+
</Box>
124+
);
125+
};
126+
127+
const ToggleButton = styled(Button).attrs({ intent: 'neutral' })<{
128+
size: 'small' | 'large';
129+
}>`
130+
display: flex;
131+
flex-direction: row;
132+
align-items: center;
133+
${({ size, theme }) =>
134+
size === 'small'
135+
? `padding: ${theme.space[1]}px ${theme.space[2]}px;`
136+
: `padding: ${theme.space[2]}px ${theme.space[3]}px;`}
137+
gap: ${({ theme }) => theme.space[2]}px;
138+
`;
139+
140+
const Bar = styled.div`
141+
position: absolute;
142+
top: ${({ theme }) => theme.space[1]}px;
143+
bottom: 0;
144+
left: 0;
145+
width: 2px;
146+
background: ${({ theme }) => theme.colors.interactive.tonal.neutral[0]};
147+
`;
148+
149+
const ContentWrapper = styled.div<{ $isOpen: boolean; $contentHeight: number }>`
150+
overflow: hidden;
151+
height: ${props => (props.$isOpen ? `${props.$contentHeight}px` : '0')};
152+
will-change: height;
153+
transition: height 200ms ease;
154+
transform-origin: top;
155+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Teleport
3+
* Copyright (C) 2025 Gravitational, Inc.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
export { CollapsibleInfoSection } from './CollapsibleInfoSection';

0 commit comments

Comments
 (0)