Skip to content

Commit de50178

Browse files
authored
Merge pull request #57 from forumone/feature-component-accordion
Adds the accordion component based on the 'Gesso for Drupal' version
2 parents b00bbb1 + caa1ecd commit de50178

File tree

9 files changed

+572
-1
lines changed

9 files changed

+572
-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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import clsx from 'clsx';
2+
import { GessoComponent } from 'gesso';
3+
import { KeyboardEvent, createRef, useId, useMemo, useState } from 'react';
4+
import styles from './accordion.module.css';
5+
import getCssVar from '../../06-utility/getCssVar';
6+
import { KEYCODE } from '../../00-config/constants';
7+
import AccordionItem, { AccordionItemProps } from './AccordionItem';
8+
9+
interface AccordionProps extends GessoComponent {
10+
accordionItems: AccordionItemProps[];
11+
accordionSpeed?: string;
12+
allowMultiple?: boolean;
13+
allowToggle?: boolean;
14+
}
15+
16+
function Accordion({
17+
accordionItems,
18+
accordionSpeed = getCssVar('duration-standard'),
19+
allowMultiple,
20+
allowToggle,
21+
modifierClasses,
22+
}: AccordionProps): JSX.Element {
23+
const accordionId = useId();
24+
const [accordionItemsStatus, setAccordionItemsStatus] = useState(
25+
accordionItems.map((item, index) => ({
26+
...item,
27+
id: `${accordionId}-${index}`,
28+
})),
29+
);
30+
const accordionItemRefs = useMemo(() => {
31+
const refs: { [key: string]: React.RefObject<HTMLButtonElement> } = {};
32+
accordionItemsStatus.forEach(item => (refs[item.id] = createRef()));
33+
return refs;
34+
}, [accordionItemsStatus]);
35+
36+
const openAccordionItem = (items: AccordionItemProps[], index: number) => {
37+
return items.with(index, {
38+
...items[index],
39+
isOpen: true,
40+
});
41+
};
42+
43+
const closeAccordionItem = (items: AccordionItemProps[], index: number) => {
44+
return items.with(index, {
45+
...items[index],
46+
isOpen: false,
47+
});
48+
};
49+
50+
const handleClick = (id: string, isOpen = false) => {
51+
const toggleAllowed = allowMultiple ? true : allowToggle;
52+
const active = accordionItemsStatus.findIndex(item => item.isOpen);
53+
const itemIndex = accordionItemsStatus.findIndex(item => item.id === id);
54+
let itemStatusUpdated = [...accordionItemsStatus];
55+
56+
// Without allowMultiple, close the open accordion
57+
if (!allowMultiple && active !== -1 && active !== itemIndex) {
58+
itemStatusUpdated = closeAccordionItem(itemStatusUpdated, active);
59+
}
60+
61+
if (!isOpen) {
62+
itemStatusUpdated = openAccordionItem(itemStatusUpdated, itemIndex);
63+
} else if (toggleAllowed && isOpen) {
64+
itemStatusUpdated = closeAccordionItem(itemStatusUpdated, itemIndex);
65+
}
66+
67+
return setAccordionItemsStatus(itemStatusUpdated);
68+
};
69+
70+
const handleKeydown = (event: KeyboardEvent) => {
71+
const currentTarget = event.target as HTMLButtonElement;
72+
73+
// Create the array of toggle elements for the accordion group
74+
const triggers = Object.values(accordionItemRefs).map(ref => ref.current);
75+
76+
// Is this coming from an accordion header?
77+
if (triggers && currentTarget.tagName === 'BUTTON') {
78+
// Up/ Down arrow and Control + Page Up/ Page Down keyboard operations
79+
if (
80+
event.code === KEYCODE.UP ||
81+
event.code === KEYCODE.DOWN ||
82+
event.code === KEYCODE.PAGEUP ||
83+
event.code === KEYCODE.PAGEDOWN
84+
) {
85+
const index = triggers.indexOf(currentTarget);
86+
let direction;
87+
if (event.code === KEYCODE.DOWN || event.code === KEYCODE.PAGEDOWN) {
88+
direction = 1;
89+
} else {
90+
direction = -1;
91+
}
92+
const triggerLength = triggers.length;
93+
const newIndex = (index + triggerLength + direction) % triggerLength;
94+
triggers[newIndex]?.focus();
95+
event.preventDefault();
96+
} else if (event.code === KEYCODE.HOME || event.code === KEYCODE.END) {
97+
switch (event.code) {
98+
// Go to first accordion
99+
case KEYCODE.HOME:
100+
triggers[0]?.focus();
101+
break;
102+
// Go to last accordion
103+
case KEYCODE.END:
104+
triggers[triggers.length - 1]?.focus();
105+
break;
106+
default:
107+
triggers[0]?.focus();
108+
break;
109+
}
110+
event.preventDefault();
111+
}
112+
}
113+
};
114+
115+
return (
116+
<>
117+
<div
118+
className={clsx(styles.accordion, modifierClasses)}
119+
id={accordionId}
120+
onKeyDown={handleKeydown}
121+
>
122+
<div className={styles.content}>
123+
{accordionItemsStatus.map(item => {
124+
return (
125+
<AccordionItem
126+
key={item.id}
127+
{...item}
128+
accordionSpeed={accordionSpeed}
129+
toggleRef={accordionItemRefs[item.id]}
130+
handleClick={() => handleClick(item.id, item.isOpen)}
131+
/>
132+
);
133+
})}
134+
</div>
135+
</div>
136+
</>
137+
);
138+
}
139+
140+
export default Accordion;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import clsx from 'clsx';
2+
import { GessoComponent } from 'gesso';
3+
import { ElementType, MouseEventHandler, useEffect, useRef } from 'react';
4+
import styles from './accordion-item.module.css';
5+
import { slideCollapse, slideExpand } from '../../06-utility/slide';
6+
7+
export interface AccordionItemProps extends GessoComponent {
8+
id: string;
9+
title: string;
10+
content: string;
11+
titleElement?: ElementType;
12+
isOpen?: boolean;
13+
accordionSpeed?: string;
14+
toggleRef?: React.RefObject<HTMLButtonElement>;
15+
handleClick: MouseEventHandler;
16+
}
17+
18+
function AccordionItem({
19+
id,
20+
title,
21+
content,
22+
titleElement: TitleElement = 'h3',
23+
isOpen,
24+
accordionSpeed,
25+
toggleRef,
26+
modifierClasses,
27+
handleClick,
28+
}: AccordionItemProps): JSX.Element {
29+
const accordionItemSectionRef = useRef(null);
30+
31+
const sectionId = `accordion-section-${id}`;
32+
const buttonId = `accordion-button-${id}`;
33+
34+
useEffect(() => {
35+
if (isOpen && accordionItemSectionRef.current) {
36+
slideExpand(accordionItemSectionRef.current, accordionSpeed);
37+
} else if (!isOpen && accordionItemSectionRef.current) {
38+
slideCollapse(accordionItemSectionRef.current, accordionSpeed);
39+
}
40+
}, [isOpen, accordionSpeed]);
41+
42+
return (
43+
<div className={clsx(styles.accordionItem, modifierClasses)}>
44+
<div className={styles.panel}>
45+
<TitleElement className={styles.heading}>
46+
<button
47+
className={styles.toggle}
48+
id={buttonId}
49+
aria-expanded={isOpen}
50+
aria-controls={sectionId}
51+
ref={toggleRef}
52+
onClick={handleClick}
53+
>
54+
{title}
55+
<span className={styles.icon}></span>
56+
</button>
57+
</TitleElement>
58+
<div
59+
ref={accordionItemSectionRef}
60+
className={styles.drawer}
61+
id={sectionId}
62+
aria-labelledby={buttonId}
63+
aria-expanded={isOpen}
64+
>
65+
<div className={styles.drawerInner}>{content}</div>
66+
</div>
67+
</div>
68+
</div>
69+
);
70+
}
71+
72+
export default AccordionItem;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
@layer components {
2+
.accordionItem {
3+
border-block-end: solid 1px var(--grayscale-gray-2);
4+
}
5+
6+
.panel {
7+
}
8+
9+
.heading {
10+
font-weight: var(--font-weight-bold);
11+
margin: 0;
12+
}
13+
14+
.toggle {
15+
background-color: transparent;
16+
border: 0;
17+
color: inherit;
18+
cursor: pointer;
19+
display: flex;
20+
font-weight: inherit;
21+
inline-size: 100%;
22+
justify-content: space-between;
23+
line-height: inherit;
24+
padding-block: var(--spacing-2);
25+
padding-inline: var(--spacing-2) var(--spacing-8);
26+
position: relative;
27+
text-align: left;
28+
text-decoration: none;
29+
transition: all var(--duration-short) var(--easing-ease-out);
30+
31+
&:hover,
32+
&:focus,
33+
&:active {
34+
color: inherit;
35+
}
36+
}
37+
38+
.icon {
39+
block-size: 1.25rem;
40+
display: block;
41+
inline-size: 1.25rem;
42+
inset-block-start: 50%;
43+
inset-inline-end: var(--spacing-3);
44+
position: absolute;
45+
transform: translateY(-50%);
46+
47+
&::before,
48+
&::after {
49+
background-color: var(--ui-accent);
50+
block-size: 2px;
51+
content: '';
52+
display: block;
53+
inline-size: 100%;
54+
inset-block-start: 50%;
55+
inset-inline-start: 50%;
56+
position: absolute;
57+
transform: translate(-50%, -50%);
58+
transform-origin: center;
59+
transition-duration: var(--duration-short);
60+
transition-property: transform;
61+
transition-timing-function: var(--easing-ease-out);
62+
}
63+
64+
&::after {
65+
transform: translate(-50%, -50%) rotate(90deg);
66+
}
67+
68+
[aria-expanded='true'] & {
69+
&::after {
70+
transform: translate(-50%, -50%) rotate(0deg);
71+
}
72+
}
73+
}
74+
75+
.drawer {
76+
background: var(--grayscale-white);
77+
}
78+
79+
.drawerInner {
80+
padding-block: 0 var(--spacing-2);
81+
padding-inline: var(--spacing-2) var(--spacing-8);
82+
83+
> :last-child {
84+
margin-block-end: 0;
85+
}
86+
}
87+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@layer components {
2+
.accordion {
3+
}
4+
5+
.content {
6+
}
7+
}

0 commit comments

Comments
 (0)