1import { css } from '@emotion/react';
2import { theme } from '@expo/styleguide';
3import { useRouter } from 'next/router';
4import React, { ComponentType, useMemo } from 'react';
5
6import { ApiVersionSelect } from './ApiVersionSelect';
7import { GroupList } from './GroupList';
8import { PageLink } from './PageLink';
9import { SectionList } from './SectionList';
10import { NavigationNode, NavigationRenderProps, NavigationType } from './types';
11
12import { LayoutScroll, usePersistScroll } from '~/ui/components/Layout';
13
14export type NavigationProps = {
15  /** The tree of navigation nodes to render in the sidebar */
16  routes: NavigationNode[];
17};
18
19export function Navigation({ routes }: NavigationProps) {
20  const router = useRouter();
21  const activeRoutes = useMemo(() => findActiveRoute(routes, router.pathname), [router.pathname]);
22  const persistScroll = usePersistScroll('navigation');
23
24  return (
25    <nav css={navigationStyle}>
26      <LayoutScroll {...persistScroll}>
27        <ApiVersionSelect />
28        {routes.map(route => navigationRenderer(route, activeRoutes))}
29      </LayoutScroll>
30    </nav>
31  );
32}
33
34const navigationStyle = css({
35  width: 280,
36  height: '100%',
37  backgroundColor: theme.background.secondary,
38  '[data-expo-theme="dark"] &': {
39    backgroundColor: theme.background.default,
40  },
41});
42
43const renderers: Record<NavigationType, ComponentType<NavigationRenderProps>> = {
44  section: SectionList,
45  group: GroupList,
46  page: PageLink,
47};
48
49function navigationRenderer(
50  route: NavigationNode,
51  activeRoutes: Record<NavigationType, NavigationNode | null>
52) {
53  if (route.hidden) return null;
54  const Component = renderers[route.type];
55  const routeKey = `${route.type}-${route.name}`;
56  const isActive = activeRoutes[route.type] === route;
57  const hasChildren = route.type !== 'page' && route.children.length;
58  return (
59    <Component key={routeKey} route={route} isActive={isActive}>
60      {hasChildren && route.children.map(nested => navigationRenderer(nested, activeRoutes))}
61    </Component>
62  );
63}
64
65/**
66 * Find the active routes by pathname, and do it once.
67 * This will iterate the tree and find the nodes which are "active".
68 *   - Page -> if the pathname matches the page's href property
69 *   - Group -> if the group contains an active page
70 *   - Section -> if the section contains an active group or page
71 */
72export function findActiveRoute(routes: NavigationNode[], pathname: string) {
73  const activeRoutes: Record<NavigationType, NavigationNode | null> = {
74    page: null,
75    group: null,
76    section: null,
77  };
78
79  for (const route of routes) {
80    // Try to exit early on hidden routes
81    if (route.hidden) continue;
82
83    switch (route.type) {
84      case 'page':
85        if (route.href === pathname) {
86          activeRoutes.page = route;
87          break;
88        }
89        break;
90
91      case 'group':
92        {
93          const nestedActiveRoutes = findActiveRoute(route.children, pathname);
94          if (nestedActiveRoutes.page) {
95            activeRoutes.page = nestedActiveRoutes.page;
96            activeRoutes.group = route;
97            break;
98          }
99        }
100        break;
101
102      case 'section':
103        {
104          const nestedActiveRoutes = findActiveRoute(route.children, pathname);
105          if (nestedActiveRoutes.group || nestedActiveRoutes.page) {
106            activeRoutes.page = nestedActiveRoutes.page;
107            activeRoutes.group = nestedActiveRoutes.group;
108            activeRoutes.section = route;
109            break;
110          }
111        }
112        break;
113    }
114  }
115
116  return activeRoutes;
117}
118