Browse Source

[Beta] Refactor navigation logic (#5492)

* Pass route lists explicitly

* Inline MarkdownPage into Page

* Pass breadcrumbs from above

* Remove state from router utils

* Pass section from above
main
dan 2 years ago
committed by GitHub
parent
commit
c9e2e39940
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      beta/src/components/Breadcrumbs.tsx
  2. 2
      beta/src/components/DocsFooter.tsx
  3. 58
      beta/src/components/Layout/MarkdownPage.tsx
  4. 18
      beta/src/components/Layout/Nav/Nav.tsx
  5. 92
      beta/src/components/Layout/Page.tsx
  6. 36
      beta/src/components/Layout/Sidebar/SidebarRouteTree.tsx
  7. 24
      beta/src/components/Layout/getRouteMeta.tsx
  8. 6
      beta/src/components/PageHeading.tsx
  9. 2
      beta/src/components/Tag.tsx
  10. 16
      beta/src/hooks/useActiveSection.ts
  11. 16
      beta/src/hooks/usePathWithoutQuerystring.ts
  12. 26
      beta/src/pages/404.js
  13. 33
      beta/src/pages/500.js
  14. 28
      beta/src/pages/[[...markdownPath]].js

6
beta/src/components/Breadcrumbs.tsx

@ -3,12 +3,10 @@
*/
import {Fragment} from 'react';
import {useRouteMeta} from 'components/Layout/useRouteMeta';
import Link from 'next/link';
import type {RouteItem} from 'components/Layout/getRouteMeta';
function Breadcrumbs() {
const {breadcrumbs} = useRouteMeta();
if (!breadcrumbs) return null;
function Breadcrumbs({breadcrumbs}: {breadcrumbs: RouteItem[]}) {
return (
<div className="flex flex-wrap">
{breadcrumbs.map(

2
beta/src/components/DocsFooter.tsx

@ -7,7 +7,7 @@ import {memo} from 'react';
import cn from 'classnames';
import {removeFromLast} from 'utils/removeFromLast';
import {IconNavArrow} from './Icon/IconNavArrow';
import {RouteMeta} from './Layout/useRouteMeta';
import type {RouteMeta} from './Layout/getRouteMeta';
export type DocsPageFooterProps = Pick<
RouteMeta,

58
beta/src/components/Layout/MarkdownPage.tsx

@ -1,58 +0,0 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*/
import * as React from 'react';
import {useRouter} from 'next/router';
import {DocsPageFooter} from 'components/DocsFooter';
import {Seo} from 'components/Seo';
import PageHeading from 'components/PageHeading';
import {useRouteMeta} from './useRouteMeta';
import {useActiveSection} from '../../hooks/useActiveSection';
import {TocContext} from '../MDX/TocContext';
import(/* webpackPrefetch: true */ '../MDX/CodeBlock/CodeBlock');
export interface MarkdownProps<Frontmatter> {
meta: Frontmatter & {description?: string};
children?: React.ReactNode;
toc: Array<{
url: string;
text: React.ReactNode;
depth: number;
}>;
}
export function MarkdownPage<
T extends {title: string; status?: string} = {title: string; status?: string}
>({children, meta, toc}: MarkdownProps<T>) {
const {route, nextRoute, prevRoute} = useRouteMeta();
const section = useActiveSection();
const title = meta.title || route?.title || '';
const description = meta.description || route?.description || '';
const isHomePage = section === 'home';
return (
<>
<div className="pl-0">
<Seo title={title} />
{!isHomePage && (
<PageHeading
title={title}
description={description}
tags={route?.tags}
/>
)}
<div className="px-5 sm:px-12">
<div className="max-w-7xl mx-auto">
<TocContext.Provider value={toc}>{children}</TocContext.Provider>
</div>
<DocsPageFooter
route={route}
nextRoute={nextRoute}
prevRoute={prevRoute}
/>
</div>
</div>
</>
);
}

18
beta/src/components/Layout/Nav/Nav.tsx

@ -12,13 +12,11 @@ import {disableBodyScroll, enableBodyScroll} from 'body-scroll-lock';
import {IconClose} from 'components/Icon/IconClose';
import {IconHamburger} from 'components/Icon/IconHamburger';
import {Search} from 'components/Search';
import {useActiveSection} from 'hooks/useActiveSection';
import {Logo} from '../../Logo';
import {Feedback} from '../Feedback';
import NavLink from './NavLink';
import {SidebarContext} from 'components/Layout/useRouteMeta';
import {SidebarRouteTree} from '../Sidebar/SidebarRouteTree';
import type {RouteItem} from '../useRouteMeta';
import type {RouteItem} from '../getRouteMeta';
import sidebarLearn from '../../../sidebarLearn.json';
import sidebarReference from '../../../sidebarReference.json';
@ -92,17 +90,22 @@ const lightIcon = (
</svg>
);
export default function Nav() {
export default function Nav({
routeTree,
breadcrumbs,
section,
}: {
routeTree: RouteItem;
breadcrumbs: RouteItem[];
section: 'learn' | 'reference' | 'home';
}) {
const [isOpen, setIsOpen] = useState(false);
const [showFeedback, setShowFeedback] = useState(false);
const scrollParentRef = useRef<HTMLDivElement>(null);
const feedbackAutohideRef = useRef<any>(null);
const section = useActiveSection();
const {asPath} = useRouter();
const feedbackPopupRef = useRef<null | HTMLDivElement>(null);
// In desktop mode, use the route tree for current route.
let routeTree: RouteItem = useContext(SidebarContext);
// In mobile mode, let the user switch tabs there and back without navigating.
// Seed the tab state from the router, but keep it independent.
const [tab, setTab] = useState(section);
@ -344,6 +347,7 @@ export default function Nav() {
// This avoids unnecessary animations and visual flicker.
key={isOpen ? 'mobile-overlay' : 'desktop-or-hidden'}
routeTree={routeTree}
breadcrumbs={breadcrumbs}
isForceExpanded={isOpen}
/>
</Suspense>

92
beta/src/components/Layout/Page.tsx

@ -6,52 +6,86 @@ import {Suspense} from 'react';
import * as React from 'react';
import {useRouter} from 'next/router';
import {Nav} from './Nav';
import {RouteItem, SidebarContext} from './useRouteMeta';
import {useActiveSection} from 'hooks/useActiveSection';
import {Footer} from './Footer';
import {Toc} from './Toc';
import SocialBanner from '../SocialBanner';
import {DocsPageFooter} from 'components/DocsFooter';
import {Seo} from 'components/Seo';
import PageHeading from 'components/PageHeading';
import {getRouteMeta} from './getRouteMeta';
import {TocContext} from '../MDX/TocContext';
import sidebarLearn from '../../sidebarLearn.json';
import sidebarReference from '../../sidebarReference.json';
import type {TocItem} from 'components/MDX/TocContext';
import type {RouteItem} from 'components/Layout/getRouteMeta';
import(/* webpackPrefetch: true */ '../MDX/CodeBlock/CodeBlock');
interface PageProps {
children: React.ReactNode;
toc: Array<TocItem>;
routeTree: RouteItem;
meta: {title?: string; description?: string};
section: 'learn' | 'reference' | 'home';
}
export function Page({children, toc}: PageProps) {
export function Page({children, toc, routeTree, meta, section}: PageProps) {
const {asPath} = useRouter();
const section = useActiveSection();
let routeTree = sidebarLearn as RouteItem;
switch (section) {
case 'reference':
routeTree = sidebarReference as RouteItem;
break;
}
const cleanedPath = asPath.split(/[\?\#]/)[0];
const {route, nextRoute, prevRoute, breadcrumbs} = getRouteMeta(
cleanedPath,
routeTree
);
const title = meta.title || route?.title || '';
const description = meta.description || route?.description || '';
const isHomePage = cleanedPath === '/';
return (
<>
<SocialBanner />
<SidebarContext.Provider value={routeTree}>
<div className="grid grid-cols-only-content lg:grid-cols-sidebar-content 2xl:grid-cols-sidebar-content-toc">
<div className="fixed lg:sticky top-0 left-0 right-0 py-0 shadow lg:shadow-none z-50">
<Nav />
</div>
{/* No fallback UI so need to be careful not to suspend directly inside. */}
<Suspense fallback={null}>
<main className="min-w-0">
<div className="lg:hidden h-16 mb-2" />
<article className="break-words" key={asPath}>
{children}
</article>
<Footer />
</main>
</Suspense>
<div className="hidden lg:max-w-xs 2xl:block">
{toc.length > 0 && <Toc headings={toc} key={asPath} />}
</div>
<div className="grid grid-cols-only-content lg:grid-cols-sidebar-content 2xl:grid-cols-sidebar-content-toc">
<div className="fixed lg:sticky top-0 left-0 right-0 py-0 shadow lg:shadow-none z-50">
<Nav
routeTree={routeTree}
breadcrumbs={breadcrumbs}
section={section}
/>
</div>
{/* No fallback UI so need to be careful not to suspend directly inside. */}
<Suspense fallback={null}>
<main className="min-w-0">
<div className="lg:hidden h-16 mb-2" />
<article className="break-words" key={asPath}>
<div className="pl-0">
<Seo title={title} />
{!isHomePage && (
<PageHeading
title={title}
description={description}
tags={route?.tags}
breadcrumbs={breadcrumbs}
/>
)}
<div className="px-5 sm:px-12">
<div className="max-w-7xl mx-auto">
<TocContext.Provider value={toc}>
{children}
</TocContext.Provider>
</div>
<DocsPageFooter
route={route}
nextRoute={nextRoute}
prevRoute={prevRoute}
/>
</div>
</div>
</article>
<Footer />
</main>
</Suspense>
<div className="hidden lg:max-w-xs 2xl:block">
{toc.length > 0 && <Toc headings={toc} key={asPath} />}
</div>
</SidebarContext.Provider>
</div>
</>
);
}

36
beta/src/components/Layout/Sidebar/SidebarRouteTree.tsx

@ -5,16 +5,16 @@
import {useRef, useLayoutEffect, Fragment} from 'react';
import cn from 'classnames';
import {RouteItem} from 'components/Layout/useRouteMeta';
import {useRouter} from 'next/router';
import {removeFromLast} from 'utils/removeFromLast';
import {useRouteMeta} from '../useRouteMeta';
import {SidebarLink} from './SidebarLink';
import useCollapse from 'react-collapsed';
import usePendingRoute from 'hooks/usePendingRoute';
import type {RouteItem} from 'components/Layout/getRouteMeta';
interface SidebarRouteTreeProps {
isForceExpanded: boolean;
breadcrumbs: RouteItem[];
routeTree: RouteItem;
level?: number;
}
@ -72,31 +72,13 @@ function CollapseWrapper({
export function SidebarRouteTree({
isForceExpanded,
breadcrumbs,
routeTree,
level = 0,
}: SidebarRouteTreeProps) {
const {breadcrumbs} = useRouteMeta(routeTree);
const cleanedPath = useRouter().asPath.split(/[\?\#]/)[0];
const slug = useRouter().asPath.split(/[\?\#]/)[0];
const pendingRoute = usePendingRoute();
const slug = cleanedPath;
const currentRoutes = routeTree.routes as RouteItem[];
const expandedPath = currentRoutes.reduce(
(acc: string | undefined, curr: RouteItem) => {
if (acc) return acc;
const breadcrumb = breadcrumbs.find((b) => b.path === curr.path);
if (breadcrumb) {
return curr.path;
}
if (curr.path === cleanedPath) {
return cleanedPath;
}
return undefined;
},
undefined
);
const expanded = expandedPath;
return (
<ul>
{currentRoutes.map(
@ -106,7 +88,6 @@ export function SidebarRouteTree({
) => {
const pagePath = path && removeFromLast(path, '.');
const selected = slug === pagePath;
let listItem = null;
if (!path || !pagePath || heading) {
// if current route item has no path and children treat it as an API sidebar heading
@ -115,11 +96,15 @@ export function SidebarRouteTree({
level={level + 1}
isForceExpanded={isForceExpanded}
routeTree={{title, routes}}
breadcrumbs={[]}
/>
);
} else if (routes) {
// if route has a path and child routes, treat it as an expandable sidebar item
const isExpanded = isForceExpanded || expanded === path;
const isBreadcrumb =
breadcrumbs.length > 1 &&
breadcrumbs[breadcrumbs.length - 1].path === path;
const isExpanded = isForceExpanded || isBreadcrumb || selected;
listItem = (
<li key={`${title}-${path}-${level}-heading`}>
<SidebarLink
@ -131,13 +116,14 @@ export function SidebarRouteTree({
title={title}
wip={wip}
isExpanded={isExpanded}
isBreadcrumb={expandedPath === path}
isBreadcrumb={isBreadcrumb}
hideArrow={isForceExpanded}
/>
<CollapseWrapper duration={250} isExpanded={isExpanded}>
<SidebarRouteTree
isForceExpanded={isForceExpanded}
routeTree={{title, routes}}
breadcrumbs={breadcrumbs}
level={level + 1}
/>
</CollapseWrapper>

24
beta/src/components/Layout/useRouteMeta.tsx → beta/src/components/Layout/getRouteMeta.tsx

@ -2,9 +2,6 @@
* Copyright (c) Facebook, Inc. and its affiliates.
*/
import {useContext, createContext} from 'react';
import {useRouter} from 'next/router';
/**
* While Next.js provides file-based routing, we still need to construct
* a sidebar for navigation and provide each markdown page
@ -57,30 +54,19 @@ export interface RouteMeta {
breadcrumbs?: RouteItem[];
}
export function useRouteMeta(rootRoute?: RouteItem) {
const sidebarContext = useContext(SidebarContext);
const routeTree = rootRoute || sidebarContext;
const router = useRouter();
if (router.pathname === '/404') {
return {
breadcrumbs: [],
};
}
const cleanedPath = router.asPath.split(/[\?\#]/)[0];
export function getRouteMeta(cleanedPath: string, routeTree: RouteItem) {
const breadcrumbs = getBreadcrumbs(cleanedPath, routeTree);
return {
...getRouteMeta(cleanedPath, routeTree),
...buildRouteMeta(cleanedPath, routeTree, {}),
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : [routeTree],
};
}
export const SidebarContext = createContext<RouteItem>({title: 'root'});
// Performs a depth-first search to find the current route and its previous/next route
function getRouteMeta(
function buildRouteMeta(
searchPath: string,
currentRoute: RouteItem,
ctx: RouteMeta = {}
ctx: RouteMeta
): RouteMeta {
const {routes} = currentRoute;
@ -101,7 +87,7 @@ function getRouteMeta(
}
for (const route of routes) {
getRouteMeta(searchPath, route, ctx);
buildRouteMeta(searchPath, route, ctx);
}
return ctx;

6
beta/src/components/PageHeading.tsx

@ -4,14 +4,15 @@
import Breadcrumbs from 'components/Breadcrumbs';
import Tag from 'components/Tag';
import {RouteTag} from './Layout/useRouteMeta';
import {H1} from './MDX/Heading';
import type {RouteTag, RouteItem} from './Layout/getRouteMeta';
interface PageHeadingProps {
title: string;
status?: string;
description?: string;
tags?: RouteTag[];
breadcrumbs: RouteItem[];
}
function PageHeading({
@ -19,11 +20,12 @@ function PageHeading({
status,
description,
tags = [],
breadcrumbs,
}: PageHeadingProps) {
return (
<div className="px-5 sm:px-12 pt-8 sm:pt-7 lg:pt-5">
<div className="max-w-4xl ml-0 2xl:mx-auto">
{tags ? <Breadcrumbs /> : null}
{breadcrumbs ? <Breadcrumbs breadcrumbs={breadcrumbs} /> : null}
<H1 className="mt-0 text-primary dark:text-primary-dark -mx-.5 break-words">
{title}
{status ? <em>{status}</em> : ''}

2
beta/src/components/Tag.tsx

@ -3,7 +3,7 @@
*/
import cn from 'classnames';
import {RouteTag} from './Layout/useRouteMeta';
import type {RouteTag} from './Layout/getRouteMeta';
const variantMap = {
foundation: {

16
beta/src/hooks/useActiveSection.ts

@ -1,16 +0,0 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*/
import {useRouter} from 'next/router';
export function useActiveSection(): 'learn' | 'reference' | 'home' {
const {asPath} = useRouter();
if (asPath.startsWith('/reference')) {
return 'reference';
} else if (asPath.startsWith('/learn')) {
return 'learn';
} else {
return 'home';
}
}

16
beta/src/hooks/usePathWithoutQuerystring.ts

@ -1,16 +0,0 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*/
import {useRouter} from 'next/router';
export function useActiveSection(): 'learn' | 'reference' | 'home' {
const {asPath} = useRouter();
if (asPath.startsWith('/learn')) {
return 'learn';
} else if (asPath.startsWith('/reference')) {
return 'reference';
} else {
return 'home';
}
}

26
beta/src/pages/404.js

@ -3,26 +3,24 @@
*/
import {Page} from 'components/Layout/Page';
import {MarkdownPage} from 'components/Layout/MarkdownPage';
import {MDXComponents} from 'components/MDX/MDXComponents';
import sidebarLearn from '../sidebarLearn.json';
const {Intro, MaxWidth, p: P, a: A} = MDXComponents;
export default function NotFound() {
return (
<Page toc={[]}>
<MarkdownPage meta={{title: 'Not Found'}}>
<MaxWidth>
<Intro>
<P>This page doesnt exist.</P>
<P>
Quite possibly, it hasnt been written yet. This beta is a{' '}
<A href="/#how-much-content-is-ready">work in progress!</A>
</P>
<P>Please check back later.</P>
</Intro>
</MaxWidth>
</MarkdownPage>
<Page toc={[]} meta={{title: 'Not Found'}} routeTree={sidebarLearn}>
<MaxWidth>
<Intro>
<P>This page doesnt exist.</P>
<P>
Quite possibly, it hasnt been written yet. This beta is a{' '}
<A href="/#how-much-content-is-ready">work in progress!</A>
</P>
<P>Please check back later.</P>
</Intro>
</MaxWidth>
</Page>
);
}

33
beta/src/pages/500.js

@ -3,28 +3,29 @@
*/
import {Page} from 'components/Layout/Page';
import {MarkdownPage} from 'components/Layout/MarkdownPage';
import {MDXComponents} from 'components/MDX/MDXComponents';
import sidebarLearn from '../sidebarLearn.json';
const {Intro, MaxWidth, p: P, a: A} = MDXComponents;
export default function NotFound() {
return (
<Page toc={[]}>
<MarkdownPage meta={{title: 'Something Went Wrong'}}>
<MaxWidth>
<Intro>
<P>Something went very wrong.</P>
<P>Sorry about that.</P>
<P>
If youd like, please{' '}
<A href="https://github.com/reactjs/reactjs.org/issues/new">
report a bug.
</A>
</P>
</Intro>
</MaxWidth>
</MarkdownPage>
<Page
toc={[]}
routeTree={sidebarLearn}
meta={{title: 'Something Went Wrong'}}>
<MaxWidth>
<Intro>
<P>Something went very wrong.</P>
<P>Sorry about that.</P>
<P>
If youd like, please{' '}
<A href="https://github.com/reactjs/reactjs.org/issues/new">
report a bug.
</A>
</P>
</Intro>
</MaxWidth>
</Page>
);
}

28
beta/src/pages/[[...markdownPath]].js

@ -3,9 +3,11 @@
*/
import {Fragment, useMemo} from 'react';
import {useRouter} from 'next/router';
import {MDXComponents} from 'components/MDX/MDXComponents';
import {MarkdownPage} from 'components/Layout/MarkdownPage';
import {Page} from 'components/Layout/Page';
import sidebarLearn from '../sidebarLearn.json';
import sidebarReference from '../sidebarReference.json';
export default function Layout({content, toc, meta}) {
const parsedContent = useMemo(
@ -13,15 +15,31 @@ export default function Layout({content, toc, meta}) {
[content]
);
const parsedToc = useMemo(() => JSON.parse(toc, reviveNodeOnClient), [toc]);
const section = useActiveSection();
let routeTree = sidebarLearn;
switch (section) {
case 'reference':
routeTree = sidebarReference;
break;
}
return (
<Page toc={parsedToc}>
<MarkdownPage meta={meta} toc={parsedToc}>
{parsedContent}
</MarkdownPage>
<Page toc={parsedToc} routeTree={routeTree} meta={meta} section={section}>
{parsedContent}
</Page>
);
}
function useActiveSection() {
const {asPath} = useRouter();
if (asPath.startsWith('/reference')) {
return 'reference';
} else if (asPath.startsWith('/learn')) {
return 'learn';
} else {
return 'home';
}
}
// Deserialize a client React tree from JSON.
function reviveNodeOnClient(key, val) {
if (Array.isArray(val) && val[0] == '$r') {

Loading…
Cancel
Save