Skip to content

Commit a62eb2b

Browse files
committed
Adds the accordion component based on the 'Gesso for Drupal' version
1 parent b00bbb1 commit a62eb2b

File tree

9 files changed

+633
-1
lines changed

9 files changed

+633
-1
lines changed

source/00-config/constants.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
11
const MAIN_ID = 'main';
22

3-
export { MAIN_ID };
3+
const KEYCODE = {
4+
TAB: 'Tab',
5+
RETURN: 'Enter',
6+
ESC: 'Escape',
7+
SPACE: 'Space',
8+
PAGEUP: 'PageUp',
9+
PAGEDOWN: 'PageDown',
10+
END: 'End',
11+
HOME: 'Home',
12+
LEFT: 'ArrowLeft',
13+
UP: 'ArrowUp',
14+
RIGHT: 'ArrowRight',
15+
DOWN: 'ArrowDown',
16+
};
17+
18+
export { MAIN_ID, KEYCODE };
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Meta, StoryObj } from '@storybook/react';
2+
import parse from 'html-react-parser';
3+
import AccordionComponent from './Accordion';
4+
import accordionArgs from './accordion.yml';
5+
6+
const meta: Meta<typeof AccordionComponent> = {
7+
title: 'Components/Accordion',
8+
component: AccordionComponent,
9+
tags: ['autodocs'],
10+
};
11+
12+
type Story = StoryObj<typeof AccordionComponent>;
13+
14+
accordionArgs.accordionItems = accordionArgs.accordionItems.map(item => {
15+
item.content = parse(item.content);
16+
return item;
17+
});
18+
19+
const Accordion: Story = {
20+
render: args => <AccordionComponent {...args} />,
21+
args: {
22+
...accordionArgs,
23+
},
24+
};
25+
26+
export default meta;
27+
export { Accordion };
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import clsx from 'clsx';
2+
import { GessoComponent } from 'gesso';
3+
import { useEffect, useId, useRef } from 'react';
4+
import styles from './accordion.module.css';
5+
import stylesAccordionItem from './accordion-item.module.css';
6+
import getCssVar from '../../06-utility/getCssVar';
7+
import { slideCollapse, slideExpand } from '../../06-utility/slide';
8+
import { KEYCODE } from '../../00-config/constants';
9+
import AccordionItem, { AccordionItemProps } from './AccordionItem';
10+
11+
interface AccordionProps extends GessoComponent {
12+
accordionItems?: AccordionItemProps[];
13+
allowMultiple?: boolean;
14+
allowToggle?: boolean;
15+
}
16+
17+
function Accordion({
18+
accordionItems,
19+
allowMultiple,
20+
allowToggle,
21+
modifierClasses,
22+
}: AccordionProps): JSX.Element {
23+
const accordionId = useId();
24+
const accordionRef = useRef(null);
25+
26+
useEffect(() => {
27+
const ACCORDION_TOGGLE_CLASS = stylesAccordionItem.toggle;
28+
const ACCORDION_SPEED = getCssVar('duration-standard');
29+
30+
const accordion = document.getElementById(accordionId);
31+
const multipleAllowed = allowMultiple;
32+
const toggleAllowed = allowMultiple ? true : allowToggle;
33+
34+
const openAccordion = (button: Element | HTMLElement | null) => {
35+
if (button && button.getAttribute('aria-expanded') === 'false') {
36+
button.setAttribute('aria-expanded', 'true');
37+
const accordionSectionId = button.getAttribute(
38+
'aria-controls',
39+
) as string;
40+
const accordionSection = document.getElementById(accordionSectionId);
41+
42+
if (accordionSection) {
43+
accordionSection.setAttribute('aria-expanded', 'true');
44+
slideExpand(accordionSection, ACCORDION_SPEED);
45+
}
46+
}
47+
};
48+
49+
const closeAccordion = (button: Element | HTMLElement | null) => {
50+
if (button && button.getAttribute('aria-expanded') === 'true') {
51+
button.setAttribute('aria-expanded', 'false');
52+
const accordionSectionId = button.getAttribute(
53+
'aria-controls',
54+
) as string;
55+
const accordionSection = document.getElementById(accordionSectionId);
56+
57+
if (accordionSection) {
58+
accordionSection.setAttribute('aria-expanded', 'false');
59+
slideCollapse(accordionSection, ACCORDION_SPEED);
60+
}
61+
}
62+
};
63+
64+
const handleClick = (event: MouseEvent) => {
65+
const currentTarget = event.target as HTMLElement;
66+
67+
// Set target differently depending on click vs. keydown
68+
// because the <span> inside <button> screws things up
69+
if (
70+
currentTarget &&
71+
(currentTarget.tagName === 'BUTTON' ||
72+
(currentTarget.parentElement &&
73+
currentTarget.parentElement.tagName === 'BUTTON'))
74+
) {
75+
let target;
76+
// Set target based on click or keydown
77+
if (currentTarget.tagName === 'BUTTON') {
78+
target = currentTarget;
79+
} else {
80+
target = currentTarget.parentElement;
81+
}
82+
// Check if the current toggle is expanded.
83+
const isExpanded = target
84+
? target.getAttribute('aria-expanded') === 'true'
85+
: false;
86+
const active = accordion
87+
? accordion.querySelector('[aria-expanded="true"]')
88+
: null;
89+
90+
// without allowMultiple, close the open accordion
91+
if (!multipleAllowed && active && active !== target) {
92+
closeAccordion(active);
93+
}
94+
95+
if (!isExpanded) {
96+
openAccordion(target);
97+
} else if (toggleAllowed && isExpanded) {
98+
closeAccordion(target);
99+
}
100+
101+
event.preventDefault();
102+
}
103+
};
104+
105+
const handleKeydown = (event: KeyboardEvent) => {
106+
const currentTarget = event.target as HTMLElement;
107+
108+
// Create the array of toggle elements for the accordion group
109+
const triggers = Array.prototype.slice.call(
110+
accordion
111+
? accordion.querySelectorAll(`.${ACCORDION_TOGGLE_CLASS}`)
112+
: [],
113+
);
114+
115+
// Is this coming from an accordion header?
116+
if (currentTarget.tagName === 'BUTTON') {
117+
// Up/ Down arrow and Control + Page Up/ Page Down keyboard operations
118+
if (
119+
event.code === KEYCODE.UP ||
120+
event.code === KEYCODE.DOWN ||
121+
event.code === KEYCODE.PAGEDOWN ||
122+
event.code === KEYCODE.UP
123+
) {
124+
const index = triggers.indexOf(currentTarget);
125+
let direction;
126+
if (event.code === KEYCODE.DOWN || event.code === KEYCODE.PAGEDOWN) {
127+
direction = 1;
128+
} else {
129+
direction = -1;
130+
}
131+
const triggerLength = triggers.length;
132+
const newIndex = (index + triggerLength + direction) % triggerLength;
133+
triggers[newIndex].focus();
134+
event.preventDefault();
135+
} else if (event.code === KEYCODE.HOME || event.code === KEYCODE.END) {
136+
switch (event.code) {
137+
// Go to first accordion
138+
case KEYCODE.HOME:
139+
triggers[0].focus();
140+
break;
141+
// Go to last accordion
142+
case KEYCODE.END:
143+
triggers[triggers.length - 1].focus();
144+
break;
145+
default:
146+
triggers[0].focus();
147+
break;
148+
}
149+
event.preventDefault();
150+
}
151+
}
152+
};
153+
154+
// Initiate accordions on page load
155+
if (accordion) {
156+
accordion.addEventListener('click', handleClick);
157+
accordion.addEventListener('keydown', handleKeydown);
158+
159+
const accordionItems = accordion.querySelectorAll(
160+
`.${stylesAccordionItem.accordionItem}`,
161+
);
162+
accordionItems.forEach(item => {
163+
const toggle = item.querySelector(`.${ACCORDION_TOGGLE_CLASS}`);
164+
console.log(toggle);
165+
// Close all accordion items that are not 'default-open'
166+
if (
167+
!item.hasAttribute('data-accordion-open') ||
168+
item.getAttribute('data-accordion-open') === 'false'
169+
) {
170+
closeAccordion(toggle);
171+
}
172+
// Update toggle tabindex
173+
if (toggle) {
174+
toggle.removeAttribute('tabindex');
175+
}
176+
// Add attribute 'processed'
177+
item.setAttribute('data-accordion-processed', '');
178+
});
179+
}
180+
}, [accordionId, allowMultiple, allowToggle]);
181+
182+
return (
183+
<>
184+
{accordionItems && (
185+
<div
186+
ref={accordionRef}
187+
className={clsx(styles.accordion, modifierClasses)}
188+
id={accordionId}
189+
>
190+
<div className={styles.content}>
191+
{accordionItems.map((item, index) => {
192+
return <AccordionItem key={index} {...item} />;
193+
})}
194+
</div>
195+
</div>
196+
)}
197+
</>
198+
);
199+
}
200+
201+
export default Accordion;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import clsx from 'clsx';
2+
import { GessoComponent } from 'gesso';
3+
import { ElementType, useId } from 'react';
4+
import styles from './accordion-item.module.css';
5+
6+
export interface AccordionItemProps extends GessoComponent {
7+
title: string;
8+
content: string;
9+
titleElement?: ElementType;
10+
isOpen?: boolean;
11+
}
12+
13+
function AccordionItem({
14+
title,
15+
content,
16+
titleElement: TitleElement = 'h3',
17+
isOpen,
18+
modifierClasses,
19+
}: AccordionItemProps): JSX.Element {
20+
const itemId = useId();
21+
22+
const sectionId = `accordion-section-${itemId}`;
23+
const buttonId = `accordion-button-${itemId}`;
24+
25+
return (
26+
<div
27+
className={clsx(styles.accordionItem, modifierClasses)}
28+
data-accordion-open={isOpen}
29+
>
30+
<div className={styles.panel}>
31+
<TitleElement className={styles.heading}>
32+
<button
33+
className={styles.toggle}
34+
id={buttonId}
35+
aria-expanded="true"
36+
aria-controls={sectionId}
37+
tabIndex={-1}
38+
>
39+
{title}
40+
<span className={styles.icon}></span>
41+
</button>
42+
</TitleElement>
43+
<div
44+
className={styles.drawer}
45+
id={sectionId}
46+
aria-labelledby={buttonId}
47+
aria-expanded="true"
48+
>
49+
<div className={styles.drawerInner}>{content}</div>
50+
</div>
51+
</div>
52+
</div>
53+
);
54+
}
55+
56+
export default AccordionItem;

0 commit comments

Comments
 (0)