diff --git a/src/components/accordion/Accordion.tsx b/src/components/accordion/Accordion.tsx new file mode 100644 index 000000000..1f992cb42 --- /dev/null +++ b/src/components/accordion/Accordion.tsx @@ -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
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((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 ( + handleOpenChange(event.currentTarget.open)} + open={internalOpen} + ref={ref}> + + {header} + + +
{children}
+
+ ); +}); +Accordion.displayName = 'Accordion'; + +function OpenCloseIndicator({ open }: { open: boolean }) { + return open ? : ; +} + +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 }; diff --git a/src/components/accordion/__tests__/Accordion-test.tsx b/src/components/accordion/__tests__/Accordion-test.tsx new file mode 100644 index 000000000..2163278bd --- /dev/null +++ b/src/components/accordion/__tests__/Accordion-test.tsx @@ -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( + +
Test Content
+
, + ); + + 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( + +
Test Content
+
, + ); + + const details = screen.getByRole('group'); + expect(details).not.toHaveAttribute('open'); + + rerender( + +
Test Content
+
, + ); + + expect(details).toHaveAttribute('open'); + }); + + it('should call onOpenChange when toggled', async () => { + const onOpenChange = jest.fn(); + const { rerender, user } = render( + +
Test Content
+
, + ); + + const headerText = screen.getByText('Test Header'); + + await user.click(headerText); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledTimes(1); + + // Simulate parent updating isOpen + rerender( + +
Test Content
+
, + ); + + 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 ( + +
Test Content
+
+ ); + } + + const { user } = render(); + + 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'); + }); +}); diff --git a/src/components/accordion/index.ts b/src/components/accordion/index.ts new file mode 100644 index 000000000..b7246f9ca --- /dev/null +++ b/src/components/accordion/index.ts @@ -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'; diff --git a/src/components/buttons/ButtonAsLink.tsx b/src/components/buttons/ButtonAsLink.tsx index adc4bb861..33c1ebdb0 100644 --- a/src/components/buttons/ButtonAsLink.tsx +++ b/src/components/buttons/ButtonAsLink.tsx @@ -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< diff --git a/src/components/index.ts b/src/components/index.ts index 3bd2adbe5..45c7124f6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; diff --git a/stories/Accordion-stories.tsx b/stories/Accordion-stories.tsx new file mode 100644 index 000000000..74f77ce38 --- /dev/null +++ b/stories/Accordion-stories.tsx @@ -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 = { + component: Accordion, + title: 'Echoes/Accordion', + parameters: { + controls: { exclude: ['children'] }, + }, + decorators: [basicWrapperDecorator], +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + header: 'Learn about the accordion', + children: 'The accordion is simply a native
element with a ', + isOpen: false, + }, + render: (args) => , +};