Sidebar.tsx 6.7 KB

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