Skip to content
Merged
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
58 changes: 30 additions & 28 deletions demo/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
const timer = setTimeout(() => {
showNavTooltip = true;
}, 2000);

// Hide tooltip after 10 seconds or when user interacts
const hideTimer = setTimeout(() => {
showNavTooltip = false;
}, 12000);

return () => {
clearTimeout(timer);
clearTimeout(hideTimer);
Expand All @@ -31,33 +31,35 @@
<div class="app">
<div class="d-flex flex-column h-100">
<Router id="root">
<Tooltip shown={showNavTooltip} placement="bottom">
{#snippet reference(ref)}
<NavBar {@attach ref} />
{/snippet}
Use these navigation links to test-drive the routing capabilities of @wjfe/n-savant.
</Tooltip>
<main class="d-flex flex-column flex-fill overflow-auto mt-3">
<div class="container-fluid flex-fill d-flex flex-column">
<div class="grid flex-fill">
<Route key="home" path="/">
<HomeView />
</Route>
<Route key="pathRouting" path="/path-routing/*">
<PathRoutingView basePath="/path-routing" />
</Route>
<Route key="hashRouting" path="/hash-routing">
<HashRoutingView basePath="/hash-routing" />
</Route>
<Fallback>
<NotFound />
</Fallback>
{#snippet children(_, rs)}
<Tooltip shown={showNavTooltip} placement="bottom">
{#snippet reference(ref)}
<NavBar {@attach ref} />
{/snippet}
Use these navigation links to test-drive the routing capabilities of @wjfe/n-savant.
</Tooltip>
<main class="d-flex flex-column flex-fill overflow-auto mt-3">
<div class="container-fluid flex-fill d-flex flex-column">
<div class="grid flex-fill">
<Route key="home" path="/">
<HomeView />
</Route>
<Route key="pathRouting" path="/path-routing/*">
<PathRoutingView basePath="/path-routing" />
</Route>
<Route key="hashRouting" path="/hash-routing">
<HashRoutingView basePath="/hash-routing" />
</Route>
<Fallback>
<NotFound />
</Fallback>
</div>
</div>
</div>
</main>
<Route key="notHome" when={(rs) => !rs.home.match}>
<RouterTrace />
</Route>
</main>
{#if !rs.home.match}
<RouterTrace />
{/if}
{/snippet}
</Router>
</div>
</div>
Expand Down
59 changes: 35 additions & 24 deletions demo/src/lib/NavBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
import { routingMode } from './hash-routing';
import type { HTMLAttributes } from 'svelte/elements';

let {
...restProps
}: HTMLAttributes<HTMLElement> = $props();
let { ...restProps }: HTMLAttributes<HTMLElement> = $props();

const pathRoutingLinks = [
{ text: 'Home', href: '/path-routing' },
Expand Down Expand Up @@ -62,32 +60,45 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<Link class="nav-link" activeState={{ class: 'active', key: 'home' }} href="/" id="homeLink">Home</Link>
<Link
class="nav-link"
activeState={{ class: 'active', key: 'home' }}
href="/"
id="homeLink">Home</Link
>
</li>
<Route key="homeMenuPr" when={(rs) => !rs.pathRouting?.match}>
<li class="nav-item">
<Link
class="nav-link"
activeState={{ class: 'active', key: 'pathRouting' }}
href="/path-routing"
>
Path Routing
</Link>
</li>
<Route key="homeMenuPr">
{#snippet children(rp, _, rs)}
{#if !rs.pathRouting?.match}
<li class="nav-item">
<Link
class="nav-link"
activeState={{ class: 'active', key: 'pathRouting' }}
href="/path-routing"
>
Path Routing
</Link>
</li>
{/if}
{/snippet}
</Route>
<Route key="pathRouting">
<SubNav title="Path Routing" links={pathRoutingLinks} />
</Route>
<Route key="homeMenuHr" when={(rs) => !rs.hashRouting?.match}>
<li class="nav-item">
<Link
class="nav-link"
activeState={{ class: 'active', key: 'hashRouting' }}
href="/hash-routing"
>
Hash Routing
</Link>
</li>
<Route key="homeMenuHr">
{#snippet children(rp, _, rs)}
{#if !rs.hashRouting?.match}
<li class="nav-item">
<Link
class="nav-link"
activeState={{ class: 'active', key: 'hashRouting' }}
href="/hash-routing"
>
Hash Routing
</Link>
</li>
{/if}
{/snippet}
</Route>
<Route key="hashRouting">
<SubNav title="Hash Routing" links={hashRoutingLinks} />
Expand Down
2 changes: 1 addition & 1 deletion src/lib/Route/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ they can be embedded anywhere down the hierarchy, including being children of ot
| `key` | `string` | (none) | | Sets the route's unique key. |
| `path` | `string \| RegExp` | (none) | | Sets the route's path pattern, or a regular expression used to test and match the browser's URL. |
| `and` | `(params: Record<RouteParameters<T>, ParameterValue> \| undefined) => boolean` | `undefined` | | Sets a function for additional matching conditions. |
| `when` | `(routeStatus: Record<string, RouteStatus>) => boolean` | `undefined` | | Sets a function for additional matching conditions. |
| `ignoreForFallback` | `boolean` | `false` | | Controls whether the matching status of this route affects the visibility of fallback content. |
| `caseSensitive` | `boolean` | `false` | | Sets whether the route's path pattern should be matched case-sensitively. |
| `hash` | `boolean \| string` | `undefined` | | Sets the hash mode of the route. |
| `params` | `Record<RouteParameters<T>, ParameterValue>` | `undefined` | Yes | Provides a way to obtain a route's parameters through property binding. |
Expand Down
37 changes: 8 additions & 29 deletions src/lib/Route/Route.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -71,32 +71,11 @@
*/
and?: (params: Record<RouteParameters<T>, ParameterValue> | undefined) => boolean;
/**
* Sets a function for additional matching conditions.
*
* Use this one when you need to match based on the final status of all routes.
* @param routeStatus The router's route status object.
* @returns `true` if the route should match, or `false` otherwise.
* Sets whether the route's match status should be ignored for fallback purposes.
*
* This is shorthand for:
*
* ```svelte
* {#if when(router.routeStatus)}
* <Route ...>...</Route>
* {/if}
* ```
*
*
* In other words, use it to further condition rendering based on the final status of all routes.
*
* Example: Match only if the home route did not:
*
* ```svelte
* <Route key="notHome" when={({ home }) => !home.match}>
* <NotHome />
* </Route>
* ```
* If `true`, the route will not be considered when determining fallback content visibility.
*/
when?: (routeStatus: Record<string, RouteStatus>) => boolean;
ignoreForFallback?: boolean;
/**
* Sets whether the route's path pattern should be matched case-sensitively.
*
Expand Down Expand Up @@ -146,7 +125,7 @@
key,
path,
and,
when,
ignoreForFallback = false,
caseSensitive = false,
hash,
params = $bindable(),
Expand All @@ -162,17 +141,17 @@

// Effect that updates the route object in the parent router.
$effect.pre(() => {
if (!path && !and && !when) {
if (!path && !and) {
return;
}
// svelte-ignore ownership_invalid_mutation
untrack(() => router.routes)[key] =
path instanceof RegExp
? { regex: path, and, when }
? { regex: path, and, ignoreForFallback }
: {
pattern: path,
and,
when,
ignoreForFallback,
caseSensitive
};
return () => {
Expand All @@ -186,6 +165,6 @@
});
</script>

{#if (router.routeStatus[key]?.match ?? true) && (untrack(() => router.routes)[key]?.when?.(router.routeStatus) ?? true)}
{#if (router.routeStatus[key]?.match ?? true)}
{@render children?.(params, router.state, router.routeStatus)}
{/if}
72 changes: 69 additions & 3 deletions src/lib/core/RouterEngine.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeAll, afterAll, vi } from "vitest";
import { describe, test, expect, beforeAll, afterAll, vi, beforeEach } from "vitest";
import { routePatternsKey, RouterEngine } from "./RouterEngine.svelte.js";
import { init, type Hash, type RouteInfo } from "$lib/index.js";
import { registerRouter } from "./trace.svelte.js";
Expand Down Expand Up @@ -39,7 +39,7 @@ describe("RouterEngine", () => {
});
});

describe("RouterEngine", () => {
describe("RouterEngine (default init)", () => {
let _href: string;
let cleanup: () => void;
let interceptedState: any = null;
Expand Down Expand Up @@ -73,6 +73,9 @@ describe("RouterEngine", () => {
replaceState: replaceStateMock
};
});
beforeEach(() => {
location.url.href = globalThis.window.location.href = "http://example.com";
});
afterAll(() => {
cleanup();
});
Expand Down Expand Up @@ -497,9 +500,72 @@ describe("RouterEngine", () => {
});
});
});
describe('noMatches', () => {
test("Should be true whenever there are no routes registered.", () => {
// Act.
const router = new RouterEngine();

// Assert.
expect(router.noMatches).toBe(true);
});
test("Should be true whenever there are no matching routes.", () => {
// Act.
const router = new RouterEngine();
router.routes['route'] = {
pattern: '/:one/:two?',
caseSensitive: false,
};

// Assert.
expect(router.noMatches).toBe(true);
});
test.each([
{
text: "is",
routeCount: 1,
totalRoutes: 5
},
{
text: "are",
routeCount: 2,
totalRoutes: 5
},
{
text: "are",
routeCount: 5,
totalRoutes: 5
},
])("Should be false whenever there $text $routeCount matching route(s) out of $totalRoutes route(s).", ({ routeCount, totalRoutes }) => {
// Act.
const router = new RouterEngine();
for (let i = 0; i < routeCount; i++) {
router.routes[`route${i}`] = {
and: () => i < routeCount
};
}

// Assert.
expect(router.noMatches).toBe(false);
});
test.each([
1, 2, 5
])("Should be true whenever the %d matching route(s) are ignored for fallback.", (routeCount) => {
// Act.
const router = new RouterEngine();
for (let i = 0; i < routeCount; i++) {
router.routes[`route${i}`] = {
and: () => true,
ignoreForFallback: true
};
}

// Assert.
expect(router.noMatches).toBe(true);
});
});
});

describe("RouterEngine", () => {
describe("RouterEngine (multi hash)", () => {
let _href: string;
let cleanup: () => void;
let interceptedState: any = null;
Expand Down
10 changes: 6 additions & 4 deletions src/lib/core/RouterEngine.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,11 @@ export class RouterEngine {
#routePatterns = $derived(Object.entries(this.routes).reduce((map, [key, route]) => {
map.set(
key, routeInfoIsRegexInfo(route) ?
{ regex: route.regex, and: route.and } :
{ regex: route.regex, and: route.and, ignoreForFallback: !!route.ignoreForFallback } :
this.#parseRoutePattern(route)
);
return map;
}, new Map<string, { regex?: RegExp; and?: AndUntyped; }>()));
}, new Map<string, { regex?: RegExp; and?: AndUntyped; ignoreForFallback: boolean; }>()));

[routePatternsKey]() {
return this.#routePatterns;
Expand All @@ -156,7 +156,7 @@ export class RouterEngine {
}
}
const match = (!!matches || !pattern.regex) && (!pattern.and || pattern.and(routeParams));
noMatches = noMatches && !match;
noMatches = noMatches && (pattern.ignoreForFallback ? true : !match);
routeStatus[routeKey] = {
match,
routeParams,
Expand All @@ -179,10 +179,11 @@ export class RouterEngine {
* @param routeInfo Pattern route information to parse.
* @returns An object with the regular expression and the optional predicate function.
*/
#parseRoutePattern(routeInfo: PatternRouteInfo): { regex?: RegExp; and?: AndUntyped; } {
#parseRoutePattern(routeInfo: PatternRouteInfo): { regex?: RegExp; and?: AndUntyped; ignoreForFallback: boolean; } {
if (!routeInfo.pattern) {
return {
and: routeInfo.and,
ignoreForFallback: !!routeInfo.ignoreForFallback
}
}
const fullPattern = joinPaths(this.basePath, routeInfo.pattern === '/' ? '' : routeInfo.pattern);
Expand All @@ -196,6 +197,7 @@ export class RouterEngine {
return {
regex: new RegExp(`^${regexPattern}$`, routeInfo.caseSensitive ? undefined : 'i'),
and: routeInfo.and,
ignoreForFallback: !!routeInfo.ignoreForFallback
};
}
/**
Expand Down
Loading