MainMenu.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import * as React from 'react';
  2. import Icon from '../../Icon/Icon';
  3. import { LinkMenuItemDefinition } from '../menu/LinkMenuItem';
  4. import { MenuItemDefinition } from '../menu/MenuItem';
  5. import { SubMenuItemDefinition } from '../menu/SubMenuItem';
  6. import { ModuleDefinition, Strings } from '../Sidebar';
  7. export function renderMenu(
  8. path: string,
  9. items: MenuItemDefinition[],
  10. slim: boolean,
  11. state: MenuState,
  12. dispatch: (action: MenuAction) => void,
  13. navigate: (url: string) => Promise<void>
  14. ) {
  15. return (
  16. <>
  17. {items.map(item => item.render({
  18. path: `${path}.${item.name}`,
  19. slim,
  20. state,
  21. dispatch,
  22. navigate,
  23. }))}
  24. </>
  25. );
  26. }
  27. interface SetActivePath {
  28. type: 'set-active-path',
  29. path: string,
  30. }
  31. interface SetNavigationPath {
  32. type: 'set-navigation-path',
  33. path: string,
  34. }
  35. export type MenuAction = SetActivePath | SetNavigationPath;
  36. export interface MenuState {
  37. navigationPath: string;
  38. activePath: string;
  39. }
  40. function menuReducer(state: MenuState, action: MenuAction) {
  41. const newState = Object.assign({}, state);
  42. if (action.type === 'set-active-path') {
  43. newState.activePath = action.path;
  44. } else if (action.type === 'set-navigation-path') {
  45. newState.navigationPath = action.path;
  46. }
  47. return newState;
  48. }
  49. interface MenuProps {
  50. menuItems: MenuItemDefinition[];
  51. accountMenuItems: MenuItemDefinition[];
  52. user: MainMenuModuleDefinition['user'];
  53. slim: boolean;
  54. expandingOrCollapsing: boolean;
  55. currentPath: string;
  56. strings: Strings;
  57. navigate(url: string): Promise<void>;
  58. }
  59. export const Menu: React.FunctionComponent<MenuProps> = (
  60. { menuItems, accountMenuItems, user, expandingOrCollapsing, slim, currentPath, strings, navigate }) => {
  61. // navigationPath and activePath are two dot-delimited path's referencing a menu item
  62. // They are created by concatenating the name fields of all the menu/sub-menu items leading to the relevant one.
  63. // For example, the "Users" item in the "Settings" sub-menu would have the path 'settings.users'
  64. // - navigationPath references the current sub-menu that the user currently has open
  65. // - activePath references the menu item for the the page the user is currently on
  66. const [state, dispatch] = React.useReducer(menuReducer, {
  67. navigationPath: '',
  68. activePath: '',
  69. });
  70. const accountSettingsOpen = state.navigationPath.startsWith('.account');
  71. // Whenever currentPath or menu changes, work out new activePath
  72. React.useEffect(() => {
  73. const urlPathsToNavigationPaths: [string, string][] = [];
  74. const walkMenu = (path: string, walkingMenuItems: MenuItemDefinition[]) => {
  75. walkingMenuItems.forEach((item) => {
  76. const newPath = `${path}.${item.name}`;
  77. if (item instanceof LinkMenuItemDefinition) {
  78. urlPathsToNavigationPaths.push([item.url, newPath]);
  79. } else if (item instanceof SubMenuItemDefinition) {
  80. walkMenu(newPath, item.menuItems);
  81. }
  82. });
  83. };
  84. walkMenu('', menuItems);
  85. walkMenu('', accountMenuItems);
  86. let bestMatch: [string, string] | null = null;
  87. urlPathsToNavigationPaths.forEach(([urlPath, navPath]) => {
  88. if (currentPath.startsWith(urlPath)) {
  89. if (bestMatch == null || urlPath.length > bestMatch[0].length) {
  90. bestMatch = [urlPath, navPath];
  91. }
  92. }
  93. });
  94. const newActivePath = bestMatch ? bestMatch[1] : '';
  95. if (newActivePath !== state.activePath) {
  96. dispatch({
  97. type: 'set-active-path',
  98. path: newActivePath,
  99. });
  100. }
  101. }, [currentPath, menuItems]);
  102. React.useEffect(() => {
  103. // Close submenus when user presses escape
  104. const onKeydown = (e: KeyboardEvent) => {
  105. // IE11 uses "Esc" instead of "Escape"
  106. if (e.key === 'Escape' || e.key === 'Esc') {
  107. dispatch({
  108. type: 'set-navigation-path',
  109. path: ''
  110. });
  111. }
  112. };
  113. document.addEventListener('keydown', onKeydown);
  114. return () => {
  115. document.removeEventListener('keydown', onKeydown);
  116. };
  117. }, []);
  118. // Whenever the parent Sidebar component collapses or expands, close any open menus
  119. React.useEffect(() => {
  120. if (expandingOrCollapsing) {
  121. dispatch({
  122. type: 'set-navigation-path',
  123. path: ''
  124. });
  125. }
  126. }, [expandingOrCollapsing]);
  127. const onClickAccountSettings = (e: React.MouseEvent) => {
  128. e.preventDefault();
  129. if (accountSettingsOpen) {
  130. dispatch({
  131. type: 'set-navigation-path',
  132. path: '',
  133. });
  134. } else {
  135. dispatch({
  136. type: 'set-navigation-path',
  137. path: '.account',
  138. });
  139. }
  140. };
  141. const className = (
  142. 'sidebar-main-menu'
  143. + (accountSettingsOpen ? ' sidebar-main-menu--open-footer' : '')
  144. );
  145. return (
  146. <>
  147. <nav className={className} aria-label={strings.MAIN_MENU}>
  148. <ul className="sidebar-main-menu__list">
  149. {renderMenu('', menuItems, slim, state, dispatch, navigate)}
  150. </ul>
  151. </nav>
  152. <div className={'sidebar-footer' + (accountSettingsOpen ? ' sidebar-footer--open' : '')}>
  153. <button
  154. className="sidebar-footer__account"
  155. title={strings.EDIT_YOUR_ACCOUNT}
  156. aria-label={strings.EDIT_YOUR_ACCOUNT}
  157. onClick={onClickAccountSettings}
  158. aria-haspopup="true"
  159. aria-expanded={accountSettingsOpen ? 'true' : 'false'}
  160. >
  161. <div className="avatar square avatar-on-dark">
  162. <img src={user.avatarUrl} alt="" />
  163. </div>
  164. <div className="sidebar-footer__account-toggle">
  165. <div className="sidebar-footer__account-label">{user.name}</div>
  166. <Icon
  167. className="sidebar-footer__account-icon"
  168. name={(accountSettingsOpen ? 'arrow-down' : 'arrow-up')}
  169. />
  170. </div>
  171. </button>
  172. <ul>
  173. {renderMenu('', accountMenuItems, slim, state, dispatch, navigate)}
  174. </ul>
  175. </div>
  176. </>
  177. );
  178. };
  179. export class MainMenuModuleDefinition implements ModuleDefinition {
  180. menuItems: MenuItemDefinition[];
  181. accountMenuItems: MenuItemDefinition[];
  182. user: {
  183. name: string;
  184. avatarUrl: string;
  185. };
  186. constructor(
  187. menuItems: MenuItemDefinition[],
  188. accountMenuItems: MenuItemDefinition[],
  189. user: MainMenuModuleDefinition['user']
  190. ) {
  191. this.menuItems = menuItems;
  192. this.accountMenuItems = accountMenuItems;
  193. this.user = user;
  194. }
  195. render({ slim, expandingOrCollapsing, key, currentPath, strings, navigate }) {
  196. return (
  197. <Menu
  198. menuItems={this.menuItems}
  199. accountMenuItems={this.accountMenuItems}
  200. user={this.user}
  201. slim={slim}
  202. expandingOrCollapsing={expandingOrCollapsing}
  203. key={key}
  204. currentPath={currentPath}
  205. strings={strings}
  206. navigate={navigate}
  207. />
  208. );
  209. }
  210. }