diff --git a/packages/core/src/components/accordion/accordion.scss b/packages/core/src/components/accordion/accordion.scss index f2c0fbbff..ab9ce4d54 100644 --- a/packages/core/src/components/accordion/accordion.scss +++ b/packages/core/src/components/accordion/accordion.scss @@ -8,14 +8,14 @@ } @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) { @@ -23,7 +23,7 @@ } } - .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'); @@ -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; } } diff --git a/packages/react/src/components/accordion/accordion.tsx b/packages/react/src/components/accordion/accordion.tsx index 34ad8cb0d..234fcc35d 100644 --- a/packages/react/src/components/accordion/accordion.tsx +++ b/packages/react/src/components/accordion/accordion.tsx @@ -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; @@ -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 = ({ +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 = ({ list: listProps, onChange, onlyOneOpen = false, separated = false, withSeparator = false, styles: stylesProps = defaultStyles, + className = '', }) => { - const [list, setList] = useState(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]; @@ -66,20 +115,46 @@ export const Accordion: FC = ({ 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 (
{list.map((item, index) => (
-
{item.content}
+ {(!ready || + content.current[index].clientHeight !== 0 || + animatedItems[index]) && ( +
+ {item.content} +
+ )}
))}