Skip to content
This repository has been archived by the owner on Oct 23, 2024. It is now read-only.

Add accordion component #343

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
89 changes: 89 additions & 0 deletions src/components/Accordion/Accordion.minors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useState } from 'react';

import {
Content,
ChevronUp,
CircleWarningIcon,
Header,
StyledChevronDown,
Subcopy,
Title,
TitleContainer,
TriggerContainer,
Wrapper,
} from './Accordion.style';
import { MinorComponent } from '../../utils';
import { AccordionItemProps } from './Accordion.types';

export interface Minors {
Item: MinorComponent<any>;
}

export function Item({
children,
title,
format,
index,
allowMultiple,
defaultActive,
setActiveIndex,
itemActive,
subcopy = '',
icon,
hasError = false,
disabled = false,
}: AccordionItemProps) {
const [active, setActive] = useState<boolean>(defaultActive);
const isActive = allowMultiple
? active && !disabled
: itemActive && !disabled;

return (
<Wrapper
active={isActive}
disabled={disabled}
format={format}
key={index}
>
<TriggerContainer
onClick={() => {
if (allowMultiple) {
setActive(!active);
} else {
setActiveIndex({ index });
}
}}
tabIndex={0}
format={format}
active={isActive}
aria-expanded={isActive}
aria-controls={`accordion-${index}-content`}
id={`accordion-${index}-trigger`}
>
<Header>
{hasError && <CircleWarningIcon />}
{!hasError && icon && icon}
<TitleContainer>
<Title>{title}</Title>
{subcopy && <Subcopy>{subcopy}</Subcopy>}
</TitleContainer>
</Header>
{isActive ? (
<ChevronUp width="24" />
) : (
<StyledChevronDown width="24" />
)}
</TriggerContainer>
{isActive && (
<Content
active={isActive}
aria-labelledby={`accordion-${index}-trigger`}
id={`accordion-${index}-content`}
role="region"
>
{children}
</Content>
)}
</Wrapper>
);
}
59 changes: 59 additions & 0 deletions src/components/Accordion/Accordion.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { Story } from '@storybook/react';

import { Accordion } from './Accordion';
import { Props } from './Accordion.types';
import styled from 'styled-components';
import { Gear } from '../../icons';
import { rem } from 'polished';
import { core } from '../../tokens';
import { Layout } from '../../storybook';

export default {
title: 'components/Accordion',
component: Accordion,
};

const Template: Story<Props> = (args) => {
return (
<Layout.StoryVertical center>
<Accordion {...args}>
<Accordion.Item
title="Accordion item title"
subcopy="Subcopy text"
>
Accordion content
</Accordion.Item>
<Accordion.Item
title="Accordion item with icon"
icon={<GearIcon />}
>
Accordion content
</Accordion.Item>
<Accordion.Item
title="Disabled accordion item"
disabled={true}
>
Accordion content
</Accordion.Item>
<Accordion.Item
title="Accordion item with error"
hasError={true}
>
Accordion content
</Accordion.Item>
</Accordion>
</Layout.StoryVertical>
);
};

const GearIcon = styled(Gear)`
width: ${rem(22)};
margin-right: ${rem(10)};
path {
fill: ${core.color.text.primary};
}
`;

export const Controls = Template.bind({});
Controls.storyName = 'Accordion';
120 changes: 120 additions & 0 deletions src/components/Accordion/Accordion.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { rem } from 'polished';
import styled, { css } from 'styled-components';

import { ChevronDown, CircleWarning } from '../../icons';
import { Paragraph } from '../../typography';

import { grayscale } from '../../color';
import { core } from '../../tokens';

export const AccordionStyled = styled.div`
display: flex;
flex-direction: column;
gap: ${rem(8)};
`;

export const Wrapper = styled.div<{
format: 'basic' | 'secondary';
active: boolean;
disabled: boolean;
}>`
${({ disabled }) =>
disabled &&
css`
pointer-events: none;
opacity: 0.4;
`}
${({ active, theme, format }) =>
active &&
css`
background-color: ${format === 'basic'
? theme.name === 'dark'
? grayscale(800)
: grayscale(50)
juliewongbandue marked this conversation as resolved.
Show resolved Hide resolved
: 'none'};
`}
border-radius: ${rem(10)};
color: ${core.color.text.primary};
${({ active, theme, format }) =>
active &&
css`
border: ${format === 'secondary'
? theme.name === 'dark'
? `${rem(1)} solid ${grayscale(800)}`
: `${rem(1)} solid ${grayscale(50)}`
Comment on lines +43 to +44
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the border color is based on the surface token, you could hold that value in a const (or css property!). You should be able to use the -- css custom property syntax (see example from https://styled-components.com/)

: 'none'};
`}
`;

export const TriggerContainer = styled.button<{
format: 'basic' | 'secondary';
active: boolean;
}>`
display: flex;
justify-content: space-between;
cursor: pointer;
padding: ${rem(12)} ${rem(15)};
border-radius: ${rem(10)};
width: 100%;
&:hover {
background-color: ${({ theme, format }) =>
format === 'basic'
? theme.name === 'dark'
? grayscale(800)
: grayscale(50)
juliewongbandue marked this conversation as resolved.
Show resolved Hide resolved
: 'none'};
outline: ${({ active, theme, format }) =>
!active && format === 'secondary'
? theme.name === 'dark'
? `${rem(1)} solid ${grayscale(800)}`
: `${rem(1)} solid ${grayscale(50)}`
: 'none'};
}
`;

export const Header = styled.div`
display: flex;
margin-right: ${rem(10)};
`;

export const TitleContainer = styled.div`
display: flex;
flex-direction: column;
text-align: left;
`;

export const Title = styled(Paragraph)`
font-size: ${rem(16)};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a Header component?

font-weight: 700;
margin-bottom: 0;
`;

export const Subcopy = styled(Paragraph)`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same goes for here, would this technically be a subheader? If so, the header component should also be used. Size and weight can be modified using the variant and size props. See this header story for example.

font-size: ${rem(14)};
font-weight: 400;
margin-bottom: -${rem(0.2)};
`;

export const CircleWarningIcon = styled(CircleWarning)`
width: ${rem(22)};
margin-right: ${rem(10)};
path {
fill: ${core.color.status.negative};
}
`;

export const StyledChevronDown = styled(ChevronDown)`
path {
juliewongbandue marked this conversation as resolved.
Show resolved Hide resolved
fill: ${core.color.text.primary};
}
`;

export const ChevronUp = styled(StyledChevronDown)`
transform: rotate(180deg);
`;

export const Content = styled.div<{ active: boolean }>`
padding: ${rem(0)} ${rem(15)} ${rem(20)};
max-height: ${({ active }) => (active ? '100%' : '0')};
overflow: hidden;
`;
95 changes: 95 additions & 0 deletions src/components/Accordion/Accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { ThemeProvider } from 'styled-components';
import { themes } from '../../themes';
import { Accordion } from './Accordion';

describe('Accordion', () => {
it('renders accordion', () => {
render(
<ThemeProvider theme={themes['light']}>
<Accordion>
<Accordion.Item />
</Accordion>
</ThemeProvider>
);

const accordion = screen.getByRole('button');
expect(accordion).toBeInTheDocument();
});

it('triggers and expands accordion item when clicking the header', () => {
render(
<ThemeProvider theme={themes['light']}>
<Accordion>
<Accordion.Item />
</Accordion>
</ThemeProvider>
);

const accordion = screen.getByRole('button');
userEvent.click(accordion).then(() => {
const content = screen.getByRole('heading');
expect(content).toBeInTheDocument();
});
});

it('can receive text as title and subcopy props and render them', () => {
render(
<ThemeProvider theme={themes['light']}>
<Accordion>
<Accordion.Item
title="Accordion item title"
subcopy="Accordion item subcopy"
/>
</Accordion>
</ThemeProvider>
);

const title = screen.getByText('Accordion item title');
const subcopy = screen.getByText('Accordion item subcopy');
expect(title).toBeInTheDocument();
expect(subcopy).toBeInTheDocument();
});

it('can receive children component and render them', () => {
render(
<ThemeProvider theme={themes['light']}>
<Accordion>
<Accordion.Item>
<div>Child component</div>
</Accordion.Item>
</Accordion>
</ThemeProvider>
);

const accordion = screen.getByRole('button');
userEvent.click(accordion).then(() => {
const childComponentContent =
screen.getByText('Child component');
expect(childComponentContent).toBeInTheDocument();
});
});

it('does not render children when disabled prop is true', () => {
render(
<ThemeProvider theme={themes['light']}>
<Accordion>
<Accordion.Item disabled={true}>
<div>Child component</div>
</Accordion.Item>
</Accordion>
</ThemeProvider>
);

const accordion = screen.getByRole('button');
userEvent.click(accordion).then(() => {
const childComponentContent =
screen.getByText('Child component');
expect(childComponentContent).not.toBeInTheDocument();
});
});
});
Loading
Loading