diff --git a/lost-pixel/baseline/components-accordion--accordion.png b/lost-pixel/baseline/components-accordion--accordion.png new file mode 100644 index 00000000..4e644df3 Binary files /dev/null and b/lost-pixel/baseline/components-accordion--accordion.png differ diff --git a/src/components/Accordion/Accordion.minors.tsx b/src/components/Accordion/Accordion.minors.tsx new file mode 100644 index 00000000..3e340fd7 --- /dev/null +++ b/src/components/Accordion/Accordion.minors.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; + +import { + Content, + ChevronUp, + CircleWarningIcon, + HeaderContainer, + StyledChevronDown, + Subcopy, + Title, + TitleContainer, + TriggerContainer, + Wrapper, +} from './Accordion.style'; +import { MinorComponent } from '../../utils'; +import { AccordionItemProps } from './Accordion.types'; +import { Focus } from '../../utils'; + +export interface Minors { + Item: MinorComponent; +} + +export function Item({ + children, + title, + format, + index, + allowMultiple, + defaultActive, + setActiveIndex, + itemActive, + subcopy = '', + icon, + errorIcon, + iconToTriggerOpen, + iconToTriggerClose, + hasError = false, + disabled = false, +}: AccordionItemProps) { + const [active, setActive] = useState(defaultActive); + const isActive = allowMultiple + ? active && !disabled + : itemActive && !disabled; + + return ( + + { + 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`} + > + + {hasError && errorIcon && errorIcon} + {hasError && !errorIcon && } + {!hasError && icon && icon} + + {title} + {subcopy && ( + + {subcopy} + + )} + + + {isActive ? ( + iconToTriggerClose ? ( + iconToTriggerClose + ) : ( + + ) + ) : iconToTriggerOpen ? ( + iconToTriggerOpen + ) : ( + + )} + + + {isActive && ( + + {children} + + )} + + ); +} diff --git a/src/components/Accordion/Accordion.story.tsx b/src/components/Accordion/Accordion.story.tsx new file mode 100644 index 00000000..0477ce0d --- /dev/null +++ b/src/components/Accordion/Accordion.story.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Story } from '@storybook/react'; + +import { Accordion } from './Accordion'; +import { Props } from './Accordion.types'; +import styled, { css } from 'styled-components'; +import { + FlagFilled, + Fullscreen, + FullscreenOff, + Gear, +} from '../../icons'; +import { rem } from 'polished'; +import { core } from '../../tokens'; +import { Layout } from '../../storybook'; +import { Input } from '../../components/inputs/Input/Input'; + +export default { + title: 'components/Accordion', + component: Accordion, +}; + +const Template: Story = (args) => { + return ( + + + + + + + + + + Accordion content + + + Accordion content + + } + > + Accordion content + + } + iconToTriggerClose={} + > + Accordion content + + } + > + Accordion content + + + + ); +}; + +const InputContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${rem(20)}; + padding: ${rem(20)} ${rem(10)}; +`; + +const iconStyle = css` + width: ${rem(22)}; + path { + fill: ${core.color.text.primary}; + } +`; + +const GearIcon = styled(Gear)` + ${iconStyle} + margin-right: ${rem(10)}; +`; + +const FullscreenIcon = styled(Fullscreen)` + ${iconStyle} +`; + +const FullscreenOffIcon = styled(FullscreenOff)` + ${iconStyle} +`; + +const FlagFilledIcon = styled(FlagFilled)` + width: ${rem(22)}; + margin-right: ${rem(10)}; + path { + fill: ${core.color.status.negative}; + } +`; + +export const Controls = Template.bind({}); +Controls.storyName = 'Accordion'; diff --git a/src/components/Accordion/Accordion.style.ts b/src/components/Accordion/Accordion.style.ts new file mode 100644 index 00000000..40944e9c --- /dev/null +++ b/src/components/Accordion/Accordion.style.ts @@ -0,0 +1,160 @@ +import { rem } from 'polished'; +import styled, { css, keyframes } from 'styled-components'; + +import { ChevronDown, CircleWarning } from '../../icons'; +import { Header } 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) + : '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)}` + : '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%; + position: relative; + &:hover { + background-color: ${({ theme, format }) => + format === 'basic' + ? theme.name === 'dark' + ? grayscale(800) + : grayscale(50) + : 'none'}; + outline: ${({ active, theme, format }) => + !active && format === 'secondary' + ? theme.name === 'dark' + ? `${rem(1)} solid ${grayscale(800)}` + : `${rem(1)} solid ${grayscale(50)}` + : 'none'}; + } + + &:focus { + outline: none; + } +`; + +export const HeaderContainer = styled.div` + display: flex; + margin-right: ${rem(10)}; +`; + +export const TitleContainer = styled.div` + display: flex; + flex-direction: column; + text-align: left; + gap: ${rem(4)}; +`; + +export const Title = styled(Header)` + margin-bottom: ${rem(0)}; +`; + +export const Subcopy = styled(Header)` + margin-bottom: -${rem(0.2)}; +`; + +export const CircleWarningIcon = styled(CircleWarning)` + width: ${rem(22)}; + margin-right: ${rem(10)}; + path { + fill: ${core.color.status.negative}; + } +`; + +const rotateUp = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(-180deg); + } +`; + +const rotateDown = keyframes` + from { + transform: rotate(-180deg); + } + to { + transform: rotate(0deg); + } +`; + +export const StyledChevronDown = styled(ChevronDown)` + animation: ${rotateDown} 120ms ease-in-out both; + path { + fill: ${core.color.text.primary}; + } +`; + +export const ChevronUp = styled(StyledChevronDown)` + animation: ${rotateUp} 120ms ease-in-out both; +`; + +const fadeAndExpand = keyframes` + from { + transform: translateY(-50%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +`; + +export const Content = styled.div<{ active: boolean }>` + padding: ${rem(0)} ${rem(15)} ${rem(20)} ${rem(18)}; + max-height: ${({ active }) => (active ? '100%' : '0')}; + overflow: hidden; + transform: translateY(-50%); + opacity: 0; + ${({ active }) => + active && + css` + animation: ${fadeAndExpand} 150ms ease-in-out both; + opacity: 1; + `}; +`; diff --git a/src/components/Accordion/Accordion.test.tsx b/src/components/Accordion/Accordion.test.tsx new file mode 100644 index 00000000..cc99b653 --- /dev/null +++ b/src/components/Accordion/Accordion.test.tsx @@ -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( + + + + + + ); + + const accordion = screen.getByRole('button'); + expect(accordion).toBeInTheDocument(); + }); + + it('triggers and expands accordion item when clicking the header', () => { + render( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + +
Child component
+
+
+
+ ); + + 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( + + + +
Child component
+
+
+
+ ); + + const accordion = screen.getByRole('button'); + userEvent.click(accordion).then(() => { + const childComponentContent = + screen.getByText('Child component'); + expect(childComponentContent).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 00000000..16663220 --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -0,0 +1,42 @@ +import React, { cloneElement, useState } from 'react'; + +import { withIris } from '../../utils'; +import { Item, Minors } from './Accordion.minors'; +import { Props } from './Accordion.types'; +import { AccordionStyled } from './Accordion.style'; + +export const Accordion = withIris( + AccordionComponent +); + +Accordion.Item = Item; + +function AccordionComponent({ + children, + defaultIndex, + allowMultiple = true, + format = 'basic', +}: Props) { + const [activeIndex, setActiveIndex] = useState(defaultIndex); + + function childClone(child, i) { + return cloneElement(child, { + format, + index: i, + allowMultiple, + defaultActive: defaultIndex === i, + setActiveIndex: ({ index }) => { + setActiveIndex(index); + }, + itemActive: !allowMultiple && activeIndex === i, + }); + } + + return ( + + {children.length > 1 + ? children.map(childClone) + : childClone(children, 0)} + + ); +} diff --git a/src/components/Accordion/Accordion.types.ts b/src/components/Accordion/Accordion.types.ts new file mode 100644 index 00000000..34a76d9d --- /dev/null +++ b/src/components/Accordion/Accordion.types.ts @@ -0,0 +1,75 @@ +import React, { ReactNode } from 'react'; +import { IrisProps } from '../../utils'; + +export type Props = IrisProps< + { + children: any; + /** + * Whether the accordion will permit multiple accordion items to be expanded at once + * + * [default = true] + */ + allowMultiple?: boolean; + /** + * Index of the accordion item that should start as open/active + */ + defaultIndex?: number; + /** + * [default = 'basic'] + */ + format?: 'basic' | 'secondary'; + }, + HTMLDivElement +>; + +export type AccordionItemProps = IrisProps<{ + children: React.ReactElement; + format: 'basic' | 'secondary'; + index: number; + allowMultiple: boolean; + defaultActive: boolean; + setActiveIndex: ({ index }) => void; + itemActive: boolean; + /** + * Header title + */ + title: string; + /** + * Optional header subcopy + */ + subcopy?: string; + /** + * Optional header icon + */ + icon?: ReactNode; + /** + * Optional icon to be displayed when `hasError` is true + * + * [default = `CircleWarning`] + */ + errorIcon?: ReactNode; + /** + * Optional icon to open accordion item + * + * [default = `ChevronDown`] + */ + iconToTriggerOpen?: ReactNode; + /** + * Optional icon to close accordion item + * + * [default = `ChevronUp`] + */ + iconToTriggerClose?: ReactNode; + /** + * Whether to show the accordion item's error state + * + * [default = false] + */ + hasError?: boolean; + /** + * Whether to show the accordion item's disabled state + * + * [default = false] + */ + disabled?: boolean; +}>;