diff --git a/packages/big-design/src/components/AccordionPanel/Accordion/Accordion.tsx b/packages/big-design/src/components/AccordionPanel/Accordion/Accordion.tsx new file mode 100644 index 000000000..f84855787 --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/Accordion/Accordion.tsx @@ -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; +} + +export const Accordion: React.FC = memo( + ({ children, header, iconLeft, isExpanded, onClick }) => { + const accordionId = useUniqueId('accordion'); + const accordionItemId = useUniqueId('accordion-item'); + + return ( + <> + } + id={accordionId} + isExpanded={isExpanded} + onClick={onClick} + variant="subtle" + > + {header} + + + + ); + }, +); diff --git a/packages/big-design/src/components/AccordionPanel/Accordion/index.ts b/packages/big-design/src/components/AccordionPanel/Accordion/index.ts new file mode 100644 index 000000000..6484592db --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/Accordion/index.ts @@ -0,0 +1,4 @@ +import { AccordionProps as _AccordionProps } from './Accordion'; + +export { Accordion } from './Accordion'; +export type AccordionProps = _AccordionProps; diff --git a/packages/big-design/src/components/AccordionPanel/Accordion/styled.tsx b/packages/big-design/src/components/AccordionPanel/Accordion/styled.tsx new file mode 100644 index 000000000..142d8bb44 --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/Accordion/styled.tsx @@ -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)` + 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)` + padding: ${({ theme }) => theme.spacing.xLarge}}; + padding-left: ${({ iconLeft, theme }) => (iconLeft ? remCalc(60) : `${theme.spacing.xLarge}`)}; +`; + +StyledAccordionButton.defaultProps = { theme: defaultTheme }; +StyledAccordionContent.defaultProps = { theme: defaultTheme }; diff --git a/packages/big-design/src/components/AccordionPanel/AccordionPanel.tsx b/packages/big-design/src/components/AccordionPanel/AccordionPanel.tsx new file mode 100644 index 000000000..ed3ecf5c5 --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/AccordionPanel.tsx @@ -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 = memo(({ header, panels }) => { + return ( + + + {panels.map((panel, index) => ( + + ))} + + + ); +}); diff --git a/packages/big-design/src/components/AccordionPanel/__snapshots__/spec.tsx.snap b/packages/big-design/src/components/AccordionPanel/__snapshots__/spec.tsx.snap new file mode 100644 index 000000000..3aa06be8e --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/__snapshots__/spec.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders accordion panel header 1`] = ` +

+ Accordion Panel Header +

+`; diff --git a/packages/big-design/src/components/AccordionPanel/index.ts b/packages/big-design/src/components/AccordionPanel/index.ts new file mode 100644 index 000000000..1a5daa750 --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/index.ts @@ -0,0 +1,5 @@ +import { AccordionPanelProps as _AccordionPanelProps } from './AccordionPanel'; + +export { AccordionPanel } from './AccordionPanel'; +export { useAccordionPanel } from './useAccordionPanel'; +export type AccordionPanelProps = _AccordionPanelProps; diff --git a/packages/big-design/src/components/AccordionPanel/spec.tsx b/packages/big-design/src/components/AccordionPanel/spec.tsx new file mode 100644 index 000000000..d13309fc9 --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/spec.tsx @@ -0,0 +1,135 @@ +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 '.'; + +const TestComponent: React.FC = () => { + const { panels } = useAccordionPanel([ + { + header: 'Panel Header', + children: This is a child component, + }, + ]); + + return ; +}; + +const TestComponentWithoutHook: React.FC = () => { + const [isExpanded, setIsExpanded] = useState(false); + + const testPanels: AccordionProps[] = [ + { + header: 'Panel Header', + isExpanded, + children: This is a child component, + onClick: () => setIsExpanded(!isExpanded), + }, + ]; + + return ; +}; + +const TestComponentWithDefault: React.FC = () => { + const { panels } = useAccordionPanel([ + { + defaultExpanded: true, + header: 'Panel Header', + iconLeft: , + children: This is a child component, + }, + ]); + + return ; +}; + +test('it renders accordion panel header', async () => { + render(); + + 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(); + + const child = await screen.findByText('Panel Header'); + + expect(child).toBeVisible(); +}); + +test('accordion renders header', async () => { + render(); + + const header = await screen.findByText('Panel Header'); + + expect(header).toBeVisible(); +}); + +test('accordion collapses and expands on click', async () => { + render(); + + 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(); + + 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(); + + const panelChild = await screen.findByRole('region'); + + expect(panelChild).toBeVisible(); +}); + +test('it renders icon if iconLeft prop is defined', async () => { + render(); + + 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(); + + 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(); +}); diff --git a/packages/big-design/src/components/AccordionPanel/styled.tsx b/packages/big-design/src/components/AccordionPanel/styled.tsx new file mode 100644 index 000000000..cb87399bb --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/styled.tsx @@ -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 }; diff --git a/packages/big-design/src/components/AccordionPanel/useAccordionPanel.ts b/packages/big-design/src/components/AccordionPanel/useAccordionPanel.ts new file mode 100644 index 000000000..8b6a64da2 --- /dev/null +++ b/packages/big-design/src/components/AccordionPanel/useAccordionPanel.ts @@ -0,0 +1,25 @@ +import { useState } from 'react'; + +import { AccordionProps } from './Accordion'; + +type InitialPanels = Array>; + +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((isExpanded, index) => ({ + ...initialPanels[index], + isExpanded, + onClick: handleOnClick(index), + })), + }; +}; diff --git a/packages/big-design/src/components/index.ts b/packages/big-design/src/components/index.ts index 5b484ce21..3ec38e0f9 100644 --- a/packages/big-design/src/components/index.ts +++ b/packages/big-design/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './AccordionPanel'; export * from './Alert'; export * from './Badge'; export * from './Box'; diff --git a/packages/docs/PropTables/AccordionPanelPropTable.tsx b/packages/docs/PropTables/AccordionPanelPropTable.tsx new file mode 100644 index 000000000..0444e8499 --- /dev/null +++ b/packages/docs/PropTables/AccordionPanelPropTable.tsx @@ -0,0 +1,104 @@ +import React from 'react'; + +import { Code, NextLink, Prop, PropTable, PropTableWrapper } from '../components'; + +const accordionPanelProps: Prop[] = [ + { + name: 'panels', + types: ( + + AccordionProps[] + + ), + description: ( + <> + See{' '} + + Accordion + {' '} + for usage. + + ), + required: true, + }, + { + name: 'header', + types: 'string', + description: ( + <> + Defines the Accordion Panel header text + + ), + required: true, + }, +]; + +export const AccordionPanelPropTable: React.FC = (props) => ( + +); + +const accordionProps: Prop[] = [ + { + name: 'children', + types: 'React.ReactNode', + description: ( + <> + Render individual components to expandable region of Accordion. + + ), + required: true, + }, + { + name: 'defaultExpanded', + types: 'boolean', + description: ( + <> + Defines if Accordion is expanded by default. + + ), + }, + { + name: 'header', + types: 'string', + description: ( + <> + Defines the Accordion header text. + + ), + required: true, + }, + { + name: 'iconLeft', + types: Icon, + description: ( + <> + Pass in an Icon component to display to the left of the{' '} + Accordion header. + + ), + }, + { + name: 'isExpanded', + types: 'boolean', + description: ( + <> + Defines if Accordion is expanded. + + ), + required: true, + }, + { + name: 'onClick', + types: 'React.MouseEventHandler', + description: ( + <> + Function that will be called when an Accordion is clicked. + + ), + required: true, + }, +]; + +export const AccordionPropTable: React.FC = (props) => ( + +); diff --git a/packages/docs/PropTables/index.ts b/packages/docs/PropTables/index.ts index a52e32547..6e35af9d7 100644 --- a/packages/docs/PropTables/index.ts +++ b/packages/docs/PropTables/index.ts @@ -1,3 +1,4 @@ +export * from './AccordionPanelPropTable'; export * from './BadgePropTable'; export * from './BoxPropTable'; export * from './ButtonPropTable'; diff --git a/packages/docs/components/SideNav/SideNav.tsx b/packages/docs/components/SideNav/SideNav.tsx index b0c28c43e..775b3f7e5 100644 --- a/packages/docs/components/SideNav/SideNav.tsx +++ b/packages/docs/components/SideNav/SideNav.tsx @@ -41,6 +41,7 @@ export const SideNav: React.FC = () => { + Accordion Panel Collapse Modal Pagination diff --git a/packages/docs/pages/accordion-panel.tsx b/packages/docs/pages/accordion-panel.tsx new file mode 100644 index 000000000..94629733d --- /dev/null +++ b/packages/docs/pages/accordion-panel.tsx @@ -0,0 +1,105 @@ +import { AccordionPanel, H1, Panel, Text, useAccordionPanel } from '@bigcommerce/big-design'; +import React from 'react'; + +import { Code, CodePreview, ContentRoutingTabs, GuidelinesTable, List } from '../components'; +import { AccordionPanelPropTable, AccordionPropTable } from '../PropTables/AccordionPanelPropTable'; + +const AccordionPanelPage = () => { + return ( + <> +

Accordion Panel

+ + + + Accordion panels are containers for content of relative importance to + the user that can be selectively expanded or collapsed. They can be useful for reducing + the content on a page & the cognitive load for the user. + + + An accordion panel can display different types of content such as + text, images, tables, media, buttons, etc. Components are added into the drop zone and + render when the user expands the corresponding panel. + + + When to use: + + On similar or redundant information that must be presented together + Step-by-step or sequential processes + + + + + + {/* jsx-to-string:start */} + {function Example() { + const { panels } = useAccordionPanel([ + { + defaultExpanded: true, + header: 'Panel Header', + children: ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Pulvinar mattis nunc sed blandit + libero volutpat. Eu lobortis elementum nibh tellus molestie nunc non. + + ), + }, + { + header: 'Panel Header', + children: ( + + Nullam eleifend a lectus non consequat. Fusce non mauris at velit sodales + venenatis vitae ut erat. In hac habitasse platea dictumst. Quisque venenatis + turpis at dapibus posuere. Phasellus pulvinar velit id tellus luctus, ac + pharetra libero consequat. + + ), + }, + ]); + + return ; + }} + {/* jsx-to-string:end */} + + + + + , + }, + { + id: 'accordion', + title: 'Accordion', + render: () => , + }, + ]} + /> + + + + + + ); +}; + +export default AccordionPanelPage;