Sidebar.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import * as React from 'react';
  2. import Icon from '../Icon/Icon';
  3. // Please keep in sync with $menu-transition-duration variable in `client/scss/settings/_variables.scss`
  4. export const SIDEBAR_TRANSITION_DURATION = 150;
  5. export interface Strings {
  6. DASHBOARD: string;
  7. EDIT_YOUR_ACCOUNT: string;
  8. SEARCH: string;
  9. TOGGLE_SIDEBAR: string;
  10. MAIN_MENU: string;
  11. }
  12. export interface ModuleRenderContext {
  13. key: number;
  14. slim: boolean;
  15. expandingOrCollapsing: boolean;
  16. onAccountExpand: () => void;
  17. onSearchClick: () => void;
  18. currentPath: string;
  19. strings: Strings;
  20. navigate(url: string): Promise<void>;
  21. }
  22. export interface ModuleDefinition {
  23. render(context: ModuleRenderContext): React.ReactFragment;
  24. }
  25. export interface SidebarProps {
  26. modules: ModuleDefinition[];
  27. currentPath: string;
  28. strings: Strings;
  29. collapsedOnLoad: boolean;
  30. navigate(url: string): Promise<void>;
  31. onExpandCollapse?(collapsed: boolean);
  32. }
  33. export const Sidebar: React.FunctionComponent<SidebarProps> = ({
  34. modules,
  35. currentPath,
  36. collapsedOnLoad = false,
  37. strings,
  38. navigate,
  39. onExpandCollapse,
  40. }) => {
  41. // 'collapsed' is a persistent state that is controlled by the arrow icon at the top
  42. // It records the user's general preference for a collapsed/uncollapsed menu
  43. // This is just a hint though, and we may still collapse the menu if the screen is too small
  44. const [collapsed, setCollapsed] = React.useState(collapsedOnLoad);
  45. // Call onExpandCollapse(true) if menu is initialised in collapsed state
  46. React.useEffect(() => {
  47. if (collapsed && onExpandCollapse) {
  48. onExpandCollapse(true);
  49. }
  50. }, []);
  51. // 'visibleOnMobile' indicates whether the sidebar is currently visible on mobile
  52. // On mobile, the sidebar is completely hidden by default and must be opened manually
  53. const [visibleOnMobile, setVisibleOnMobile] = React.useState(false);
  54. // Tracks whether the screen is below 800 pixels. In this state, the menu is completely hidden.
  55. // State is used here in case the user changes their browser size
  56. const checkWindowSizeIsMobile = () => window.innerWidth < 800;
  57. const [isMobile, setIsMobile] = React.useState(checkWindowSizeIsMobile());
  58. React.useEffect(() => {
  59. function handleResize() {
  60. if (checkWindowSizeIsMobile()) {
  61. setIsMobile(true);
  62. } else {
  63. setIsMobile(false);
  64. // Close the menu as this state is not used in desktop
  65. setVisibleOnMobile(false);
  66. }
  67. }
  68. window.addEventListener('resize', handleResize);
  69. handleResize();
  70. return () => window.removeEventListener('resize', handleResize);
  71. }, []);
  72. // Whether or not to display the menu with slim layout.
  73. // Separate from 'collapsed' as the menu can still be displayed with an expanded
  74. // layout while in 'collapsed' mode if the user is 'peeking' into it (see above)
  75. const slim = collapsed && !isMobile;
  76. // 'expandingOrCollapsing' is set to true whilst the the menu is transitioning between slim and expanded layouts
  77. const [expandingOrCollapsing, setExpandingOrCollapsing] =
  78. React.useState(false);
  79. React.useEffect(() => {
  80. setExpandingOrCollapsing(true);
  81. const finishTimeout = setTimeout(() => {
  82. setExpandingOrCollapsing(false);
  83. }, SIDEBAR_TRANSITION_DURATION);
  84. return () => {
  85. clearTimeout(finishTimeout);
  86. };
  87. }, [slim]);
  88. const onClickCollapseToggle = () => {
  89. setCollapsed(!collapsed);
  90. if (onExpandCollapse) {
  91. onExpandCollapse(!collapsed);
  92. }
  93. };
  94. const onClickOpenCloseToggle = () => {
  95. setVisibleOnMobile(!visibleOnMobile);
  96. setExpandingOrCollapsing(true);
  97. const finishTimeout = setTimeout(() => {
  98. setExpandingOrCollapsing(false);
  99. }, SIDEBAR_TRANSITION_DURATION);
  100. return () => {
  101. clearTimeout(finishTimeout);
  102. };
  103. };
  104. const [focused, setFocused] = React.useState(false);
  105. const onBlurHandler = () => {
  106. if (focused) {
  107. setFocused(false);
  108. setCollapsed(true);
  109. }
  110. };
  111. const onFocusHandler = () => {
  112. if (focused) {
  113. setCollapsed(false);
  114. setFocused(true);
  115. }
  116. };
  117. const onSearchClick = () => {
  118. if (slim) {
  119. onClickCollapseToggle();
  120. }
  121. };
  122. const onAccountExpand = () => {
  123. if (slim) {
  124. onClickCollapseToggle();
  125. }
  126. };
  127. // Render modules
  128. const renderedModules = modules.map((module, index) =>
  129. module.render({
  130. key: index,
  131. slim,
  132. expandingOrCollapsing,
  133. onAccountExpand,
  134. onSearchClick,
  135. currentPath,
  136. strings,
  137. navigate,
  138. }),
  139. );
  140. return (
  141. <>
  142. <div
  143. className={
  144. 'sidebar' +
  145. (slim ? ' sidebar--slim' : '') +
  146. (isMobile ? ' sidebar--mobile' : '') +
  147. (isMobile && !visibleOnMobile ? ' sidebar--hidden' : '')
  148. }
  149. >
  150. <div
  151. className="sidebar__inner"
  152. onFocus={onFocusHandler}
  153. onBlur={onBlurHandler}
  154. >
  155. <div
  156. className={`${
  157. slim ? 'w-justify-center' : 'w-justify-end'
  158. } w-flex w-items-center`}
  159. >
  160. <button
  161. onClick={onClickCollapseToggle}
  162. aria-label={strings.TOGGLE_SIDEBAR}
  163. aria-expanded={slim ? 'false' : 'true'}
  164. type="button"
  165. className={`
  166. ${!slim ? 'w-mr-4' : ''}
  167. button
  168. sidebar__collapse-toggle
  169. w-flex
  170. w-justify-center
  171. w-items-center
  172. sm:w-mt-4
  173. hover:w-bg-primary-200
  174. hover:text-white
  175. hover:opacity-100`}
  176. >
  177. <Icon
  178. name="expand-right"
  179. className={`w-transition motion-reduce:w-transition-none
  180. ${!collapsed ? '-w-rotate-180' : ''}
  181. `}
  182. />
  183. </button>
  184. </div>
  185. {renderedModules}
  186. </div>
  187. </div>
  188. <button
  189. onClick={onClickOpenCloseToggle}
  190. aria-label={strings.TOGGLE_SIDEBAR}
  191. aria-expanded={visibleOnMobile ? 'true' : 'false'}
  192. className={
  193. 'button sidebar-nav-toggle' +
  194. (isMobile ? ' sidebar-nav-toggle--mobile' : '') +
  195. (visibleOnMobile ? ' sidebar-nav-toggle--open' : '')
  196. }
  197. type="button"
  198. >
  199. {visibleOnMobile ? <Icon name="cross" /> : <Icon name="bars" />}
  200. </button>
  201. </>
  202. );
  203. };