Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/components/accordion/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Echoes React
* Copyright (C) 2023-2025 SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import styled from '@emotion/styled';
import { forwardRef, useCallback, useEffect, useState } from 'react';
import { cssVar } from '~utils/design-tokens';
import { IconChevronDown, IconChevronRight } from '../icons';

export interface AccordionProps {
/**
* aria-label is not required by the ARIA spec for <details> elements, but has been included for backward compatibility
*/
ariaLabel?: string;
children: React.ReactNode;
className?: string;
header: React.ReactNode;
/**
* isOpen and onOpenChange can be used for bi-directional control of the accordion.
*/
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}

const Accordion = forwardRef<HTMLDetailsElement, AccordionProps>((props, ref) => {
const { ariaLabel, children, className, header, isOpen, onOpenChange } = props;

const [internalOpen, setInternalOpen] = useState(isOpen ?? false);

const handleOpenChange = useCallback(
(isOpen: boolean) => {
setInternalOpen(isOpen);
onOpenChange?.(isOpen);
},
[onOpenChange],
);

useEffect(() => {
if (isOpen !== undefined) {
setInternalOpen(isOpen);
}
}, [isOpen]);

return (
<AccordionWrapper
aria-label={ariaLabel}
className={className}
onToggle={(event) => handleOpenChange(event.currentTarget.open)}
open={internalOpen}
ref={ref}>
<summary>
{header}
<OpenCloseIndicator open={internalOpen} />
</summary>
<div className="accordion-content">{children}</div>
</AccordionWrapper>
);
});
Accordion.displayName = 'Accordion';

function OpenCloseIndicator({ open }: { open: boolean }) {
return open ? <IconChevronDown /> : <IconChevronRight />;
}

const AccordionWrapper = styled.details`
border: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')};
border-radius: ${cssVar('border-radius-400')};
width: 100%;

& > summary {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
padding: ${cssVar('dimension-space-200')};
}

&[open] summary {
border-bottom: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')};
}

& .accordion-content {
padding: ${cssVar('dimension-space-200')};
}

/* Hide the default marker */
& > summary::marker {
content: none;
}
`;

export { Accordion };
120 changes: 120 additions & 0 deletions src/components/accordion/__tests__/Accordion-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Echoes React
* Copyright (C) 2023-2025 SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { screen } from '@testing-library/react';
import { useState } from 'react';
import { render } from '~common/helpers/test-utils';
import { Accordion } from '../Accordion';

describe('Accordion - Internal Control (Default)', () => {
it('should toggle open/closed when clicking the summary', async () => {
const { user } = render(
<Accordion header="Test Header">
<div>Test Content</div>
</Accordion>,
);

const details = screen.getByRole('group');
const headerText = screen.getByText('Test Header');

expect(details).not.toHaveAttribute('open');

await user.click(headerText);
expect(details).toHaveAttribute('open');

await user.click(headerText);
expect(details).not.toHaveAttribute('open');
});
});

describe('Accordion - External Control', () => {
it('should be controlled by isOpen prop', () => {
const { rerender } = render(
<Accordion header="Test Header" isOpen={false}>
<div>Test Content</div>
</Accordion>,
);

const details = screen.getByRole('group');
expect(details).not.toHaveAttribute('open');

rerender(
<Accordion header="Test Header" isOpen>
<div>Test Content</div>
</Accordion>,
);

expect(details).toHaveAttribute('open');
});

it('should call onOpenChange when toggled', async () => {
const onOpenChange = jest.fn();
const { rerender, user } = render(
<Accordion header="Test Header" isOpen={false} onOpenChange={onOpenChange}>
<div>Test Content</div>
</Accordion>,
);

const headerText = screen.getByText('Test Header');

await user.click(headerText);
expect(onOpenChange).toHaveBeenCalledWith(true);
expect(onOpenChange).toHaveBeenCalledTimes(1);

// Simulate parent updating isOpen
rerender(
<Accordion header="Test Header" isOpen onOpenChange={onOpenChange}>
<div>Test Content</div>
</Accordion>,
);

await user.click(headerText);
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onOpenChange).toHaveBeenCalledTimes(2);
});

it('should work with both isOpen and onOpenChange for full external control', async () => {
function FullyControlledAccordion() {
const [isOpen, setIsOpen] = useState(false);

return (
<Accordion header="Test Header" isOpen={isOpen} onOpenChange={setIsOpen}>
<div>Test Content</div>
</Accordion>
);
}

const { user } = render(<FullyControlledAccordion />);

const details = screen.getByRole('group');
const headerText = screen.getByText('Test Header');

// Initially closed
expect(details).not.toHaveAttribute('open');

// Click to open
await user.click(headerText);
expect(details).toHaveAttribute('open');

// Click to close
await user.click(headerText);
expect(details).not.toHaveAttribute('open');
});
});
21 changes: 21 additions & 0 deletions src/components/accordion/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Echoes React
* Copyright (C) 2023-2025 SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

export { Accordion, type AccordionProps } from './Accordion';
2 changes: 1 addition & 1 deletion src/components/buttons/ButtonAsLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { Link as RouterLink } from 'react-router-dom';
import { styled } from 'storybook/internal/theming';
import { LinkBaseProps } from '../links/LinkTypes';
import { buttonIconStyles, ButtonStyled } from './ButtonStyles';
import { ButtonStyled, buttonIconStyles } from './ButtonStyles';
import { ButtonCommonProps, HTMLButtonAttributesSubset } from './ButtonTypes';

type LinkPropsSubset = Pick<
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

export * from './accordion';
export * from './badges';
export * from './breadcrumbs';
export * from './buttons';
Expand Down
45 changes: 45 additions & 0 deletions stories/Accordion-stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Echoes React
* Copyright (C) 2023-2025 SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import type { Meta, StoryObj } from '@storybook/react-vite';
import { Accordion } from '../src';
import { basicWrapperDecorator } from './helpers/BasicWrapper';

const meta: Meta<typeof Accordion> = {
component: Accordion,
title: 'Echoes/Accordion',
parameters: {
controls: { exclude: ['children'] },
},
decorators: [basicWrapperDecorator],
};

export default meta;

type Story = StoryObj<typeof Accordion>;

export const Basic: Story = {
args: {
header: 'Learn about the accordion',
children: 'The accordion is simply a native <details> element with a <summary>',
isOpen: false,
},
render: (args) => <Accordion {...args} />,
};