Sidebar.tsx 8.9 KB

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