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..a1349372e
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/Accordion.mdx
@@ -0,0 +1,29 @@
+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
+import { Accordion } from '@livechat/design-system-react-components';
+
+
+ Default accordion content
+
+```
+
+## Component API
+
+
\ No newline at end of file
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..93ecb9ae5
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/Accordion.module.scss
@@ -0,0 +1,89 @@
+$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);
+ width: 100%;
+ min-height: 24px;
+
+ &:focus-visible {
+ outline: 0;
+ box-shadow: var(--shadow-focus);
+ }
+
+ &:hover {
+ border-color: var(--border-basic-hover);
+ box-shadow: var(--shadow-float);
+ }
+
+ &--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 {
+ border: 1px solid var(--action-primary-default);
+ box-shadow: var(--shadow-float);
+ background-color: var(--surface-primary-default);
+
+ &:hover {
+ border-color: var(--action-primary-default);
+ }
+ }
+
+ &__chevron {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ transition: inherit;
+ pointer-events: none;
+
+ &--open {
+ transform: rotate(180deg);
+ }
+ }
+
+ &__label {
+ margin: 0;
+ padding: var(--spacing-5) var(--spacing-12) var(--spacing-5)
+ var(--spacing-5);
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+
+ &__content {
+ transition: inherit;
+ height: 100%;
+ overflow: hidden;
+
+ &__inner {
+ 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.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.stories.css b/packages/react-components/src/components/Accordion/Accordion.stories.css
new file mode 100644
index 000000000..27f3d9712
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/Accordion.stories.css
@@ -0,0 +1,31 @@
+.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;
+}
+
+.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
new file mode 100644
index 000000000..12b8383fe
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/Accordion.stories.tsx
@@ -0,0 +1,113 @@
+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',
+ component: Accordion,
+} 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 Examples = (): 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 (
+
+
+
+
+ Tag {ruleSelected && ruleSelected[0].name}
+ and
+ {tagSelected &&
+ tagSelected &&
+ tagSelected.map((tag) => {tag.name})}
+
+ ),
+ open: Edit your tags
,
+ }}
+ >
+
+
+
setRuleSelected(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
new file mode 100644
index 000000000..274b9e519
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/Accordion.tsx
@@ -0,0 +1,120 @@
+import * as React from 'react';
+
+import { ChevronDown } from '@livechat/design-system-icons';
+import cx from 'clsx';
+
+import { useAnimations, useHeightResizer } from '../../hooks';
+import { Icon } from '../Icon';
+import { Text } from '../Typography';
+
+import { AccordionMultilineElement } from './components/AccordionMultilineElement';
+import { getLabel } from './helpers';
+import { IAccordionProps } from './types';
+
+import styles from './Accordion.module.scss';
+
+const baseClass = 'accordion';
+
+export const Accordion: React.FC = ({
+ className,
+ children,
+ label,
+ multilineElement,
+ kind = 'default',
+ openOnInit = false,
+ isOpen,
+ onClose,
+ onOpen,
+ ...props
+}) => {
+ const isControlled = isOpen !== undefined;
+ const currentlyOpen = isControlled ? isOpen : openOnInit;
+ const contentRef = React.useRef(null);
+ const {
+ isOpen: isExpanded,
+ isMounted,
+ setIsOpen,
+ } = useAnimations({
+ isVisible: currentlyOpen,
+ elementRef: contentRef,
+ });
+ const mergedClassName = cx(
+ styles[baseClass],
+ styles[`${baseClass}--${kind}`],
+ {
+ [styles[`${baseClass}--open`]]: isExpanded,
+ },
+ className
+ );
+ const { size, handleResize } = useHeightResizer();
+
+ const handleExpandChange = (isExpanded: boolean) => {
+ if (isExpanded) {
+ onClose?.();
+ !isControlled && setIsOpen(false);
+ } else {
+ onOpen?.();
+ !isControlled && setIsOpen(true);
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (!isExpanded && (event.key === 'Enter' || event.key === ' ')) {
+ handleExpandChange(isExpanded);
+ }
+
+ if (isExpanded && event.key === 'Escape') {
+ handleExpandChange(isExpanded);
+ }
+ };
+
+ return (
+
+
+
handleExpandChange(isExpanded)}
+ >
+ {getLabel(label, isExpanded)}
+
+ {multilineElement && (
+
+ {multilineElement}
+
+ )}
+
+
+ {isMounted && (
+
+ {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..f77493545
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.module.scss
@@ -0,0 +1,20 @@
+$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;
+ max-width: 100%;
+
+ &--visible {
+ opacity: 1;
+ }
+ }
+}
diff --git a/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.spec.tsx b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.spec.tsx
new file mode 100644
index 000000000..f8d950e11
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.spec.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+
+import { render } from 'test-utils';
+
+import { IAccordionAnimatedLabelProps } from '../types';
+
+import { AccordionAnimatedLabel } from './AccordionAnimatedLabel';
+
+const DEFAULT_PROPS = {
+ open: 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/AccordionAnimatedLabel.tsx b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx
new file mode 100644
index 000000000..d7db5b53d
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/components/AccordionAnimatedLabel.tsx
@@ -0,0 +1,54 @@
+import * as React from 'react';
+
+import cx from 'clsx';
+
+import { useAnimations } from '../../../hooks';
+import { IAccordionAnimatedLabelProps } from '../types';
+
+import styles from './AccordionAnimatedLabel.module.scss';
+
+const baseClass = `accordion-animated-label`;
+
+export const AccordionAnimatedLabel: React.FC = ({
+ open,
+ closed,
+ isOpen,
+}) => {
+ 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,
+ }
+ );
+
+ return (
+
+ {isOpenMounted && (
+
+ {open}
+
+ )}
+ {isClosedMounted && (
+
+ {closed}
+
+ )}
+
+ );
+};
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..ec43b19c7
--- /dev/null
+++ b/packages/react-components/src/components/Accordion/components/AccordionMultilineElement.tsx
@@ -0,0 +1,37 @@
+import * as React from 'react';
+
+import { useAnimations, useHeightResizer } from '../../../hooks';
+
+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 { isOpen: isVisible, isMounted } = useAnimations({
+ isVisible: !isExpanded,
+ elementRef: multilineRef,
+ });
+ const { size, handleResize } = useHeightResizer();
+
+ return (
+
+
+ {isMounted && (
+
{children}
+ )}
+
+
+ );
+};
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/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/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',
+ },
+];
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;
+}
diff --git a/packages/react-components/src/components/AppFrame/AppFrame.tsx b/packages/react-components/src/components/AppFrame/AppFrame.tsx
index b301320ca..c00838302 100644
--- a/packages/react-components/src/components/AppFrame/AppFrame.tsx
+++ b/packages/react-components/src/components/AppFrame/AppFrame.tsx
@@ -2,9 +2,9 @@ import * as React from 'react';
import cx from 'clsx';
+import { useAnimations } from '../../hooks';
import { AppFrameProvider, useAppFrame } from '../../providers';
-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..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,9 +3,9 @@ import * as React from 'react';
import { Close } from '@livechat/design-system-icons';
import cx from 'clsx';
+import { useAnimations } from '../../../../hooks';
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..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,10 +3,10 @@ import * as React from 'react';
import { ChevronRight } from '@livechat/design-system-icons';
import cx from 'clsx';
+import { useAnimations } from '../../../../hooks';
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/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/components/AppFrame/hooks/useAppFrameAnimations.ts b/packages/react-components/src/hooks/useAnimations.ts
similarity index 56%
rename from packages/react-components/src/components/AppFrame/hooks/useAppFrameAnimations.ts
rename to packages/react-components/src/hooks/useAnimations.ts
index 261ad8577..c8dcb97c4 100644
--- a/packages/react-components/src/components/AppFrame/hooks/useAppFrameAnimations.ts
+++ b/packages/react-components/src/hooks/useAnimations.ts
@@ -1,34 +1,34 @@
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 => {
- const [isMounted, setisMounted] = React.useState(isVisible);
+}: 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;
+ const currentElement = elementRef.current;
- if (!isOpen && sideNavWrapper) {
- const handleTransitionEnd = () => setisMounted(false);
+ if (!isOpen && currentElement) {
+ const handleTransitionEnd = () => setIsMounted(false);
- sideNavWrapper.addEventListener('transitionend', handleTransitionEnd);
+ currentElement.addEventListener('transitionend', handleTransitionEnd);
return () => {
- sideNavWrapper.removeEventListener(
+ currentElement.removeEventListener(
'transitionend',
handleTransitionEnd
);
@@ -36,7 +36,7 @@ export const useAppFrameAnimations = ({
}
if (isOpen) {
- setisMounted(true);
+ setIsMounted(true);
requestAnimationFrame(() => setIsOpen(true));
return;
@@ -45,10 +45,10 @@ 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);
+ setIsMounted(true);
requestAnimationFrame(() => setIsOpen(true));
return;
diff --git a/packages/react-components/src/hooks/useHeightResizer.ts b/packages/react-components/src/hooks/useHeightResizer.ts
new file mode 100644
index 000000000..f1017f70e
--- /dev/null
+++ b/packages/react-components/src/hooks/useHeightResizer.ts
@@ -0,0 +1,80 @@
+import * as React from 'react';
+
+type NODE = HTMLDivElement | null;
+type CALLBACK = (newSize: number) => void;
+
+interface IUseResizer {
+ size: number;
+ handleResize: (node: NODE) => void;
+}
+
+// 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();
+
+ // 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 (nodeCallback) {
+ nodeCallback(entry.contentRect.height);
+ }
+ });
+ });
+ }
+
+ if (node) {
+ callbacks.set(node, callback);
+ 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);
+
+ if (callbacks.size === 0) {
+ resizeObserver.disconnect();
+ resizeObserver = null;
+ }
+ }
+ };
+
+ return {
+ observe,
+ unobserve,
+ };
+})();
+
+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);
+ });
+ } else {
+ useSharedResizeObserver.unobserve(node);
+ }
+ }, []);
+
+ return {
+ size,
+ handleResize,
+ };
+};
diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts
index 7e360acd0..a467a83be 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';