Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const App = () => (
{/* Changelog and the demos below are self-contained apps: one static mount each,
matched non-exact, with everything under it routed inside the component. */}
<Route on={changelogRoute}>
<Changelog rootAtom={changelogRoute} />
<Changelog />
</Route>
<Route on={basicRoutingDemoRoute}>
<BasicRoutingDemo />
Expand Down
132 changes: 55 additions & 77 deletions packages/docs/src/demos/BlogRoutingApp.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useMemo } from "react";
import { rootAtom as defaultRootAtom, numericRouteAtom, paramRouteAtom, DefaultParams, RouteAtom } from "jarl-atoms";
import { createRootAtom, numericRouteAtom, paramRouteAtom, validateAtom } from "jarl-atoms";
import { Link, Route, Switch } from "jarl-react";
import {
BlogPost,
Expand Down Expand Up @@ -30,43 +29,41 @@ const MONTH_NAMES = [

const formatDate = (post: BlogPost) => `${MONTH_NAMES[post.month - 1]} ${post.day}, ${post.year}`;

// The demo's whole route tree hangs off whatever root it is given, so the app
// never knows the URL it is mounted on.
const createBlogRoutes = (root: RouteAtom<DefaultParams>) => {
const year = numericRouteAtom("year", { parent: root });
const month = numericRouteAtom("month", { parent: year, min: 1, max: 12 });
const day = numericRouteAtom("day", { parent: month, min: 1, max: 31 });
const post = paramRouteAtom("slug", { parent: day });
return { root, year, month, day, post };
};

type BlogRoutes = ReturnType<typeof createBlogRoutes>;
// The page this demo is mounted on, so its whole tree below is plain module-level atoms.
const blogRoot = createRootAtom({ basePath: "/demos/blog-routing" });
const yearRoute = numericRouteAtom("year", { parent: blogRoot });
const monthRoute = numericRouteAtom("month", { parent: yearRoute, min: 1, max: 12 });
const daySegment = numericRouteAtom("day", { parent: monthRoute });
// A segment's own min/max only bounds it in isolation; a real calendar date needs all three
// together, so the whole date is validated as part of matching rather than in a page component.
const dayRoute = validateAtom(daySegment, ({ year, month, day }) => isValidCalendarDate(year, month, day));
const postRoute = paramRouteAtom("slug", { parent: dayRoute });

const BlogNav = ({ routes }: { routes: BlogRoutes }) => (
const BlogNav = () => (
<nav>
<Link route={routes.root} to={{}} exact>
<Link route={blogRoot} to={{}} exact>
All posts
</Link>
</nav>
);

const BlogNotFound = ({ routes, reason }: { routes: BlogRoutes; reason: string }) => (
const BlogNotFound = ({ reason }: { reason: string }) => (
<div>
<h3>Not found</h3>
<p>{reason}</p>
<p>
<Link route={routes.root} to={{}}>
<Link route={blogRoot} to={{}}>
Back to all posts
</Link>
</p>
</div>
);

const PostList = ({ routes, posts }: { routes: BlogRoutes; posts: BlogPost[] }) => (
const PostList = ({ posts }: { posts: BlogPost[] }) => (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link route={routes.post} to={post}>
<Link route={postRoute} to={post}>
{post.title}
</Link>{" "}
<span>&mdash; {formatDate(post)}</span>
Expand All @@ -75,13 +72,13 @@ const PostList = ({ routes, posts }: { routes: BlogRoutes; posts: BlogPost[] })
</ul>
);

const BlogIndex = ({ routes }: { routes: BlogRoutes }) => (
const BlogIndex = () => (
<div>
<h3>Blog</h3>
<ul>
{yearsWithPosts().map((year) => (
<li key={year}>
<Link route={routes.year} to={{ year }}>
<Link route={yearRoute} to={{ year }}>
{year}
</Link>{" "}
({postsForYear(year).length} posts)
Expand All @@ -91,33 +88,33 @@ const BlogIndex = ({ routes }: { routes: BlogRoutes }) => (
</div>
);

const YearPage = ({ routes, year }: { routes: BlogRoutes; year: number }) => {
const YearPage = ({ year }: { year: number }) => {
const posts = postsForYear(year);
if (posts.length === 0) {
return <BlogNotFound routes={routes} reason={`No posts in ${year}.`} />;
return <BlogNotFound reason={`No posts in ${year}.`} />;
}
return (
<div>
<h3>{year}</h3>
<ul>
{monthsInYear(year).map((month) => (
<li key={month}>
<Link route={routes.month} to={{ year, month }}>
<Link route={monthRoute} to={{ year, month }}>
{MONTH_NAMES[month - 1]}
</Link>{" "}
({postsForMonth(year, month).length})
</li>
))}
</ul>
<PostList routes={routes} posts={posts} />
<PostList posts={posts} />
</div>
);
};

const MonthPage = ({ routes, year, month }: { routes: BlogRoutes; year: number; month: number }) => {
const MonthPage = ({ year, month }: { year: number; month: number }) => {
const posts = postsForMonth(year, month);
if (posts.length === 0) {
return <BlogNotFound routes={routes} reason={`No posts in ${MONTH_NAMES[month - 1]} ${year}.`} />;
return <BlogNotFound reason={`No posts in ${MONTH_NAMES[month - 1]} ${year}.`} />;
}
return (
<div>
Expand All @@ -127,52 +124,37 @@ const MonthPage = ({ routes, year, month }: { routes: BlogRoutes; year: number;
<ul>
{daysInMonth(year, month).map((day) => (
<li key={day}>
<Link route={routes.day} to={{ year, month, day }}>
<Link route={dayRoute} to={{ year, month, day }}>
{day}
</Link>{" "}
({postsForDay(year, month, day).length})
</li>
))}
</ul>
<PostList routes={routes} posts={posts} />
<PostList posts={posts} />
</div>
);
};

const DayPage = ({ routes, year, month, day }: { routes: BlogRoutes; year: number; month: number; day: number }) => {
if (!isValidCalendarDate(year, month, day)) {
return <BlogNotFound routes={routes} reason={`${MONTH_NAMES[month - 1]} ${day}, ${year} isn't a real date.`} />;
}
const DayPage = ({ year, month, day }: { year: number; month: number; day: number }) => {
const posts = postsForDay(year, month, day);
if (posts.length === 0) {
return <BlogNotFound routes={routes} reason={`No posts on ${MONTH_NAMES[month - 1]} ${day}, ${year}.`} />;
return <BlogNotFound reason={`No posts on ${MONTH_NAMES[month - 1]} ${day}, ${year}.`} />;
}
return (
<div>
<h3>
{MONTH_NAMES[month - 1]} {day}, {year}
</h3>
<PostList routes={routes} posts={posts} />
<PostList posts={posts} />
</div>
);
};

const PostPage = ({
routes,
year,
month,
day,
slug,
}: {
routes: BlogRoutes;
year: number;
month: number;
day: number;
slug: string;
}) => {
const PostPage = ({ year, month, day, slug }: { year: number; month: number; day: number; slug: string }) => {
const post = postBySlug(year, month, day, slug);
if (!post) {
return <BlogNotFound routes={routes} reason="No post at this address." />;
return <BlogNotFound reason="No post at this address." />;
}
return (
<article>
Expand All @@ -186,34 +168,30 @@ const PostPage = ({
};

/**
* Self-contained demo of a classic /blog/:year/:month/:day/:slug tree: URL-shape 404s via the
* Switch fallback, content-level 404s via isValidCalendarDate and empty-list checks. Pass the
* route atom it is mounted on as `rootAtom` and it builds its own tree under that.
* Self-contained demo of a classic /blog/:year/:month/:day/:slug tree: URL-shape 404s (an
* impossible date included) via the Switch fallback, content-level 404s via empty-list checks.
*/
export const BlogRoutingApp = ({ rootAtom = defaultRootAtom }: { rootAtom?: RouteAtom<DefaultParams> }) => {
const routes = useMemo(() => createBlogRoutes(rootAtom), [rootAtom]);
return (
<>
<BlogNav routes={routes} />
<Switch fallback={<BlogNotFound routes={routes} reason="No blog route matches this address." />}>
<Route on={routes.root} exact>
<BlogIndex routes={routes} />
</Route>
<Route on={routes.year} exact>
{({ year }) => <YearPage routes={routes} year={year} />}
</Route>
<Route on={routes.month} exact>
{({ year, month }) => <MonthPage routes={routes} year={year} month={month} />}
</Route>
<Route on={routes.day} exact>
{({ year, month, day }) => <DayPage routes={routes} year={year} month={month} day={day} />}
</Route>
<Route on={routes.post} exact>
{({ year, month, day, slug }) => <PostPage routes={routes} year={year} month={month} day={day} slug={slug} />}
</Route>
</Switch>
</>
);
};
export const BlogRoutingApp = () => (
<>
<BlogNav />
<Switch fallback={<BlogNotFound reason="No blog route matches this address." />}>
<Route on={blogRoot} exact>
<BlogIndex />
</Route>
<Route on={yearRoute} exact>
{({ year }) => <YearPage year={year} />}
</Route>
<Route on={monthRoute} exact>
{({ year, month }) => <MonthPage year={year} month={month} />}
</Route>
<Route on={dayRoute} exact>
{({ year, month, day }) => <DayPage year={year} month={month} day={day} />}
</Route>
<Route on={postRoute} exact>
{({ year, month, day, slug }) => <PostPage year={year} month={month} day={day} slug={slug} />}
</Route>
</Switch>
</>
);

export default BlogRoutingApp;
67 changes: 32 additions & 35 deletions packages/docs/src/demos/DataGridApp.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { atom, useAtom, useAtomValue } from "jotai";
import { DefaultParams, queryParamAtom, rootAtom as defaultRootAtom, RouteAtom, transformRouteAtom } from "jarl-atoms";
import { createRootAtom, queryParamAtom, transformRouteAtom } from "jarl-atoms";
import { Table } from "./DataGridTable";
import { Ware, wares } from "./wares";

Expand Down Expand Up @@ -49,33 +49,32 @@ const sortWares = (rows: Ware[], key: SortKey, direction: SortDirection) => {
return direction === "desc" ? sorted.reverse() : sorted;
};

// Only a factory because this demo harness mounts at any root - a real app declares these as
// static atoms. Memoised in the component below so they stay stable references across renders.
const createGridRoutes = (root: RouteAtom<DefaultParams>) => {
// The raw "sort" query segment, chained off whatever root this demo is mounted on.
const sort = queryParamAtom("sort", { parent: root });
const parsedSort = transformRouteAtom(
sort,
// Down: parse the raw query value into the shape the UI actually wants.
(values) => parseSort(values.sort),
// Up: serialize back to the raw string queryParamAtom expects to write.
(values) => ({ sort: stringifySort(values.key, values.direction) }),
);
// Chains off parsedSort, not sort - so filter's own values carry the already-parsed sort
// alongside the filter text. Writing here re-composes the whole chain back into a URL, so
// whichever field didn't change comes along for free via the current match.
const filter = queryParamAtom("filter", { parent: parsedSort });
// A plain read off the chain's tip - no useMemo in the component needed for this.
const rows = atom((get) => {
const values = get(filter).values ?? { ...parseSort(undefined), filter: undefined };
return sortWares(filterWares(wares, values.filter), values.key, values.direction);
});
return { filter, rows };
};
// The page this demo is mounted on, so everything below it is a plain module-level atom.
const gridRoot = createRootAtom({ basePath: "/demos/data-grid" });

// The raw "sort" query segment.
const sortParam = queryParamAtom("sort", { parent: gridRoot });

const sortRoute = transformRouteAtom(
sortParam,
// Down: parse the raw query value into the shape the UI actually wants.
(values) => parseSort(values.sort),
// Up: serialize back to the raw string queryParamAtom expects to write.
(values) => ({ sort: stringifySort(values.key, values.direction) }),
);

// Chains off sortRoute, not sortParam - so filter's own values carry the already-parsed sort
// alongside the filter text. Writing here re-composes the whole chain back into a URL, so
// whichever field didn't change comes along for free via the current match.
const filterRoute = queryParamAtom("filter", { parent: sortRoute });

// A plain read off the chain's tip - no useMemo in the component needed for this.
const rowsAtom = atom((get) => {
const values = get(filterRoute).values ?? { ...parseSort(undefined), filter: undefined };
return sortWares(filterWares(wares, values.filter), values.key, values.direction);
});

// Doesn't depend on root, so it's a single static atom rather than one more thing
// createGridRoutes has to build per instance. Local and un-navigated - only reaches the chain
// (and the URL) via filter's setter on submit.
// Local and un-navigated - only reaches the chain (and the URL) via filter's setter on submit.
const filterInputAtom = atom("");

// State is never really undefined because the optional query params always match;
Expand All @@ -85,14 +84,12 @@ const defaultFilter = { ...defaultSort, filter: undefined };
/**
* Self-contained demo: a data grid whose filter text and sort column/direction both live in the
* URL query string (`?sort=-price&filter=axe`), so the grid's state is shareable/bookmarkable and
* moves with back/forward navigation. Pass the route atom it is mounted on as `rootAtom`. Both
* query params are optional, so the route always matches - no `<Route>` needed, just reading the
* atoms directly.
* moves with back/forward navigation. Both query params are optional, so the route always matches -
* no `<Route>` needed, just reading the atoms directly.
*/
export const DataGridApp = ({ rootAtom = defaultRootAtom }: { rootAtom?: RouteAtom<DefaultParams> }) => {
const routes = useMemo(() => createGridRoutes(rootAtom), [rootAtom]);
const [filter, setFilter] = useAtom(routes.filter);
const rows = useAtomValue(routes.rows);
export const DataGridApp = () => {
const [filter, setFilter] = useAtom(filterRoute);
const rows = useAtomValue(rowsAtom);
const [filterInput, setFilterInput] = useAtom(filterInputAtom);

const currentFilter = filter.values ?? defaultFilter;
Expand Down
3 changes: 1 addition & 2 deletions packages/docs/src/pages/BlogRoutingDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { blogRoutingDemoRoute } from "../router/routes";
import { BlogRoutingApp } from "../demos/BlogRoutingApp";
import DemoPage from "../lib/DemoPage";
import demoSource from "../demos/BlogRoutingApp.tsx?raw";
Expand All @@ -9,7 +8,7 @@ export const BlogRoutingDemo = () => (
sourcePath="packages/docs/src/demos/BlogRoutingApp.tsx"
source={demoSource}
>
<BlogRoutingApp rootAtom={blogRoutingDemoRoute} />
<BlogRoutingApp />
</DemoPage>
);

Expand Down
Loading
Loading