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

feat(component): add AccordionPanel component #933

Merged
merged 14 commits into from
Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from 13 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ExpandMoreIcon } from '@bigcommerce/big-design-icons';
import React, { memo } from 'react';

import { useUniqueId } from '../../../hooks';

import { StyledAccordionButton, StyledAccordionContent } from './styled';

export interface AccordionProps {
children?: React.ReactNode;
defaultExpanded?: boolean;
header: string;
iconLeft?: React.ReactNode;
isExpanded: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}

export const Accordion: React.FC<AccordionProps> = memo(
({ children, header, iconLeft, isExpanded, onClick }) => {
const accordionId = useUniqueId('accordion');
const accordionItemId = useUniqueId('accordion-item');

return (
<>
<StyledAccordionButton
aria-controls={accordionItemId}
aria-expanded={isExpanded}
iconLeft={iconLeft}
iconRight={<ExpandMoreIcon className="collapse-icon" color="secondary70" />}
id={accordionId}
isExpanded={isExpanded}
onClick={onClick}
variant="subtle"
>
{header}
</StyledAccordionButton>
<StyledAccordionContent
aria-labelledby={accordionId}
display={isExpanded ? 'block' : 'none'}
hidden={!isExpanded}
iconLeft={iconLeft}
id={accordionItemId}
role="region"
>
{children}
</StyledAccordionContent>
</>
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { AccordionProps as _AccordionProps } from './Accordion';

export { Accordion } from './Accordion';
export type AccordionProps = _AccordionProps;
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { theme as defaultTheme, remCalc } from '@bigcommerce/big-design-theme';
import styled, { css } from 'styled-components';

import { withTransition } from '../../../mixins/transitions';
import { Box } from '../../Box';
import { StyleableButton } from '../../Button/private';

interface StyledAccordionButtonProps {
isExpanded: boolean;
}

interface StyledAccordionContentProps {
iconLeft: React.ReactNode;
}

export const StyledAccordionButton = styled(StyleableButton)<StyledAccordionButtonProps>`
border-top: ${({ theme }) => theme.border.box};
border-radius: 0;
padding: ${({ theme }) => theme.spacing.xLarge};
text-align: left;
width: 100%;
span {
width: 100%;
color: ${({ theme }) => theme.colors.secondary70};
grid-template-columns: ${({ iconLeft, theme }) =>
iconLeft
? `${theme.spacing.xLarge} 1fr ${theme.spacing.medium}`
: `1fr ${theme.spacing.medium}`};
}

${({ isExpanded }) =>
isExpanded &&
css`
border-bottom: ${({ theme }) => theme.border.box};
`}

&:focus {
z-index: ${({ theme }) => theme.zIndex.fixed};
}

.collapse-icon {
${withTransition(['transform'])}

${({ isExpanded }) =>
isExpanded &&
css`
transform: rotate(-180deg);
`}
}
`;

export const StyledAccordionContent = styled(Box)<StyledAccordionContentProps>`
padding: ${({ theme }) => theme.spacing.xLarge}};
padding-left: ${({ iconLeft, theme }) => (iconLeft ? remCalc(60) : `${theme.spacing.xLarge}`)};
`;

StyledAccordionButton.defaultProps = { theme: defaultTheme };
StyledAccordionContent.defaultProps = { theme: defaultTheme };
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { memo } from 'react';

import { Panel } from '../Panel';

import { Accordion, AccordionProps } from './Accordion';
import { StyledAccordionPanelWrapper } from './styled';

export interface AccordionPanelProps {
header: string;
panels: AccordionProps[];
}

export const AccordionPanel: React.FC<AccordionPanelProps> = memo(({ header, panels }) => {
return (
<Panel header={header}>
<StyledAccordionPanelWrapper>
{panels.map((panel, index) => (
<Accordion {...panel} key={index} />
))}
</StyledAccordionPanelWrapper>
</Panel>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`it renders accordion panel header 1`] = `
<h2
class="styled__StyledH2-sc-tqnj75-2 styled__StyledH2-sc-1h6ef3q-1 kEGvNd fjdBDH"
>
Accordion Panel Header
</h2>
`;
5 changes: 5 additions & 0 deletions packages/big-design/src/components/AccordionPanel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AccordionPanelProps as _AccordionPanelProps } from './AccordionPanel';

export { AccordionPanel } from './AccordionPanel';
export { useAccordionPanel } from './useAccordionPanel';
export type AccordionPanelProps = _AccordionPanelProps;
137 changes: 137 additions & 0 deletions packages/big-design/src/components/AccordionPanel/spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { ErrorIcon } from '@bigcommerce/big-design-icons';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';

import { render, screen } from '@test/utils';

import { Text } from '../Typography';

import { AccordionProps } from './Accordion';

import { AccordionPanel, useAccordionPanel } from '.';

let panels: AccordionProps[];

const TestComponent: React.FC = () => {
({ panels } = useAccordionPanel([
{
header: 'Panel Header',
children: <Text>This is a child component</Text>,
},
]));
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we should just assign this to a local const instead of using this type of JavaScript sorcery.

Suggested change
({ panels } = useAccordionPanel([
{
header: 'Panel Header',
children: <Text>This is a child component</Text>,
},
]));
const { panels } = useAccordionPanel([
{
header: 'Panel Header',
children: <Text>This is a child component</Text>,
},
]);

Same below...


return <AccordionPanel header="Accordion Panel Header" panels={panels} />;
};

const TestComponentWithoutHook: React.FC = () => {
const [isExpanded, setIsExpanded] = useState(false);

const testPanels: AccordionProps[] = [
{
header: 'Panel Header',
isExpanded,
children: <Text>This is a child component</Text>,
onClick: () => setIsExpanded(!isExpanded),
},
];

return <AccordionPanel header="Accordion Panel Header" panels={testPanels} />;
};

const TestComponentWithDefault: React.FC = () => {
({ panels } = useAccordionPanel([
{
defaultExpanded: true,
header: 'Panel Header',
iconLeft: <ErrorIcon />,
children: <Text>This is a child component</Text>,
},
]));

return <AccordionPanel header="Accordion Panel Header" panels={panels} />;
};

test('it renders accordion panel header', async () => {
render(<TestComponent />);

const header = await screen.findByRole('heading', { name: /Accordion Panel Header/i });

expect(header).toMatchSnapshot();
expect(header).toBeVisible();
});

test('it renders accordion panel children', async () => {
render(<TestComponent />);

const child = await screen.findByText('Panel Header');

expect(child).toBeVisible();
});

test('accordion renders header', async () => {
render(<TestComponent />);

const header = await screen.findByText('Panel Header');

expect(header).toBeVisible();
});

test('accordion collapses and expands on click', async () => {
render(<TestComponent />);

const buttonPanel = await screen.findByRole('button');

await userEvent.click(buttonPanel);

const panelChild = await screen.findByRole('region');

expect(panelChild).toBeVisible();

await userEvent.click(buttonPanel);

expect(panelChild).not.toBeVisible();
});

test('accordion renders children', async () => {
render(<TestComponent />);

const buttonPanel = await screen.findByRole('button');

await userEvent.click(buttonPanel);

const child = await screen.findByRole('region');

expect(child).toBeVisible();
});

test('if defaultExpanded is set to true, accordion panel is expanded', async () => {
render(<TestComponentWithDefault />);

const panelChild = await screen.findByRole('region');

expect(panelChild).toBeVisible();
});

test('it renders icon if iconLeft prop is defined', async () => {
render(<TestComponentWithDefault />);

const icons = (await screen.findByRole('button')).querySelectorAll('svg');

const icon = icons[0];

expect(icon).toBeVisible();
});

test('it renders header and children accordion elements without hook', async () => {
render(<TestComponentWithoutHook />);

const header = await screen.findByText('Panel Header');
const buttonPanel = await screen.findByRole('button');

await userEvent.click(buttonPanel);

const child = await screen.findByRole('region');

expect(child).toBeVisible();
expect(header).toBeVisible();
});
20 changes: 20 additions & 0 deletions packages/big-design/src/components/AccordionPanel/styled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { theme as defaultTheme } from '@bigcommerce/big-design-theme';
import styled from 'styled-components';

import { Flex } from '../Flex';

export const StyledAccordionPanelWrapper = styled(Flex)`
flex-direction: column;

margin-bottom: -${({ theme }) => theme.spacing.medium};
margin-left: -${({ theme }) => theme.spacing.medium};
margin-right: -${({ theme }) => theme.spacing.medium};

${({ theme }) => theme.breakpoints.tablet} {
margin-bottom: -${({ theme }) => theme.spacing.xLarge};
margin-left: -${({ theme }) => theme.spacing.xLarge};
margin-right: -${({ theme }) => theme.spacing.xLarge};
}
`;

StyledAccordionPanelWrapper.defaultProps = { theme: defaultTheme };
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useState } from 'react';

import { AccordionProps } from './Accordion';

type InitialPanels = Array<Omit<AccordionProps, 'isExpanded' | 'onClick'>>;

export const useAccordionPanel = (initialPanels: InitialPanels) => {
const [expandedPanels, setExpandedPanels] = useState(() => {
return initialPanels.map(({ defaultExpanded }) => defaultExpanded ?? false);
});

const handleOnClick = (panelIndex: number) => () => {
setExpandedPanels(
expandedPanels.map((isExpanded, index) => (index === panelIndex ? !isExpanded : isExpanded)),
);
};

return {
panels: expandedPanels.map<AccordionProps>((isExpanded, index) => ({
...initialPanels[index],
isExpanded,
onClick: handleOnClick(index),
})),
};
};
1 change: 1 addition & 0 deletions packages/big-design/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './AccordionPanel';
export * from './Alert';
export * from './Badge';
export * from './Box';
Expand Down
Loading