Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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.
Jump to
Jump to file
Failed to load files.
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();
});
});
});