Sidebar.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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 openClass = `${isCollapsedContentsOpened ? 'open' : ''}`;
  100. const collapsibleContentsWidth = isCollapsedMode() ? currentProductNavWidth : undefined;
  101. return (
  102. <div className={`flex-expand-horiz ${className}`} onMouseLeave={mouseLeaveHandler}>
  103. <Nav onPrimaryItemHover={primaryItemHoverHandler} />
  104. <div className={`sidebar-contents-container flex-grow-1 overflow-y-auto overflow-x-hidden ${openClass}`} style={{ width: collapsibleContentsWidth }}>
  105. {children}
  106. </div>
  107. </div>
  108. );
  109. });
  110. // for data-* attributes
  111. type HTMLElementProps = JSX.IntrinsicElements &
  112. Record<keyof JSX.IntrinsicElements, { [p: `data-${string}`]: string | number }>;
  113. type DrawableContainerProps = {
  114. divProps?: HTMLElementProps['div'],
  115. className?: string,
  116. children?: React.ReactNode,
  117. }
  118. const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
  119. const { divProps, className, children } = props;
  120. const { data: isDrawerOpened, mutate } = useDrawerOpened();
  121. const openClass = `${isDrawerOpened ? 'open' : ''}`;
  122. return (
  123. <>
  124. <div {...divProps} className={`${className} ${openClass}`}>
  125. {children}
  126. </div>
  127. { isDrawerOpened && (
  128. <div className="modal-backdrop fade show" onClick={() => mutate(false)} />
  129. ) }
  130. </>
  131. );
  132. });
  133. export const Sidebar = (): JSX.Element => {
  134. const {
  135. data: sidebarMode,
  136. isDrawerMode, isCollapsedMode, isDockMode,
  137. } = useSidebarMode();
  138. const { data: isSearchPage } = useIsSearchPage();
  139. // css styles
  140. const grwSidebarClass = styles['grw-sidebar'];
  141. // eslint-disable-next-line no-nested-ternary
  142. let modeClass;
  143. switch (sidebarMode) {
  144. case SidebarMode.DRAWER:
  145. modeClass = 'grw-sidebar-drawer';
  146. break;
  147. case SidebarMode.COLLAPSED:
  148. modeClass = 'grw-sidebar-collapsed';
  149. break;
  150. case SidebarMode.DOCK:
  151. modeClass = 'grw-sidebar-dock';
  152. break;
  153. }
  154. return (
  155. <>
  156. { sidebarMode != null && isDrawerMode() && (
  157. <DrawerToggler className="position-fixed d-none d-md-block">
  158. <span className="material-symbols-outlined">reorder</span>
  159. </DrawerToggler>
  160. ) }
  161. { sidebarMode != null && !isDockMode() && !isSearchPage && <AppTitleOnSubnavigation /> }
  162. <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
  163. <ResizableContainer>
  164. { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
  165. <SidebarHead />
  166. <CollapsibleContainer Nav={SidebarNav} className="border-top">
  167. <SidebarContents />
  168. </CollapsibleContainer>
  169. </ResizableContainer>
  170. </DrawableContainer>
  171. </>
  172. );
  173. };