Skip to content

Commit

Permalink
refactor: implement proper breadcrumbs via Remix handles (#64)
Browse files Browse the repository at this point in the history
  • Loading branch information
wKovacs64 authored Dec 20, 2023
1 parent 25339e3 commit c82e98a
Show file tree
Hide file tree
Showing 12 changed files with 183 additions and 128 deletions.
80 changes: 80 additions & 0 deletions app/navigation/breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as React from 'react';
import {
useLocation,
useMatches,
useSearchParams,
type UIMatch,
} from '@remix-run/react';
import Nav from './nav';
import NavLink from './nav-link';
import NavDivider from './nav-divider';
import type { SerializeFrom } from '@remix-run/node';

export default function Breadcrumbs() {
const matches = useMatches();
const { pathname: currentPathname } = useLocation();
const [searchParams] = useSearchParams();
const q = searchParams.get('q');
const isSearching = currentPathname === '/search' && q;
const matchesWithBreadcrumbData = matches.filter(hasHandleWithBreadcrumb);

return (
<Nav>
<ul>
{matchesWithBreadcrumbData.map((match, matchIndex) => {
const { title } = match.handle.breadcrumb(matches);
const isActive = !isSearching && match.pathname === currentPathname;

return (
<li key={match.id} className="inline">
{isActive ? (
title
) : (
<NavLink to={match.pathname}>{title}</NavLink>
)}
{matchIndex < matchesWithBreadcrumbData.length - 1 ? (
<NavDivider />
) : null}
</li>
);
})}
{isSearching ? (
<>
<NavDivider />
<span>"{q}"</span>
</>
) : null}
</ul>
</Nav>
);
}

export type BreadcrumbHandle = {
breadcrumb: (matches: ReturnType<typeof useMatches>) => {
title: string | React.ReactElement;
};
};

function hasHandleWithBreadcrumb(
match: UIMatch,
): match is UIMatch<any, BreadcrumbHandle> {
return (
match.handle !== null &&
typeof match.handle === 'object' &&
'breadcrumb' in match.handle &&
typeof match.handle.breadcrumb === 'function'
);
}

/**
* Gets loader data for an ancestor route from a route id and the `matches`
* object returned from the `useMatches` function.
*/
export function getLoaderDataForHandle<Loader>(
routeId: string,
matches: ReturnType<typeof useMatches>,
) {
const match = matches.find((match) => match.id === routeId);
if (!match) throw new Error(`No match found for route id "${routeId}"`);
return match.data as SerializeFrom<Loader>;
}
16 changes: 7 additions & 9 deletions app/navigation/nav-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ import { Link, type LinkProps } from '@remix-run/react';

export default function NavLink({ children, to }: NavLinkProps) {
return (
<li className="inline">
<Link
className="drinks-focusable border-b border-dotted pb-1 transition hover:border-solid focus:border-solid"
to={to}
prefetch="viewport"
>
{children}
</Link>
</li>
<Link
className="drinks-focusable border-b border-dotted pb-1 transition hover:border-solid focus:border-solid"
to={to}
prefetch="viewport"
>
{children}
</Link>
);
}

Expand Down
4 changes: 1 addition & 3 deletions app/navigation/nav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export default function Nav({ children }: { children: React.ReactNode }) {
return (
<nav className="mb-6 px-4 text-gray-100 sm:mb-8 sm:p-0">{children}</nav>
);
return <nav className="px-4 text-gray-100 sm:p-0">{children}</nav>;
}
14 changes: 11 additions & 3 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as React from 'react';
import { cssBundleHref } from '@remix-run/css-bundle';
import { json, type LinksFunction, type MetaFunction } from '@remix-run/node';
import {
Expand All @@ -20,6 +19,8 @@ import { getEnvVars } from '~/utils/env.server';
import SkipNavLink from '~/core/skip-nav-link';
import Header from '~/core/header';
import Footer from '~/core/footer';
import Breadcrumbs from '~/navigation/breadcrumbs';
import type { AppRouteHandle } from './types';

export const loader = async () => {
const { SITE_IMAGE_URL, SITE_IMAGE_ALT } = getEnvVars();
Expand Down Expand Up @@ -61,6 +62,10 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
];
};

export const handle: AppRouteHandle = {
breadcrumb: () => ({ title: 'All Drinks' }),
};

export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : []),
{
Expand Down Expand Up @@ -98,8 +103,11 @@ export default function App() {
<body className="relative flex min-h-screen flex-col font-sans font-light">
<SkipNavLink contentId="main" />
<Header />
<div className="flex-1 py-4 sm:w-[26rem] sm:self-center sm:py-8 lg:w-full lg:max-w-[60rem] xl:max-w-[80rem]">
<Outlet />
<div className="flex-1 py-4 sm:w-[26rem] sm:self-center sm:py-8 lg:w-full lg:max-w-[60rem] xl:max-w-[80rem] flex flex-col gap-6 sm:gap-8">
<Breadcrumbs />
<main id="main">
<Outlet />
</main>
</div>
<Footer />
<ScrollRestoration />
Expand Down
52 changes: 27 additions & 25 deletions app/routes/_app.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import { withPlaceholderImages } from '~/utils/placeholder-images.server';
import { markdownToHtml } from '~/utils/markdown.server';
import { makeImageUrl } from '~/core/image';
import { notFoundMeta } from '~/routes/_app.$';
import Nav from '~/navigation/nav';
import NavDivider from '~/navigation/nav-divider';
import NavLink from '~/navigation/nav-link';
import { getLoaderDataForHandle } from '~/navigation/breadcrumbs';
import Glass from '~/drinks/glass';
import DrinkSummary from '~/drinks/drink-summary';
import DrinkDetails from '~/drinks/drink-details';
import type { Drink, DrinksResponse, EnhancedDrink } from '~/types';
import type {
AppRouteHandle,
Drink,
DrinksResponse,
EnhancedDrink,
} from '~/types';

export const loader = async ({ params, request }: LoaderFunctionArgs) => {
if (!params.slug) throw json('Missing slug', 400);
Expand Down Expand Up @@ -88,6 +91,16 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
return json(loaderData);
};

export const handle: AppRouteHandle = {
breadcrumb: (matches) => {
const data = getLoaderDataForHandle<typeof loader>(
'routes/_app.$slug',
matches,
);
return { title: data.drink.title };
},
};

export const meta = mergeMeta<typeof loader>(({ data }) => {
if (!data) return notFoundMeta;

Expand Down Expand Up @@ -131,26 +144,15 @@ export default function DrinkPage() {
];

return (
<div>
<Nav>
<ul>
<NavLink to="/">All Drinks</NavLink>
<NavDivider />
<li className="inline">{drink.title}</li>
</ul>
</Nav>
<main id="main">
<Glass>
<DrinkSummary
className="lg:flex-row"
drink={drink}
imageWidths={imageWidths}
imageSizesPerViewport={imageSizesPerViewport}
stacked
/>
<DrinkDetails drink={drink} />
</Glass>
</main>
</div>
<Glass>
<DrinkSummary
className="lg:flex-row"
drink={drink}
imageWidths={imageWidths}
imageSizesPerViewport={imageSizesPerViewport}
stacked
/>
<DrinkDetails drink={drink} />
</Glass>
);
}
14 changes: 1 addition & 13 deletions app/routes/_app._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { getEnvVars } from '~/utils/env.server';
import { fetchGraphQL } from '~/utils/graphql.server';
import { cache } from '~/utils/cache.server';
import { withPlaceholderImages } from '~/utils/placeholder-images.server';
import Nav from '~/navigation/nav';
import DrinkList from '~/drinks/drink-list';
import type { Drink, DrinksResponse, EnhancedDrink } from '~/types';

Expand Down Expand Up @@ -77,16 +76,5 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
export default function HomePage() {
const { drinks } = useLoaderData<typeof loader>();

return (
<div>
<Nav>
<ul>
<li>All Drinks</li>
</ul>
</Nav>
<main id="main">
<DrinkList drinks={drinks} />
</main>
</div>
);
return <DrinkList drinks={drinks} />;
}
41 changes: 12 additions & 29 deletions app/routes/_app.search/route.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as React from 'react';
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import {
useLoaderData,
Expand All @@ -9,11 +8,8 @@ import { getEnvVars } from '~/utils/env.server';
import { mergeMeta } from '~/utils/meta';
import { fetchGraphQL } from '~/utils/graphql.server';
import { withPlaceholderImages } from '~/utils/placeholder-images.server';
import Nav from '~/navigation/nav';
import NavLink from '~/navigation/nav-link';
import NavDivider from '~/navigation/nav-divider';
import DrinkList from '~/drinks/drink-list';
import type { Drink, DrinksResponse } from '~/types';
import type { AppRouteHandle, Drink, DrinksResponse } from '~/types';
import NoDrinksFound from './no-drinks-found';
import NoSearchTerm from './no-search-term';
import SearchForm from './search-form';
Expand Down Expand Up @@ -96,6 +92,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return json(loaderData);
};

export const handle: AppRouteHandle = {
breadcrumb: () => ({ title: 'Search' }),
};

export const meta = mergeMeta<typeof loader>(() => {
return [
{ title: 'Search Drinks' },
Expand All @@ -122,30 +122,13 @@ export default function SearchPage() {
const hasResults = isIdle && drinks.length > 0;

return (
<div>
<Nav>
<ul>
<NavLink to="/">All Drinks</NavLink>
<NavDivider />
{q ? (
<React.Fragment>
<NavLink to="/search">Search</NavLink>
<NavDivider />
<li className="inline">"{q}"</li>
</React.Fragment>
) : (
<li className="inline">Search</li>
)}
</ul>
</Nav>
<main id="main">
<SearchForm initialSearchTerm={q ?? ''} />
{hasNoSearchTerm && <NoSearchTerm />}
{isSearching && <Searching />}
{hasNoResults && <NoDrinksFound />}
{hasResults && <DrinkList drinks={drinks} />}
</main>
</div>
<>
<SearchForm initialSearchTerm={q ?? ''} />
{hasNoSearchTerm && <NoSearchTerm />}
{isSearching && <Searching />}
{hasNoResults && <NoDrinksFound />}
{hasResults && <DrinkList drinks={drinks} />}
</>
);
}

Expand Down
52 changes: 27 additions & 25 deletions app/routes/_app.tags.$tag.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, useParams } from '@remix-run/react';
import { useLoaderData } from '@remix-run/react';
import lowerCase from 'lodash/lowerCase';
import startCase from 'lodash/startCase';
import { getEnvVars } from '~/utils/env.server';
Expand All @@ -8,11 +8,14 @@ import { fetchGraphQL } from '~/utils/graphql.server';
import { cache } from '~/utils/cache.server';
import { withPlaceholderImages } from '~/utils/placeholder-images.server';
import { notFoundMeta } from '~/routes/_app.$';
import Nav from '~/navigation/nav';
import NavLink from '~/navigation/nav-link';
import NavDivider from '~/navigation/nav-divider';
import { getLoaderDataForHandle } from '~/navigation/breadcrumbs';
import DrinkList from '~/drinks/drink-list';
import type { Drink, DrinksResponse, EnhancedDrink } from '~/types';
import type {
AppRouteHandle,
Drink,
DrinksResponse,
EnhancedDrink,
} from '~/types';

export const loader = async ({ params, request }: LoaderFunctionArgs) => {
if (!params.tag) throw json('Missing tag', 400);
Expand Down Expand Up @@ -83,6 +86,23 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
return json(loaderData);
};

export const handle: AppRouteHandle = {
breadcrumb: (matches) => {
const data = getLoaderDataForHandle<typeof loader>(
'routes/_app.tags.$tag',
matches,
);
return {
title: (
<div className="inline-flex gap-2">
<span>{lowerCase(matches.at(-1)?.params.tag)}</span>
<span>( {data.drinks.length} )</span>
</div>
),
};
},
};

export const meta = mergeMeta<typeof loader>(({ data, params }) => {
if (!data) return notFoundMeta;

Expand All @@ -96,24 +116,6 @@ export const meta = mergeMeta<typeof loader>(({ data, params }) => {

export default function TagPage() {
const { drinks } = useLoaderData<typeof loader>();
const { tag } = useParams();
const totalCount = drinks.length;

return (
<div>
<Nav>
<ul>
<NavLink to="/">All Drinks</NavLink>
<NavDivider />
<NavLink to="/tags">Tags</NavLink>
<NavDivider />
<li className="inline">{lowerCase(tag)}</li>
<li className="ml-2 inline">( {totalCount} )</li>
</ul>
</Nav>
<main id="main">
<DrinkList drinks={drinks} />
</main>
</div>
);

return <DrinkList drinks={drinks} />;
}
Loading

0 comments on commit c82e98a

Please sign in to comment.