Skip to content

Commit b2b9e34

Browse files
authored
Accessible Accordion Component (#358)
1 parent 8230c45 commit b2b9e34

File tree

15 files changed

+293
-161
lines changed

15 files changed

+293
-161
lines changed

src/components/ui/Accordion/Accordion.stories.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default {
77
component: Accordion,
88
render: (args) => <SandboxEditor>
99
<div >
10-
<div className='flex space-x-2'>
10+
<div className='flex space-x-2 w-full flex-1'>
1111
<Accordion {...args} />
1212

1313
</div>
@@ -22,6 +22,8 @@ export const All = {
2222
items: [
2323
{title: 'Section 1', content: 'Content for Section 1'},
2424
{title: 'Section 2', content: 'Content for Section 2'},
25+
{title: 'Section 3', content: 'Content for Section 3'},
26+
{title: 'Section 4', content: 'Content for Section 4'},
2527
// Add more items as needed
2628
],
2729
},

src/components/ui/Accordion/Accordion.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,16 @@ export type AccordionProps = {
1010
}
1111

1212
const Accordion = ({items} : AccordionProps) => {
13-
const [activeIndex, setActiveIndex] = useState<number>(-1);
14-
const handleClick = (index: number) => {
15-
setActiveIndex(activeIndex === index ? -1 : index);
16-
};
17-
1813
return (
1914
<AccordionRoot>
2015
{items.map((item, index) => (
21-
<AccordionItem value={index}>
16+
<AccordionItem value={index} key={index} >
2217
<AccordionHeader>
23-
<AccordionTrigger handleClick={handleClick} index={index} activeIndex={activeIndex} >
18+
<AccordionTrigger >
2419
Item {index+1}
2520
</AccordionTrigger>
2621
</AccordionHeader>
27-
<AccordionContent index={index} activeIndex={activeIndex}>
22+
<AccordionContent>
2823
{item.content}
2924
</AccordionContent>
3025
</AccordionItem>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {createContext} from 'react';
2+
3+
export const AccordionContext = createContext({});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {createContext} from 'react';
2+
3+
export const AccordionItemContext = createContext({});

src/components/ui/Accordion/shards/AccordionContent.tsx

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
1-
import React from 'react';
2-
// @ts-ignore
3-
import {customClassSwitcher} from '~/core';
1+
import React, {useContext} from 'react';
2+
import {AccordionContext} from '../contexts/AccordionContext';
3+
import {AccordionItemContext} from '../contexts/AccordionItemContext';
4+
45

56
type AccordionContentProps = {
67
children: React.ReactNode;
78
index: number,
89
activeIndex: number,
9-
customRootClass? :string
10+
className? :string
1011
};
1112

12-
const AccordionContent: React.FC<AccordionContentProps> = ({children, index, activeIndex, customRootClass}: AccordionContentProps) => {
13-
const rootClass = customClassSwitcher(customRootClass, 'Accordion');
13+
const AccordionContent: React.FC<AccordionContentProps> = ({children, index, activeIndex, className=''}: AccordionContentProps) => {
14+
const {activeItem, rootClass} = useContext(AccordionContext);
15+
16+
const {itemValue} = useContext(AccordionItemContext);
17+
1418
return (
15-
<span className={`${rootClass}-content`}>
16-
<div
17-
id={`content-${index}`}
18-
role="region"
19-
aria-labelledby={`section-${index}`}
20-
hidden={activeIndex !== index}
21-
>
22-
{children}
23-
</div>
24-
</span>
19+
<div
20+
className={`${rootClass}-content ${className}`}
21+
id={`content-${index}`}
22+
role="region"
23+
aria-labelledby={`section-${index}`}
24+
hidden={itemValue !== activeItem}>
25+
26+
{children}
27+
28+
</div>
2529
);
2630
};
2731

src/components/ui/Accordion/shards/AccordionHeader.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import React from 'react';
2-
// @ts-ignore
3-
import {customClassSwitcher} from '~/core';
2+
43

54
export type AccordionHeaderProps = {
65
children: React.ReactNode;
7-
customHeaderClass?: string;
6+
className?: string;
87
}
98

10-
const AccordionHeader: React.FC<AccordionHeaderProps> = ({children, customHeaderClass=''}) => {
11-
const rootClass = customClassSwitcher(customHeaderClass, 'Accordion');
9+
const AccordionHeader: React.FC<AccordionHeaderProps> = ({children, className=''}) => {
1210
return (
13-
<div className={`${rootClass}-header`}>
11+
<div className={className}>
1412
{children}
1513
</div>
1614
);

src/components/ui/Accordion/shards/AccordionItem.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,44 @@
1-
import React from 'react';
2-
// @ts-ignore
3-
import {customClassSwitcher} from '~/core';
1+
import React, {useState, useContext, useId, useEffect} from 'react';
2+
3+
import {AccordionContext} from '../contexts/AccordionContext';
4+
import {AccordionItemContext} from '../contexts/AccordionItemContext';
45

56
export type AccordionItemProps = {
67
children: React.ReactNode;
7-
customItemClass?: string;
8+
className?: string;
89
value?: number;
910
}
1011

11-
const AccordionItem: React.FC<AccordionItemProps> = ({children, value, customItemClass=''}) => {
12-
const rootClass = customClassSwitcher(customItemClass, 'Accordion');
12+
const AccordionItem: React.FC<AccordionItemProps> = ({children, value, className='', ...props}) => {
13+
const [itemValue, setItemValue] = useState(value);
14+
const {rootClass, activeItem} = useContext(AccordionContext);
15+
16+
const [isOpen, setIsOpen] = useState(false);
17+
useEffect(() => {
18+
if (itemValue === activeItem) {
19+
setIsOpen(true);
20+
} else {
21+
setIsOpen(false);
22+
}
23+
}
24+
, [itemValue, activeItem]);
25+
const id = useId();
26+
27+
1328
return (
14-
<div className={`${rootClass}-item`}>
15-
{children}
16-
</div>
29+
<AccordionItemContext.Provider value={{itemValue, setItemValue}}>
30+
<div
31+
className={`${rootClass}-item ${className}`} {...props}
32+
id={`accordion-data-item-${id}`}
33+
role="region"
34+
aria-labelledby={`accordion-trigger-${id}`}
35+
aria-hidden={!isOpen}
36+
data-state={isOpen ? 'open' : 'closed'}
37+
38+
>
39+
{children}
40+
</div>
41+
</AccordionItemContext.Provider>
1742
);
1843
};
1944

src/components/ui/Accordion/shards/AccordionRoot.tsx

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,70 @@
1-
import React from 'react';
2-
// @ts-ignore
1+
import React, {useState, useRef} from 'react';
2+
33
import {customClassSwitcher} from '~/core';
4+
import {AccordionContext} from '../contexts/AccordionContext';
5+
6+
const COMPONENT_NAME = 'Accordion';
47

58
export type AccordionRootProps = {
69
children: React.ReactNode;
710
customRootClass?: string;
811
}
912

1013
const AccordionRoot= ({children, customRootClass}: AccordionRootProps) => {
11-
const rootClass = customClassSwitcher(customRootClass, 'Accordion');
14+
const accordionRef = useRef(null);
15+
const [activeItem, setActiveItem] = useState(null);
16+
const [focusItem, setFocusItem] = useState(null);
17+
const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME);
18+
19+
20+
const getActiveItemId = () => {
21+
let elem = accordionRef?.current;
22+
// get children that have data-state open
23+
if (focusItem) {
24+
elem = focusItem;
25+
} else {
26+
elem = elem?.querySelector('[data-state="open"]');
27+
}
28+
29+
return elem;
30+
};
31+
32+
const focusNextItem = () => {
33+
const elem = getActiveItemId();
34+
const nextElem = elem.nextElementSibling;
35+
setFocusItem(nextElem);
36+
// get button
37+
const button = nextElem.querySelector('button');
38+
// focus button
39+
button?.focus();
40+
};
41+
const focusPrevItem = () => {
42+
const elem = getActiveItemId();
43+
const prevElem = elem.previousElementSibling;
44+
setFocusItem(prevElem);
45+
// get button
46+
const button = prevElem.querySelector('button');
47+
// focus button
48+
button?.focus();
49+
};
50+
1251
return (
13-
<span className={`${rootClass}-root`}>
14-
{children}
15-
</span>
52+
<AccordionContext.Provider
53+
value={{
54+
rootClass: rootClass,
55+
activeItem,
56+
setActiveItem,
57+
focusNextItem,
58+
focusPrevItem,
59+
focusItem,
60+
setFocusItem,
61+
62+
}}>
63+
<div className={`${rootClass}-root`} ref={accordionRef} >
64+
{children}
65+
</div>
66+
</AccordionContext.Provider>
67+
1668
);
1769
};
1870

src/components/ui/Accordion/shards/AccordionTrigger.tsx

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,44 @@
1-
import React from 'react';
2-
// @ts-ignore
3-
import {customClassSwitcher} from '~/core';
1+
import React, {useContext, useState} from 'react';
2+
import {AccordionContext} from '../contexts/AccordionContext';
3+
import {AccordionItemContext} from '../contexts/AccordionItemContext';
4+
45

56
type AccordionTriggerProps = {
67
children: React.ReactNode;
7-
customRootClass?: string,
8+
className?: string,
89
index: number,
910
activeIndex: number,
1011
handleClick: (index: number) => void
1112
};
1213

13-
const AccordionTrigger: React.FC<AccordionTriggerProps> = ({children, handleClick, index, activeIndex, customRootClass}) => {
14-
const rootClass = customClassSwitcher(customRootClass, 'Accordion');
14+
const AccordionTrigger: React.FC<AccordionTriggerProps> = ({children, index, activeIndex, className=''}) => {
15+
const {setActiveItem, rootClass, focusNextItem, focusPrevItem, activeItem} = useContext(AccordionContext);
16+
17+
const {itemValue} = useContext(AccordionItemContext);
18+
console.log(activeItem, itemValue);
19+
1520
return (
16-
<span className={`${rootClass}-trigger`}>
1721

18-
<button
19-
onClick={() => handleClick(index)}
20-
aria-expanded={activeIndex === index}
21-
aria-controls={`content-${index}`}
22-
>
23-
{children}
24-
</button>
22+
<button
23+
className={`${rootClass}-trigger ${className}`}
24+
onKeyDown={(e) => {
25+
if (e.key === 'ArrowDown') {
26+
focusNextItem();
27+
}
28+
if (e.key === 'ArrowUp') {
29+
focusPrevItem();
30+
}
31+
}}
32+
onClick={() => {
33+
setActiveItem(itemValue);
34+
}}
35+
aria-expanded={activeItem === itemValue}
36+
aria-controls={`content-${index}`}
37+
>
38+
{children}
39+
</button>
40+
2541

26-
</span>
2742
);
2843
};
2944

src/components/ui/Progress/shards/ProgressIndicator.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,16 @@ export default function ProgressIndicator({
2626
}
2727

2828

29-
return (
30-
<div
31-
role="progressbar"
32-
className={`${rootClass}-indicator`}
33-
style={{ transform: `translateX(-${maxValue - value}%)` }}
34-
aria-valuenow={value}
35-
aria-valuemax={maxValue}
36-
aria-valuemin={minValue}
37-
>
38-
{renderLabel?.(value)}
39-
</div>
40-
)
41-
29+
return (
30+
<div
31+
role="progressbar"
32+
className={`${rootClass}-indicator`}
33+
style={{transform: `translateX(-${maxValue - value}%)`}}
34+
aria-valuenow={value}
35+
aria-valuemax={maxValue}
36+
aria-valuemin={minValue}
37+
>
38+
{renderLabel?.(value)}
39+
</div>
40+
);
4241
}

0 commit comments

Comments
 (0)