Skip to content

Commit 0be8fdd

Browse files
committed
ECHOES-1009 Add Accordion Element
1 parent 2496b38 commit 0be8fdd

File tree

6 files changed

+296
-1
lines changed

6 files changed

+296
-1
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Echoes React
3+
* Copyright (C) 2023-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
import styled from '@emotion/styled';
22+
import { forwardRef, useCallback, useEffect, useState } from 'react';
23+
import { cssVar } from '~utils/design-tokens';
24+
import { IconChevronDown, IconChevronRight } from '../icons';
25+
26+
export interface AccordionProps {
27+
/**
28+
* aria-label is not required by the ARIA spec for <details> elements, but has been included for backward compatibility
29+
*/
30+
ariaLabel?: string;
31+
children: React.ReactNode;
32+
className?: string;
33+
header: React.ReactNode;
34+
/**
35+
* isOpen and onOpenChange can be used for bi-directional control of the accordion.
36+
*/
37+
isOpen?: boolean;
38+
onOpenChange?: (isOpen: boolean) => void;
39+
}
40+
41+
const Accordion = forwardRef<HTMLDetailsElement, AccordionProps>((props, ref) => {
42+
const { ariaLabel, children, className, header, isOpen, onOpenChange } = props;
43+
44+
const [internalOpen, setInternalOpen] = useState(isOpen ?? false);
45+
46+
const handleOpenChange = useCallback(
47+
(isOpen: boolean) => {
48+
setInternalOpen(isOpen);
49+
onOpenChange?.(isOpen);
50+
},
51+
[onOpenChange],
52+
);
53+
54+
useEffect(() => {
55+
if (isOpen !== undefined) {
56+
setInternalOpen(isOpen);
57+
}
58+
}, [isOpen]);
59+
60+
return (
61+
<AccordionWrapper
62+
aria-label={ariaLabel}
63+
className={className}
64+
onToggle={(event) => handleOpenChange(event.currentTarget.open)}
65+
open={internalOpen}
66+
ref={ref}>
67+
<summary>
68+
{header}
69+
<OpenCloseIndicator open={internalOpen} />
70+
</summary>
71+
<div className="accordion-content">{children}</div>
72+
</AccordionWrapper>
73+
);
74+
});
75+
Accordion.displayName = 'Accordion';
76+
77+
function OpenCloseIndicator({ open }: { open: boolean }) {
78+
return open ? <IconChevronDown /> : <IconChevronRight />;
79+
}
80+
81+
const AccordionWrapper = styled.details`
82+
border: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')};
83+
border-radius: ${cssVar('border-radius-400')};
84+
width: 100%;
85+
86+
& > summary {
87+
cursor: pointer;
88+
display: flex;
89+
align-items: center;
90+
justify-content: space-between;
91+
padding: ${cssVar('dimension-space-200')};
92+
}
93+
94+
&[open] summary {
95+
border-bottom: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')};
96+
}
97+
98+
& .accordion-content {
99+
padding: ${cssVar('dimension-space-200')};
100+
}
101+
102+
/* Hide the default marker */
103+
& > summary::marker {
104+
content: none;
105+
}
106+
`;
107+
108+
export { Accordion };
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Echoes React
3+
* Copyright (C) 2023-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
import { screen } from '@testing-library/react';
22+
import { useState } from 'react';
23+
import { render } from '~common/helpers/test-utils';
24+
import { Accordion } from '../Accordion';
25+
26+
describe('Accordion - Internal Control (Default)', () => {
27+
it('should toggle open/closed when clicking the summary', async () => {
28+
const { user } = render(
29+
<Accordion header="Test Header">
30+
<div>Test Content</div>
31+
</Accordion>,
32+
);
33+
34+
const details = screen.getByRole('group');
35+
const headerText = screen.getByText('Test Header');
36+
37+
expect(details).not.toHaveAttribute('open');
38+
39+
await user.click(headerText);
40+
expect(details).toHaveAttribute('open');
41+
42+
await user.click(headerText);
43+
expect(details).not.toHaveAttribute('open');
44+
});
45+
});
46+
47+
describe('Accordion - External Control', () => {
48+
it('should be controlled by isOpen prop', () => {
49+
const { rerender } = render(
50+
<Accordion header="Test Header" isOpen={false}>
51+
<div>Test Content</div>
52+
</Accordion>,
53+
);
54+
55+
const details = screen.getByRole('group');
56+
expect(details).not.toHaveAttribute('open');
57+
58+
rerender(
59+
<Accordion header="Test Header" isOpen>
60+
<div>Test Content</div>
61+
</Accordion>,
62+
);
63+
64+
expect(details).toHaveAttribute('open');
65+
});
66+
67+
it('should call onOpenChange when toggled', async () => {
68+
const onOpenChange = jest.fn();
69+
const { rerender, user } = render(
70+
<Accordion header="Test Header" isOpen={false} onOpenChange={onOpenChange}>
71+
<div>Test Content</div>
72+
</Accordion>,
73+
);
74+
75+
const headerText = screen.getByText('Test Header');
76+
77+
await user.click(headerText);
78+
expect(onOpenChange).toHaveBeenCalledWith(true);
79+
expect(onOpenChange).toHaveBeenCalledTimes(1);
80+
81+
// Simulate parent updating isOpen
82+
rerender(
83+
<Accordion header="Test Header" isOpen onOpenChange={onOpenChange}>
84+
<div>Test Content</div>
85+
</Accordion>,
86+
);
87+
88+
await user.click(headerText);
89+
expect(onOpenChange).toHaveBeenCalledWith(false);
90+
expect(onOpenChange).toHaveBeenCalledTimes(2);
91+
});
92+
93+
it('should work with both isOpen and onOpenChange for full external control', async () => {
94+
function FullyControlledAccordion() {
95+
const [isOpen, setIsOpen] = useState(false);
96+
97+
return (
98+
<Accordion header="Test Header" isOpen={isOpen} onOpenChange={setIsOpen}>
99+
<div>Test Content</div>
100+
</Accordion>
101+
);
102+
}
103+
104+
const { user } = render(<FullyControlledAccordion />);
105+
106+
const details = screen.getByRole('group');
107+
const headerText = screen.getByText('Test Header');
108+
109+
// Initially closed
110+
expect(details).not.toHaveAttribute('open');
111+
112+
// Click to open
113+
await user.click(headerText);
114+
expect(details).toHaveAttribute('open');
115+
116+
// Click to close
117+
await user.click(headerText);
118+
expect(details).not.toHaveAttribute('open');
119+
});
120+
});

src/components/accordion/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Echoes React
3+
* Copyright (C) 2023-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
export { Accordion, type AccordionProps } from './Accordion';

src/components/buttons/ButtonAsLink.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import { Link as RouterLink } from 'react-router-dom';
2222
import { styled } from 'storybook/internal/theming';
2323
import { LinkBaseProps } from '../links/LinkTypes';
24-
import { buttonIconStyles, ButtonStyled } from './ButtonStyles';
24+
import { ButtonStyled, buttonIconStyles } from './ButtonStyles';
2525
import { ButtonCommonProps, HTMLButtonAttributesSubset } from './ButtonTypes';
2626

2727
type LinkPropsSubset = Pick<

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
*/
2020

21+
export * from './accordion';
2122
export * from './badges';
2223
export * from './breadcrumbs';
2324
export * from './buttons';

stories/Accordion-stories.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Echoes React
3+
* Copyright (C) 2023-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
import type { Meta, StoryObj } from '@storybook/react-vite';
22+
import { Accordion } from '../src';
23+
import { basicWrapperDecorator } from './helpers/BasicWrapper';
24+
25+
const meta: Meta<typeof Accordion> = {
26+
component: Accordion,
27+
title: 'Echoes/Accordion',
28+
parameters: {
29+
controls: { exclude: ['children'] },
30+
},
31+
decorators: [basicWrapperDecorator],
32+
};
33+
34+
export default meta;
35+
36+
type Story = StoryObj<typeof Accordion>;
37+
38+
export const Basic: Story = {
39+
args: {
40+
header: 'Learn about the accordion',
41+
children: 'The accordion is simply a native <details> element with a <summary>',
42+
isOpen: false,
43+
},
44+
render: (args) => <Accordion {...args} />,
45+
};

0 commit comments

Comments
 (0)