-
Notifications
You must be signed in to change notification settings - Fork 63
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(store-ui): Accordion molecule (#985)
* 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
1 parent
c9fad30
commit a35fb51
Showing
11 changed files
with
677 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
192 changes: 192 additions & 0 deletions
192
packages/store-ui/src/molecules/Accordion/Accordion.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
76
packages/store-ui/src/molecules/Accordion/AccordionButton.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
}) |
Oops, something went wrong.