Skip to content

Commit

Permalink
feat(react): accordion component
Browse files Browse the repository at this point in the history
  • Loading branch information
itupix committed Sep 4, 2023
1 parent c468f01 commit 3bee6a9
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 56 deletions.
82 changes: 47 additions & 35 deletions packages/core/src/components/accordion/accordion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@
}

@mixin _Accordion() {
.ods-accordion-wrapper:not(.separated) {
.ods-accordion-box:not(.-separated) {
background-color: helpers.color('background-surface');
border: 1px solid helpers.color('border-separator');
border-radius: helpers.border-radius('large');
overflow: hidden;
}

.ods-accordion {
.ods-accordion-item {
overflow: hidden;

&:not(:last-child) {
border-bottom: 1px solid helpers.color('border-separator');
}
}

.separated .ods-accordion {
.-separated > .ods-accordion-item {
background-color: helpers.color('background-surface');
border: 1px solid helpers.color('border-separator');
border-radius: helpers.border-radius('large');
Expand All @@ -40,67 +40,79 @@
border: 0;
cursor: pointer;
display: flex;
gap: helpers.space(1);
gap: helpers.space(2);
justify-content: flex-start;
outline-offset: helpers.space(-0.5);
padding: var(--padding);
transition: background-color linear 0.2s;
width: 100%;

&:hover {
background-color: helpers.color('background-action-subtle');
background-color: helpers.color('background-action-subtle-hover');
}
}

.ods-accordion-secondary-content > * {
margin: 0;
.ods-accordion-secondary-content {
* {
margin: 0;
}
}

.ods-accordion-chevron {
transition: transform linear 0.2s;
}

.-open > .ods-accordion-toggler > .ods-accordion-chevron {
transform: rotateZ(180deg);
}

.ods-accordion-title {
margin-right: auto;
}

.ods-accordion-content-wrapper {
max-height: 0;
box-sizing: border-box;
overflow: hidden;
padding: 0 var(--padding);
transition: padding ease-in-out 0.3s, max-height ease-in-out 0.3s;
position: absolute;
transition: height ease-in-out 0.3s;
visibility: hidden;
will-change: height;

&.-ready {
position: relative;
visibility: visible;
}

&.-with-separator {
position: relative;

&::before {
border-bottom: 1px solid helpers.color('border-separator');
content: '';
left: var(--padding);
position: absolute;
right: var(--padding);
top: 0;
}
}
}

.separator {
position: relative;
.-with-separator > .ods-accordion-content {
margin-top: var(--padding);
}

&::before {
border-bottom: 1px solid helpers.color('border-separator');
content: '';
left: var(--padding);
position: absolute;
right: var(--padding);
top: 0;
}
.ods-accordion-content {
animation: fadeIn 0.4s;
margin: helpers.space(1) var(--padding) var(--padding);
}

.open {
.ods-accordion-content-wrapper {
max-height: 100vh;
padding-bottom: var(--padding);
padding-top: var(--padding);
@keyframes fadeIn {
0% {
opacity: 0;
}

.ods-accordion-content {
100% {
opacity: 1;
}

.ods-accordion-chevron {
transform: rotateZ(180deg);
}
}

.ods-accordion-content {
opacity: 0;
transition: opacity linear 0.4s;
}
}
129 changes: 108 additions & 21 deletions packages/react/src/components/accordion/accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { c, classy, cssVars, space } from '@onfido/castor';
import type { IconName } from '@onfido/castor-icons';
import { Icon } from '@onfido/castor-react';
import React, { useEffect, useState, type FC } from 'react';
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
type FC,
} from 'react';

export type AccordionItem = {
name: string;
isOpen?: boolean;
title: string;
title: React.ReactElement;
iconName?: IconName;
content: React.ReactElement;
secondaryContent?: React.ReactElement;
Expand All @@ -15,44 +22,86 @@ export type AccordionStyles = {
padding?: number;
};

export interface AccordionProps {
interface Props {
list: AccordionItem[];
onChange?: (newList: AccordionItem[]) => void;
onlyOneOpen: boolean;
separated: boolean;
withSeparator: boolean;
onChange?: (accordionItem: AccordionItem) => void;
onlyOneOpen?: boolean;
separated?: boolean;
withSeparator?: boolean;
styles?: AccordionStyles;
className?: string;
}

const defaultStyles = {
padding: 3,
};

export const Accordion: FC<AccordionProps> = ({
const useWindowSize = () => {
const [size, setSize] = useState([0, 0]);

useLayoutEffect(() => {
const updateSize = () => {
setSize([window.innerWidth, window.innerHeight]);
};

window.addEventListener('resize', updateSize);
updateSize();

return () => window.removeEventListener('resize', updateSize);
}, []);

return size;
};

export const Accordion: FC<Props> = ({
list: listProps,
onChange,
onlyOneOpen = false,
separated = false,
withSeparator = false,
styles: stylesProps = defaultStyles,
className = '',
}) => {
const [list, setList] = useState<AccordionItem[]>(listProps);
const initHeights: number[] = [];
const [list, setList] = useState(listProps);
const [heights, setHeights] = useState(initHeights);
const [ready, setReady] = useState(false);
const init: HTMLDivElement[] = [];
const content = useRef(init);
const [windowWidth] = useWindowSize();
const [animatedItems, setAnimatedItems] = useState(
new Array(list.length).fill(false)
);

const _styles = {
...defaultStyles,
...stylesProps,
};

const updateHeights = () =>
setHeights(content.current.map((item) => item.clientHeight));

useEffect(() => {
let newList = [...list];
setReady(false);
}, [windowWidth]);

useEffect(() => {
if (!ready) {
updateHeights();
}
}, [ready]);

newList = newList.map((item) => ({
...item,
isOpen: false,
}));
useEffect(() => {
if (content.current.length) {
updateHeights();
}
}, [content]);

if (onChange) onChange(newList);
}, [onlyOneOpen]);
useEffect(() => {
if (heights.length === list.length) {
setReady(true);
}
}, [heights]);

const handleClick = (index: number) => () => {
let newList = [...list];
Expand All @@ -66,20 +115,46 @@ export const Accordion: FC<AccordionProps> = ({
newList[index].isOpen = !list[index].isOpen;
}
setList(newList);
if (onChange) onChange(newList);
if (onChange) onChange(newList[index]);
};

const handleRef = (index: number) => (el: HTMLDivElement) =>
(content.current[index] = el);

const style = cssVars({ padding: space(_styles.padding) });

const animate = (index: number, started: boolean) => () => {
const newAnimatedItems = [...animatedItems];
newAnimatedItems[index] = started;
setAnimatedItems(newAnimatedItems);
};

useLayoutEffect(() => {
list.forEach((_, index) => {
const el = content.current[index];
el.addEventListener('transitionstart', animate(index, true));
el.addEventListener('transitionend', animate(index, false));
});

return () =>
list.forEach((_, index) => {
const el = content.current[index];
el.removeEventListener('transitionstart', animate(index, true));
el.removeEventListener('transitionend', animate(index, false));
});
}, []);

return (
<div
style={style}
className={classy(c('accordion-wrapper'), { separated })}
className={classy(c('accordion-box'), className, {
'-separated': separated,
})}
>
{list.map((item, index) => (
<div
key={index}
className={classy(c('accordion'), { open: item.isOpen })}
className={classy(c('accordion-item'), { '--open': item.isOpen })}
>
<button
className={classy(c('accordion-toggler'))}
Expand All @@ -99,11 +174,23 @@ export const Accordion: FC<AccordionProps> = ({
/>
</button>
<div
ref={handleRef(index)}
className={classy(c('accordion-content-wrapper'), {
separator: withSeparator,
'-ready': ready,
'-with-separator': withSeparator,
})}
style={{
height: ready ? (item.isOpen ? heights[index] : 0) : 'auto',
}}
aria-hidden={!item.isOpen}
>
<div className={classy(c('accordion-content'))}>{item.content}</div>
{(!ready ||
content.current[index].clientHeight !== 0 ||
animatedItems[index]) && (
<div className={classy(c('accordion-content'))}>
{item.content}
</div>
)}
</div>
</div>
))}
Expand Down

0 comments on commit 3bee6a9

Please sign in to comment.