From 81ce453065ec0d09f05ff7c6a1131e08c3d5c19e Mon Sep 17 00:00:00 2001 From: Marcin Sawicki Date: Wed, 11 Sep 2024 10:33:10 +0200 Subject: [PATCH] feat(AppFrame): mobile view and MobileNavigation component (#1319) --- .../src/components/Accordion/Accordion.tsx | 4 +- .../components/AccordionMultilineElement.tsx | 4 +- .../src/components/AppFrame/AppFrame.mdx | 43 ++++- .../components/AppFrame/AppFrame.module.scss | 19 ++ .../src/components/AppFrame/AppFrame.spec.tsx | 17 +- .../components/AppFrame/AppFrame.stories.css | 47 +++++ .../components/AppFrame/AppFrame.stories.tsx | 19 ++ .../src/components/AppFrame/AppFrame.tsx | 80 +++++--- .../MobileNavigation.module.scss | 12 ++ .../MobileNavigation.spec.tsx | 40 ++++ .../MobileNavigation/MobileNavigation.tsx | 21 +++ .../components/MobileNavigation/types.ts | 10 + .../NavigationItem/NavigationItem.module.scss | 7 + .../NavigationItem/NavigationItem.spec.tsx | 8 +- .../NavigationItem/NavigationItem.tsx | 109 ++++++----- .../components/AppFrame/components/index.ts | 1 + .../components/AppFrame/stories-helpers.tsx | 171 ++++++++++++------ .../src/components/AppFrame/types.ts | 9 + .../react-components/src/hooks/helpers.ts | 18 ++ packages/react-components/src/hooks/index.ts | 1 + packages/react-components/src/hooks/types.ts | 16 ++ .../src/hooks/useHeightResizer.ts | 81 ++------- .../src/hooks/useMobileViewDetector.ts | 27 +++ .../src/hooks/useSharedResizeObserver.ts | 50 +++++ .../src/providers/AppFrameProvider.tsx | 16 +- 25 files changed, 625 insertions(+), 205 deletions(-) create mode 100644 packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.module.scss create mode 100644 packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.spec.tsx create mode 100644 packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.tsx create mode 100644 packages/react-components/src/components/AppFrame/components/MobileNavigation/types.ts create mode 100644 packages/react-components/src/hooks/helpers.ts create mode 100644 packages/react-components/src/hooks/types.ts create mode 100644 packages/react-components/src/hooks/useMobileViewDetector.ts create mode 100644 packages/react-components/src/hooks/useSharedResizeObserver.ts diff --git a/packages/react-components/src/components/Accordion/Accordion.tsx b/packages/react-components/src/components/Accordion/Accordion.tsx index 274b9e519..598620cb9 100644 --- a/packages/react-components/src/components/Accordion/Accordion.tsx +++ b/packages/react-components/src/components/Accordion/Accordion.tsx @@ -46,7 +46,7 @@ export const Accordion: React.FC = ({ }, className ); - const { size, handleResize } = useHeightResizer(); + const { size, handleResizeRef } = useHeightResizer(); const handleExpandChange = (isExpanded: boolean) => { if (isExpanded) { @@ -102,7 +102,7 @@ export const Accordion: React.FC = ({ style={{ maxHeight: isExpanded ? size : 0 }} ref={contentRef} > -
+
{isMounted && ( -
+
{isMounted && (
{children}
)} diff --git a/packages/react-components/src/components/AppFrame/AppFrame.mdx b/packages/react-components/src/components/AppFrame/AppFrame.mdx index d34327d3d..12d7df6fa 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.mdx +++ b/packages/react-components/src/components/AppFrame/AppFrame.mdx @@ -17,7 +17,7 @@ The most important functionality of this component, apart from the UI layout, is ## How to build the navigation -[Main navigation](#MainNav) | [Side navigation](#SideNav) | [AppFrameProvider](#Provider) | [Expiration Counter](#ExpirationCounter) +[Main navigation](#MainNav) | [Side navigation](#SideNav) | [MobileNavigation](#MobileNav) | [AppFrameProvider](#Provider) | [Expiration Counter](#ExpirationCounter) The component allows you to place navigation available on the left side, consisting of icons, and also allows you to display additional navigation in the opening panel. @@ -137,6 +137,47 @@ import { Home, Settings } from '@livechat/design-system-icons'; } ``` +### Mobile navigation + +To build the mobile navigation, use the available components: +- `MobileNavigation` +- `NavigationItem` + +#### `MobileNavigation` component + +The `MobileNavigation` component serves as the main wrapper for navigation items passed as children. + +#### `NavigationItem` component + +The `NavigationItem` is the same component used in the `Navigation`. You must remember to set the `isMobile` prop to `true` to display items in the mobile mode (label is visible in the button instead of tooltip). + +#### Mobile navigation implementation example + +```jsx +import { AppFrame, Navigation, NavigationItem, MobileNavigation } from '@livechat/design-system-react-components'; +import { Home, Settings } from '@livechat/design-system-icons'; + +...} + mobileNavigation={ + + ... + } + onClick={(e, id) => {}} + isActive={activeItem === 'home'} + badge={5} + url="#" + /> + ... + + } +``` + #### Side navigation visibility with `AppFrameProvider` Side navigation passed by the `sideNavigation` prop will be displayed in an appropriate container, located next to the application frame. By default, the navigation will be visible, but the component allows you to control its visibility. diff --git a/packages/react-components/src/components/AppFrame/AppFrame.module.scss b/packages/react-components/src/components/AppFrame/AppFrame.module.scss index e0823b514..5437febd1 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.module.scss +++ b/packages/react-components/src/components/AppFrame/AppFrame.module.scss @@ -17,6 +17,10 @@ $base-class: 'app-frame'; width: 100%; height: 100%; + &--mobile { + padding: 0; + } + &__top-bar { display: flex; flex-shrink: 0; @@ -39,6 +43,10 @@ $base-class: 'app-frame'; height: 100%; overflow: hidden; + &--mobile { + flex-direction: column; + } + &__nav-bar-wrapper { flex-shrink: 0; transition: all var(--transition-duration-fast-2) ease-in-out; @@ -60,6 +68,17 @@ $base-class: 'app-frame'; height: 100%; overflow: hidden; color: var(--content-default); + + &--mobile { + border-radius: 0; + } + } + + &__mobile-top-bar { + position: absolute; + top: 0; + right: 0; + left: 0; } } } diff --git a/packages/react-components/src/components/AppFrame/AppFrame.spec.tsx b/packages/react-components/src/components/AppFrame/AppFrame.spec.tsx index 60afb71be..2e894dd55 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.spec.tsx +++ b/packages/react-components/src/components/AppFrame/AppFrame.spec.tsx @@ -4,6 +4,7 @@ import { render, vi } from 'test-utils'; import { AppFrame } from './AppFrame'; import { + MobileNavigation, Navigation, NavigationItem, SideNavigation, @@ -25,11 +26,20 @@ const defaultProps = { id="item-1" label="Item 1" icon={
Icon
} - url="#" onClick={vi.fn()} /> ), + mobileNavigation: ( + + Icon
} + onClick={vi.fn()} + /> + + ), }; const renderComponent = (props: IAppFrameProps) => { @@ -46,10 +56,11 @@ describe(' component', () => { expect(container.firstChild).toHaveClass('custom-class'); }); - it('should render with given navigation', () => { - const { getByTestId } = renderComponent(defaultProps); + it('should render navigation and not render mobile navigation', () => { + const { getByTestId, queryByTestId } = renderComponent(defaultProps); expect(getByTestId('navigation')).toBeInTheDocument(); + expect(queryByTestId('mobile-navigation')).not.toBeInTheDocument(); }); it('should render with top bar if provided', () => { diff --git a/packages/react-components/src/components/AppFrame/AppFrame.stories.css b/packages/react-components/src/components/AppFrame/AppFrame.stories.css index 9f7168a0a..dae37fcfe 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.stories.css +++ b/packages/react-components/src/components/AppFrame/AppFrame.stories.css @@ -39,3 +39,50 @@ font-size: 14px; font-weight: 600; } + +.app-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 100%; + overflow: auto; +} + +.app-content-1 { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + justify-content: center; + width: 100%; +} + +.page-title { + text-align: center; +} + +.switchers { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: 270px; +} + +.switch { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + p { + margin: 0; + } +} + +.app-content-2 { + padding: 16px; + width: 100%; + max-width: 800px; +} diff --git a/packages/react-components/src/components/AppFrame/AppFrame.stories.tsx b/packages/react-components/src/components/AppFrame/AppFrame.stories.tsx index 8ee2e737e..433b68756 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.stories.tsx +++ b/packages/react-components/src/components/AppFrame/AppFrame.stories.tsx @@ -19,6 +19,7 @@ import { NavigationTopBarAlert, NavigationTopBarTitle, ExpirationCounter, + MobileNavigation, } from './components'; import { ExampleAppContent, @@ -207,6 +208,24 @@ export const Default = (): React.ReactElement => { } + mobileNavigation={ + + {navigationItems.slice(0, 5).map((item, index) => ( + } + onClick={(e, id) => { + e.preventDefault(); + setActiveItem(id); + }} + isActive={activeItem === item} + badge={getBadgeContent(item)} + /> + ))} + + } sideNavigation={getSubNav()} topBar={ topBarVisible || visibleAlerts.some((alert) => alert) ? ( diff --git a/packages/react-components/src/components/AppFrame/AppFrame.tsx b/packages/react-components/src/components/AppFrame/AppFrame.tsx index c00838302..c8b324229 100644 --- a/packages/react-components/src/components/AppFrame/AppFrame.tsx +++ b/packages/react-components/src/components/AppFrame/AppFrame.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import cx from 'clsx'; -import { useAnimations } from '../../hooks'; +import { useAnimations, useMobileViewDetector } from '../../hooks'; import { AppFrameProvider, useAppFrame } from '../../providers'; import { IAppFrameProps } from './types'; @@ -17,43 +17,62 @@ const Frame = (props: IAppFrameProps) => { children, className, navigation, + mobileNavigation, sideNavigation, topBar, topBarClassName, sideNavigationContainerClassName, contentClassName, + mobileViewBreakpoint = 705, } = props; const mergedClassNames = cx(styles[baseClass], className); - const { isSideNavigationVisible } = useAppFrame(); + const { + isSideNavigationVisible, + setIsMobileViewEnabled, + isMobileViewEnabled, + } = useAppFrame(); const sideNavWrapperRef = React.useRef(null); const { isOpen, isMounted } = useAnimations({ isVisible: isSideNavigationVisible, elementRef: sideNavWrapperRef, }); + const { isMobile, handleResizeRef } = useMobileViewDetector({ + mobileBreakpoint: mobileViewBreakpoint, + }); + + React.useEffect(() => { + setIsMobileViewEnabled(isMobile); + }, [isMobile]); return ( -
- {navigation} -
-
- {topBar} -
+
+ {!isMobileViewEnabled && navigation} +
+ {!isMobileViewEnabled && ( +
+ {topBar} +
+ )}
- {sideNavigation && ( + {!isMobileViewEnabled && sideNavigation && (
{
{children}
+ {isMobileViewEnabled && ( + <> +
+ {topBar} +
+
{mobileNavigation}
+ + )}
diff --git a/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.module.scss b/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.module.scss new file mode 100644 index 000000000..bb1f21e80 --- /dev/null +++ b/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.module.scss @@ -0,0 +1,12 @@ +$base-class: 'mobile-navigation'; + +.#{$base-class} { + display: flex; + flex-direction: row; + flex-shrink: 0; + align-items: center; + justify-content: space-around; + z-index: 1; + background-color: var(--navbar-background); + padding: 6px; +} diff --git a/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.spec.tsx b/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.spec.tsx new file mode 100644 index 000000000..782657547 --- /dev/null +++ b/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.spec.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { render } from 'test-utils'; + +import { MobileNavigation } from './MobileNavigation'; +import { IMobileNavigationProps } from './types'; + +const defaultProps: IMobileNavigationProps = { + children:
Mobile navigation content
, +}; + +const renderComponent = (props: IMobileNavigationProps) => { + return render(); +}; + +describe(' component', () => { + it('should allow for custom class', () => { + const { getByRole } = renderComponent({ + ...defaultProps, + className: 'custom-class', + }); + + expect(getByRole('navigation')).toHaveClass('custom-class'); + }); + + it('should render children', () => { + const { getByText } = renderComponent(defaultProps); + + expect(getByText('Mobile navigation content')).toBeInTheDocument(); + }); + + it('should pass additional props', () => { + const { getByTestId } = renderComponent({ + ...defaultProps, + 'data-testid': 'mobile-navigation-test', + }); + + expect(getByTestId('mobile-navigation-test')).toBeInTheDocument(); + }); +}); diff --git a/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.tsx b/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.tsx new file mode 100644 index 000000000..81ee2da0f --- /dev/null +++ b/packages/react-components/src/components/AppFrame/components/MobileNavigation/MobileNavigation.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import cx from 'clsx'; + +import { IMobileNavigationProps } from './types'; + +import styles from './MobileNavigation.module.scss'; + +const baseClass = 'mobile-navigation'; + +export const MobileNavigation: React.FC = ({ + children, + className, + ...props +}) => { + return ( + + ); +}; diff --git a/packages/react-components/src/components/AppFrame/components/MobileNavigation/types.ts b/packages/react-components/src/components/AppFrame/components/MobileNavigation/types.ts new file mode 100644 index 000000000..39316ad5c --- /dev/null +++ b/packages/react-components/src/components/AppFrame/components/MobileNavigation/types.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; + +import { ComponentCoreProps } from '../../../../utils/types'; + +export interface IMobileNavigationProps extends ComponentCoreProps { + /** + * It will display your navigation elements + */ + children: React.ReactNode; +} diff --git a/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.module.scss b/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.module.scss index 009a5976d..1d6ec355d 100644 --- a/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.module.scss +++ b/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.module.scss @@ -80,5 +80,12 @@ $base-class: 'navigation-item'; &--opacity { opacity: 1; } + + &--mobile { + flex-direction: column; + width: 56px; + height: 48px; + font-size: 10px; + } } } diff --git a/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.spec.tsx b/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.spec.tsx index db1f8dbf1..4e8808d8a 100644 --- a/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.spec.tsx +++ b/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.spec.tsx @@ -4,6 +4,8 @@ import { vi } from 'vitest'; import { render, userEvent } from 'test-utils'; +import { AppFrameProvider } from '../../../../providers'; + import { NavigationItem } from './NavigationItem'; import { INavigationItemProps } from './types'; @@ -16,7 +18,11 @@ const defaultProps: INavigationItemProps = { }; const renderComponent = (props: INavigationItemProps) => { - return render(); + return render( + + + + ); }; describe(' component', () => { diff --git a/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.tsx b/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.tsx index 84324577d..bb45fb157 100644 --- a/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.tsx +++ b/packages/react-components/src/components/AppFrame/components/NavigationItem/NavigationItem.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import cx from 'clsx'; +import { useAppFrame } from '../../../../providers'; import { Badge } from '../../../Badge'; import { Tooltip } from '../../../Tooltip'; @@ -45,54 +46,62 @@ export const NavigationItem: React.FC = ({ onClick, className, ...props -}) => ( -
  • - - !disabled && onClick(e, id)} - href={url} - {...props} - > - {icon} - - {badge && getBadge(badge, id)} - - } +}) => { + const { isMobileViewEnabled } = useAppFrame(); + + return ( +
  • - {label} - -
  • -); + + !disabled && onClick(e, id)} + href={url} + {...props} + > + {icon} + {isMobileViewEnabled && label} + + {badge && getBadge(badge, id)} + + } + > + {label} + + + ); +}; diff --git a/packages/react-components/src/components/AppFrame/components/index.ts b/packages/react-components/src/components/AppFrame/components/index.ts index 917bf1db3..65f200111 100644 --- a/packages/react-components/src/components/AppFrame/components/index.ts +++ b/packages/react-components/src/components/AppFrame/components/index.ts @@ -15,3 +15,4 @@ export { SIDE_NAVIGATION_PARENT_ICON_TEST_ID, } from './SideNavigationItem/constants'; export { ExpirationCounter } from './ExpirationCounter/ExpirationCounter'; +export { MobileNavigation } from './MobileNavigation/MobileNavigation'; diff --git a/packages/react-components/src/components/AppFrame/stories-helpers.tsx b/packages/react-components/src/components/AppFrame/stories-helpers.tsx index 55b14aade..2d5e88f18 100644 --- a/packages/react-components/src/components/AppFrame/stories-helpers.tsx +++ b/packages/react-components/src/components/AppFrame/stories-helpers.tsx @@ -4,8 +4,8 @@ import * as Icons from '@livechat/design-system-icons'; import { useAppFrame } from '../../providers/AppFrameProvider'; import { Badge } from '../Badge'; -import { Button } from '../Button'; import { Icon } from '../Icon'; +import { Switch } from '../Switch'; import { Tag } from '../Tag'; import { Tooltip } from '../Tooltip'; import { Heading, Text } from '../Typography'; @@ -22,6 +22,8 @@ import { } from './components/NavigationTopBar/examples'; import { NavigationTopBar } from './components/NavigationTopBar/NavigationTopBar'; +import './AppFrame.stories.css'; + interface ExampleAppContentProps { showToggle: boolean; alerts?: boolean[]; @@ -39,62 +41,123 @@ export const ExampleAppContent: React.FC = ({ }) => { const { isSideNavigationVisible, toggleSideNavigationVisibility } = useAppFrame(); + const [allBannersVisible, setAllBannersVisible] = React.useState(false); return ( -
    - App content -
    -
    - Toggle TopBar Visibility - -
    - {showToggle && ( -
    - Set sub-navigation visibility - +
    + + App content + +
    +
    +
    + Toggle top bar visibility + setTopBarVisible(!topBarVisible)} + />
    - )} - {alerts && setAlerts && ( -
    - Set alerts - - - - {alerts.map((show, index) => ( - - ))} -
    - )} + {showToggle && ( +
    + Toogle sub-navigation visibility + +
    + )} + {alerts && setAlerts && ( + <> +
    + Toggle all alerts + { + setAllBannersVisible(!allBannersVisible); + setAlerts(alerts.map(() => !allBannersVisible)); + }} + /> +
    + {alerts.map((show, index) => ( +
    + Taggle alert {index + 1} + + setAlerts(alerts.map((_, i) => (i === index ? !show : _))) + } + /> +
    + ))} + + )} +
    +
    +
    + + "Sed ut perspiciatis unde omnis iste natus error sit voluptatem + accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae + ab illo inventore veritatis et quasi architecto beatae vitae dicta + sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit + aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos + qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui + dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed + quia non numquam eius modi tempora incidunt ut labore et dolore magnam + aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum + exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex + ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in + ea voluptate velit esse quam nihil molestiae consequatur, vel illum + qui dolorem eum fugiat quo voluptas nulla pariatur?" + + + "Sed ut perspiciatis unde omnis iste natus error sit voluptatem + accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae + ab illo inventore veritatis et quasi architecto beatae vitae dicta + sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit + aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos + qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui + dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed + quia non numquam eius modi tempora incidunt ut labore et dolore magnam + aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum + exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex + ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in + ea voluptate velit esse quam nihil molestiae consequatur, vel illum + qui dolorem eum fugiat quo voluptas nulla pariatur?" + + + "Sed ut perspiciatis unde omnis iste natus error sit voluptatem + accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae + ab illo inventore veritatis et quasi architecto beatae vitae dicta + sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit + aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos + qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui + dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed + quia non numquam eius modi tempora incidunt ut labore et dolore magnam + aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum + exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex + ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in + ea voluptate velit esse quam nihil molestiae consequatur, vel illum + qui dolorem eum fugiat quo voluptas nulla pariatur?" + + + "Sed ut perspiciatis unde omnis iste natus error sit voluptatem + accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae + ab illo inventore veritatis et quasi architecto beatae vitae dicta + sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit + aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos + qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui + dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed + quia non numquam eius modi tempora incidunt ut labore et dolore magnam + aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum + exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex + ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in + ea voluptate velit esse quam nihil molestiae consequatur, vel illum + qui dolorem eum fugiat quo voluptas nulla pariatur?" +
    ); diff --git a/packages/react-components/src/components/AppFrame/types.ts b/packages/react-components/src/components/AppFrame/types.ts index 9eaf5c156..53a12a9c1 100644 --- a/packages/react-components/src/components/AppFrame/types.ts +++ b/packages/react-components/src/components/AppFrame/types.ts @@ -11,6 +11,10 @@ export interface IAppFrameProps extends ComponentCoreProps { * It will display navigation elements */ navigation: React.ReactNode; + /** + * It will display mobile navigation elements + */ + mobileNavigation: React.ReactNode; /** * It will display the side navigation bar */ @@ -31,6 +35,11 @@ export interface IAppFrameProps extends ComponentCoreProps { * The CSS class for the content container */ contentClassName?: string; + /** + * The value that will determine on which resolution mobile view will be displayed + * @default 705 + */ + mobileViewBreakpoint?: number; } export type { INavigationProps } from './components/Navigation/types'; diff --git a/packages/react-components/src/hooks/helpers.ts b/packages/react-components/src/hooks/helpers.ts new file mode 100644 index 000000000..770c60379 --- /dev/null +++ b/packages/react-components/src/hooks/helpers.ts @@ -0,0 +1,18 @@ +import { NODE } from './types'; +import { useSharedResizeObserver } from './useSharedResizeObserver'; + +export const resizeCallback = ( + node: NODE, + handler: (newSize: DOMRectReadOnly) => void +): void => { + const hasIOSupport = !!window.ResizeObserver; + if (!hasIOSupport) return; + + if (node !== null) { + useSharedResizeObserver.observe(node, (newSize: DOMRectReadOnly) => { + handler(newSize); + }); + } else { + useSharedResizeObserver.unobserve(node); + } +}; diff --git a/packages/react-components/src/hooks/index.ts b/packages/react-components/src/hooks/index.ts index 3a3e07d51..29657f8f7 100644 --- a/packages/react-components/src/hooks/index.ts +++ b/packages/react-components/src/hooks/index.ts @@ -1,2 +1,3 @@ export { useAnimations } from './useAnimations'; export { useHeightResizer } from './useHeightResizer'; +export { useMobileViewDetector } from './useMobileViewDetector'; diff --git a/packages/react-components/src/hooks/types.ts b/packages/react-components/src/hooks/types.ts new file mode 100644 index 000000000..c2c301bbd --- /dev/null +++ b/packages/react-components/src/hooks/types.ts @@ -0,0 +1,16 @@ +export type NODE = HTMLDivElement | null; +export type CALLBACK = (newSize: DOMRectReadOnly) => void; + +export interface IUseHeightResizer { + size: number; + handleResizeRef: (node: NODE) => void; +} + +export interface IUseMobileViewDetectorProps { + mobileBreakpoint: number; +} + +export interface IUseMobileViewDetector { + isMobile: boolean; + handleResizeRef: (node: NODE) => void; +} diff --git a/packages/react-components/src/hooks/useHeightResizer.ts b/packages/react-components/src/hooks/useHeightResizer.ts index f1017f70e..f155b34b7 100644 --- a/packages/react-components/src/hooks/useHeightResizer.ts +++ b/packages/react-components/src/hooks/useHeightResizer.ts @@ -1,80 +1,21 @@ import * as React from 'react'; -type NODE = HTMLDivElement | null; -type CALLBACK = (newSize: number) => void; +import { resizeCallback } from './helpers'; +import { IUseHeightResizer, NODE } from './types'; -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 => { +export const useHeightResizer = (): IUseHeightResizer => { 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); - } - }, []); + const handleResizeRef = React.useCallback( + (node: NODE) => + resizeCallback(node, (newSize: DOMRectReadOnly) => + setSize(newSize.height) + ), + [] + ); return { size, - handleResize, + handleResizeRef, }; }; diff --git a/packages/react-components/src/hooks/useMobileViewDetector.ts b/packages/react-components/src/hooks/useMobileViewDetector.ts new file mode 100644 index 000000000..8b386c586 --- /dev/null +++ b/packages/react-components/src/hooks/useMobileViewDetector.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +import { resizeCallback } from './helpers'; +import { + IUseMobileViewDetector, + IUseMobileViewDetectorProps, + NODE, +} from './types'; + +export const useMobileViewDetector = ({ + mobileBreakpoint, +}: IUseMobileViewDetectorProps): IUseMobileViewDetector => { + const [isMobile, setIsMobile] = React.useState(false); + + const handleResizeRef = React.useCallback( + (node: NODE) => + resizeCallback(node, (newSize: DOMRectReadOnly) => + setIsMobile(newSize.width <= mobileBreakpoint) + ), + [] + ); + + return { + isMobile, + handleResizeRef, + }; +}; diff --git a/packages/react-components/src/hooks/useSharedResizeObserver.ts b/packages/react-components/src/hooks/useSharedResizeObserver.ts new file mode 100644 index 000000000..adfa217fb --- /dev/null +++ b/packages/react-components/src/hooks/useSharedResizeObserver.ts @@ -0,0 +1,50 @@ +import { CALLBACK, NODE } from './types'; + +// 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. +export 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); + } + }); + }); + } + + 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, + }; +})(); diff --git a/packages/react-components/src/providers/AppFrameProvider.tsx b/packages/react-components/src/providers/AppFrameProvider.tsx index 0d43d6ac6..90f1aa9c8 100644 --- a/packages/react-components/src/providers/AppFrameProvider.tsx +++ b/packages/react-components/src/providers/AppFrameProvider.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; interface AppFrameContextProps { isSideNavigationVisible: boolean; toggleSideNavigationVisibility: () => void; + isMobileViewEnabled: boolean; + setIsMobileViewEnabled: (isEnabled: boolean) => void; } const AppFrameContext = React.createContext( @@ -21,22 +23,34 @@ export const useAppFrame = (): AppFrameContextProps => { interface AppFrameProviderProps { isSideNavigationVisible?: boolean; + isMobileViewVisible?: boolean; } export const AppFrameProvider: React.FC = ({ children, isSideNavigationVisible = true, + isMobileViewVisible = false, }) => { const [isSsideNavigationBarOpen, setIsSideNavigationBarOpen] = React.useState(isSideNavigationVisible); + const [isMobileViewEnabled, setIsMobileViewEnabled] = + React.useState(isMobileViewVisible); const value = React.useMemo( () => ({ isSideNavigationVisible: isSsideNavigationBarOpen, toggleSideNavigationVisibility: () => setIsSideNavigationBarOpen(!isSsideNavigationBarOpen), + isMobileViewEnabled, + setIsMobileViewEnabled: (isEnabled: boolean) => + setIsMobileViewEnabled(isEnabled), }), - [isSsideNavigationBarOpen, setIsSideNavigationBarOpen] + [ + isSsideNavigationBarOpen, + setIsSideNavigationBarOpen, + isMobileViewEnabled, + setIsMobileViewEnabled, + ] ); return (