From ff56e6a1392a63e9e74c2ffdc707c3625aec8828 Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 28 Aug 2024 16:52:51 +0200 Subject: [PATCH 01/14] feat(Accordion): new component --- .../Accordion/Accordion.module.scss | 68 +++++++++++ .../Accordion/Accordion.stories.tsx | 18 +++ .../src/components/Accordion/Accordion.tsx | 112 ++++++++++++++++++ .../src/components/Accordion/index.ts | 1 + packages/react-components/src/index.ts | 1 + 5 files changed, 200 insertions(+) create mode 100644 packages/react-components/src/components/Accordion/Accordion.module.scss create mode 100644 packages/react-components/src/components/Accordion/Accordion.stories.tsx create mode 100644 packages/react-components/src/components/Accordion/Accordion.tsx create mode 100644 packages/react-components/src/components/Accordion/index.ts diff --git a/packages/react-components/src/components/Accordion/Accordion.module.scss b/packages/react-components/src/components/Accordion/Accordion.module.scss new file mode 100644 index 000000000..cae06ae84 --- /dev/null +++ b/packages/react-components/src/components/Accordion/Accordion.module.scss @@ -0,0 +1,68 @@ +$base-class: 'accordion'; + +.#{$base-class} { + display: flex; + position: relative; + flex-direction: column; + justify-content: space-between; + transition: all var(--transition-duration-moderate-1); + border: 1px solid transparent; + border-radius: var(--radius-4); + box-shadow: unset; + background-color: var(--surface-secondary-default); + padding: var(--spacing-5) var(--spacing-12) var(--spacing-5) var(--spacing-5); + width: 100%; + min-height: 24px; + + &:focus-visible { + outline: 0; + box-shadow: var(--shadow-focus); + } + + &:hover { + border-color: var(--border-basic-hover); + cursor: pointer; + } + + &--warning { + background-color: var(--surface-accent-emphasis-min-warning); + } + + &--error { + background-color: var(--surface-accent-emphasis-min-negative); + } + + &--open { + box-shadow: var(--shadow-pop-over); + background-color: var(--surface-primary-default); + } + + &__chevron { + position: absolute; + top: 20px; + right: 20px; + transition: inherit; + + &--open { + transform: rotate(180deg); + } + } + + &__label { + margin: 0; + } + + &__multiline-element { + margin-top: var(--spacing-1); + } + + &__content { + transition: inherit; + height: 100%; + overflow: hidden; + + &__inner { + padding-top: var(--spacing-5); + } + } +} diff --git a/packages/react-components/src/components/Accordion/Accordion.stories.tsx b/packages/react-components/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 000000000..15d064f6a --- /dev/null +++ b/packages/react-components/src/components/Accordion/Accordion.stories.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { Meta } from '@storybook/react'; + +import { Accordion } from './Accordion'; + +export default { + title: 'Components/Accordion', + component: Accordion, +} as Meta; + +export const Default = (): React.ReactElement => { + return ( +
+ Accordion content +
+ ); +}; diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx new file mode 100644 index 000000000..246a11448 --- /dev/null +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; + +import { ChevronDown } from '@livechat/design-system-icons'; +import cx from 'clsx'; + +import { ComponentCoreProps } from '../../utils/types'; +import { Icon } from '../Icon'; +import { Text } from '../Typography'; + +import styles from './Accordion.module.scss'; + +const baseClass = 'accordion'; + +export interface IAccordionProps extends ComponentCoreProps { + /** + * Specify the content of the accordion + */ + children: React.ReactNode; + /** + * Specify the label of the accordion + */ + label: React.ReactNode; + /** + * Specify the multiline element, which will be displayed under the label + */ + multilineElement?: React.ReactNode; + /** + * Specify the kind of the accordion + */ + kind?: 'default' | 'warning' | 'error'; +} + +export const Accordion: React.FC = ({ + children, + label, + multilineElement, + kind = 'default', +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const contentRef = React.useRef(null); + const [size, setSize] = React.useState(0); + const previousSizeRef = React.useRef(size); + + const handleClick = () => { + setIsOpen((prev) => !prev); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + setIsOpen((prev) => !prev); + } + }; + + React.useEffect(() => { + const hasIOSupport = !!window.ResizeObserver; + + if (contentRef.current && hasIOSupport) { + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + + const newSize = entry.contentRect.height; + + if (previousSizeRef.current !== newSize) { + setSize(newSize); + previousSizeRef.current = newSize; + } + }); + + resizeObserver.observe(contentRef.current); + + return () => resizeObserver.disconnect(); + } + }, [contentRef]); + + return ( +
+ + + {label} + + {multilineElement && ( +
+ {multilineElement} +
+ )} +
+
+ + {children} + +
+
+
+ ); +}; diff --git a/packages/react-components/src/components/Accordion/index.ts b/packages/react-components/src/components/Accordion/index.ts new file mode 100644 index 000000000..feee0c4f0 --- /dev/null +++ b/packages/react-components/src/components/Accordion/index.ts @@ -0,0 +1 @@ +export { Accordion } from './Accordion'; diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index 2378378df..9cc226a3a 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -4,6 +4,7 @@ export * from './foundations'; export * from './providers'; export * from './utils'; +export * from './components/Accordion'; export * from './components/ActionBar'; export * from './components/ActionCard'; export * from './components/ActionMenu'; From 8ab71a8c9869a7f0ac42c1c6655d81aab8a3d835 Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Thu, 29 Aug 2024 15:55:34 +0200 Subject: [PATCH 02/14] feat(Accordion): styles and keyboard controls --- .../Accordion/Accordion.module.scss | 30 +++++++- .../Accordion/Accordion.stories.css | 16 ++++ .../Accordion/Accordion.stories.tsx | 75 ++++++++++++++++++- .../src/components/Accordion/Accordion.tsx | 40 ++++++++-- .../components/Accordion/stories-helpers.tsx | 39 ++++++++++ 5 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 packages/react-components/src/components/Accordion/Accordion.stories.css create mode 100644 packages/react-components/src/components/Accordion/stories-helpers.tsx diff --git a/packages/react-components/src/components/Accordion/Accordion.module.scss b/packages/react-components/src/components/Accordion/Accordion.module.scss index cae06ae84..717c744e2 100644 --- a/packages/react-components/src/components/Accordion/Accordion.module.scss +++ b/packages/react-components/src/components/Accordion/Accordion.module.scss @@ -10,7 +10,6 @@ $base-class: 'accordion'; border-radius: var(--radius-4); box-shadow: unset; background-color: var(--surface-secondary-default); - padding: var(--spacing-5) var(--spacing-12) var(--spacing-5) var(--spacing-5); width: 100%; min-height: 24px; @@ -21,20 +20,31 @@ $base-class: 'accordion'; &:hover { border-color: var(--border-basic-hover); - cursor: pointer; } &--warning { background-color: var(--surface-accent-emphasis-min-warning); + + &:hover { + border-color: var(--border-basic-warning); + } } &--error { background-color: var(--surface-accent-emphasis-min-negative); + + &:hover { + border-color: var(--content-basic-negative); + } } &--open { box-shadow: var(--shadow-pop-over); background-color: var(--surface-primary-default); + + &:hover { + border-color: transparent; + } } &__chevron { @@ -42,6 +52,7 @@ $base-class: 'accordion'; top: 20px; right: 20px; transition: inherit; + z-index: 0; &--open { transform: rotate(180deg); @@ -49,7 +60,14 @@ $base-class: 'accordion'; } &__label { + z-index: 1; margin: 0; + padding: var(--spacing-5) var(--spacing-12) var(--spacing-5) + var(--spacing-5); + + &:hover { + cursor: pointer; + } } &__multiline-element { @@ -62,7 +80,13 @@ $base-class: 'accordion'; overflow: hidden; &__inner { - padding-top: var(--spacing-5); + transition: all var(--transition-duration-moderate-1); + opacity: 0; + padding: 0 var(--spacing-12) var(--spacing-5) var(--spacing-5); + + &--open { + opacity: 1; + } } } } diff --git a/packages/react-components/src/components/Accordion/Accordion.stories.css b/packages/react-components/src/components/Accordion/Accordion.stories.css new file mode 100644 index 000000000..573bdb0cd --- /dev/null +++ b/packages/react-components/src/components/Accordion/Accordion.stories.css @@ -0,0 +1,16 @@ +.accordion-content-container { + border-left: 1px dashed var(--border-basic-primary); + padding-left: var(--spacing-6); +} + +.rule-picker { + margin-bottom: var(--spacing-4); + max-width: 50%; +} + +.label { + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; +} diff --git a/packages/react-components/src/components/Accordion/Accordion.stories.tsx b/packages/react-components/src/components/Accordion/Accordion.stories.tsx index 15d064f6a..b90853c60 100644 --- a/packages/react-components/src/components/Accordion/Accordion.stories.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.stories.tsx @@ -1,8 +1,17 @@ import * as React from 'react'; +import { Tag as TagIcon } from '@livechat/design-system-icons'; import { Meta } from '@storybook/react'; +import { StoryDescriptor } from '../../stories/components/StoryDescriptor'; +import { Icon } from '../Icon'; +import { IPickerListItem, Picker } from '../Picker'; +import { Tag } from '../Tag'; + import { Accordion } from './Accordion'; +import { RULE_PICKER_OPTIONS, TAGS_PICKER_OPTIONS } from './stories-helpers'; + +import './Accordion.stories.css'; export default { title: 'Components/Accordion', @@ -10,9 +19,73 @@ export default { } as Meta; export const Default = (): React.ReactElement => { + return Accordion content; +}; + +export const Kinds = (): React.ReactElement => { + return ( +
+ + + Default accordion content + + + + + Warning accordion content + + + + + Error accordion content + + +
+ ); +}; + +export const Example = (): React.ReactElement => { + const [ruleSelected, setRuleSelected] = React.useState< + IPickerListItem[] | null + >([RULE_PICKER_OPTIONS[0]]); + const [tagSelected, setTagSelected] = React.useState< + IPickerListItem[] | null + >([TAGS_PICKER_OPTIONS[0]]); + return (
- Accordion content + + + Tag {ruleSelected && ruleSelected[0].name} + and + {tagSelected && + tagSelected && + tagSelected.map((tag) => {tag.name})} +
+ } + > +
+
+ setRuleSelected(selected)} + /> +
+ setTagSelected(selected)} + /> +
+ ); }; diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index 246a11448..4e70bbd5f 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -28,27 +28,45 @@ export interface IAccordionProps extends ComponentCoreProps { * Specify the kind of the accordion */ kind?: 'default' | 'warning' | 'error'; + /** + * Specify if the accordion should be open on init + */ + openOnInit?: boolean; } export const Accordion: React.FC = ({ + className, children, label, multilineElement, kind = 'default', + openOnInit = false, }) => { - const [isOpen, setIsOpen] = React.useState(false); + const [isOpen, setIsOpen] = React.useState(openOnInit); const contentRef = React.useRef(null); const [size, setSize] = React.useState(0); const previousSizeRef = React.useRef(size); + const mergedClassName = cx( + styles[baseClass], + styles[`${baseClass}--${kind}`], + { + [styles[`${baseClass}--open`]]: isOpen, + }, + className + ); const handleClick = () => { setIsOpen((prev) => !prev); }; const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { + if (!isOpen && (event.key === 'Enter' || event.key === ' ')) { setIsOpen((prev) => !prev); } + + if (isOpen && event.key === 'Escape') { + setIsOpen(false); + } }; React.useEffect(() => { @@ -77,10 +95,7 @@ export const Accordion: React.FC = ({
= ({ [styles[`${baseClass}__chevron--open`]]: isOpen, })} /> - + {label} {multilineElement && ( @@ -102,7 +121,12 @@ export const Accordion: React.FC = ({ style={{ maxHeight: isOpen ? size : 0 }} >
- + {children}
diff --git a/packages/react-components/src/components/Accordion/stories-helpers.tsx b/packages/react-components/src/components/Accordion/stories-helpers.tsx new file mode 100644 index 000000000..84e7569c4 --- /dev/null +++ b/packages/react-components/src/components/Accordion/stories-helpers.tsx @@ -0,0 +1,39 @@ +import { IPickerListItem } from '../Picker'; + +export const RULE_PICKER_OPTIONS: IPickerListItem[] = [ + { + key: 'is', + name: 'Is', + }, + { + key: 'is-not', + name: 'Is not', + }, +]; + +export const TAGS_PICKER_OPTIONS: IPickerListItem[] = [ + { + key: 'chat', + name: 'Chat', + }, + { + key: 'email', + name: 'Email', + }, + { + key: 'on-hold', + name: 'On Hold', + }, + { + key: 'chatbot', + name: 'Chatbot', + }, + { + key: 'messages', + name: 'Messages', + }, + { + key: 'urgent', + name: 'Urgent', + }, +]; From aa80829d2d75e315ce6e63633aa0d01c485d2a3d Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Fri, 30 Aug 2024 15:01:23 +0200 Subject: [PATCH 03/14] feat(Accordion): animated label with two types --- .../Accordion/Accordion.stories.tsx | 23 ++--- .../src/components/Accordion/Accordion.tsx | 70 +++++++--------- .../AccordionAnimatedLabel.module.scss | 19 +++++ .../components/AccordionAnimatedLabel.tsx | 84 +++++++++++++++++++ .../src/components/Accordion/helpers.tsx | 33 ++++++++ .../src/components/Accordion/types.ts | 48 +++++++++++ 6 files changed, 228 insertions(+), 49 deletions(-) create mode 100644 packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss create mode 100644 packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx create mode 100644 packages/react-components/src/components/Accordion/helpers.tsx create mode 100644 packages/react-components/src/components/Accordion/types.ts diff --git a/packages/react-components/src/components/Accordion/Accordion.stories.tsx b/packages/react-components/src/components/Accordion/Accordion.stories.tsx index b90853c60..21dff8398 100644 --- a/packages/react-components/src/components/Accordion/Accordion.stories.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.stories.tsx @@ -55,16 +55,19 @@ export const Example = (): React.ReactElement => { return (
- - Tag {ruleSelected && ruleSelected[0].name} - and - {tagSelected && - tagSelected && - tagSelected.map((tag) => {tag.name})} -
- } + label={{ + closed: ( +
+ + Tag {ruleSelected && ruleSelected[0].name} + and + {tagSelected && + tagSelected && + tagSelected.map((tag) => {tag.name})} +
+ ), + open:
Edit your tags
, + }} >
diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index 4e70bbd5f..b659cff09 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -3,37 +3,16 @@ import * as React from 'react'; import { ChevronDown } from '@livechat/design-system-icons'; import cx from 'clsx'; -import { ComponentCoreProps } from '../../utils/types'; import { Icon } from '../Icon'; import { Text } from '../Typography'; +import { getLabel } from './helpers'; +import { IAccordionProps } from './types'; + import styles from './Accordion.module.scss'; const baseClass = 'accordion'; -export interface IAccordionProps extends ComponentCoreProps { - /** - * Specify the content of the accordion - */ - children: React.ReactNode; - /** - * Specify the label of the accordion - */ - label: React.ReactNode; - /** - * Specify the multiline element, which will be displayed under the label - */ - multilineElement?: React.ReactNode; - /** - * Specify the kind of the accordion - */ - kind?: 'default' | 'warning' | 'error'; - /** - * Specify if the accordion should be open on init - */ - openOnInit?: boolean; -} - export const Accordion: React.FC = ({ className, children, @@ -41,31 +20,43 @@ export const Accordion: React.FC = ({ multilineElement, kind = 'default', openOnInit = false, + isOpen, + onClose, + onOpen, + ...props }) => { - const [isOpen, setIsOpen] = React.useState(openOnInit); + const isControlled = isOpen !== undefined; const contentRef = React.useRef(null); + const [open, setOpen] = React.useState(openOnInit); + const currentlyOpen = isControlled ? isOpen : open; const [size, setSize] = React.useState(0); const previousSizeRef = React.useRef(size); const mergedClassName = cx( styles[baseClass], styles[`${baseClass}--${kind}`], { - [styles[`${baseClass}--open`]]: isOpen, + [styles[`${baseClass}--open`]]: currentlyOpen, }, className ); - const handleClick = () => { - setIsOpen((prev) => !prev); + const handleStateChange = (state: boolean) => { + if (state) { + onClose?.(); + !isControlled && setOpen(false); + } else { + onOpen?.(); + !isControlled && setOpen(true); + } }; const handleKeyDown = (event: React.KeyboardEvent) => { - if (!isOpen && (event.key === 'Enter' || event.key === ' ')) { - setIsOpen((prev) => !prev); + if (!currentlyOpen && (event.key === 'Enter' || event.key === ' ')) { + handleStateChange(currentlyOpen); } - if (isOpen && event.key === 'Escape') { - setIsOpen(false); + if (currentlyOpen && event.key === 'Escape') { + handleStateChange(currentlyOpen); } }; @@ -94,22 +85,23 @@ export const Accordion: React.FC = ({ return (
handleStateChange(currentlyOpen)} > - {label} + {getLabel(label, currentlyOpen)} {multilineElement && (
@@ -118,13 +110,13 @@ export const Accordion: React.FC = ({ )}
-
+
{children} diff --git a/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss new file mode 100644 index 000000000..b7bac1d64 --- /dev/null +++ b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss @@ -0,0 +1,19 @@ +$base-class: 'accordion-animated-label'; + +.#{$base-class} { + display: flex; + position: relative; + align-items: center; + height: 21px; + + &__open, + &__close { + position: absolute; + transition: all var(--transition-duration-fast-2) ease-in-out; + opacity: 0; + + &--visible { + opacity: 1; + } + } +} diff --git a/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx new file mode 100644 index 000000000..1c51c1cdc --- /dev/null +++ b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; + +import cx from 'clsx'; + +import { IAccordionAnimatedLabelProps } from '../types'; + +import styles from './AccordionAnimatedLabel.module.scss'; + +const baseClass = `accordion-animated-label`; + +export const AccordionAnimatedLabel: React.FC = ({ + open, + closed, + isOpen, +}) => { + const openLabelRef = React.useRef(null); + const closeLabelRef = React.useRef(null); + const [isOpenMounted, setIsOpenMounted] = React.useState(isOpen); + const [isOpenVisible, setIsOpenVisible] = React.useState(isOpen); + const [isClosedMounted, setIsClosedMounted] = React.useState(!isOpen); + const [isClosedVisible, setIsClosedVisible] = React.useState(!isOpen); + + React.useEffect(() => { + const openRef = openLabelRef.current; + const closeRef = closeLabelRef.current; + + if (isOpen) { + setIsOpenMounted(true); + setIsClosedVisible(false); + + requestAnimationFrame(() => setIsOpenVisible(true)); + + if (closeRef) { + closeRef.addEventListener( + 'transitionend', + () => { + setIsClosedMounted(false); + }, + { once: true } + ); + } + } else { + setIsClosedMounted(true); + setIsOpenVisible(false); + + requestAnimationFrame(() => setIsClosedVisible(true)); + + if (openRef) { + openRef.addEventListener( + 'transitionend', + () => { + setIsOpenMounted(false); + }, + { once: true } + ); + } + } + }, [isOpen]); + + return ( +
+ {isOpenMounted && ( +
+ {open} +
+ )} + {isClosedMounted && ( +
+ {closed} +
+ )} +
+ ); +}; diff --git a/packages/react-components/src/components/Accordion/helpers.tsx b/packages/react-components/src/components/Accordion/helpers.tsx new file mode 100644 index 000000000..81d3abaff --- /dev/null +++ b/packages/react-components/src/components/Accordion/helpers.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { AccordionAnimatedLabel } from './components/AccordionAnimatedLabel'; + +import type { AccordionLabel } from './types'; + +export const isLabelObject = ( + label: React.ReactNode | { open: React.ReactNode; closed: React.ReactNode } +): label is { open: React.ReactNode; closed: React.ReactNode } => { + return ( + (label as { open: React.ReactNode; closed: React.ReactNode }).open !== + undefined && + (label as { open: React.ReactNode; closed: React.ReactNode }).closed !== + undefined + ); +}; + +export const getLabel = ( + label: AccordionLabel, + currentlyOpen: boolean +): React.ReactNode => { + if (isLabelObject(label)) { + const props = { + open: label.open, + closed: label.closed, + isOpen: currentlyOpen, + }; + + return ; + } + + return label; +}; diff --git a/packages/react-components/src/components/Accordion/types.ts b/packages/react-components/src/components/Accordion/types.ts new file mode 100644 index 000000000..3d57601a0 --- /dev/null +++ b/packages/react-components/src/components/Accordion/types.ts @@ -0,0 +1,48 @@ +import * as React from 'react'; + +import { ComponentCoreProps } from '../../utils/types'; + +export type AccordionLabel = + | React.ReactNode + | { open: React.ReactNode; closed: React.ReactNode }; + +export interface IAccordionProps extends ComponentCoreProps { + /** + * Specify the content of the accordion + */ + children: React.ReactNode; + /** + * Specify the label of the accordion, single element or different for open and closed state + */ + label: AccordionLabel; + /** + * Specify the multiline element, which will be displayed under the label + */ + multilineElement?: React.ReactNode; + /** + * Specify the kind of the accordion + */ + kind?: 'default' | 'warning' | 'error'; + /** + * Specify if the accordion should be open on init + */ + openOnInit?: boolean; + /** + * Set to control accordion state + */ + isOpen?: boolean; + /** + * Optional handler called on accordion close + */ + onClose?: () => void; + /** + * Optional handler called on accordion open + */ + onOpen?: () => void; +} + +export interface IAccordionAnimatedLabelProps { + open: React.ReactNode; + closed: React.ReactNode; + isOpen: boolean; +} From c3790ffd2fff0f8ecf3d7ec58a3886fbef4618ff Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Mon, 2 Sep 2024 09:12:37 +0200 Subject: [PATCH 04/14] feat(Accordion): rendering hook --- .../src/components/Accordion/Accordion.tsx | 35 +++++++++++-------- .../src/components/AppFrame/AppFrame.tsx | 4 +-- .../NavigationTopBar/NavigationTopBar.tsx | 4 +-- .../SideNavigationGroup.tsx | 4 +-- packages/react-components/src/utils/index.ts | 1 + .../useAnimations.ts} | 12 +++---- 6 files changed, 34 insertions(+), 26 deletions(-) rename packages/react-components/src/{components/AppFrame/hooks/useAppFrameAnimations.ts => utils/useAnimations.ts} (78%) diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index b659cff09..f3378d21b 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { ChevronDown } from '@livechat/design-system-icons'; import cx from 'clsx'; +import { useAnimations } from '../../utils'; import { Icon } from '../Icon'; import { Text } from '../Typography'; @@ -39,6 +40,10 @@ export const Accordion: React.FC = ({ }, className ); + const { isOpen: isVisible, isMounted } = useAnimations({ + isVisible: currentlyOpen, + elementRef: contentRef, + }); const handleStateChange = (state: boolean) => { if (state) { @@ -85,7 +90,7 @@ export const Accordion: React.FC = ({ return (
= ({ handleStateChange(currentlyOpen)} + onClick={() => handleStateChange(isVisible)} > - {getLabel(label, currentlyOpen)} + {getLabel(label, isVisible)} {multilineElement && (
@@ -110,17 +115,19 @@ export const Accordion: React.FC = ({ )}
-
- - {children} - +
+ {isMounted && ( + + {children} + + )}
diff --git a/packages/react-components/src/components/AppFrame/AppFrame.tsx b/packages/react-components/src/components/AppFrame/AppFrame.tsx index b301320ca..c36465bdd 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.tsx +++ b/packages/react-components/src/components/AppFrame/AppFrame.tsx @@ -3,8 +3,8 @@ import * as React from 'react'; import cx from 'clsx'; import { AppFrameProvider, useAppFrame } from '../../providers'; +import { useAnimations } from '../../utils'; -import { useAppFrameAnimations } from './hooks/useAppFrameAnimations'; import { IAppFrameProps } from './types'; import styles from './AppFrame.module.scss'; @@ -26,7 +26,7 @@ const Frame = (props: IAppFrameProps) => { const mergedClassNames = cx(styles[baseClass], className); const { isSideNavigationVisible } = useAppFrame(); const sideNavWrapperRef = React.useRef(null); - const { isOpen, isMounted } = useAppFrameAnimations({ + const { isOpen, isMounted } = useAnimations({ isVisible: isSideNavigationVisible, elementRef: sideNavWrapperRef, }); diff --git a/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx b/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx index 3d104e443..f1a73c739 100644 --- a/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx +++ b/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx @@ -3,9 +3,9 @@ import * as React from 'react'; import { Close } from '@livechat/design-system-icons'; import cx from 'clsx'; +import { useAnimations } from '../../../../utils'; import { Button } from '../../../Button'; import { Icon } from '../../../Icon'; -import { useAppFrameAnimations } from '../../hooks/useAppFrameAnimations'; import { INavigationTopBarProps, @@ -90,7 +90,7 @@ export const NavigationTopBarAlert: React.FC = ({ isVisible = true, }) => { const alertRef = React.useRef(null); - const { isMounted, isOpen } = useAppFrameAnimations({ + const { isMounted, isOpen } = useAnimations({ isVisible, elementRef: alertRef, }); diff --git a/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx b/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx index 4b6ec0e62..512040345 100644 --- a/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx +++ b/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx @@ -3,10 +3,10 @@ import * as React from 'react'; import { ChevronRight } from '@livechat/design-system-icons'; import cx from 'clsx'; +import { useAnimations } from '../../../../utils'; import noop from '../../../../utils/noop'; import { Icon } from '../../../Icon'; import { Text } from '../../../Typography'; -import { useAppFrameAnimations } from '../../hooks/useAppFrameAnimations'; import { SideNavigationItem } from '../SideNavigationItem/SideNavigationItem'; import { ISideNavigationGroupProps } from './types'; @@ -31,7 +31,7 @@ export const SideNavigationGroup: React.FC = ({ const [listHeight, setListHeight] = React.useState(0); const hadActiveListElementsRef = React.useRef(false); const listWrapperRef = React.useRef(null); - const { isOpen, isMounted, setIsOpen } = useAppFrameAnimations({ + const { isOpen, isMounted, setIsOpen } = useAnimations({ isVisible: !isCollapsible || shouldOpenOnInit, elementRef: listWrapperRef, }); diff --git a/packages/react-components/src/utils/index.ts b/packages/react-components/src/utils/index.ts index e93c5a470..72986c8ca 100644 --- a/packages/react-components/src/utils/index.ts +++ b/packages/react-components/src/utils/index.ts @@ -1,2 +1,3 @@ export type { Size } from './types'; export { getDesignTokenWithOpacity } from './getDesignTokenWithOpacity'; +export { useAnimations } from './useAnimations'; diff --git a/packages/react-components/src/components/AppFrame/hooks/useAppFrameAnimations.ts b/packages/react-components/src/utils/useAnimations.ts similarity index 78% rename from packages/react-components/src/components/AppFrame/hooks/useAppFrameAnimations.ts rename to packages/react-components/src/utils/useAnimations.ts index 261ad8577..b81dcfeee 100644 --- a/packages/react-components/src/components/AppFrame/hooks/useAppFrameAnimations.ts +++ b/packages/react-components/src/utils/useAnimations.ts @@ -1,24 +1,24 @@ import * as React from 'react'; -interface UseAppFrameAnimationsProps { +interface UseAnimationsProps { isVisible: boolean; elementRef: React.RefObject; } -interface IUseAppFrameAnimations { +interface IUseAnimations { isOpen: boolean; isMounted: boolean; setIsOpen: React.Dispatch>; } -export const useAppFrameAnimations = ({ +export const useAnimations = ({ isVisible, elementRef, -}: UseAppFrameAnimationsProps): IUseAppFrameAnimations => { +}: UseAnimationsProps): IUseAnimations => { const [isMounted, setisMounted] = React.useState(isVisible); const [isOpen, setIsOpen] = React.useState(isVisible); - // The main part of the logic responsible for managing the states used to animate the side menu group opening/closing and mounting/unmounting the side menu elements + // The main part of the logic responsible for managing the states used to animate the container opening/closing and mounting/unmounting the container elements React.useEffect(() => { const sideNavWrapper = elementRef.current; @@ -45,7 +45,7 @@ export const useAppFrameAnimations = ({ return setIsOpen(false); }, [isOpen]); - // Additional logic, dedicated to the side menu wrapper whose visibility is managed by the context + // Additional logic, dedicated to the container wrapper whose visibility is managed by the context React.useEffect(() => { if (isVisible) { setisMounted(true); From a160df7f73e6091257743a48d43f5f4081f79e3b Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Mon, 2 Sep 2024 13:56:34 +0200 Subject: [PATCH 05/14] feat(Accordion): multiline component --- .../Accordion/Accordion.module.scss | 4 - .../Accordion/Accordion.stories.css | 15 ++++ .../Accordion/Accordion.stories.tsx | 83 ++++++++++++------- .../src/components/Accordion/Accordion.tsx | 33 ++++---- .../AccordionMultilineElement.module.scss | 11 +++ .../components/AccordionMultilineElement.tsx | 59 +++++++++++++ 6 files changed, 153 insertions(+), 52 deletions(-) create mode 100644 packages/react-components/src/components/Accordion/components/AccordionMultilineElement.module.scss create mode 100644 packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx diff --git a/packages/react-components/src/components/Accordion/Accordion.module.scss b/packages/react-components/src/components/Accordion/Accordion.module.scss index 717c744e2..8ec7b14af 100644 --- a/packages/react-components/src/components/Accordion/Accordion.module.scss +++ b/packages/react-components/src/components/Accordion/Accordion.module.scss @@ -70,10 +70,6 @@ $base-class: 'accordion'; } } - &__multiline-element { - margin-top: var(--spacing-1); - } - &__content { transition: inherit; height: 100%; diff --git a/packages/react-components/src/components/Accordion/Accordion.stories.css b/packages/react-components/src/components/Accordion/Accordion.stories.css index 573bdb0cd..27f3d9712 100644 --- a/packages/react-components/src/components/Accordion/Accordion.stories.css +++ b/packages/react-components/src/components/Accordion/Accordion.stories.css @@ -14,3 +14,18 @@ gap: 5px; align-items: center; } + +.multiline { + border-radius: var(--radius-3); + background-color: var(--surface-tertiary-default); + padding: var(--spacing-3); + white-space: pre-wrap; + + p { + margin-top: 0; + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/packages/react-components/src/components/Accordion/Accordion.stories.tsx b/packages/react-components/src/components/Accordion/Accordion.stories.tsx index 21dff8398..12b8383fe 100644 --- a/packages/react-components/src/components/Accordion/Accordion.stories.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.stories.tsx @@ -44,7 +44,7 @@ export const Kinds = (): React.ReactElement => { ); }; -export const Example = (): React.ReactElement => { +export const Examples = (): React.ReactElement => { const [ruleSelected, setRuleSelected] = React.useState< IPickerListItem[] | null >([RULE_PICKER_OPTIONS[0]]); @@ -54,41 +54,60 @@ export const Example = (): React.ReactElement => { return (
- - - Tag {ruleSelected && ruleSelected[0].name} - and - {tagSelected && - tagSelected && - tagSelected.map((tag) => {tag.name})} + + + + Tag {ruleSelected && ruleSelected[0].name} + and + {tagSelected && + tagSelected && + tagSelected.map((tag) => {tag.name})} +
+ ), + open:
Edit your tags
, + }} + > +
+
+ setRuleSelected(selected)} + />
- ), - open:
Edit your tags
, - }} - > -
-
setRuleSelected(selected)} + id="first-picker" + type="multi" + options={TAGS_PICKER_OPTIONS} + selected={tagSelected} + onSelect={(selected) => setTagSelected(selected)} />
- setTagSelected(selected)} - /> -
- + + + + +

{`Hello {{ticket.requesterName}},`}

+

+ We haven't heard back from you for some time. If you need any + further help, please follow up on this email. +

+

Thank you.

+
+ } + > + Default accordion content + +
); }; diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index f3378d21b..4df7f9941 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -7,6 +7,7 @@ import { useAnimations } from '../../utils'; import { Icon } from '../Icon'; import { Text } from '../Typography'; +import { AccordionMultilineElement } from './components/AccordionMultilineElement'; import { getLabel } from './helpers'; import { IAccordionProps } from './types'; @@ -26,12 +27,9 @@ export const Accordion: React.FC = ({ onOpen, ...props }) => { - const isControlled = isOpen !== undefined; - const contentRef = React.useRef(null); const [open, setOpen] = React.useState(openOnInit); + const isControlled = isOpen !== undefined; const currentlyOpen = isControlled ? isOpen : open; - const [size, setSize] = React.useState(0); - const previousSizeRef = React.useRef(size); const mergedClassName = cx( styles[baseClass], styles[`${baseClass}--${kind}`], @@ -40,7 +38,10 @@ export const Accordion: React.FC = ({ }, className ); - const { isOpen: isVisible, isMounted } = useAnimations({ + const contentRef = React.useRef(null); + const [size, setSize] = React.useState(0); + const previousSizeRef = React.useRef(size); + const { isOpen: isExpanded, isMounted } = useAnimations({ isVisible: currentlyOpen, elementRef: contentRef, }); @@ -56,11 +57,11 @@ export const Accordion: React.FC = ({ }; const handleKeyDown = (event: React.KeyboardEvent) => { - if (!currentlyOpen && (event.key === 'Enter' || event.key === ' ')) { + if (!isExpanded && (event.key === 'Enter' || event.key === ' ')) { handleStateChange(currentlyOpen); } - if (currentlyOpen && event.key === 'Escape') { + if (isExpanded && event.key === 'Escape') { handleStateChange(currentlyOpen); } }; @@ -90,7 +91,7 @@ export const Accordion: React.FC = ({ return (
= ({ handleStateChange(isVisible)} + onClick={() => handleStateChange(isExpanded)} > - {getLabel(label, isVisible)} + {getLabel(label, isExpanded)} {multilineElement && ( -
+ {multilineElement} -
+ )}
-
+
{isMounted && ( {children} diff --git a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.module.scss b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.module.scss new file mode 100644 index 000000000..3fd42a7c1 --- /dev/null +++ b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.module.scss @@ -0,0 +1,11 @@ +$base-class: 'accordion-multiline'; + +.#{$base-class} { + transition: inherit; + height: 100%; + overflow: hidden; + + &__inner { + padding: 0 var(--spacing-12) var(--spacing-5) var(--spacing-5); + } +} diff --git a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx new file mode 100644 index 000000000..1c7ecfb92 --- /dev/null +++ b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; + +import { useAnimations } from '../../../utils'; + +import styles from './AccordionMultilineElement.module.scss'; + +const baseClass = 'accordion-multiline'; + +export interface IAccordionMultilineElementProps { + children: React.ReactNode; + isExpanded: boolean; +} + +export const AccordionMultilineElement: React.FC< + IAccordionMultilineElementProps +> = ({ children, isExpanded }) => { + const multilineRef = React.useRef(null); + const [size, setSize] = React.useState(0); + const previousMultilineSizeRef = React.useRef(size); + const { isOpen, isMounted } = useAnimations({ + isVisible: !isExpanded, + elementRef: multilineRef, + }); + + React.useEffect(() => { + const hasIOSupport = !!window.ResizeObserver; + + if (multilineRef.current && hasIOSupport) { + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + + const newSize = entry.contentRect.height; + + if (previousMultilineSizeRef.current !== newSize) { + setSize(newSize); + previousMultilineSizeRef.current = newSize; + } + }); + + resizeObserver.observe(multilineRef.current); + + return () => resizeObserver.disconnect(); + } + }, [multilineRef]); + + return ( +
+
+ {isMounted && ( +
{children}
+ )} +
+
+ ); +}; From d114608e265acc57577e3af0800f2726b70d29f2 Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Mon, 2 Sep 2024 14:28:18 +0200 Subject: [PATCH 06/14] feat(Accordion): update --- .../Accordion/components/AccordionMultilineElement.tsx | 6 +++--- packages/react-components/src/utils/useAnimations.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx index 1c7ecfb92..69f2db8a2 100644 --- a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx +++ b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx @@ -17,7 +17,7 @@ export const AccordionMultilineElement: React.FC< const multilineRef = React.useRef(null); const [size, setSize] = React.useState(0); const previousMultilineSizeRef = React.useRef(size); - const { isOpen, isMounted } = useAnimations({ + const { isOpen: isVisible, isMounted } = useAnimations({ isVisible: !isExpanded, elementRef: multilineRef, }); @@ -47,9 +47,9 @@ export const AccordionMultilineElement: React.FC< return (
-
+
{isMounted && (
{children}
)} diff --git a/packages/react-components/src/utils/useAnimations.ts b/packages/react-components/src/utils/useAnimations.ts index b81dcfeee..ee17ffc86 100644 --- a/packages/react-components/src/utils/useAnimations.ts +++ b/packages/react-components/src/utils/useAnimations.ts @@ -20,15 +20,15 @@ export const useAnimations = ({ // The main part of the logic responsible for managing the states used to animate the container opening/closing and mounting/unmounting the container elements React.useEffect(() => { - const sideNavWrapper = elementRef.current; + const currentElement = elementRef.current; - if (!isOpen && sideNavWrapper) { + if (!isOpen && currentElement) { const handleTransitionEnd = () => setisMounted(false); - sideNavWrapper.addEventListener('transitionend', handleTransitionEnd); + currentElement.addEventListener('transitionend', handleTransitionEnd); return () => { - sideNavWrapper.removeEventListener( + currentElement.removeEventListener( 'transitionend', handleTransitionEnd ); From 3dfba2d0c95ac9c00dcc606259697f5b89bed3d2 Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Tue, 3 Sep 2024 13:04:27 +0200 Subject: [PATCH 07/14] feat(Accordion): docs tests and updates --- .../src/components/Accordion/Accordion.mdx | 27 ++++++ .../components/Accordion/Accordion.spec.tsx | 83 +++++++++++++++++++ .../src/components/Accordion/Accordion.tsx | 36 ++++---- .../AccordionAnimatedLabel.spec.tsx | 36 ++++++++ .../components/AccordionMultilineElement.tsx | 2 +- .../src/utils/useAnimations.ts | 8 +- 6 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 packages/react-components/src/components/Accordion/Accordion.mdx create mode 100644 packages/react-components/src/components/Accordion/Accordion.spec.tsx create mode 100644 packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.spec.tsx diff --git a/packages/react-components/src/components/Accordion/Accordion.mdx b/packages/react-components/src/components/Accordion/Accordion.mdx new file mode 100644 index 000000000..52f7c5404 --- /dev/null +++ b/packages/react-components/src/components/Accordion/Accordion.mdx @@ -0,0 +1,27 @@ +import { Canvas, ArgTypes, Meta, Title } from '@storybook/blocks'; + +import * as AccordionStories from './Accordion.stories'; + + + +Accordion + +[Intro](#Intro) | [Component API](#ComponentAPI) + +## Intro + +Accordion is a simple component for building expandable tiles that display provided content when opened. + + + +#### Example implementation + +```jsx + + Default accordion content + +``` + +## Component API + + \ No newline at end of file diff --git a/packages/react-components/src/components/Accordion/Accordion.spec.tsx b/packages/react-components/src/components/Accordion/Accordion.spec.tsx new file mode 100644 index 000000000..30d5df0a1 --- /dev/null +++ b/packages/react-components/src/components/Accordion/Accordion.spec.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; + +import { vi } from 'vitest'; + +import { render, userEvent, waitFor } from 'test-utils'; + +import { Accordion } from './Accordion'; +import { IAccordionProps } from './types'; + +const DEFAULT_PROPS = { + label: 'Label', + children:
Content
, +}; + +const renderComponent = (props: IAccordionProps) => { + return render(); +}; + +describe(' component', () => { + it('should allow for custom class', () => { + const { container } = renderComponent({ + ...DEFAULT_PROPS, + className: 'my-class', + }); + + expect(container.firstChild).toHaveClass('my-class'); + }); + + it('should render as closed by default', () => { + const { getByText, queryByText } = renderComponent(DEFAULT_PROPS); + + expect(getByText('Label')).toBeInTheDocument(); + expect(queryByText('Content')).not.toBeInTheDocument(); + }); + + it('should render as open if openOnInit is set true', () => { + const { getByText } = renderComponent({ + ...DEFAULT_PROPS, + openOnInit: true, + }); + + expect(getByText('Label')).toBeInTheDocument(); + expect(getByText('Content')).toBeInTheDocument(); + }); + + it('should call onClose and onOpen handlers on label click', () => { + const onClose = vi.fn(); + const onOpen = vi.fn(); + const { getByRole } = renderComponent({ + ...DEFAULT_PROPS, + onClose, + onOpen, + }); + + userEvent.click(getByRole('button')); + expect(onOpen).toHaveBeenCalledTimes(1); + userEvent.click(getByRole('button')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should show different label content when open and closed', async () => { + const { getByText, getByRole } = renderComponent({ + ...DEFAULT_PROPS, + label: { + open:
Open label
, + closed:
Closed label
, + }, + }); + + expect(getByText('Closed label')).toBeInTheDocument(); + userEvent.click(getByRole('button')); + await waitFor(() => expect(getByText('Open label')).toBeInTheDocument()); + }); + + it('should show multiline element if closed', () => { + const { getByText } = renderComponent({ + ...DEFAULT_PROPS, + multilineElement:
Multi
, + }); + + expect(getByText('Multi')).toBeInTheDocument(); + }); +}); diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index 4df7f9941..7d1834d22 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -27,42 +27,45 @@ export const Accordion: React.FC = ({ onOpen, ...props }) => { - const [open, setOpen] = React.useState(openOnInit); const isControlled = isOpen !== undefined; - const currentlyOpen = isControlled ? isOpen : open; + const currentlyOpen = isControlled ? isOpen : openOnInit; + const contentRef = React.useRef(null); + const [size, setSize] = React.useState(0); + const previousSizeRef = React.useRef(size); + const { + isOpen: isExpanded, + isMounted, + setIsOpen, + } = useAnimations({ + isVisible: currentlyOpen, + elementRef: contentRef, + }); const mergedClassName = cx( styles[baseClass], styles[`${baseClass}--${kind}`], { - [styles[`${baseClass}--open`]]: currentlyOpen, + [styles[`${baseClass}--open`]]: isExpanded, }, className ); - const contentRef = React.useRef(null); - const [size, setSize] = React.useState(0); - const previousSizeRef = React.useRef(size); - const { isOpen: isExpanded, isMounted } = useAnimations({ - isVisible: currentlyOpen, - elementRef: contentRef, - }); const handleStateChange = (state: boolean) => { if (state) { onClose?.(); - !isControlled && setOpen(false); + !isControlled && setIsOpen(false); } else { onOpen?.(); - !isControlled && setOpen(true); + !isControlled && setIsOpen(true); } }; const handleKeyDown = (event: React.KeyboardEvent) => { if (!isExpanded && (event.key === 'Enter' || event.key === ' ')) { - handleStateChange(currentlyOpen); + handleStateChange(isExpanded); } if (isExpanded && event.key === 'Escape') { - handleStateChange(currentlyOpen); + handleStateChange(isExpanded); } }; @@ -103,6 +106,9 @@ export const Accordion: React.FC = ({ })} /> handleStateChange(isExpanded)} @@ -118,7 +124,7 @@ export const Accordion: React.FC = ({ className={styles[`${baseClass}__content`]} style={{ maxHeight: isExpanded ? size : 0 }} > -
+
{isMounted && ( Open label
, + closed:
Closed label
, + isOpen: false, +}; + +const renderComponent = (props: IAccordionAnimatedLabelProps) => { + return render(); +}; + +describe(' component', () => { + it('should render closed label if isOpen false', () => { + const { getByText, queryByText } = renderComponent(DEFAULT_PROPS); + + expect(getByText('Closed label')).toBeInTheDocument(); + expect(queryByText('Open label')).not.toBeInTheDocument(); + }); + + it('should render open label if isOpen true', () => { + const { getByText, queryByText } = renderComponent({ + ...DEFAULT_PROPS, + isOpen: true, + }); + + expect(getByText('Open label')).toBeInTheDocument(); + expect(queryByText('Closed label')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx index 69f2db8a2..694c3f6f9 100644 --- a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx +++ b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx @@ -49,7 +49,7 @@ export const AccordionMultilineElement: React.FC< className={styles[`${baseClass}`]} style={{ maxHeight: isVisible ? size : 0 }} > -
+
{isMounted && (
{children}
)} diff --git a/packages/react-components/src/utils/useAnimations.ts b/packages/react-components/src/utils/useAnimations.ts index ee17ffc86..c8dcb97c4 100644 --- a/packages/react-components/src/utils/useAnimations.ts +++ b/packages/react-components/src/utils/useAnimations.ts @@ -15,7 +15,7 @@ export const useAnimations = ({ isVisible, elementRef, }: UseAnimationsProps): IUseAnimations => { - const [isMounted, setisMounted] = React.useState(isVisible); + const [isMounted, setIsMounted] = React.useState(isVisible); const [isOpen, setIsOpen] = React.useState(isVisible); // The main part of the logic responsible for managing the states used to animate the container opening/closing and mounting/unmounting the container elements @@ -23,7 +23,7 @@ export const useAnimations = ({ const currentElement = elementRef.current; if (!isOpen && currentElement) { - const handleTransitionEnd = () => setisMounted(false); + const handleTransitionEnd = () => setIsMounted(false); currentElement.addEventListener('transitionend', handleTransitionEnd); @@ -36,7 +36,7 @@ export const useAnimations = ({ } if (isOpen) { - setisMounted(true); + setIsMounted(true); requestAnimationFrame(() => setIsOpen(true)); return; @@ -48,7 +48,7 @@ export const useAnimations = ({ // Additional logic, dedicated to the container wrapper whose visibility is managed by the context React.useEffect(() => { if (isVisible) { - setisMounted(true); + setIsMounted(true); requestAnimationFrame(() => setIsOpen(true)); return; From 1ea52944f2d1fc47f512f0213977ca18f211885d Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Tue, 3 Sep 2024 13:39:37 +0200 Subject: [PATCH 08/14] feat(Accordion): styles update --- .../Accordion/components/AccordionAnimatedLabel.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss index b7bac1d64..f77493545 100644 --- a/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss +++ b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss @@ -11,6 +11,7 @@ $base-class: 'accordion-animated-label'; position: absolute; transition: all var(--transition-duration-fast-2) ease-in-out; opacity: 0; + max-width: 100%; &--visible { opacity: 1; From 429a03ebd422c13d2a219e2409b06ddf4e461b63 Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 4 Sep 2024 09:27:15 +0200 Subject: [PATCH 09/14] feat(Accordion): changes after review --- .../Accordion/Accordion.module.scss | 3 +- .../src/components/Accordion/Accordion.tsx | 40 +++++-------------- .../components/AccordionAnimatedLabel.tsx | 34 +++++----------- .../components/AccordionMultilineElement.tsx | 30 ++------------ packages/react-components/src/utils/index.ts | 1 + .../src/utils/useHeightResizer.ts | 40 +++++++++++++++++++ 6 files changed, 66 insertions(+), 82 deletions(-) create mode 100644 packages/react-components/src/utils/useHeightResizer.ts diff --git a/packages/react-components/src/components/Accordion/Accordion.module.scss b/packages/react-components/src/components/Accordion/Accordion.module.scss index 8ec7b14af..8dcd89510 100644 --- a/packages/react-components/src/components/Accordion/Accordion.module.scss +++ b/packages/react-components/src/components/Accordion/Accordion.module.scss @@ -52,7 +52,7 @@ $base-class: 'accordion'; top: 20px; right: 20px; transition: inherit; - z-index: 0; + pointer-events: none; &--open { transform: rotate(180deg); @@ -60,7 +60,6 @@ $base-class: 'accordion'; } &__label { - z-index: 1; margin: 0; padding: var(--spacing-5) var(--spacing-12) var(--spacing-5) var(--spacing-5); diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index 7d1834d22..2e2ae78dc 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ChevronDown } from '@livechat/design-system-icons'; import cx from 'clsx'; -import { useAnimations } from '../../utils'; +import { useAnimations, useHeightResizer } from '../../utils'; import { Icon } from '../Icon'; import { Text } from '../Typography'; @@ -30,8 +30,6 @@ export const Accordion: React.FC = ({ const isControlled = isOpen !== undefined; const currentlyOpen = isControlled ? isOpen : openOnInit; const contentRef = React.useRef(null); - const [size, setSize] = React.useState(0); - const previousSizeRef = React.useRef(size); const { isOpen: isExpanded, isMounted, @@ -48,9 +46,10 @@ export const Accordion: React.FC = ({ }, className ); + const { size, handleResize } = useHeightResizer(); - const handleStateChange = (state: boolean) => { - if (state) { + const handleExpandChange = (isExpanded: boolean) => { + if (isExpanded) { onClose?.(); !isControlled && setIsOpen(false); } else { @@ -61,36 +60,14 @@ export const Accordion: React.FC = ({ const handleKeyDown = (event: React.KeyboardEvent) => { if (!isExpanded && (event.key === 'Enter' || event.key === ' ')) { - handleStateChange(isExpanded); + handleExpandChange(isExpanded); } if (isExpanded && event.key === 'Escape') { - handleStateChange(isExpanded); + handleExpandChange(isExpanded); } }; - React.useEffect(() => { - const hasIOSupport = !!window.ResizeObserver; - - if (contentRef.current && hasIOSupport) { - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - - const newSize = entry.contentRect.height; - - if (previousSizeRef.current !== newSize) { - setSize(newSize); - previousSizeRef.current = newSize; - } - }); - - resizeObserver.observe(contentRef.current); - - return () => resizeObserver.disconnect(); - } - }, [contentRef]); - return (
= ({ as="div" bold className={styles[`${baseClass}__label`]} - onClick={() => handleStateChange(isExpanded)} + onClick={() => handleExpandChange(isExpanded)} > {getLabel(label, isExpanded)} @@ -123,8 +100,9 @@ export const Accordion: React.FC = ({
-
+
{isMounted && ( = ({ React.useEffect(() => { const openRef = openLabelRef.current; const closeRef = closeLabelRef.current; + const currentRef = isOpen ? openRef : closeRef; if (isOpen) { setIsOpenMounted(true); setIsClosedVisible(false); - - requestAnimationFrame(() => setIsOpenVisible(true)); - - if (closeRef) { - closeRef.addEventListener( - 'transitionend', - () => { - setIsClosedMounted(false); - }, - { once: true } - ); - } } else { setIsClosedMounted(true); setIsOpenVisible(false); + } - requestAnimationFrame(() => setIsClosedVisible(true)); + requestAnimationFrame(() => + isOpen ? setIsOpenVisible(true) : setIsClosedVisible(true) + ); - if (openRef) { - openRef.addEventListener( - 'transitionend', - () => { - setIsOpenMounted(false); - }, - { once: true } - ); - } - } + currentRef && + currentRef.addEventListener( + 'transitionend', + () => (isOpen ? setIsClosedMounted(false) : setIsOpenMounted(false)), + { once: true } + ); }, [isOpen]); return ( diff --git a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx index 694c3f6f9..90f55a128 100644 --- a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx +++ b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { useAnimations } from '../../../utils'; +import { useAnimations, useHeightResizer } from '../../../utils'; import styles from './AccordionMultilineElement.module.scss'; @@ -15,41 +15,19 @@ export const AccordionMultilineElement: React.FC< IAccordionMultilineElementProps > = ({ children, isExpanded }) => { const multilineRef = React.useRef(null); - const [size, setSize] = React.useState(0); - const previousMultilineSizeRef = React.useRef(size); const { isOpen: isVisible, isMounted } = useAnimations({ isVisible: !isExpanded, elementRef: multilineRef, }); - - React.useEffect(() => { - const hasIOSupport = !!window.ResizeObserver; - - if (multilineRef.current && hasIOSupport) { - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - - const newSize = entry.contentRect.height; - - if (previousMultilineSizeRef.current !== newSize) { - setSize(newSize); - previousMultilineSizeRef.current = newSize; - } - }); - - resizeObserver.observe(multilineRef.current); - - return () => resizeObserver.disconnect(); - } - }, [multilineRef]); + const { size, handleResize } = useHeightResizer(); return (
-
+
{isMounted && (
{children}
)} diff --git a/packages/react-components/src/utils/index.ts b/packages/react-components/src/utils/index.ts index 72986c8ca..49f1cbe80 100644 --- a/packages/react-components/src/utils/index.ts +++ b/packages/react-components/src/utils/index.ts @@ -1,3 +1,4 @@ export type { Size } from './types'; export { getDesignTokenWithOpacity } from './getDesignTokenWithOpacity'; export { useAnimations } from './useAnimations'; +export { useHeightResizer } from './useHeightResizer'; diff --git a/packages/react-components/src/utils/useHeightResizer.ts b/packages/react-components/src/utils/useHeightResizer.ts new file mode 100644 index 000000000..b4230d929 --- /dev/null +++ b/packages/react-components/src/utils/useHeightResizer.ts @@ -0,0 +1,40 @@ +import * as React from 'react'; + +interface IUseResizer { + size: number; + handleResize: (node: HTMLDivElement) => void; +} + +export const useHeightResizer = (): IUseResizer => { + const [size, setSize] = React.useState(0); + const previousSizeRef = React.useRef(size); + + const handleResize = React.useCallback((node) => { + if (node !== null) { + const hasIOSupport = !!window.ResizeObserver; + + if (hasIOSupport) { + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + + const newSize = entry.contentRect.height; + + if (previousSizeRef.current !== newSize) { + setSize(newSize); + previousSizeRef.current = newSize; + } + }); + + resizeObserver.observe(node); + + return () => resizeObserver.disconnect(); + } + } + }, []); + + return { + size, + handleResize, + }; +}; From 42859691e0480c8e684075e7d7daf1f48093620c Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 4 Sep 2024 11:10:06 +0200 Subject: [PATCH 10/14] feat(Accordion): styles update --- .../react-components/src/components/Accordion/Accordion.mdx | 2 ++ .../src/components/Accordion/Accordion.module.scss | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react-components/src/components/Accordion/Accordion.mdx b/packages/react-components/src/components/Accordion/Accordion.mdx index 52f7c5404..a1349372e 100644 --- a/packages/react-components/src/components/Accordion/Accordion.mdx +++ b/packages/react-components/src/components/Accordion/Accordion.mdx @@ -17,6 +17,8 @@ Accordion is a simple component for building expandable tiles that display provi #### Example implementation ```jsx +import { Accordion } from '@livechat/design-system-react-components'; + Default accordion content diff --git a/packages/react-components/src/components/Accordion/Accordion.module.scss b/packages/react-components/src/components/Accordion/Accordion.module.scss index 8dcd89510..93ecb9ae5 100644 --- a/packages/react-components/src/components/Accordion/Accordion.module.scss +++ b/packages/react-components/src/components/Accordion/Accordion.module.scss @@ -20,6 +20,7 @@ $base-class: 'accordion'; &:hover { border-color: var(--border-basic-hover); + box-shadow: var(--shadow-float); } &--warning { @@ -39,11 +40,12 @@ $base-class: 'accordion'; } &--open { - box-shadow: var(--shadow-pop-over); + border: 1px solid var(--action-primary-default); + box-shadow: var(--shadow-float); background-color: var(--surface-primary-default); &:hover { - border-color: transparent; + border-color: var(--action-primary-default); } } From c0695bf6459042fa265aed7d06504d366db658ee Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 4 Sep 2024 12:26:42 +0200 Subject: [PATCH 11/14] feat(Accordion): new resize observer implementation --- .../src/utils/useHeightResizer.ts | 75 ++++++++++++++----- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/packages/react-components/src/utils/useHeightResizer.ts b/packages/react-components/src/utils/useHeightResizer.ts index b4230d929..257ce8b2e 100644 --- a/packages/react-components/src/utils/useHeightResizer.ts +++ b/packages/react-components/src/utils/useHeightResizer.ts @@ -1,36 +1,73 @@ import * as React from 'react'; +type NODE = HTMLDivElement | null; +type CALLBACK = (newSize: number) => void; + interface IUseResizer { size: number; - handleResize: (node: HTMLDivElement) => void; + handleResize: (node: NODE) => void; } -export const useHeightResizer = (): IUseResizer => { - const [size, setSize] = React.useState(0); - const previousSizeRef = React.useRef(size); - - const handleResize = React.useCallback((node) => { - if (node !== null) { - const hasIOSupport = !!window.ResizeObserver; - - if (hasIOSupport) { - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; +// The useSharedResizeObserver is a singleton pattern that holds a single ResizeObserver instance. +// It maps nodes to their callbacks using a Map, ensuring that each observed node has its own callback. +const useSharedResizeObserver = (() => { + let resizeObserver: ResizeObserver | null = null; + const callbacks = new Map(); - const newSize = entry.contentRect.height; + // The observe function checks if the ResizeObserver already exists. If not, it creates one and starts observing the node. + // The node's callback is stored in the callbacks map. + // When the ResizeObserver triggers, it loops through each observed entry and calls the respective callback associated with that node. + const observe = (node: NODE, callback: CALLBACK) => { + if (!resizeObserver) { + resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const observedNode = entry.target; + const nodeCallback = callbacks.get(observedNode as HTMLElement); - if (previousSizeRef.current !== newSize) { - setSize(newSize); - previousSizeRef.current = newSize; + if (nodeCallback) { + nodeCallback(entry.contentRect.height); } }); + }); + } + + if (node) { + callbacks.set(node, callback); + resizeObserver.observe(node); + } + }; - resizeObserver.observe(node); + // The unobserve function stops observing the node and removes its callback from the map. + // If no more nodes are being observed, the ResizeObserver is disconnected and set to null. + const unobserve = (node: NODE) => { + if (resizeObserver && node) { + resizeObserver.unobserve(node); + callbacks.delete(node); - return () => resizeObserver.disconnect(); + if (callbacks.size === 0) { + resizeObserver.disconnect(); + resizeObserver = null; } } + }; + + return { + observe, + unobserve, + }; +})(); + +export const useHeightResizer = (): IUseResizer => { + const [size, setSize] = React.useState(0); + + const handleResize = React.useCallback((node: NODE) => { + if (node !== null) { + useSharedResizeObserver.observe(node, (newSize: number) => { + setSize(newSize); + }); + } else { + useSharedResizeObserver.unobserve(node); + } }, []); return { From 58ef00f0433e9fd5225411bc7de8e47cd499474d Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 4 Sep 2024 12:40:38 +0200 Subject: [PATCH 12/14] feat(Accordion): check if resize observer is available --- packages/react-components/src/utils/useHeightResizer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-components/src/utils/useHeightResizer.ts b/packages/react-components/src/utils/useHeightResizer.ts index 257ce8b2e..f1017f70e 100644 --- a/packages/react-components/src/utils/useHeightResizer.ts +++ b/packages/react-components/src/utils/useHeightResizer.ts @@ -59,8 +59,11 @@ const useSharedResizeObserver = (() => { export const useHeightResizer = (): IUseResizer => { const [size, setSize] = React.useState(0); + const hasIOSupport = !!window.ResizeObserver; const handleResize = React.useCallback((node: NODE) => { + if (!hasIOSupport) return; + if (node !== null) { useSharedResizeObserver.observe(node, (newSize: number) => { setSize(newSize); From 528591f18a847133915e5dc06d80f33397a6c7a2 Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 4 Sep 2024 12:50:54 +0200 Subject: [PATCH 13/14] feat(Accordion): update --- .../react-components/src/components/Accordion/Accordion.tsx | 2 +- .../Accordion/components/AccordionMultilineElement.tsx | 2 +- packages/react-components/src/components/AppFrame/AppFrame.tsx | 2 +- .../AppFrame/components/NavigationTopBar/NavigationTopBar.tsx | 2 +- .../components/SideNavigationGroup/SideNavigationGroup.tsx | 2 +- packages/react-components/src/hooks/index.ts | 2 ++ packages/react-components/src/{utils => hooks}/useAnimations.ts | 0 .../react-components/src/{utils => hooks}/useHeightResizer.ts | 0 packages/react-components/src/utils/index.ts | 2 -- 9 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 packages/react-components/src/hooks/index.ts rename packages/react-components/src/{utils => hooks}/useAnimations.ts (100%) rename packages/react-components/src/{utils => hooks}/useHeightResizer.ts (100%) diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index 2e2ae78dc..274b9e519 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ChevronDown } from '@livechat/design-system-icons'; import cx from 'clsx'; -import { useAnimations, useHeightResizer } from '../../utils'; +import { useAnimations, useHeightResizer } from '../../hooks'; import { Icon } from '../Icon'; import { Text } from '../Typography'; diff --git a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx index 90f55a128..ec43b19c7 100644 --- a/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx +++ b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { useAnimations, useHeightResizer } from '../../../utils'; +import { useAnimations, useHeightResizer } from '../../../hooks'; import styles from './AccordionMultilineElement.module.scss'; diff --git a/packages/react-components/src/components/AppFrame/AppFrame.tsx b/packages/react-components/src/components/AppFrame/AppFrame.tsx index c36465bdd..c00838302 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.tsx +++ b/packages/react-components/src/components/AppFrame/AppFrame.tsx @@ -2,8 +2,8 @@ import * as React from 'react'; import cx from 'clsx'; +import { useAnimations } from '../../hooks'; import { AppFrameProvider, useAppFrame } from '../../providers'; -import { useAnimations } from '../../utils'; import { IAppFrameProps } from './types'; diff --git a/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx b/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx index f1a73c739..987ab1ed5 100644 --- a/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx +++ b/packages/react-components/src/components/AppFrame/components/NavigationTopBar/NavigationTopBar.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Close } from '@livechat/design-system-icons'; import cx from 'clsx'; -import { useAnimations } from '../../../../utils'; +import { useAnimations } from '../../../../hooks'; import { Button } from '../../../Button'; import { Icon } from '../../../Icon'; diff --git a/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx b/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx index 512040345..60eef433b 100644 --- a/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx +++ b/packages/react-components/src/components/AppFrame/components/SideNavigationGroup/SideNavigationGroup.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ChevronRight } from '@livechat/design-system-icons'; import cx from 'clsx'; -import { useAnimations } from '../../../../utils'; +import { useAnimations } from '../../../../hooks'; import noop from '../../../../utils/noop'; import { Icon } from '../../../Icon'; import { Text } from '../../../Typography'; diff --git a/packages/react-components/src/hooks/index.ts b/packages/react-components/src/hooks/index.ts new file mode 100644 index 000000000..3a3e07d51 --- /dev/null +++ b/packages/react-components/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { useAnimations } from './useAnimations'; +export { useHeightResizer } from './useHeightResizer'; diff --git a/packages/react-components/src/utils/useAnimations.ts b/packages/react-components/src/hooks/useAnimations.ts similarity index 100% rename from packages/react-components/src/utils/useAnimations.ts rename to packages/react-components/src/hooks/useAnimations.ts diff --git a/packages/react-components/src/utils/useHeightResizer.ts b/packages/react-components/src/hooks/useHeightResizer.ts similarity index 100% rename from packages/react-components/src/utils/useHeightResizer.ts rename to packages/react-components/src/hooks/useHeightResizer.ts diff --git a/packages/react-components/src/utils/index.ts b/packages/react-components/src/utils/index.ts index 49f1cbe80..e93c5a470 100644 --- a/packages/react-components/src/utils/index.ts +++ b/packages/react-components/src/utils/index.ts @@ -1,4 +1,2 @@ export type { Size } from './types'; export { getDesignTokenWithOpacity } from './getDesignTokenWithOpacity'; -export { useAnimations } from './useAnimations'; -export { useHeightResizer } from './useHeightResizer'; From 12d26afe32fc97753f71006293ef9cf7326e3285 Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 4 Sep 2024 13:38:41 +0200 Subject: [PATCH 14/14] feat(Accordion): small code refactor --- .../components/AccordionAnimatedLabel.tsx | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx index 8cc5832e9..d7db5b53d 100644 --- a/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx +++ b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import cx from 'clsx'; +import { useAnimations } from '../../../hooks'; import { IAccordionAnimatedLabelProps } from '../types'; import styles from './AccordionAnimatedLabel.module.scss'; @@ -13,43 +14,24 @@ export const AccordionAnimatedLabel: React.FC = ({ closed, isOpen, }) => { - const openLabelRef = React.useRef(null); - const closeLabelRef = React.useRef(null); - const [isOpenMounted, setIsOpenMounted] = React.useState(isOpen); - const [isOpenVisible, setIsOpenVisible] = React.useState(isOpen); - const [isClosedMounted, setIsClosedMounted] = React.useState(!isOpen); - const [isClosedVisible, setIsClosedVisible] = React.useState(!isOpen); - - React.useEffect(() => { - const openRef = openLabelRef.current; - const closeRef = closeLabelRef.current; - const currentRef = isOpen ? openRef : closeRef; - - if (isOpen) { - setIsOpenMounted(true); - setIsClosedVisible(false); - } else { - setIsClosedMounted(true); - setIsOpenVisible(false); + const openRef = React.useRef(null); + const closedRef = React.useRef(null); + const { isOpen: isOpenVisible, isMounted: isOpenMounted } = useAnimations({ + isVisible: isOpen, + elementRef: openRef, + }); + const { isOpen: isClosedVisible, isMounted: isClosedMounted } = useAnimations( + { + isVisible: !isOpen, + elementRef: closedRef, } - - requestAnimationFrame(() => - isOpen ? setIsOpenVisible(true) : setIsClosedVisible(true) - ); - - currentRef && - currentRef.addEventListener( - 'transitionend', - () => (isOpen ? setIsClosedMounted(false) : setIsOpenMounted(false)), - { once: true } - ); - }, [isOpen]); + ); return (
{isOpenMounted && (
= ({ )} {isClosedMounted && (