Skip to content

Commit

Permalink
feat(store-ui): Accordion molecule (#985)
Browse files Browse the repository at this point in the history
* Add accordion molecule

* Fix imports

* Remove Reach and start own accordion

* Add test and css

* Add index through clone

* Use Iterable instead of array

* Add more tests

* Add suggestions from code review

* Change spreading name from props to otherProps

* Fix typo

* Fix code that is displayed on storybook docs
  • Loading branch information
lariciamota authored Oct 14, 2021
1 parent c9fad30 commit a35fb51
Show file tree
Hide file tree
Showing 11 changed files with 677 additions and 0 deletions.
12 changes: 12 additions & 0 deletions packages/store-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ export type { IconButtonProps } from './molecules/IconButton'
export { default as Modal } from './molecules/Modal'
export type { ModalProps } from './molecules/Modal'

export { default as Accordion } from './molecules/Accordion'
export type { AccordionProps } from './molecules/Accordion'

export { AccordionItem } from './molecules/Accordion'
export type { AccordionItemProps } from './molecules/Accordion'

export { AccordionButton } from './molecules/Accordion'
export type { AccordionButtonProps } from './molecules/Accordion'

export { AccordionPanel } from './molecules/Accordion'
export type { AccordionPanelProps } from './molecules/Accordion'

// Hooks
export { default as useSlider } from './hooks/useSlider'
export type {
Expand Down
192 changes: 192 additions & 0 deletions packages/store-ui/src/molecules/Accordion/Accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { render, fireEvent, cleanup } from '@testing-library/react'
import { axe } from 'jest-axe'
import React, { useState } from 'react'

import { AccordionButton, AccordionItem, AccordionPanel } from '.'
import Accordion from './Accordion'

const TestAccordion = () => {
const [indices, setIndices] = useState<number[]>([])
const onChange = (index: number) => {
if (indices.includes(index)) {
setIndices(indices.filter((currentIndex) => currentIndex !== index))
} else {
setIndices([...indices, index])
}
}

return (
<Accordion
aria-label="test accordion"
indices={indices}
onChange={onChange}
>
<AccordionItem>
<AccordionButton>Clothing</AccordionButton>
<AccordionPanel>
<ul>
<li>
<a href="/">Shorts</a>
</li>
<li>
<a href="/">Sweatshirt</a>
</li>
<li>
<a href="/">Tank tops</a>
</li>
</ul>
</AccordionPanel>
</AccordionItem>

<AccordionItem>
<AccordionButton>Sale</AccordionButton>
<AccordionPanel>
<ul>
<li>
<a href="/">Smartphones</a>
</li>
<li>
<a href="/">TVs</a>
</li>
</ul>
</AccordionPanel>
</AccordionItem>
</Accordion>
)
}

describe('Accordion', () => {
let accordion: HTMLElement
let items: HTMLElement[]
let buttons: HTMLElement[]
let panels: HTMLElement[]

beforeEach(() => {
const { getByTestId, getAllByTestId } = render(<TestAccordion />)

accordion = getByTestId('store-accordion')
items = getAllByTestId('store-accordion-item')
buttons = getAllByTestId('store-accordion-button')
panels = getAllByTestId('store-accordion-panel')
})

afterEach(cleanup)

it('should show panel specified by `indices`', () => {
const { getAllByTestId } = render(
<Accordion indices={[1]} onChange={() => {}}>
<AccordionItem>
<AccordionButton />
<AccordionPanel testId="store-accordion-panel-mock" />
</AccordionItem>
<AccordionItem>
<AccordionButton />
<AccordionPanel testId="store-accordion-panel-mock" />
</AccordionItem>
</Accordion>
)

const panelsMock = getAllByTestId('store-accordion-panel-mock')

expect(panelsMock[0]).not.toBeVisible()
expect(panelsMock[1]).toBeVisible()
})

describe('Data attributes', () => {
it('`Accordion` component should have `data-store-accordion` attribute', () => {
expect(accordion).toHaveAttribute('data-store-accordion')
})

it('`AccordionItem` component should have `data-store-accordion-item` attribute', () => {
for (const item of items) {
expect(item).toHaveAttribute('data-store-accordion-item')
}
})

it('`AccordionButton` component should have `data-store-accordion-button` attribute', () => {
for (const button of buttons) {
expect(button).toHaveAttribute('data-store-accordion-button')
}
})

it('`AccordionPanel` component should have `data-store-accordion-panel` attribute', () => {
for (const panel of panels) {
expect(panel).toHaveAttribute('data-store-accordion-panel')
}
})
})

describe('User actions', () => {
it('clicking item should call `onChange` function', () => {
const mockOnChange = jest.fn()
const { getByTestId } = render(
<Accordion onChange={mockOnChange} indices={[]}>
<AccordionItem>
<AccordionButton testId="store-accordion-button-mock" />
</AccordionItem>
</Accordion>
)

const button = getByTestId('store-accordion-button-mock')

fireEvent.click(button)
expect(mockOnChange).toHaveBeenCalledTimes(1)
})

it('should move focus to the next focusable button on `ArrowDown` press', () => {
buttons[1].focus()
expect(buttons[1]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
expect(buttons[0]).toHaveFocus()
})

it('should move focus to the previous focusable button on `ArrowUp` press', () => {
buttons[1].focus()
expect(buttons[1]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(buttons[0]).toHaveFocus()
})
})

describe('Accessibility', () => {
// WAI-ARIA tests
// https://www.w3.org/TR/wai-aria-practices-1.2/#accordion
it('should not have violations', async () => {
expect(await axe(document.body)).toHaveNoViolations()

// Open a panel and check again
fireEvent.click(buttons[0])
expect(await axe(document.body)).toHaveNoViolations()
})

it('`role` should be set to `region` for panel elements', () => {
for (const panel of panels) {
expect(panel).toHaveAttribute('role', 'region')
}
})

it('`aria-labelledby` for panel elements should point to the corresponding button', () => {
panels.forEach((panel, index) => {
expect(panel).toHaveAttribute(
'aria-labelledby',
buttons[index].getAttribute('id')
)
})
})

it('`aria-controls` for button elements should point to the corresponding panel', () => {
buttons.forEach((button, index) => {
expect(button).toHaveAttribute(
'aria-controls',
panels[index].getAttribute('id')
)
})
})

it('`aria-expanded` should be true only for active button', () => {
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(buttons[0])
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true')
})
})
})
76 changes: 76 additions & 0 deletions packages/store-ui/src/molecules/Accordion/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { HTMLAttributes, ReactElement } from 'react'
import React, {
useContext,
cloneElement,
forwardRef,
createContext,
} from 'react'

interface AccordionContext {
indices: Set<number>
onChange: (index: number) => void
numberOfItems: number
}

const AccordionContext = createContext<AccordionContext | undefined>(undefined)

export interface AccordionProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
/**
* ID to find this component in testing tools (e.g.: cypress,
* testing-library, and jest).
*/
testId?: string
/**
* Indices that indicate which accordion items are opened.
*/
indices: Iterable<number>
/**
* Function that is triggered when an accordion item is opened/closed.
*/
onChange: (index: number) => void
}

const Accordion = forwardRef<HTMLDivElement, AccordionProps>(function Accordion(
{ testId = 'store-accordion', indices, onChange, children, ...otherProps },
ref
) {
const childrenWithIndex = React.Children.map(
children as ReactElement,
(child, index) => cloneElement(child, { index: child.props.index ?? index })
)

const context = {
indices: new Set(indices),
onChange,
numberOfItems: childrenWithIndex.length,
}

return (
<AccordionContext.Provider value={context}>
<div
ref={ref}
data-store-accordion
data-testid={testId}
role="region"
{...otherProps}
>
{childrenWithIndex}
</div>
</AccordionContext.Provider>
)
})

export function useAccordion() {
const context = useContext(AccordionContext)

if (context === undefined) {
throw new Error(
'Do not use Accordion components outside the Accordion context.'
)
}

return context
}

export default Accordion
76 changes: 76 additions & 0 deletions packages/store-ui/src/molecules/Accordion/AccordionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { ButtonHTMLAttributes } from 'react'
import React, { forwardRef } from 'react'

import { Button } from '../..'
import { useAccordion } from './Accordion'
import { useAccordionItem } from './AccordionItem'

export interface AccordionButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* ID to find this component in testing tools (e.g.: cypress, testing library, and jest).
*/
testId?: string
}

export const AccordionButton = forwardRef<
HTMLButtonElement,
AccordionButtonProps
>(function AccordionButton(
{ testId = 'store-accordion-button', children, ...otherProps },
ref
) {
const { indices, onChange, numberOfItems } = useAccordion()
const { index, panel, button } = useAccordionItem()

const onKeyDown = (event: React.KeyboardEvent) => {
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) {
return
}

const getNext = () => {
const next = Number(index) + 1 === numberOfItems ? 0 : Number(index) + 1

return document.getElementById(`button--${next}`)
}

const getPrevious = () => {
const previous =
Number(index) - 1 < 0 ? numberOfItems - 1 : Number(index) - 1

return document.getElementById(`button--${previous}`)
}

switch (event.key) {
case 'ArrowDown':
event.preventDefault()
getNext()?.focus()
break

case 'ArrowUp':
event.preventDefault()
getPrevious()?.focus()
break

default:
}
}

return (
<Button
ref={ref}
id={button}
aria-expanded={indices.has(index)}
aria-controls={panel}
data-store-accordion-button
data-testid={testId}
onKeyDown={onKeyDown}
onClick={() => {
onChange(index)
}}
{...otherProps}
>
{children}
</Button>
)
})
Loading

0 comments on commit a35fb51

Please sign in to comment.