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 apps/react/boilerplate-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@
"check:biome": "biome check",
"check:biome:fix": "biome check --write",
"check:ts": "tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"storybook": "storybook dev -p 6010 --no-open --host 0.0.0.0"
},
"dependencies": {
"@canonical/react-ds-global": "^0.22.0",
"@canonical/router-core": "^0.22.0",
"@canonical/router-react": "^0.22.0",
"@canonical/react-ssr": "^0.22.0",
"@canonical/storybook-config": "^0.22.0",
"@canonical/styles": "^0.22.0",
Expand All @@ -45,6 +49,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"@vitest/coverage-v8": "4.1.2",
"globals": "^17.4.0",
"storybook": "^10.3.1",
"typescript": "^5.9.3",
Expand Down
127 changes: 110 additions & 17 deletions apps/react/boilerplate-vite/src/Application.css
Original file line number Diff line number Diff line change
@@ -1,28 +1,121 @@
#root {
max-width: 1280px;
min-height: 100vh;
}

.app-shell {
box-sizing: border-box;
display: grid;
gap: 2rem;
margin: 0 auto;
max-width: 1120px;
min-height: 100vh;
padding: 3rem 1.5rem;
}

.shell-header {
align-items: start;
display: grid;
gap: 1.5rem;
}

.shell-title {
font-size: clamp(2rem, 4vw, 3rem);
margin: 0;
}

.shell-copy,
.lede {
color: #4b5563;
font-size: 1.05rem;
line-height: 1.7;
margin: 0;
}

.shell-nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}

.shell-nav a {
background: #ffffff;
border: 1px solid #d7dbe6;
border-radius: 999px;
color: #111827;
padding: 0.7rem 1rem;
text-decoration: none;
transition:
transform 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease;
}

.shell-nav a:hover,
.shell-nav a:focus-visible {
border-color: #0f62fe;
box-shadow: 0 12px 32px rgba(15, 98, 254, 0.12);
outline: none;
transform: translateY(-1px);
}

.shell-main {
display: grid;
}

.route-panel {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 1.5rem;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.08);
padding: 2rem;
text-align: center;
}

.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
.eyebrow {
color: #0f62fe;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
margin: 0;
text-transform: uppercase;
}

@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
.feature-list {
display: grid;
gap: 0.9rem;
margin: 0;
padding-left: 1.2rem;
}

.callout {
background: #eef4ff;
border: 1px solid #c7d7fe;
border-radius: 1rem;
color: #1f2937;
padding: 1rem 1.25rem;
}

@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
.route-fallback {
color: #4b5563;
margin: 0;
}

.stack {
display: grid;
gap: 1rem;
}

.stack-tight {
display: grid;
gap: 0.5rem;
}

@media (min-width: 960px) {
.app-shell {
grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
padding: 4rem 2rem;
}

.shell-main {
align-content: start;
}
}
46 changes: 46 additions & 0 deletions apps/react/boilerplate-vite/src/Navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createMemoryAdapter, createRouter } from "@canonical/router-core";
import { RouterProvider } from "@canonical/router-react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import Navigation from "./Navigation.js";
import { appRoutes, withAuth } from "./routes.js";

describe("Navigation", () => {
it("renders typed links and prefetches the guide route on hover", async () => {
const router = createRouter(appRoutes, {
adapter: createMemoryAdapter("/"),
middleware: [withAuth("/login")],
});
const prefetchSpy = vi.spyOn(router, "prefetch");

render(
<RouterProvider router={router}>
<Navigation />
</RouterProvider>,
);

expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute(
"href",
"/",
);
expect(screen.getByRole("link", { name: "Guide" })).toHaveAttribute(
"href",
"/guides/router-core",
);
expect(
screen.getByRole("link", { name: "Protected account" }),
).toHaveAttribute("href", "/account");
expect(screen.getByRole("link", { name: "Demo sign-in" })).toHaveAttribute(
"href",
"/account?auth=1",
);

fireEvent.mouseEnter(screen.getByRole("link", { name: "Guide" }));

await waitFor(() => {
expect(prefetchSpy).toHaveBeenCalledWith("guide", {
params: { slug: "router-core" },
});
});
});
});
26 changes: 26 additions & 0 deletions apps/react/boilerplate-vite/src/Navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Link } from "@canonical/router-react";
import type { ReactElement } from "react";

export default function Navigation(): ReactElement {
return (
<header className="shell-header">
<div className="brand stack-tight">
<p className="eyebrow">React boilerplate</p>
<h1 className="shell-title">Router-enabled Vite shell</h1>
<p className="shell-copy">
Hover a navigation link to prefetch route data before you click.
</p>
</div>
<nav aria-label="Primary" className="shell-nav">
<Link to="home">Home</Link>
<Link params={{ slug: "router-core" }} to="guide">
Guide
</Link>
<Link to="account">Protected account</Link>
<Link search={{ auth: "1" }} to="account">
Demo sign-in
</Link>
</nav>
</header>
);
}
105 changes: 105 additions & 0 deletions apps/react/boilerplate-vite/src/domains/account/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { route } from "@canonical/router-core";
import type { ReactElement } from "react";

interface LoginSearch {
readonly from?: string;
}

interface AccountSearch {
readonly auth?: string;
}

interface AccountData {
readonly nextSteps: readonly string[];
readonly team: string;
}

function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

const loginSearchSchema = {
"~standard": {
output: {} as LoginSearch,
validate(value: unknown): LoginSearch {
const record = value as Record<string, unknown>;

return {
from: readString(record.from),
};
},
},
};

const accountSearchSchema = {
"~standard": {
output: {} as AccountSearch,
validate(value: unknown): AccountSearch {
const record = value as Record<string, unknown>;

return {
auth: readString(record.auth),
};
},
},
};

const accountRoutes = {
account: route({
url: "/account",
fetch: async (): Promise<AccountData> => ({
nextSteps: [
"Review the prefetched guide data.",
"Confirm the auth middleware redirected correctly.",
"Use the same route map in React and future Lit bindings.",
],
team: "Router adoption squad",
}),
search: accountSearchSchema,
content: ({ data }: { data: AccountData }): ReactElement => {
return (
<section className="route-panel stack" aria-labelledby="account-title">
<p className="eyebrow">Account domain</p>
<h1 id="account-title">Protected account workspace</h1>
<p className="lede">
You reached the protected route after passing the demo auth
middleware.
</p>
<div className="callout">
<strong>Team:</strong> {data.team}
</div>
<ul className="feature-list">
{data.nextSteps.map((item: string) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
);
},
}),
login: route({
url: "/login",
search: loginSearchSchema,
content: ({ search }: { search: LoginSearch }): ReactElement => {
return (
<section className="route-panel stack" aria-labelledby="login-title">
<p className="eyebrow">Account domain</p>
<h1 id="login-title">Sign in to the demo account</h1>
<p className="lede">
The local <strong>withAuth("/login")</strong> middleware redirected
this protected request before the route rendered.
</p>
<div className="callout">
<strong>Redirected from:</strong> {search.from ?? "direct visit"}
</div>
<p>
Use the “Demo sign-in” link in the navigation to revisit the
protected route with ?auth=1 applied.
</p>
</section>
);
},
}),
} as const;

export default accountRoutes;
Loading
Loading