From 15ee5e56a77e84ce605bc0bb1ac380dadf8ae40e Mon Sep 17 00:00:00 2001 From: marcoskolodny Date: Wed, 27 Sep 2023 19:26:04 +0200 Subject: [PATCH 01/13] first POC --- src/accordion.css.ts | 84 ++++++++++++++ src/accordion.tsx | 253 +++++++++++++++++++++++++++++++++++++++++++ src/boxed.tsx | 3 + src/index.tsx | 7 ++ src/list.tsx | 2 +- 5 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/accordion.css.ts create mode 100644 src/accordion.tsx diff --git a/src/accordion.css.ts b/src/accordion.css.ts new file mode 100644 index 0000000000..bcc2218bbb --- /dev/null +++ b/src/accordion.css.ts @@ -0,0 +1,84 @@ +import {style} from '@vanilla-extract/css'; +import * as mq from './media-queries.css'; +import {vars} from './skins/skin-contract.css'; +import {sprinkles} from './sprinkles.css'; + +export const accordionContent = sprinkles({ + width: '100%', + border: 'none', + background: 'transparent', + display: 'block', + height: '100%', +}); + +export const touchableBackground = style({ + '@media': { + [mq.supportsHover]: { + transition: 'background-color 0.1s ease-in-out', + ':hover': { + background: vars.colors.backgroundContainerHover, + }, + ':active': { + background: vars.colors.backgroundContainerPressed, + }, + }, + }, +}); + +export const touchableBackgroundInverse = style({ + '@media': { + [mq.supportsHover]: { + transition: 'background-color 0.1s ease-in-out', + ':hover': { + background: vars.colors.backgroundContainerBrandHover, + }, + ':active': { + background: vars.colors.backgroundContainerBrandPressed, + }, + }, + }, +}); + +const baseChevron = style({ + height: '100%', + display: 'flex', + alignItems: 'center', + transition: 'transform 0.4s', +}); + +export const openChevron = style([ + baseChevron, + { + transform: 'rotate(-90deg)', + }, +]); + +export const closeChevron = style([ + baseChevron, + { + transform: 'rotate(90deg)', + }, +]); + +const basePanel = style({ + display: 'grid', + transition: 'grid-template-rows 0.4s', +}); + +export const openPanel = style([ + basePanel, + { + gridTemplateRows: '1fr', + }, +]); + +export const closePanel = style([ + basePanel, + { + gridTemplateRows: '0fr', + }, +]); + +export const panelContent = style({ + overflow: 'hidden', +}); diff --git a/src/accordion.tsx b/src/accordion.tsx new file mode 100644 index 0000000000..7befd306a8 --- /dev/null +++ b/src/accordion.tsx @@ -0,0 +1,253 @@ +import * as React from 'react'; +import {Content} from './list'; +import IconChevron from './icons/icon-chevron'; +import Box from './box'; +import * as styles from './accordion.css'; +import Stack from './stack'; +import {BaseTouchable} from './touchable'; +import classNames from 'classnames'; +import {vars as skinVars} from './skins/skin-contract.css'; +import {Text3} from './text'; +import {getPrefixedDataAttributes} from './utils/dom'; +import Divider from './divider'; +import {Boxed} from './boxed'; +import {useIsInverseVariant} from './theme-variant-context'; + +import type {DataAttributes, TrackingEvent} from './utils/types'; +import type {TouchableElement} from './touchable'; + +interface AccordionContentProps { + children?: void; + title: string; + subtitle?: string; + asset?: React.ReactNode; + extra?: React.ReactNode; + content: string | Array; + isOpen?: boolean; + isInitialOpen?: boolean; + onToogle?: (value: boolean) => void; + dataAttributes?: DataAttributes; + trackingEvent?: TrackingEvent | ReadonlyArray; +} + +const useAccordionState = ({ + value, + defaultValue, + onChange, +}: { + value?: boolean; + defaultValue?: boolean; + onChange?: (value: boolean) => void; +}): [boolean, () => void] => { + const isControlledByParent = value !== undefined; + const [isOpen, setIsOpen] = React.useState(defaultValue !== undefined ? defaultValue : !!value); + + const toggle = () => { + if (!isControlledByParent) { + setIsOpen(!isOpen); + } + if (onChange) { + onChange(isControlledByParent ? !value : !isOpen); + } + }; + + if (isControlledByParent) { + return [!!value, toggle]; + } + + return [isOpen, toggle]; +}; + +const AccordionContent = React.forwardRef( + ( + {extra, content, isOpen: openValue, isInitialOpen, onToogle, dataAttributes, trackingEvent, ...props}, + ref + ) => { + const [isOpen, toggle] = useAccordionState({ + value: openValue, + defaultValue: isInitialOpen, + onChange: onToogle, + }); + const isInverse = useIsInverseVariant(); + + const contentParagraphs = !content ? [] : typeof content === 'string' ? [content] : content; + + return ( +
+ + + + +
+ } + /> + + +
+
+ + + + {contentParagraphs.map((text, index) => ( +

+ {text} +

+ ))} +
+ + {extra} +
+
+
+
+ + ); + } +); + +export const Accordion = React.forwardRef( + ({dataAttributes, ...props}, ref) => ( + + ) +); + +type AccordionListProps = { + children: React.ReactNode; + ariaLabelledby?: string; + role?: string; + noLastDivider?: boolean; + dataAttributes?: DataAttributes; +}; + +export const AccordionList: React.FC = ({ + children, + ariaLabelledby, + role, + dataAttributes, + noLastDivider, +}) => { + const lastIndex = React.Children.count(children) - 1; + const showLastDivider = !noLastDivider; + + return ( +
+ {React.Children.toArray(children) + .filter(Boolean) + .map((child, index) => ( + + {child} + {(index < lastIndex || showLastDivider) && ( + + + + )} + + ))} +
+ ); +}; + +interface BoxedAccordionProps extends AccordionContentProps { + isInverse?: boolean; +} + +export const BoxedAccordion = React.forwardRef( + ({dataAttributes, ...props}, ref) => ( + + + + ) +); + +type BoxedAccordionListProps = { + children: React.ReactNode; + ariaLabelledby?: string; + role?: string; + dataAttributes?: DataAttributes; +}; + +export const BoxedAccordionList: React.FC = ({ + children, + ariaLabelledby, + role, + dataAttributes, +}) => ( + + {children} + +); + +type GroupedAccordionListProps = { + children: React.ReactNode; + ariaLabelledby?: string; + role?: string; + dataAttributes?: DataAttributes; + isInverse?: boolean; +}; + +export const GroupedAccordionList: React.FC = ({ + children, + ariaLabelledby, + role, + dataAttributes, + isInverse, +}) => { + const lastIndex = React.Children.count(children) - 1; + return ( + + {React.Children.toArray(children) + .filter(Boolean) + .map((child, index) => ( + + {child} + {index < lastIndex && ( + + + + )} + + ))} + + ); +}; diff --git a/src/boxed.tsx b/src/boxed.tsx index 8464f6391c..950bf5326b 100644 --- a/src/boxed.tsx +++ b/src/boxed.tsx @@ -16,6 +16,7 @@ type Props = { /** "data-" prefix is automatically added. For example, use "testid" instead of "data-testid" */ dataAttributes?: DataAttributes; 'aria-label'?: string; + 'aria-labelledby'?: string; width?: number | string; height?: number | string; minHeight?: number | string; @@ -42,6 +43,7 @@ export const InternalBoxed = React.forwardRef {children} diff --git a/src/index.tsx b/src/index.tsx index 715e2cd4e9..91e00fa825 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,13 @@ export type {TouchableElement} from './touchable'; export {default as Spinner} from './spinner'; export {default as FadeIn} from './fade-in'; +export { + Accordion, + BoxedAccordion, + AccordionList, + BoxedAccordionList, + GroupedAccordionList, +} from './accordion'; export {ButtonPrimary, ButtonSecondary, ButtonDanger, ButtonLink} from './button'; export {default as ButtonLayout} from './button-layout'; export {default as FixedFooterLayout} from './fixed-footer-layout'; diff --git a/src/list.tsx b/src/list.tsx index 90b72a9fd1..0865772b88 100644 --- a/src/list.tsx +++ b/src/list.tsx @@ -66,7 +66,7 @@ interface ContentProps extends CommonProps { labelId?: string; } -const Content: React.FC = ({ +export const Content: React.FC = ({ withChevron, headline, title, From 49a330cd8284e2cd5fe2114385a7eb75155e3221 Mon Sep 17 00:00:00 2001 From: marcoskolodny Date: Wed, 27 Sep 2023 20:06:00 +0200 Subject: [PATCH 02/13] fix chevron styling and overflow bug --- src/accordion.css.ts | 26 ++++++++++++-------------- src/accordion.tsx | 12 ++++++++++-- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/accordion.css.ts b/src/accordion.css.ts index bcc2218bbb..733c9e6265 100644 --- a/src/accordion.css.ts +++ b/src/accordion.css.ts @@ -9,6 +9,7 @@ export const accordionContent = sprinkles({ background: 'transparent', display: 'block', height: '100%', + padding: 0, }); export const touchableBackground = style({ @@ -39,26 +40,23 @@ export const touchableBackgroundInverse = style({ }, }); -const baseChevron = style({ +export const chevronContainer = style({ height: '100%', display: 'flex', alignItems: 'center', - transition: 'transform 0.4s', }); -export const openChevron = style([ - baseChevron, - { - transform: 'rotate(-90deg)', - }, -]); +export const openChevron = style({ + display: 'flex', + transition: 'transform 0.4s', + transform: 'rotate(-90deg)', +}); -export const closeChevron = style([ - baseChevron, - { - transform: 'rotate(90deg)', - }, -]); +export const closeChevron = style({ + display: 'flex', + transition: 'transform 0.4s', + transform: 'rotate(90deg)', +}); const basePanel = style({ display: 'grid', diff --git a/src/accordion.tsx b/src/accordion.tsx index 7befd306a8..588b118ad7 100644 --- a/src/accordion.tsx +++ b/src/accordion.tsx @@ -88,8 +88,16 @@ const AccordionContent = React.forwardRef - +
+
} /> From cd138686b40882f79cd7c8591ad8ca84dc22bd0f Mon Sep 17 00:00:00 2001 From: marcoskolodny Date: Thu, 28 Sep 2023 18:40:45 +0200 Subject: [PATCH 03/13] update accordions --- src/accordion.css.ts | 2 +- src/accordion.tsx | 268 ++++++++++++++++++++++++++----------------- src/index.tsx | 8 +- 3 files changed, 164 insertions(+), 114 deletions(-) diff --git a/src/accordion.css.ts b/src/accordion.css.ts index 733c9e6265..d971b831d5 100644 --- a/src/accordion.css.ts +++ b/src/accordion.css.ts @@ -3,7 +3,7 @@ import * as mq from './media-queries.css'; import {vars} from './skins/skin-contract.css'; import {sprinkles} from './sprinkles.css'; -export const accordionContent = sprinkles({ +export const itemContent = sprinkles({ width: '100%', border: 'none', background: 'transparent', diff --git a/src/accordion.tsx b/src/accordion.tsx index 588b118ad7..707916c68c 100644 --- a/src/accordion.tsx +++ b/src/accordion.tsx @@ -7,22 +7,33 @@ import Stack from './stack'; import {BaseTouchable} from './touchable'; import classNames from 'classnames'; import {vars as skinVars} from './skins/skin-contract.css'; -import {Text3} from './text'; import {getPrefixedDataAttributes} from './utils/dom'; import Divider from './divider'; import {Boxed} from './boxed'; import {useIsInverseVariant} from './theme-variant-context'; +import {useAriaId} from './hooks'; import type {DataAttributes, TrackingEvent} from './utils/types'; import type {TouchableElement} from './touchable'; -interface AccordionContentProps { +type AccordionContextType = { + index?: Array; + defaultIndex?: Array; + onChange?: (index: number, value: boolean) => void; +}; +const AccordionContext = React.createContext({ + index: undefined, + defaultIndex: undefined, + onChange: () => {}, +}); +export const useAccordionContext = (): AccordionContextType => React.useContext(AccordionContext); + +interface AccordionItemContentProps { children?: void; title: string; subtitle?: string; asset?: React.ReactNode; - extra?: React.ReactNode; - content: string | Array; + content: React.ReactNode; isOpen?: boolean; isInitialOpen?: boolean; onToogle?: (value: boolean) => void; @@ -30,7 +41,7 @@ interface AccordionContentProps { trackingEvent?: TrackingEvent | ReadonlyArray; } -const useAccordionState = ({ +const useAccordionItemState = ({ value, defaultValue, onChange, @@ -40,7 +51,9 @@ const useAccordionState = ({ onChange?: (value: boolean) => void; }): [boolean, () => void] => { const isControlledByParent = value !== undefined; - const [isOpen, setIsOpen] = React.useState(defaultValue !== undefined ? defaultValue : !!value); + const [isOpen, setIsOpen] = React.useState(!!defaultValue); + + React.useEffect(() => setIsOpen(!!defaultValue), [defaultValue]); const toggle = () => { if (!isControlledByParent) { @@ -58,33 +71,51 @@ const useAccordionState = ({ return [isOpen, toggle]; }; -const AccordionContent = React.forwardRef( - ( - {extra, content, isOpen: openValue, isInitialOpen, onToogle, dataAttributes, trackingEvent, ...props}, - ref - ) => { - const [isOpen, toggle] = useAccordionState({ - value: openValue, - defaultValue: isInitialOpen, - onChange: onToogle, - }); +const getAccordionItemIndex = (element: Element | null) => { + const accordionAncestor = element?.closest('[data-accordion]'); + if (!accordionAncestor) return undefined; + + return Array.from(accordionAncestor.querySelectorAll('[data-accordion-item]')) + .filter((e) => e.closest('[data-accordion]') === accordionAncestor) + .findIndex((e) => e === element); +}; + +const AccordionItemContent = React.forwardRef( + ({content, dataAttributes, trackingEvent, ...props}, ref) => { + const itemRef = React.useRef(null); + const labelId = useAriaId(); + const panelId = useAriaId(); + const {index, defaultIndex, onChange} = useAccordionContext(); const isInverse = useIsInverseVariant(); - const contentParagraphs = !content ? [] : typeof content === 'string' ? [content] : content; + const itemIndex = getAccordionItemIndex(itemRef.current); + + const [isOpen, toggle] = useAccordionItemState({ + value: itemIndex !== undefined ? index?.includes(itemIndex) : undefined, + defaultValue: itemIndex !== undefined ? defaultIndex?.includes(itemIndex) : undefined, + onChange: (value) => { + if (itemIndex) { + onChange?.(itemIndex, value); + } + }, + }); return ( -
+
-
+
- - - {contentParagraphs.map((text, index) => ( -

- {text} -

- ))} -
- - {extra} -
+ {content}
@@ -132,130 +146,172 @@ const AccordionContent = React.forwardRef( +export const AccordionItem = React.forwardRef( ({dataAttributes, ...props}, ref) => ( - ) ); -type AccordionListProps = { +type AccordionProps = { children: React.ReactNode; ariaLabelledby?: string; - role?: string; noLastDivider?: boolean; dataAttributes?: DataAttributes; + index?: number | Array; + defaultIndex?: number | Array; + onChange?: (index: number, value: boolean) => void; }; -export const AccordionList: React.FC = ({ +export const Accordion: React.FC = ({ children, ariaLabelledby, - role, dataAttributes, noLastDivider, + index, + defaultIndex, + onChange, }) => { const lastIndex = React.Children.count(children) - 1; const showLastDivider = !noLastDivider; + const indexList = index === undefined ? undefined : typeof index === 'number' ? [index] : index; + const defaultIndexList = + defaultIndex === undefined + ? undefined + : typeof defaultIndex === 'number' + ? [defaultIndex] + : defaultIndex; + return ( -
- {React.Children.toArray(children) - .filter(Boolean) - .map((child, index) => ( - - {child} - {(index < lastIndex || showLastDivider) && ( - - - - )} - - ))} -
+ +
+ {React.Children.toArray(children) + .filter(Boolean) + .map((child, index) => ( + + {child} + {(index < lastIndex || showLastDivider) && ( + + + + )} + + ))} +
+
); }; -interface BoxedAccordionProps extends AccordionContentProps { +interface BoxedAccordionItemProps extends AccordionItemContentProps { isInverse?: boolean; } -export const BoxedAccordion = React.forwardRef( - ({dataAttributes, ...props}, ref) => ( +export const BoxedAccordionItem = React.forwardRef( + ({dataAttributes, isInverse, ...props}, ref) => ( - + ) ); -type BoxedAccordionListProps = { +type BoxedAccordionProps = { children: React.ReactNode; ariaLabelledby?: string; - role?: string; dataAttributes?: DataAttributes; + index?: number | Array; + defaultIndex?: number | Array; + onChange?: (index: number, value: boolean) => void; }; -export const BoxedAccordionList: React.FC = ({ +export const BoxedAccordion: React.FC = ({ children, ariaLabelledby, - role, dataAttributes, -}) => ( - - {children} - -); + index, + defaultIndex, + onChange, +}) => { + const indexList = index === undefined ? undefined : typeof index === 'number' ? [index] : index; + const defaultIndexList = + defaultIndex === undefined + ? undefined + : typeof defaultIndex === 'number' + ? [defaultIndex] + : defaultIndex; + + return ( + + + {children} + + + ); +}; -type GroupedAccordionListProps = { +type GroupedAccordionProps = { children: React.ReactNode; ariaLabelledby?: string; - role?: string; dataAttributes?: DataAttributes; isInverse?: boolean; + index?: number | Array; + defaultIndex?: number | Array; + onChange?: (index: number, value: boolean) => void; }; -export const GroupedAccordionList: React.FC = ({ +export const GroupedAccordion: React.FC = ({ children, ariaLabelledby, - role, dataAttributes, isInverse, + index, + defaultIndex, + onChange, }) => { const lastIndex = React.Children.count(children) - 1; + + const indexList = index === undefined ? undefined : typeof index === 'number' ? [index] : index; + const defaultIndexList = + defaultIndex === undefined + ? undefined + : typeof defaultIndex === 'number' + ? [defaultIndex] + : defaultIndex; + return ( - - {React.Children.toArray(children) - .filter(Boolean) - .map((child, index) => ( - - {child} - {index < lastIndex && ( - - - - )} - - ))} - + + + {React.Children.toArray(children) + .filter(Boolean) + .map((child, index) => ( + + {child} + {index < lastIndex && ( + + + + )} + + ))} + + ); }; diff --git a/src/index.tsx b/src/index.tsx index 91e00fa825..a15308ab0d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,13 +11,7 @@ export type {TouchableElement} from './touchable'; export {default as Spinner} from './spinner'; export {default as FadeIn} from './fade-in'; -export { - Accordion, - BoxedAccordion, - AccordionList, - BoxedAccordionList, - GroupedAccordionList, -} from './accordion'; +export {AccordionItem, BoxedAccordionItem, Accordion, BoxedAccordion, GroupedAccordion} from './accordion'; export {ButtonPrimary, ButtonSecondary, ButtonDanger, ButtonLink} from './button'; export {default as ButtonLayout} from './button-layout'; export {default as FixedFooterLayout} from './fixed-footer-layout'; From 61bd9a52c5c26d43b036e3cb726358fc25099bbd Mon Sep 17 00:00:00 2001 From: marcoskolodny Date: Fri, 29 Sep 2023 11:44:04 +0200 Subject: [PATCH 04/13] unmount panel on close and use CSSTransition --- src/accordion.css.ts | 30 ++++++++++++++++-------------- src/accordion.tsx | 35 ++++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/accordion.css.ts b/src/accordion.css.ts index d971b831d5..2791243677 100644 --- a/src/accordion.css.ts +++ b/src/accordion.css.ts @@ -58,25 +58,27 @@ export const closeChevron = style({ transform: 'rotate(90deg)', }); -const basePanel = style({ +export const panelContainer = style({ display: 'grid', - transition: 'grid-template-rows 0.4s', }); -export const openPanel = style([ - basePanel, - { +export const panelTransitionClasses = { + enter: style({ + gridTemplateRows: '0fr', + }), + enterActive: style({ gridTemplateRows: '1fr', - }, -]); - -export const closePanel = style([ - basePanel, - { + transition: 'grid-template-rows 0.4s', + }), + exit: style({ + gridTemplateRows: '1fr', + }), + exitActive: style({ gridTemplateRows: '0fr', - }, -]); + transition: 'grid-template-rows 0.4s', + }), +}; -export const panelContent = style({ +export const panel = style({ overflow: 'hidden', }); diff --git a/src/accordion.tsx b/src/accordion.tsx index 707916c68c..8425f6f860 100644 --- a/src/accordion.tsx +++ b/src/accordion.tsx @@ -12,10 +12,13 @@ import Divider from './divider'; import {Boxed} from './boxed'; import {useIsInverseVariant} from './theme-variant-context'; import {useAriaId} from './hooks'; +import {CSSTransition} from 'react-transition-group'; import type {DataAttributes, TrackingEvent} from './utils/types'; import type {TouchableElement} from './touchable'; +const ACCORDION_TRANSITION_DURATION_IN_MS = 400; + type AccordionContextType = { index?: Array; defaultIndex?: Array; @@ -82,13 +85,14 @@ const getAccordionItemIndex = (element: Element | null) => { const AccordionItemContent = React.forwardRef( ({content, dataAttributes, trackingEvent, ...props}, ref) => { + const panelContainerRef = React.useRef(null); const itemRef = React.useRef(null); - const labelId = useAriaId(); - const panelId = useAriaId(); const {index, defaultIndex, onChange} = useAccordionContext(); const isInverse = useIsInverseVariant(); + const labelId = useAriaId(); + const panelId = useAriaId(); - const itemIndex = getAccordionItemIndex(itemRef.current); + const [itemIndex, setItemIndex] = React.useState(); const [isOpen, toggle] = useAccordionItemState({ value: itemIndex !== undefined ? index?.includes(itemIndex) : undefined, @@ -100,6 +104,10 @@ const AccordionItemContent = React.forwardRef { + setItemIndex(getAccordionItemIndex(itemRef.current)); + }, []); + return (
-
-
- - {content} - + +
+
+ + {content} + +
-
+
); } From b77c69a974883f69f421b80bfde0f08839af37e3 Mon Sep 17 00:00:00 2001 From: marcoskolodny Date: Fri, 29 Sep 2023 16:05:59 +0200 Subject: [PATCH 05/13] add singleOpen and update opened items internal logic --- src/accordion.tsx | 192 ++++++++++++++++++++++++---------------------- 1 file changed, 102 insertions(+), 90 deletions(-) diff --git a/src/accordion.tsx b/src/accordion.tsx index 8425f6f860..27454ec1d3 100644 --- a/src/accordion.tsx +++ b/src/accordion.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import {Content} from './list'; +import {Content as HeaderContent} from './list'; import IconChevron from './icons/icon-chevron'; import Box from './box'; import * as styles from './accordion.css'; @@ -14,20 +14,19 @@ import {useIsInverseVariant} from './theme-variant-context'; import {useAriaId} from './hooks'; import {CSSTransition} from 'react-transition-group'; +import type {ExclusifyUnion} from './utils/utility-types'; import type {DataAttributes, TrackingEvent} from './utils/types'; import type {TouchableElement} from './touchable'; const ACCORDION_TRANSITION_DURATION_IN_MS = 400; type AccordionContextType = { - index?: Array; - defaultIndex?: Array; - onChange?: (index: number, value: boolean) => void; + index: Array; + toogle: (item: number) => void; }; const AccordionContext = React.createContext({ - index: undefined, - defaultIndex: undefined, - onChange: () => {}, + index: [], + toogle: () => {}, }); export const useAccordionContext = (): AccordionContextType => React.useContext(AccordionContext); @@ -44,34 +43,68 @@ interface AccordionItemContentProps { trackingEvent?: TrackingEvent | ReadonlyArray; } -const useAccordionItemState = ({ +const useAccordionState = ({ value, defaultValue, onChange, + singleOpen, }: { - value?: boolean; - defaultValue?: boolean; - onChange?: (value: boolean) => void; -}): [boolean, () => void] => { + value?: number | Array; + defaultValue?: number | Array; + onChange?: (item: number, value: boolean) => void; + singleOpen?: boolean; +}): [Array, (value: number) => void] => { const isControlledByParent = value !== undefined; - const [isOpen, setIsOpen] = React.useState(!!defaultValue); - React.useEffect(() => setIsOpen(!!defaultValue), [defaultValue]); + const getValueAsList = (value?: number | Array) => { + return value === undefined ? [] : typeof value === 'number' ? [value] : value; + }; + + const [index, setIndex] = React.useState>(getValueAsList(defaultValue)); + + React.useEffect(() => setIndex(getValueAsList(defaultValue)), [defaultValue]); + + React.useEffect(() => { + if (index.length > 1 && singleOpen) { + index.splice(1); + setIndex([...index]); + } + }, [singleOpen, index]); + + const updateIndexOnToogle = (item: number, index?: Array) => { + if (!index) { + return [item]; + } + + const valueIndex = index.indexOf(item); + if (valueIndex === -1) { + if (singleOpen) { + index = [item]; + } else { + index.push(item); + } + } else { + index.splice(valueIndex, 1); + } + + return [...index]; + }; - const toggle = () => { + const toggle = (item: number) => { if (!isControlledByParent) { - setIsOpen(!isOpen); + setIndex(updateIndexOnToogle(item, index)); } if (onChange) { - onChange(isControlledByParent ? !value : !isOpen); + const currentItemValue = (isControlledByParent ? getValueAsList(value) : index).includes(item); + onChange(item, !currentItemValue); } }; if (isControlledByParent) { - return [!!value, toggle]; + return [getValueAsList(value), toggle]; } - return [isOpen, toggle]; + return [index, toggle]; }; const getAccordionItemIndex = (element: Element | null) => { @@ -87,42 +120,35 @@ const AccordionItemContent = React.forwardRef { const panelContainerRef = React.useRef(null); const itemRef = React.useRef(null); - const {index, defaultIndex, onChange} = useAccordionContext(); + const {index, toogle} = useAccordionContext(); const isInverse = useIsInverseVariant(); const labelId = useAriaId(); const panelId = useAriaId(); const [itemIndex, setItemIndex] = React.useState(); - - const [isOpen, toggle] = useAccordionItemState({ - value: itemIndex !== undefined ? index?.includes(itemIndex) : undefined, - defaultValue: itemIndex !== undefined ? defaultIndex?.includes(itemIndex) : undefined, - onChange: (value) => { - if (itemIndex) { - onChange?.(itemIndex, value); - } - }, - }); + const isOpen = itemIndex !== undefined && index?.includes(itemIndex); React.useEffect(() => { setItemIndex(getAccordionItemIndex(itemRef.current)); }, []); return ( -
+
{ + if (itemIndex !== undefined) toogle(itemIndex); + }} trackingEvent={trackingEvent} aria-expanded={isOpen} aria-controls={panelId} > - void; +}; + +type SingleOpenProps = { + singleOpen: true; + index?: number; + defaultIndex?: number; +}; + +type MultipleOpenProps = { + singleOpen?: false; index?: number | Array; defaultIndex?: number | Array; - onChange?: (index: number, value: boolean) => void; }; +type AccordionProps = AccordionBaseProps & ExclusifyUnion; + export const Accordion: React.FC = ({ children, - ariaLabelledby, dataAttributes, noLastDivider, index, defaultIndex, onChange, + singleOpen, }) => { + const [indexList, toogle] = useAccordionState({ + value: index, + defaultValue: defaultIndex, + onChange, + singleOpen, + }); const lastIndex = React.Children.count(children) - 1; const showLastDivider = !noLastDivider; - const indexList = index === undefined ? undefined : typeof index === 'number' ? [index] : index; - const defaultIndexList = - defaultIndex === undefined - ? undefined - : typeof defaultIndex === 'number' - ? [defaultIndex] - : defaultIndex; - return ( - -
+ +
{React.Children.toArray(children) .filter(Boolean) .map((child, index) => ( @@ -242,37 +274,28 @@ export const BoxedAccordionItem = React.forwardRef; - defaultIndex?: number | Array; - onChange?: (index: number, value: boolean) => void; -}; +type BoxedAccordionProps = Omit; export const BoxedAccordion: React.FC = ({ children, - ariaLabelledby, dataAttributes, index, defaultIndex, onChange, + singleOpen, }) => { - const indexList = index === undefined ? undefined : typeof index === 'number' ? [index] : index; - const defaultIndexList = - defaultIndex === undefined - ? undefined - : typeof defaultIndex === 'number' - ? [defaultIndex] - : defaultIndex; + const [indexList, toogle] = useAccordionState({ + value: index, + defaultValue: defaultIndex, + onChange, + singleOpen, + }); return ( - + {children} @@ -280,41 +303,30 @@ export const BoxedAccordion: React.FC = ({ ); }; -type GroupedAccordionProps = { - children: React.ReactNode; - ariaLabelledby?: string; - dataAttributes?: DataAttributes; - isInverse?: boolean; - index?: number | Array; - defaultIndex?: number | Array; - onChange?: (index: number, value: boolean) => void; -}; +type GroupedAccordionProps = Omit & {isInverse?: boolean}; export const GroupedAccordion: React.FC = ({ children, - ariaLabelledby, dataAttributes, isInverse, index, defaultIndex, onChange, + singleOpen, }) => { + const [indexList, toogle] = useAccordionState({ + value: index, + defaultValue: defaultIndex, + onChange, + singleOpen, + }); const lastIndex = React.Children.count(children) - 1; - const indexList = index === undefined ? undefined : typeof index === 'number' ? [index] : index; - const defaultIndexList = - defaultIndex === undefined - ? undefined - : typeof defaultIndex === 'number' - ? [defaultIndex] - : defaultIndex; - return ( - + {React.Children.toArray(children) .filter(Boolean) From d7ae488710e124a179f5b4d94ca178aef3c4f516 Mon Sep 17 00:00:00 2001 From: marcoskolodny Date: Fri, 29 Sep 2023 16:44:16 +0200 Subject: [PATCH 06/13] add unit tests and playroom snippets --- playroom/snippets.tsx | 220 +++++++++++++++++++++++++++++++ src/__tests__/accordion-test.tsx | 119 +++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 src/__tests__/accordion-test.tsx diff --git a/playroom/snippets.tsx b/playroom/snippets.tsx index 2797ed0542..a21724c019 100644 --- a/playroom/snippets.tsx +++ b/playroom/snippets.tsx @@ -52,6 +52,225 @@ const menuSnippet = { group: 'Menu', }; +const accordionSnippets: Array = [ + { + group: 'Accordion', + name: 'Accordion', + code: ` + + } + title="What is Movistar Money" + content={ + + It's a loan available to anyone, whether or not you're a Movistar + customer. It offers from €2,000 to €15,000 with a simple and fast + application process, and you receive the money in less than 48 hours. + + } + /> + } + title="To whom is it aimed?" + content={ + + The Movistar Money loan service is aimed at anyone, whether you are a{" "} + Movistar customer or not. + + } + /> + } + title="Who offers Movistar Money?" + content={ + +

+ At Telefónica, we have our own financial institution, Telefonica + Consumer Finance, and agreements with other institutions to assist + you in obtaining your loan. +

+
+ +
+

+ Depending on the characteristics of the information you provide us, + your application will be sent to one of the institutions{" "} + with which Movistar has agreements. +

+
+ } + /> + } + title="How can I hire it?" + content={ + + It's a very agile process that you can access through the + money.movistar.es website. You can find more detailed information + about the process on our "How It Works" page. + + } + /> + } + title="What should I do if I don't receive the SMS with the contracting code?" + content={ + + It may take a few minutes until you receive the SMS with the code. If + you still haven't received the code, you can request a new one by + clicking on "resend SMS." + + } + /> +
+ `, + }, + { + group: 'Accordion', + name: 'BoxedAccordion', + code: ` + + } + title="What is Movistar Money" + content={ + + It's a loan available to anyone, whether or not you're a Movistar + customer. It offers from €2,000 to €15,000 with a simple and fast + application process, and you receive the money in less than 48 hours. + + } + /> + } + title="To whom is it aimed?" + content={ + + The Movistar Money loan service is aimed at anyone, whether you are a{" "} + Movistar customer or not. + + } + /> + } + title="Who offers Movistar Money?" + content={ + +

+ At Telefónica, we have our own financial institution, Telefonica + Consumer Finance, and agreements with other institutions to assist + you in obtaining your loan. +

+
+ +
+

+ Depending on the characteristics of the information you provide us, + your application will be sent to one of the institutions{" "} + with which Movistar has agreements. +

+
+ } + /> + } + title="How can I hire it?" + content={ + + It's a very agile process that you can access through the + money.movistar.es website. You can find more detailed information + about the process on our "How It Works" page. + + } + /> + } + title="What should I do if I don't receive the SMS with the contracting code?" + content={ + + It may take a few minutes until you receive the SMS with the code. If + you still haven't received the code, you can request a new one by + clicking on "resend SMS." + + } + /> +
+ `, + }, + { + group: 'Accordion', + name: 'GroupedAccordion', + code: ` + + } + title="What is Movistar Money" + content={ + + It's a loan available to anyone, whether or not you're a Movistar + customer. It offers from €2,000 to €15,000 with a simple and fast + application process, and you receive the money in less than 48 hours. + + } + /> + } + title="To whom is it aimed?" + content={ + + The Movistar Money loan service is aimed at anyone, whether you are a{" "} + Movistar customer or not. + + } + /> + } + title="Who offers Movistar Money?" + content={ + +

+ At Telefónica, we have our own financial institution, Telefonica + Consumer Finance, and agreements with other institutions to assist + you in obtaining your loan. +

+
+ +
+

+ Depending on the characteristics of the information you provide us, + your application will be sent to one of the institutions{" "} + with which Movistar has agreements. +

+
+ } + /> + } + title="How can I hire it?" + content={ + + It's a very agile process that you can access through the + money.movistar.es website. You can find more detailed information + about the process on our "How It Works" page. + + } + /> + } + title="What should I do if I don't receive the SMS with the contracting code?" + content={ + + It may take a few minutes until you receive the SMS with the code. If + you still haven't received the code, you can request a new one by + clicking on "resend SMS." + + } + /> +
+ `, + }, +]; + const buttonSnippets: Array = [ {name: 'ButtonPrimary', code: ' {}}>Action'}, {name: 'ButtonSecondary', code: ' {}}>Action'}, @@ -2458,6 +2677,7 @@ export default [ {group: 'Badge', name: 'Icon with badge', code: ''}, {group: 'Text', name: 'Text', code: 'some text'}, ...headerSnippets, + ...accordionSnippets, ...listSnippets, ...listSnippetsAvatar, ...listRowSnippets, diff --git a/src/__tests__/accordion-test.tsx b/src/__tests__/accordion-test.tsx new file mode 100644 index 0000000000..b9a8579817 --- /dev/null +++ b/src/__tests__/accordion-test.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import userEvent from '@testing-library/user-event'; +import {render, screen, waitFor} from '@testing-library/react'; +import {ThemeContextProvider, Text3, AccordionItem, Accordion} from '..'; +import {makeTheme} from './test-utils'; + +const items = [ + { + title: 'Title 1', + content: Content 1, + }, + { + title: 'Title 2', + content: Content 2, + }, + { + title: 'Title 3', + content: Content 3, + }, +]; + +test('Accordion', async () => { + render( + + + {items.map((item) => ( + + ))} + + + ); + + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 3')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Title 1')); + await userEvent.click(screen.getByText('Title 3')); + + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.getByText('Content 3')).toBeInTheDocument(); +}); + +test('Accordion with index', async () => { + render( + + + {items.map((item) => ( + + ))} + + + ); + + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.getByText('Content 3')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Title 1')); + await userEvent.click(screen.getByText('Title 3')); + + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.getByText('Content 3')).toBeInTheDocument(); +}); + +test('Accordion with default index', async () => { + render( + + + {items.map((item) => ( + + ))} + + + ); + + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.getByText('Content 3')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Title 1')); + await userEvent.click(screen.getByText('Title 2')); + + /** We need to wait for CSS transition to finish in order for panel to be removed */ + await waitFor(() => { + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + expect(screen.getByText('Content 3')).toBeInTheDocument(); +}); + +test('Accordion with singleOpen', async () => { + render( + + + {items.map((item) => ( + + ))} + + + ); + + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 3')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Title 1')); + await userEvent.click(screen.getByText('Title 2')); + await userEvent.click(screen.getByText('Title 3')); + + /** We need to wait for CSS transition to finish in order for panel to be removed */ + await waitFor(() => { + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Content 3')).toBeInTheDocument(); +}); From 21a44737058a05079bc7df385f3aed06e85629ab Mon Sep 17 00:00:00 2001 From: marcoskolodny Date: Fri, 29 Sep 2023 17:30:09 +0200 Subject: [PATCH 07/13] add accordion stories --- src/__stories__/accordion-story.tsx | 160 ++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/__stories__/accordion-story.tsx diff --git a/src/__stories__/accordion-story.tsx b/src/__stories__/accordion-story.tsx new file mode 100644 index 0000000000..1012a3fe49 --- /dev/null +++ b/src/__stories__/accordion-story.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { + Accordion, + AccordionItem, + Avatar, + Box, + BoxedAccordion, + BoxedAccordionItem, + Circle, + GroupedAccordion, + IconLikeFilled, + IconMobileDeviceRegular, + Image, + ResponsiveLayout, + skinVars, + Text3, +} from '..'; +import usingVrImg from './images/using-vr.jpg'; +import laptopImg from './images/laptop.jpg'; +import avatarImg from './images/avatar.jpg'; +import touchImg from './images/touch.jpg'; +import personPortraitImg from './images/person-portrait.jpg'; + +export default { + title: 'Components/Accordions', + parameters: {fullScreen: true}, +}; + +type Args = {title: string; subtitle: string; singleOpen: boolean; inverse: boolean}; + +const Template: StoryComponent = ({ + title, + subtitle, + singleOpen, + inverse, + type, +}) => { + const content = ( + + Content + + ); + + const AccordionComponent = + type === 'boxed' ? BoxedAccordion : type === 'grouped' ? GroupedAccordion : Accordion; + const ItemComponent = type === 'boxed' ? BoxedAccordionItem : AccordionItem; + + return ( + + + + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-2'}} + /> + + + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-3'}} + /> + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-4'}} + /> + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-5'}} + /> + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-6'}} + /> + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-7'}} + /> + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-8'}} + /> + + } + {...(type === 'boxed' && {isInverse: inverse})} + dataAttributes={{testid: 'accordion-item-9'}} + /> + + + + ); +}; + +const defaultArgs = { + title: 'Title', + subtitle: 'Subtitle', + singleOpen: false, + inverse: false, +}; + +export const AccordionStory: StoryComponent = (args) =>