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
5 changes: 5 additions & 0 deletions packages/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
basicRoutingDemoRoute,
blogRoutingDemoRoute,
dataGridDemoRoute,
cancelNavigationDemoRoute,
asyncLookupDemoRoute,
} from "./router/routes";
import Home from "./pages/Home";
Expand All @@ -24,6 +25,7 @@ import DemosIndex from "./pages/DemosIndex";
import BasicRoutingDemo from "./pages/BasicRoutingDemo";
import BlogRoutingDemo from "./pages/BlogRoutingDemo";
import DataGridDemo from "./pages/DataGridDemo";
import CancelNavigationDemo from "./pages/CancelNavigationDemo";
import AsyncLookupDemo from "./pages/AsyncLookupDemo";
import NotFound from "./pages/NotFound";

Expand Down Expand Up @@ -67,6 +69,9 @@ export const App = () => (
<Route on={dataGridDemoRoute}>
<DataGridDemo />
</Route>
<Route on={cancelNavigationDemoRoute}>
<CancelNavigationDemo />
</Route>
<Route on={asyncLookupDemoRoute}>
<AsyncLookupDemo />
</Route>
Expand Down
89 changes: 89 additions & 0 deletions packages/docs/src/demos/CancelNavigationApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ReactNode, useState } from "react";
import { Route, useLink } from "jarl-react";
import { DefaultParams, RouteAtom } from "jarl-atoms";
import { cancelNavigationDemoRoute, cancelNavigationOtherRoute } from "../router/routes";

/** A nav link that confirms before leaving while `dirty` is true, discarding the click if declined. */
const GuardedLink = <T extends DefaultParams>({
route,
to,
dirty,
children,
}: {
route: RouteAtom<T>;
to: T;
dirty: boolean;
children: ReactNode;
}) => {
const { href, active, onClick } = useLink(route, to, { exact: true });
return (
<a
href={href}
data-active={active || undefined}
onClick={(event) => {
event.preventDefault();
if (dirty && !window.confirm("You have unsaved changes. Leave without saving?")) return;
onClick();
}}
>
{children}
</a>
);
};

const Editor = ({ dirty, setDirty }: { dirty: boolean; setDirty: (dirty: boolean) => void }) => {
const [text, setText] = useState("");
return (
<div>
<h3>Editor</h3>
<textarea
rows={3}
value={text}
onChange={(event) => {
setText(event.target.value);
setDirty(true);
}}
/>
<p>{dirty ? "Unsaved changes." : "Nothing to save."}</p>
<button type="button" disabled={!dirty} onClick={() => setDirty(false)}>
Save
</button>
</div>
);
};

const Other = () => (
<div>
<h3>Other page</h3>
<p>Reached from the editor - navigating away while it&apos;s dirty asks for confirmation first.</p>
</div>
);

/**
* Demo of blocking in-app navigation while a form has unsaved edits. Built on `useLink` directly
* rather than `Link`, since the guard needs to intercept the click before the route atom is
* written - `dirty` state itself is plain component state, not a route concern.
*/
export const CancelNavigationApp = () => {
const [dirty, setDirty] = useState(false);
return (
<>
<nav>
<GuardedLink route={cancelNavigationDemoRoute} to={{}} dirty={dirty}>
Editor
</GuardedLink>
<GuardedLink route={cancelNavigationOtherRoute} to={{}} dirty={dirty}>
Other
</GuardedLink>
</nav>
<Route on={cancelNavigationDemoRoute} exact>
<Editor dirty={dirty} setDirty={setDirty} />
</Route>
<Route on={cancelNavigationOtherRoute} exact>
<Other />
</Route>
</>
);
};

export default CancelNavigationApp;
15 changes: 15 additions & 0 deletions packages/docs/src/pages/CancelNavigationDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { CancelNavigationApp } from "../demos/CancelNavigationApp";
import DemoPage from "../lib/DemoPage";
import demoSource from "../demos/CancelNavigationApp.tsx?raw";

export const CancelNavigationDemo = () => (
<DemoPage
title="Live demo: cancel navigation on dirty edits"
sourcePath="packages/docs/src/demos/CancelNavigationApp.tsx"
source={demoSource}
>
<CancelNavigationApp />
</DemoPage>
);

export default CancelNavigationDemo;
15 changes: 14 additions & 1 deletion packages/docs/src/pages/DemosIndex.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Link } from "jarl-react";
import LinkList from "../lib/LinkList";
import { asyncLookupDemoRoute, basicRoutingDemoRoute, blogRoutingDemoRoute, dataGridDemoRoute } from "../router/routes";
import {
asyncLookupDemoRoute,
basicRoutingDemoRoute,
blogRoutingDemoRoute,
cancelNavigationDemoRoute,
dataGridDemoRoute,
} from "../router/routes";

export const DemosIndex = () => (
<>
Expand Down Expand Up @@ -34,6 +40,13 @@ export const DemosIndex = () => (
&mdash; a table whose filter text and sort column live in <code>queryParamAtom</code>s chained off the mount
route, so the grid&apos;s state is shareable and moves with back/forward navigation.
</li>
<li>
<Link route={cancelNavigationDemoRoute} to={{}}>
Cancel navigation on dirty edits
</Link>{" "}
&mdash; a link that confirms before leaving an in-progress edit, built by wrapping <code>useLink</code>&apos;s
own <code>onClick</code> rather than the <code>Link</code> component.
</li>
<li>
<Link route={asyncLookupDemoRoute} to={{}}>
Async lookup
Expand Down
10 changes: 10 additions & 0 deletions packages/docs/src/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export const blogRoutingDemoRoute = staticRouteAtom("blog-routing", { parent: de
// chained inside DataGridApp, on its own basePath-scoped root.
export const dataGridDemoRoute = staticRouteAtom("data-grid", { parent: demosIndexRoute });

// Cancel-navigation demo: the site's own mount point, with one further static child for the
// "other" page it navigates to. Dirty-tracking and the confirm prompt live entirely inside
// CancelNavigationApp - the router only needs the two destinations to exist.
export const cancelNavigationDemoRoute = staticRouteAtom("cancel-navigation", { parent: demosIndexRoute });
export const cancelNavigationOtherRoute = staticRouteAtom("other", { parent: cancelNavigationDemoRoute });

// Async-lookup demo: /demos/async-lookup/:slug exists only if the demo's fake database has an
// article at that slug, and the article it found rides along on the route's own values. Its
// nested atoms stay module-level, unlike the blog demo's, because the server render needs them:
Expand All @@ -63,6 +69,8 @@ const exactRouteMissedAtom = notAtom(
basicRoutingDemoPageRoute,
blogRoutingDemoRoute,
dataGridDemoRoute,
cancelNavigationDemoRoute,
cancelNavigationOtherRoute,
asyncLookupDemoRoute,
asyncArticleRoute,
);
Expand Down Expand Up @@ -107,6 +115,8 @@ export const staticPaths: string[] = [
"/demos/basic-routing/about",
...blogStaticPaths(),
"/demos/data-grid",
"/demos/cancel-navigation",
"/demos/cancel-navigation/other",
"/demos/async-lookup",
...articleSlugs().map((slug) => `/demos/async-lookup/${slug}`),
];
Loading