Sidebar.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import {
  2. type FC,
  3. memo, useCallback, useEffect, useState,
  4. useRef,
  5. } from 'react';
  6. import withLoadingProps from 'next-dynamic-loading-props';
  7. import dynamic from 'next/dynamic';
  8. import SimpleBar from 'simplebar-react';
  9. import { useIsomorphicLayoutEffect } from 'usehooks-ts';
  10. import { SidebarMode } from '~/interfaces/ui';
  11. import { useIsSearchPage } from '~/stores-universal/context';
  12. import {
  13. useDrawerOpened,
  14. useCollapsedContentsOpened,
  15. useCurrentProductNavWidth,
  16. usePreferCollapsedMode,
  17. useSidebarMode,
  18. useSidebarScrollerRef,
  19. } from '~/stores/ui';
  20. import { DrawerToggler } from '../Common/DrawerToggler';
  21. import { AppTitleOnSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
  22. import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
  23. import type { ResizableAreaProps } from './ResizableArea/props';
  24. import { SidebarHead } from './SidebarHead';
  25. import { SidebarNav, type SidebarNavProps } from './SidebarNav';
  26. import 'simplebar-react/dist/simplebar.min.css';
  27. import styles from './Sidebar.module.scss';
  28. const SidebarContents = dynamic(() => import('./SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
  29. const ResizableArea = withLoadingProps<ResizableAreaProps>(useLoadingProps => dynamic(
  30. () => import('./ResizableArea').then(mod => mod.ResizableArea),
  31. {
  32. ssr: false,
  33. loading: () => {
  34. // eslint-disable-next-line react-hooks/rules-of-hooks
  35. const { children, ...rest } = useLoadingProps();
  36. return <ResizableAreaFallback {...rest}>{children}</ResizableAreaFallback>;
  37. },
  38. },
  39. ));
  40. const resizableAreaMinWidth = 348;
  41. const sidebarNavCollapsedWidth = 48;
  42. const getWidthByMode = (isDrawerMode: boolean, isCollapsedMode: boolean, currentProductNavWidth: number | undefined): number | undefined => {
  43. if (isDrawerMode) {
  44. return undefined;
  45. }
  46. if (isCollapsedMode) {
  47. return sidebarNavCollapsedWidth;
  48. }
  49. return currentProductNavWidth;
  50. };
  51. type ResizableContainerProps = {
  52. children?: React.ReactNode,
  53. }
  54. const ResizableContainer = memo((props: ResizableContainerProps): JSX.Element => {
  55. const { children } = props;
  56. const { isDrawerMode, isCollapsedMode, isDockMode } = useSidebarMode();
  57. const { mutate: mutateDrawerOpened } = useDrawerOpened();
  58. const { data: currentProductNavWidth, mutateAndSave: mutateProductNavWidth } = useCurrentProductNavWidth();
  59. const { mutateAndSave: mutatePreferCollapsedMode } = usePreferCollapsedMode();
  60. const { mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
  61. const [isClient, setClient] = useState(false);
  62. const [resizableAreaWidth, setResizableAreaWidth] = useState<number|undefined>(
  63. getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth),
  64. );
  65. const resizeHandler = useCallback((newWidth: number) => {
  66. setResizableAreaWidth(newWidth);
  67. }, []);
  68. const resizeDoneHandler = useCallback((newWidth: number) => {
  69. mutateProductNavWidth(newWidth, false);
  70. }, [mutateProductNavWidth]);
  71. const collapsedByResizableAreaHandler = useCallback(() => {
  72. mutatePreferCollapsedMode(true);
  73. mutateCollapsedContentsOpened(false);
  74. }, [mutateCollapsedContentsOpened, mutatePreferCollapsedMode]);
  75. useIsomorphicLayoutEffect(() => {
  76. setClient(true);
  77. }, []);
  78. // open/close resizable container when drawer mode
  79. useEffect(() => {
  80. setResizableAreaWidth(getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth));
  81. mutateDrawerOpened(false);
  82. }, [currentProductNavWidth, isCollapsedMode, isDrawerMode, mutateDrawerOpened]);
  83. return !isClient
  84. ? (
  85. <ResizableAreaFallback
  86. className="flex-expand-vert"
  87. width={resizableAreaWidth}
  88. >
  89. {children}
  90. </ResizableAreaFallback>
  91. )
  92. : (
  93. <ResizableArea
  94. className="flex-expand-vert"
  95. width={resizableAreaWidth}
  96. minWidth={resizableAreaMinWidth}
  97. disabled={!isDockMode()}
  98. onResize={resizeHandler}
  99. onResizeDone={resizeDoneHandler}
  100. onCollapsed={collapsedByResizableAreaHandler}
  101. >
  102. {children}
  103. </ResizableArea>
  104. );
  105. });
  106. type CollapsibleContainerProps = {
  107. Nav: FC<SidebarNavProps>,
  108. className?: string,
  109. children?: React.ReactNode,
  110. }
  111. const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Element => {
  112. const { Nav, className, children } = props;
  113. const { isCollapsedMode } = useSidebarMode();
  114. const { data: currentProductNavWidth } = useCurrentProductNavWidth();
  115. const { data: isCollapsedContentsOpened, mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
  116. const sidebarScrollerRef = useRef<HTMLDivElement>(null);
  117. const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
  118. mutateSidebarScroller(sidebarScrollerRef);
  119. // open menu when collapsed mode
  120. const primaryItemHoverHandler = useCallback(() => {
  121. // reject other than collapsed mode
  122. if (!isCollapsedMode()) {
  123. return;
  124. }
  125. mutateCollapsedContentsOpened(true);
  126. }, [isCollapsedMode, mutateCollapsedContentsOpened]);
  127. // close menu when collapsed mode
  128. const mouseLeaveHandler = useCallback(() => {
  129. // reject other than collapsed mode
  130. if (!isCollapsedMode()) {
  131. return;
  132. }
  133. mutateCollapsedContentsOpened(false);
  134. }, [isCollapsedMode, mutateCollapsedContentsOpened]);
  135. const closedClass = isCollapsedMode() && !isCollapsedContentsOpened ? 'd-none' : '';
  136. const openedClass = isCollapsedMode() && isCollapsedContentsOpened ? 'open' : '';
  137. const collapsibleContentsWidth = isCollapsedMode() ? currentProductNavWidth : undefined;
  138. return (
  139. <div className={`flex-expand-horiz ${className}`} onMouseLeave={mouseLeaveHandler}>
  140. <Nav onPrimaryItemHover={primaryItemHoverHandler} />
  141. <div
  142. className={`sidebar-contents-container flex-grow-1 overflow-hidden ${closedClass} ${openedClass}`}
  143. >
  144. <SimpleBar
  145. scrollableNodeProps={{ ref: sidebarScrollerRef }}
  146. className="simple-scrollbar h-100"
  147. style={{ width: collapsibleContentsWidth }}
  148. autoHide
  149. >
  150. {children}
  151. </SimpleBar>
  152. </div>
  153. </div>
  154. );
  155. });
  156. // for data-* attributes
  157. type HTMLElementProps = JSX.IntrinsicElements &
  158. Record<keyof JSX.IntrinsicElements, { [p: `data-${string}`]: string | number }>;
  159. type DrawableContainerProps = {
  160. divProps?: HTMLElementProps['div'],
  161. className?: string,
  162. children?: React.ReactNode,
  163. }
  164. const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
  165. const { divProps, className, children } = props;
  166. const { data: isDrawerOpened, mutate } = useDrawerOpened();
  167. const openClass = `${isDrawerOpened ? 'open' : ''}`;
  168. return (
  169. <>
  170. <div {...divProps} className={`${className} ${openClass}`}>
  171. {children}
  172. </div>
  173. { isDrawerOpened && (
  174. <div className="modal-backdrop fade show" onClick={() => mutate(false)} />
  175. ) }
  176. </>
  177. );
  178. });
  179. export const Sidebar = (): JSX.Element => {
  180. const {
  181. data: sidebarMode,
  182. isDrawerMode, isCollapsedMode, isDockMode,
  183. } = useSidebarMode();
  184. const { data: isSearchPage } = useIsSearchPage();
  185. // css styles
  186. const grwSidebarClass = styles['grw-sidebar'];
  187. // eslint-disable-next-line no-nested-ternary
  188. let modeClass;
  189. switch (sidebarMode) {
  190. case SidebarMode.DRAWER:
  191. modeClass = 'grw-sidebar-drawer';
  192. break;
  193. case SidebarMode.COLLAPSED:
  194. modeClass = 'grw-sidebar-collapsed';
  195. break;
  196. case SidebarMode.DOCK:
  197. modeClass = 'grw-sidebar-dock';
  198. break;
  199. }
  200. return (
  201. <>
  202. { sidebarMode != null && isDrawerMode() && (
  203. <DrawerToggler className="position-fixed d-none d-md-block">
  204. <span className="material-symbols-outlined">reorder</span>
  205. </DrawerToggler>
  206. ) }
  207. { sidebarMode != null && !isDockMode() && !isSearchPage && <AppTitleOnSubnavigation /> }
  208. <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
  209. <ResizableContainer>
  210. { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
  211. <SidebarHead />
  212. <CollapsibleContainer Nav={SidebarNav} className="border-top">
  213. <SidebarContents />
  214. </CollapsibleContainer>
  215. </ResizableContainer>
  216. </DrawableContainer>
  217. </>
  218. );
  219. };