Sidebar.tsx 6.4 KB

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