Sidebar.tsx 7.1 KB

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