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

Feature/accordion molecule #985

Merged
merged 11 commits into from
Oct 14, 2021
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)
lariciamota marked this conversation as resolved.
Show resolved Hide resolved
}}
{...otherProps}
>
{children}
</Button>
)
})
Loading