❗ 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 theloader
function - use
category/$categorySlug
in the previous/next post links - handle (sort of)
categorySlug
in themeta
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">←</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">→</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?
- https://github.com/remix-run/remix/discussions/2926
- https://github.com/remix-run/remix/discussions/5383
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.