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';