Skip to content

Commit 71d0a82

Browse files
authored
feat(ui): Add new Collapsible component (#22647)
1 parent 1379345 commit 71d0a82

File tree

3 files changed

+220
-0
lines changed

3 files changed

+220
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
import {withInfo} from '@storybook/addon-info';
3+
import {number} from '@storybook/addon-knobs';
4+
5+
import Button from 'app/components/button';
6+
import Collapsible from 'app/components/collapsible';
7+
import {tn} from 'app/locale';
8+
9+
export default {
10+
title: 'Utilities/Collapsible',
11+
};
12+
13+
export const Default = withInfo(
14+
'This component is used to show first X items and collapse the rest'
15+
)(() => {
16+
return (
17+
<Collapsible maxVisibleItems={number('Max visible items', 5)}>
18+
{[1, 2, 3, 4, 5, 6, 7].map(item => (
19+
<div key={item}>Item {item}</div>
20+
))}
21+
</Collapsible>
22+
);
23+
});
24+
25+
export const CustomButtons = () => {
26+
return (
27+
<Collapsible
28+
maxVisibleItems={number('Max visible items', 5)}
29+
collapseButton={({onCollapse}) => <Button onClick={onCollapse}>Collapse</Button>}
30+
expandButton={({onExpand, numberOfCollapsedItems}) => (
31+
<Button onClick={onExpand}>
32+
{tn('Expand %s item', 'Expand %s items', numberOfCollapsedItems)}
33+
</Button>
34+
)}
35+
>
36+
{[1, 2, 3, 4, 5, 6, 7].map(item => (
37+
<div key={item}>Item {item}</div>
38+
))}
39+
</Collapsible>
40+
);
41+
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React from 'react';
2+
3+
import Button from 'app/components/button';
4+
import {t, tn} from 'app/locale';
5+
6+
type CollapseButtonRenderProps = {
7+
onCollapse: () => void;
8+
};
9+
10+
type ExpandButtonRenderProps = {
11+
onExpand: () => void;
12+
numberOfCollapsedItems: number;
13+
};
14+
15+
type DefaultProps = {
16+
maxVisibleItems: number;
17+
};
18+
19+
type Props = {
20+
collapseButton?: (props: CollapseButtonRenderProps) => React.ReactNode;
21+
expandButton?: (props: ExpandButtonRenderProps) => React.ReactNode;
22+
} & DefaultProps;
23+
24+
type State = {
25+
collapsed: boolean;
26+
};
27+
28+
class Collapsible extends React.Component<Props, State> {
29+
static defaultProps: DefaultProps = {
30+
maxVisibleItems: 5,
31+
};
32+
33+
state: State = {
34+
collapsed: true,
35+
};
36+
37+
handleCollapseToggle = () => {
38+
this.setState(prevState => ({
39+
collapsed: !prevState.collapsed,
40+
}));
41+
};
42+
43+
renderCollapseButton() {
44+
const {collapseButton} = this.props;
45+
46+
if (typeof collapseButton === 'function') {
47+
return collapseButton({onCollapse: this.handleCollapseToggle});
48+
}
49+
50+
return (
51+
<Button priority="link" onClick={this.handleCollapseToggle}>
52+
{t('Collapse')}
53+
</Button>
54+
);
55+
}
56+
57+
renderExpandButton(numberOfCollapsedItems: number) {
58+
const {expandButton} = this.props;
59+
60+
if (typeof expandButton === 'function') {
61+
return expandButton({
62+
onExpand: this.handleCollapseToggle,
63+
numberOfCollapsedItems,
64+
});
65+
}
66+
67+
return (
68+
<Button priority="link" onClick={this.handleCollapseToggle}>
69+
{tn('Show %s collapsed item', 'Show %s collapsed items', numberOfCollapsedItems)}
70+
</Button>
71+
);
72+
}
73+
74+
render() {
75+
const {maxVisibleItems, children} = this.props;
76+
const {collapsed} = this.state;
77+
78+
const items = React.Children.toArray(children);
79+
const canExpand = items.length > maxVisibleItems;
80+
const itemsToRender =
81+
collapsed && canExpand ? items.slice(0, maxVisibleItems) : items;
82+
const numberOfCollapsedItems = items.length - itemsToRender.length;
83+
84+
return (
85+
<React.Fragment>
86+
{itemsToRender}
87+
88+
{numberOfCollapsedItems > 0 && this.renderExpandButton(numberOfCollapsedItems)}
89+
90+
{numberOfCollapsedItems === 0 && canExpand && this.renderCollapseButton()}
91+
</React.Fragment>
92+
);
93+
}
94+
}
95+
96+
export default Collapsible;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React from 'react';
2+
3+
import {mountWithTheme} from 'sentry-test/enzyme';
4+
5+
import Button from 'app/components/button';
6+
import Collapsible from 'app/components/collapsible';
7+
8+
const items = [1, 2, 3, 4, 5, 6, 7].map(i => <div key={i}>Item {i}</div>);
9+
10+
describe('Collapsible', function () {
11+
it('collapses items', function () {
12+
const wrapper = mountWithTheme(<Collapsible>{items}</Collapsible>);
13+
14+
expect(wrapper.find('div').length).toBe(5);
15+
expect(wrapper.find('div').at(2).text()).toBe('Item 3');
16+
17+
expect(wrapper.find('button[aria-label="Show 2 collapsed items"]').text()).toBe(
18+
'Show 2 collapsed items'
19+
);
20+
expect(wrapper.find('button[aria-label="Collapse"]').exists()).toBeFalsy();
21+
});
22+
23+
it('expands items', function () {
24+
const wrapper = mountWithTheme(<Collapsible>{items}</Collapsible>);
25+
26+
// expand
27+
wrapper.find('button[aria-label="Show 2 collapsed items"]').simulate('click');
28+
29+
expect(wrapper.find('div').length).toBe(7);
30+
31+
// collapse back
32+
wrapper.find('button[aria-label="Collapse"]').simulate('click');
33+
34+
expect(wrapper.find('div').length).toBe(5);
35+
});
36+
37+
it('respects maxVisibleItems prop', function () {
38+
const wrapper = mountWithTheme(
39+
<Collapsible maxVisibleItems={2}>{items}</Collapsible>
40+
);
41+
42+
expect(wrapper.find('div').length).toBe(2);
43+
});
44+
45+
it('does not collapse items below threshold', function () {
46+
const wrapper = mountWithTheme(
47+
<Collapsible maxVisibleItems={100}>{items}</Collapsible>
48+
);
49+
50+
expect(wrapper.find('div').length).toBe(7);
51+
52+
expect(wrapper.find('button').exists()).toBeFalsy();
53+
});
54+
55+
it('takes custom buttons', function () {
56+
const wrapper = mountWithTheme(
57+
<Collapsible
58+
collapseButton={({onCollapse}) => (
59+
<Button onClick={onCollapse}>Custom Collapse</Button>
60+
)}
61+
expandButton={({onExpand, numberOfCollapsedItems}) => (
62+
<Button onClick={onExpand} aria-label="Expand">
63+
Custom Expand {numberOfCollapsedItems}
64+
</Button>
65+
)}
66+
>
67+
{items}
68+
</Collapsible>
69+
);
70+
71+
expect(wrapper.find('button').length).toBe(1);
72+
73+
// custom expand
74+
wrapper.find('button[aria-label="Expand"]').simulate('click');
75+
76+
expect(wrapper.find('div').length).toBe(7);
77+
78+
// custom collapse back
79+
wrapper.find('button[aria-label="Custom Collapse"]').simulate('click');
80+
81+
expect(wrapper.find('div').length).toBe(5);
82+
});
83+
});

0 commit comments

Comments
 (0)