Contextual Navigation (take two)

❗ This is not a tutorial ❗

The goal is to give the site’s users a sense of where they are without overloading them with details. I’ve made a couple of attempts at this:

  • display listings of the site’s posts, or a category’s posts, in a sidebar
  • display breadcrumb navigation at the top of the page

The sidebar approach was interesting from a technical perspective, but didn’t meet the criteria of not overloading users with details. Breadcrumbs just felt off – appropriate for an online store, but not for a blog.

The obvious solution is to add a navigation bar. The navigation bar could be incorporated into the site’s header, or it could be in a separate component that’s displayed below the site’s header. I’ll start by trying to add navigation directly to the header.

Site header component

Move the existing Header component from app/root.tsx to the site’s index route. First, remove the Header from root.tsx, then:

$ mv app/components/Header.tsx app/components/SiteHeader.tsx

Add the SiteHeader to _index.tsx:

app/routes/_index.tsx
// app/routes/_index.tsx

import type { MetaFunction } from "@remix-run/node";
import { Link, useRouteError } from "@remix-run/react";
import SiteHeader from "~/components/SiteHeader";

export const meta: MetaFunction = () => {
  return [
    { title: "Zalgorithm" },
    { name: "description", content: "A zigzag algorithm." },
  ];
};

export default function Index() {
  return (
    <div>
      <SiteHeader />
      <div className="sm:max-w-prose mx-auto my-2">
        <h2 className="text-3xl my-1">Hello</h2>
        <p className="my-2">
          Welcome to the Zalgorithm test site! I'm using it to learn about
          headless WordPress, GraphQL, Remix, React, Typescript, Tailwind CSS...
        </p>
        <p className="my-2">
          Most of the work is happening in the site's{" "}
          <Link to="/blog" className="text-sky-700 hover:underline">
            blog
          </Link>
          . I'm converting my WordPress site (
          <Link
            to="https://zalgorithm.com"
            className="text-sky-700 hover:underline"
          >
            https://zalgorithm.com
          </Link>
          ) into a headless WordPress site and blogging about it as I go.
        </p>
        <p className="my-2">
          Posts in the blog's{" "}
          <Link
            to="/blog/category/web-development"
            className="text-sky-700 hover:underline"
          >
            Web Development Category
          </Link>
          , should not taken as tutorials. I'm learning as I go.{" "}
        </p>
      </div>
    </div>
  );
}

export function ErrorBoundary() {
  const error = useRouteError();

  const errorMessage = error instanceof Error ? error.message : "Unknown error";
  return (
    <div className="mx-auto max-w-2xl px-20 py-4 my-10 bg-red-200 border-2 border-red-700 rounded">
      <h1>App Error</h1>
      <pre>{errorMessage}</pre>
    </div>
  );
}

I’ve got plans for the non-blog part of the site, but need to sort out the blog first.

Blog header component

I’ll start by copying SiteHeader.tsx to BlogHeader.tsx and adding it to the blog’s index route.

This leads to an interesting problem. I’ll need to add the BlogHeader component to every blog route. That might be ok, but I wonder if I can create blog layout route that all blog routes are loaded into?

$ touch app/routes/blog.tsx
// app/routes/blog.tsx

import { MetaFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";
import BlogHeader from "~/components/BlogHeader";

export const meta: MetaFunction = () => {
  return [
    { title: "Zalgorithm" },
    { name: "description", content: "Simon's blog" },
  ];
};

export default function BlogIndex() {
  return (
    <div>
      <BlogHeader />
      <Outlet />
    </div>
  );
}

That works. Now all routes that begin with blog. will load in that component’s Outlet. Docs: https://remix.run/docs/en/main/file-conventions/routes#nested-routes.

Onto updating the blog header. I’ll keep this simple:

I’ll replace the “Blog” text with an icon (eventually an SVG icon: https://www.mattstobbs.com/optimal-icons-remix/). Clicking the icon will open a details element that contains a list of the site’s archive pages (categories).

… moving the header component off the root route has caused an issue with ErrorBoundary components being displayed on a page without a header. But developing the BlogHeader component gave me some clues about how to use the same header for the entire site. For now, I’ll go with the two headers, and no header on error pages.

The big changes are that the blog parent (blog.tsx) route now makes a request to a CATEGORY_DETAILS_QUERY that returns the category name and slug for the WordPress categories.

CATEGORY_DETAILS_QUERY
// app/models/wp_queries.ts

...
export const CATEGORIES_DETAILS_QUERY = gql(`
query GetCategoriesDetails {
  categories {
    nodes {
      name
      slug
    }
  }
}
`);
...

The blog.tsx loader function passes the categories to its component. Based on yesterday’s dive into typescript, this assignment is making more sense:

const { categories } = useLoaderData<typeof loader>();

That pattern is used repeatedly in the Remix docs. I just accepted it because it works. Here’s what I think is going on with it:

useLoaderData is a typescript “generic” function. That means that it can work with a variety of types. That makes sense because it has to work with the types of data that may be passed from a loader function. A loader function can return a string, an array, JSON… maybe it can return null (I haven’t checked.)

Typescript uses angle brackets (<>) for generic type parameters. Don’t quote me on this, but calling const foo = someFunction<T>, is saying that the value of foo will be of type T. Calling useLoaderData<typeof loader>()is saying that the type of data that’s assigned from the function call will equal the type that was passed from the route’s loader function. (Again, I’m just learning.)

Back to the code… the BlogIndex component passes the categories to the BlogHeader component:

// app/routes/blog.tsx

...
export default function BlogIndex() {
  const { categories } = useLoaderData<typeof loader>();
  return (
    <div>
      <BlogHeader categories={categories} />
      <Outlet />
    </div>
  );
}
...

That component uses the category name and slug values to create the menu:

BlogHeader.tsx
// app/components/BlogHeader.tsx

import { Link, NavLink, useLocation } from "@remix-run/react";
import { useEffect } from "react";
import { Maybe } from "graphql/jsutils/Maybe";
import { type RootQueryToCategoryConnection } from "~/graphql/__generated__/graphql";

interface BlogHeaderProps {
  categories: Maybe<RootQueryToCategoryConnection>;
}

export default function BlogHeader({ categories }: BlogHeaderProps) {
  const location = useLocation();
  useEffect(() => {
    const detailsElement = document.getElementById("blog-nav");
    if (detailsElement) {
      detailsElement.removeAttribute("open");
    }
  }, [location]);
  return (
    <header className="bg-sky-800 text-slate-50 text-xl px-3 py-4 top-0 sticky overflow-visible">
      <div className="flex justify-between items-center w-full max-w-screen-xl mx-auto">
        <h1>
          <Link to="/">Zalgorithm</Link>
        </h1>
        <div>
          <details
            className="cursor-pointer absolute top-1/3 right-4 z-10"
            id="blog-nav"
          >
            <summary className="_no-triangle block absolute right-4">
              Menu
            </summary>
            <ul className="bg-slate-50 text-slate-800 text-lg p-2 rounded relative top-8 shadow-md">
              {categories?.nodes
                ? categories.nodes.map((category, index) => (
                    <li key={index}>
                      <NavLink
                        to={`/blog/category/${category.slug}`}
                        className={({ isActive, isPending }) =>
                          isPending ? "pending" : isActive ? "text-sky-700" : ""
                        }
                      >
                        {category.name}
                      </NavLink>
                    </li>
                  ))
                : ""}
            </ul>
          </details>
        </div>
      </div>
    </header>
  );
}

A bit of learning went on there too. Creating an interface for setting the type that’s passed to a component is (essentially?) the same as creating a type alias:

import { Maybe } from "graphql/jsutils/Maybe";
import { type RootQueryToCategoryConnection } from "~/graphql/__generated__/graphql";

interface BlogHeaderProps {
  categories: Maybe<RootQueryToCategoryConnection>;
}

export default function BlogHeader({ categories }: BlogHeaderProps) {

The interface in the above code could also have been defined as a type:

type BlogHeaderProps = {
  categories: Maybe<RootQueryToCategoryConnection>;
};

Maybe (imported from graphql/jsutils/Maybe) is an example of a generic type. It expects a type to be set in angle brackets after its name (Maybe<T>). I’ve been using the pattern:

categories: Maybe<RootQueryToCategoryConnection | null>;

but I think including null as a type for Maybe is redundant. The purpose of Maybe is to say that the value could be null or (I think) undefined.

Searching the web for the CSS to remove the disclosure triangle from the details summary element turned up a few results:

  • diplay: none
  • summary::-webkit-details-marker { display: hidden; }
  • summary::marker { display: hidden; }

The rules are browser specific. I applied all of them, but missed this (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details#customizing_the_disclosure_widget):

details > summary {
  list-style: none;
}

I’ve added it now. I’ll check the results on the browsers I’ve got access to.

I worked out the other day how to get the details element to close when a menu item is selected:

// app/components/BlogHeader.tsx

import { useEffect } from "react";
...
export default function BlogHeader({ categories }: BlogHeaderProps) {
  const location = useLocation();
  useEffect(() => {
    const detailsElement = document.getElementById("blog-nav");
    if (detailsElement) {
      detailsElement.removeAttribute("open");
    }
  }, [location]);
...

In the above code, "blog-nav" needs to be set to the id that’s been added to the details element.

This is alright for a first attempt:

Lots of options for where to go from here:

  • add a /blog home page link to the menu
  • configure the BlogHeader component so it can work as the site header and be moved back to the root route
  • update the WPGraphQL queries so that “uncategorized” topics aren’t being returned
  • prevent the Remix site from crashing if the WordPress site is down
  • figure out how to add icons to the Remix app and replace the header text “Menu” with a hamburger icon

I’ll start with the last item.

Adding icons to the site

I’m following this: https://www.mattstobbs.com/optimal-icons-remix/.

… after a bit of thought, I’ll save that for another day. It’s adding a lot of complexity to the project to just display a hamburger icon.

Now reading this: https://benadam.me/thoughts/react-svg-sprites/.

The SVG sprites approach outlined in the last article I linked to was the way to go. The idea is simple. First, create a sprite.svg file. I put it in the /public directory.

This is the basic template for the file:

<!-- sprite.svg -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <defs>
    <symbol viewBox="0 0 24 24" id="icon-1">
      <!-- path element for first svg goes here -->
    </symbol>
    <symbol viewBox="0 0 24 24" id="icon-2">
      <!-- path element for second svg goes here -->
    </symbol>
    <!-- etc -->
  </defs>
</svg>

I copied a menu, left-arrow, and right-arrow SVG from https://www.radix-ui.com/icons. It’s a little fiddly to setup. Basically copy the full SVG, then extract its path element so that it can be wrapped it in symbol tags. Here’s the file I’m using for now:

<!-- sprite.svg -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <defs>
    <symbol viewBox="0 0 24 24" id="hamburger">
      <path d="M7.49999 3.09998C7.27907 3.09998 7.09999 3.27906 7.09999 3.49998C7.09999 3.72089 7.27907 3.89998 7.49999 3.89998H14.5C14.7209 3.89998 14.9 3.72089 14.9 3.49998C14.9 3.27906 14.7209 3.09998 14.5 3.09998H7.49999ZM7.49998 5.1C7.27907 5.1 7.09998 5.27908 7.09998 5.5C7.09998 5.72091 7.27907 5.9 7.49998 5.9H14.5C14.7209 5.9 14.9 5.72091 14.9 5.5C14.9 5.27908 14.7209 5.1 14.5 5.1H7.49998ZM7.1 7.5C7.1 7.27908 7.27909 7.1 7.5 7.1H14.5C14.7209 7.1 14.9 7.27908 14.9 7.5C14.9 7.72091 14.7209 7.9 14.5 7.9H7.5C7.27909 7.9 7.1 7.72091 7.1 7.5ZM7.49998 9.1C7.27907 9.1 7.09998 9.27908 7.09998 9.5C7.09998 9.72091 7.27907 9.9 7.49998 9.9H14.5C14.7209 9.9 14.9 9.72091 14.9 9.5C14.9 9.27908 14.7209 9.1 14.5 9.1H7.49998ZM7.09998 11.5C7.09998 11.2791 7.27907 11.1 7.49998 11.1H14.5C14.7209 11.1 14.9 11.2791 14.9 11.5C14.9 11.7209 14.7209 11.9 14.5 11.9H7.49998C7.27907 11.9 7.09998 11.7209 7.09998 11.5ZM2.5 9.25003L5 6.00003H0L2.5 9.25003Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path>
    </symbol>
    <symbol viewBox="0 0 15 15" id="arrow-left">
      <path d="M6.85355 3.14645C7.04882 3.34171 7.04882 3.65829 6.85355 3.85355L3.70711 7H12.5C12.7761 7 13 7.22386 13 7.5C13 7.77614 12.7761 8 12.5 8H3.70711L6.85355 11.1464C7.04882 11.3417 7.04882 11.6583 6.85355 11.8536C6.65829 12.0488 6.34171 12.0488 6.14645 11.8536L2.14645 7.85355C1.95118 7.65829 1.95118 7.34171 2.14645 7.14645L6.14645 3.14645C6.34171 2.95118 6.65829 2.95118 6.85355 3.14645Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path>    
    </symbol>
    <symbol viewBox="0 0 15 15" id="arrow-right">
      <path d="M8.14645 3.14645C8.34171 2.95118 8.65829 2.95118 8.85355 3.14645L12.8536 7.14645C13.0488 7.34171 13.0488 7.65829 12.8536 7.85355L8.85355 11.8536C8.65829 12.0488 8.34171 12.0488 8.14645 11.8536C7.95118 11.6583 7.95118 11.3417 8.14645 11.1464L11.2929 8H2.5C2.22386 8 2 7.77614 2 7.5C2 7.22386 2.22386 7 2.5 7H11.2929L8.14645 3.85355C7.95118 3.65829 7.95118 3.34171 8.14645 3.14645Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path>
    </symbol>
  </defs>
</svg>

To use the icons, I created an Icon component in app/components:

// app/components/Icon.tsx

interface IconProps {
  id: "hamburger" | "arrow-left" | "arrow-right";
  className: string | undefined;
}

export function Icon({ id, className }: IconProps): JSX.Element {
  return (
    <svg className={className}>
      <use href={`/sprite.svg#${id}`} />
    </svg>
  );
}

I only need a few. Each time I add an SVG to sprite.svg, I’ll add its id to the IconProps interface.

I’m using the SVG’s like this:

// app/components/BlogHeader.tsx

...
import { Icon } from "~/components/Icon";
...

export default function BlogHeader({ categories }: BlogHeaderProps) {
  const location = useLocation();
  useEffect(() => {
    const detailsElement = document.getElementById("blog-nav");
    if (detailsElement) {
      detailsElement.removeAttribute("open");
    }
  }, [location]);
  return (
    <header className="bg-sky-800 text-slate-50 text-xl px-3 py-4 top-0 sticky overflow-visible">
      <div className="flex justify-between items-center w-full max-w-screen-xl mx-auto">
        <h1>
          <Link to="/">Zalgorithm</Link>
        </h1>
        <div>
          <details
            className="cursor-pointer absolute top-1/4 right-0 z-10"
            id="blog-nav"
          >
            <summary className="_no-triangle block absolute right-2 list-none">
              <Icon
                key="hamburger"
                id="hamburger"
                className="w-14 h-14 text-slate-50 hover:text-slate-200"
              />
            </summary>

It seems to work well. Testing them locally, I found I had to clear my browser’s cache if I made any changes to either the SVG file or to the CSS I was passing to the icons.

The end results are looking good on small screens. There’s a positioning issue with the header menu on larger screens. That’s not actually related to the SVGs though. The results are live here: https://hello.zalgorithm.com/blog.

Clicking through the site, I’m seeing a few things that are off, but it’s starting to take shape 🙂