Contextual Navigation

❗ This is not a tutorial ❗

I thought I’d invented a term, but it turns out “contextual navigation” is a thing. Breadcrumbs provide context about where you are on a site, and navigation links that can be used to retrace your path back to where you came from.

Adding breadcrumbs to a Remix site is looking straightforward: https://remix.run/docs/en/main/guides/breadcrumbs. I’ll likely go with that approach, but there’s something I want to try first.

For my own reference (I get by with something like 4 git commands):

$ git status
On branch load_category_posts_in_child_route
$ git add .
$ git commit -m "commit changes before trying another approach"
$ git checkout main
$ git checkout -b contextual_navigation

Some housecleaning

Move blog.category.$categorySlug.tsx to an index route:

$ mv app/routes/blog.category.\$categorySlug.tsx app/routes/blog.category.\$categorySlug._index.tsx

Add a basePath prop to the PostExcerptCard component so that it can load posts on blog/category/$categorySlug/$postId/$slug:

code
// app/components/PostExcerptCard.tsx

import { Link } from "@remix-run/react";
import { Maybe } from "graphql/jsutils/Maybe";

interface PostExcerptCardProps {
  date: Maybe<string | null>;
  featuredImage: Maybe<string | null>;
  title: Maybe<string | null>;
  excerpt: Maybe<string | null>;
  authorName: Maybe<string | null>;
  slug: Maybe<string | null>;
  basePath: string;
  databaseId: number;
}

export default function PostExcerptCard({
  date,
  featuredImage,
  title,
  excerpt,
  authorName,
  slug,
  basePath,
  databaseId,
}: PostExcerptCardProps) {
  const formattedDate = date
    ? `${new Date(date).getFullYear()}-${String(
        new Date(date).getMonth() + 1
      ).padStart(2, "0")}-${String(new Date(date).getDate()).padStart(2, "0")}`
    : null;

  return (
    <article>
      {featuredImage ? <img className="rounded-md" src={featuredImage} /> : ""}
      <Link prefetch="intent" to={`${basePath}/${databaseId}/${slug}`}>
        <h3 className="text-xl font-serif font-bold mt-3 text-sky-700 hover:underline">
          {title}
        </h3>
      </Link>
...

Set the basePath prop in routes that are using the PostExcerptCard:

code
// app/routes/blog.category/$categorySlug

...
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
  const { searchParams } = new URL(request.url);
  // todo: should this really be optional? (Note that this code was copied from blog.posts.tsx)
  const categorySlug = params?.categorySlug ?? null;
...
  return json({
    pageInfo: pageInfo,
    postConnectionEdges: postConnectionEdges,
    categorySlug: categorySlug,
    categoryName: categoryName,
  });
};

export default function CategorySlug() {
  const { pageInfo, postConnectionEdges, categorySlug, categoryName } =
    useLoaderData<typeof loader>();

  return (
    <div className="px-6 mx-auto max-w-screen-lg">
      <div className="flex justify-center items-center h-full">
        <h2 className="text-3xl py-3 font-serif text-center">{categoryName}</h2>
      </div>
      <hr className="border-solid border-slate-300" />
      <div className="mx-auto max-w-screen-lg pt-6">
        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
          {postConnectionEdges.map((edge: PostConnectionEdge) => (
            <PostExcerptCard
              title={edge.node?.title}
              date={edge.node?.date}
              featuredImage={edge.node?.featuredImage?.node?.sourceUrl}
              excerpt={edge.node?.excerpt}
              authorName={edge.node?.author?.node?.name}
              slug={edge.node?.slug}
              basePath={
                categorySlug ? `/blog/category/${categorySlug}` : `/blog`
              }
              databaseId={edge.node.databaseId}
              key={edge.node.databaseId}
            />
          ))}
        </div>
      </div>
...
// app/routes/blog.posts.tsx

...
export default function Posts() {
  const { pageInfo, postConnectionEdges } = useLoaderData<typeof loader>();
  const basePath = "/blog";
...

With the above changes, posts loaded from a /blog/category page are now using the category path:

Admittedly, that’s a long URL path, but it allows for previous/next post navigation within a category.

Add a query for finding the previous/next posts within a category:

code
// app/models/wp_queries.ts

...
export const CATEGORY_POST_BY_ID_QUERY = gql(`
query GetCategoryPostById ($id: ID!) {
  post(id: $id, idType: DATABASE_ID) {
    title
    content
    excerpt
    slug
    databaseId
    date
    author {
      node {
        name
      }
    }
    featuredImage {
      node {
        altText
        description
        caption
        id
        sourceUrl
      }
    }
    previousPostInCategory {
      title
      slug
      databaseId
    }
    nextPostInCategory {
      title
      slug
      databaseId
    }
  }
}
`);
...

Update blog.category.$categorySlug.$postId.$slug.tsx:

  • make categorySlug a required param
  • return categorySlug from the loader function
  • use category/$categorySlug in the previous/next post links
  • handle (sort of) categorySlug in the meta function
code
// app/routes/blog.category.$categorySlug.$postId.$slug.tsx

...
// questionable, I'm really just making typescript happy here
interface LoaderData {
  post: Post;
  categorySlug: string;
}

export const meta: MetaFunction = ({ data }) => {
  const { post } = data as LoaderData;
...

export default function BlogPost() {
  const { post, categorySlug } = useLoaderData<typeof loader>();
  const caption = post?.featuredImage?.node?.altText
    ? post.featuredImage.node.altText
    : "";
  const author = post?.author?.node?.name;
  // todo: improve this and extract into a utility file:
  const date = post?.date
    ? `${new Date(post.date).getFullYear()}-${String(
        new Date(post.date).getMonth() + 1
      ).padStart(2, "0")}-${String(new Date(post.date).getDate()).padStart(
        2,
        "0"
      )}`
    : "";
  const previousTitle = post?.previousPostInCategory?.title;
  const previousSlug = post?.previousPostInCategory?.slug;
  const previousId = post?.previousPostInCategory?.databaseId;
  const nextTitle = post?.nextPostInCategory?.title;
  const nextSlug = post?.nextPostInCategory?.slug;
  const nextId = post?.nextPostInCategory?.databaseId;
...
      <div className="my-3 grid grid-cols-1 min-[431px]:grid-cols-2 gap-4 items-center h-full">
        {previousTitle && previousSlug && previousId ? (
          <div>
            <div>
              <span className="text-5xl">&larr;</span>
              <span>Previous</span>
            </div>
            <Link
              prefetch="intent"
              to={`/blog/category/${categorySlug}/${previousId}/${previousSlug}`}
              className="text-lg font-bold text-sky-700 hover:underline"
            >
              {previousTitle}
            </Link>
          </div>
        ) : null}
        {nextTitle && nextSlug && nextId ? (
          <div className="min-[431px]:text-right">
            <div>
              <span>Next</span>
              <span className="text-5xl">&rarr;</span>
            </div>
            <Link
              prefetch="intent"
              to={`/blog/category/${categorySlug}/${nextId}/${nextSlug}`}
              className="text-lg font-bold text-sky-700 hover:underline"
            >
              {nextTitle}
            </Link>
          </div>
        ) : null}
      </div>
    </div>
  );
}
...

I want to merge these changes into the main branch, but keep working on the contextual_navigation branch. I’m sure there’s another way:

$ git add .
$ git commit -m "use CATEGORY_POST_BY_ID_QUERY"
$ git checkout main
$ git merge contextual_navigation
$ git checkout contextual_navigation

That works well enough.

Add some options to the PostExcerptCard component:

// app/components/PostExcerptCard.tsx

...
import { stripHtml, truncateText } from "~/utils/utilities";

interface PostExcerptCardProps {
  date: Maybe<string | null>;
  featuredImage: Maybe<string | null>;
  title: Maybe<string | null>;
  excerpt: Maybe<string | null>;
  authorName: Maybe<string | null>;
  slug: Maybe<string | null>;
  excerptLength: number; // length of excerpt
  includeMetaData: boolean; // include author and date
  basePath: string;
  databaseId: number;
}

I’m realizing that if I passed a Post object to the PostExcerptCard component, the number of props could be reduced. This is good for now though. I’ll merge the changes into the main branch.

The fun begins here:

Create a ContextualNavigation component

$ touch app/components/ContextualNavigation.tsx
// app/components/ContextualNavigation.tsx

export default function ContextualNavigation() {
  return (
    <div>
      <h3>Contextual Navigation</h3>
    </div>
  );
}

… imagining copilot pulling its hair out.

Use the new component:

// app/routes/blog.category.$categorySlug.$postId.$slug.tsx

...
import ContextualNavigation from "~/components/ContextualNavigation";

...
export default function BlogPost() {
...
  return (
    <div>
      <div className="sticky top-16 bg-slate-200 px-2">
        <ContextualNavigation />
      </div>
...

This might go nowhere. Can a component have action or loader functions?

At least it seems like it’s not a dumb question. Possibly a Resource Route would do the trick: https://remix.run/docs/en/main/guides/resource-routes.

For now I’ll just try creating the navigation element without abstracting it into a component:

$ rm app/components/ContextualNavigation.tsx

Ok, this seems theoretically possible with something like:

code
<details>
  <summary>Categories: {categorySlug}</summary>
    {categories.map((cat: Category) => (
      <details key={cat.slug}>
        <summary>
          <fetcher.Form className="inline-block">
            <input type="hidden" name="navCategory" value={cat.slug} />
            <button type="submit">{cat.name}</button>
          </fetcher.Form>
        </summary>
        {categoryPosts ? (
        <ul>
          {categoryPosts.map((p) => (
            <li key={p.slug}>{p.title}</li>
          ))}
        </ul>
      ) : (
        ""
      )}
    </details>
  ))}
</details>

but it’s going to make things super complex without adding much (any) value to the page.

$ git add .
$ git commit -m "try something"
$ git checkout main

Breadcrumbs!

Documentation: https://remix.run/docs/en/main/guides/breadcrumbs.

Oh Typescript 🙁 I’ve been loving it for the most part, but kind of hacked away the warnings I got with useMatches:

code
// app/root.tsx

import type { LinksFunction } from "@remix-run/node";
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useRouteError,
  useMatches,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import type { UIMatch } from "@remix-run/react";

import Header from "~/components/Header";
import Footer from "~/components/Footer";
import styles from "./tailwind.css";

interface Handle {
  breadcrumb?: (match: any) => JSX.Element;
}
...

export default function App() {
  const matches = useMatches();

  console.log(`matches: ${JSON.stringify(matches, null, 2)}`);
  return (
    <Document>
      <Header />
      <div className="flex-1">
        {matches
          .filter(
            (match) => match.handle && (match?.handle as Handle)?.breadcrumb
          )
          .map((match, index) => (
            <li key={index}>{(match.handle as Handle).breadcrumb!(match)}</li>
          ))}
        <Outlet />
      </div>
      <ScrollRestoration />
      <Scripts />
      <LiveReload />
      <Footer />
    </Document>
  );
}

A day later, the Typescript thing is still bothering me. I’ve generally been able to learn programming languages by just using them. With Typescript I’m still doing a lot of guessing. I think that’s because I don’t have a grasp of its basic concepts. I might as well sort that out now: https://www.codecademy.com/learn/learn-typescript.