diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 28a9d90..5634b5d 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -7,9 +7,8 @@ import { TanStackRouterDevtoolsComponent } from '../router/router-devtools';
imports: [Outlet, TanStackRouterDevtoolsComponent, Link],
template: `
Welcome to {{ title }}!
-
- Home | About |
- Parent 1
+ Home | About |
+ Parent 1
@@ -22,7 +21,15 @@ import { TanStackRouterDevtoolsComponent } from '../router/router-devtools';
/>
}
`,
- styles: [],
+ styles: [
+ `
+ a[data-active='true'] {
+ font-weight: bold;
+ padding: 0.5rem;
+ border: 1px solid;
+ }
+ `,
+ ],
})
export class AppComponent {
title = 'tanstack-router-angular';
diff --git a/src/app/parent.component.ts b/src/app/parent.component.ts
index ebbb386..b4af440 100644
--- a/src/app/parent.component.ts
+++ b/src/app/parent.component.ts
@@ -32,6 +32,12 @@ export const Route = createRoute({
a {
text-decoration: underline;
}
+
+ a[data-active='true'] {
+ font-weight: bold;
+ padding: 0.5rem;
+ border: 1px solid red;
+ }
`,
],
})
diff --git a/src/router/link.ts b/src/router/link.ts
index dd18190..c59e950 100644
--- a/src/router/link.ts
+++ b/src/router/link.ts
@@ -1,36 +1,277 @@
-import { computed, Directive, input } from '@angular/core';
-import { NavigateOptions } from '@tanstack/router-core';
+import {
+ computed,
+ Directive,
+ effect,
+ ElementRef,
+ inject,
+ input,
+ signal,
+ untracked,
+} from '@angular/core';
+import {
+ deepEqual,
+ exactPathTest,
+ LinkOptions,
+ preloadWarning,
+ removeTrailingSlash,
+} from '@tanstack/router-core';
import { injectRouter } from './router';
@Directive({
selector: 'a[link]',
exportAs: 'link',
host: {
- '(click)': 'navigate($event)',
+ '(click)': 'type() === "internal" && handleClick($event)',
+ '(focus)': 'type() === "internal" && handleFocus()',
+ '(touchstart)': 'type() === "internal" && handleClick($event)',
+ '(mouseenter)': 'type() === "internal" && handleMouseEnter($event)',
+ '(mouseleave)': 'type() === "internal" && handleMouseLeave()',
+ '[class]': '[isActive() ? activeClass() : ""]',
+ '[attr.data-active]': 'isActive()',
+ '[attr.data-type]': 'type()',
+ '[attr.data-transitioning]':
+ 'transitioning() ? "transitioning" : undefined',
'[attr.href]': 'hostHref()',
+ '[attr.role]': 'disabled() ? "link" : undefined',
+ '[attr.aria-disabled]': 'disabled()',
+ '[attr.aria-current]': 'isActive() ? "page" : undefined',
},
})
export class Link {
- toOptions = input.required<
- | (Omit & { to: NonNullable })
- | NonNullable
- >({ alias: 'link' });
+ linkOptions = input.required({
+ alias: 'link',
+ transform: (
+ value:
+ | (Omit & {
+ to: NonNullable;
+ })
+ | NonNullable
+ ) => {
+ return (typeof value === 'object' ? value : { to: value }) as LinkOptions;
+ },
+ });
+ linkActiveOptions = input(
+ { class: 'active' },
+ {
+ alias: 'linkActive',
+ transform: (
+ value: (LinkOptions['activeOptions'] & { class?: string }) | string
+ ) => {
+ if (typeof value === 'string') return { class: value };
+
+ if (!value.class) value.class = 'active';
+ return value;
+ },
+ }
+ );
router = injectRouter();
+ hostElement = inject>(ElementRef);
+
+ private location = computed(() => this.router.routerState().location);
+ private matches = computed(() => this.router.routerState().matches);
+ private currentSearch = computed(
+ () => this.router.routerState().location.search
+ );
+
+ protected disabled = computed(() => this.linkOptions().disabled);
+ private to = computed(() => this.linkOptions().to);
+ private userFrom = computed(() => this.linkOptions().from);
+ private userReloadDocument = computed(
+ () => this.linkOptions().reloadDocument
+ );
+ private userPreload = computed(() => this.linkOptions().preload);
+ private userPreloadDelay = computed(() => this.linkOptions().preloadDelay);
+ private exactActiveOptions = computed(() => this.linkActiveOptions().exact);
+ private includeHashActiveOptions = computed(
+ () => this.linkActiveOptions().includeHash
+ );
+ private includeSearchActiveOptions = computed(
+ () => this.linkActiveOptions().includeSearch
+ );
+ private explicitUndefinedActiveOptions = computed(
+ () => this.linkActiveOptions().explicitUndefined
+ );
+ protected activeClass = computed(() => this.linkActiveOptions().class);
+
+ protected type = computed(() => {
+ const to = this.to();
+ try {
+ new URL(`${to}`);
+ return 'external';
+ } catch {
+ return 'internal';
+ }
+ });
+
+ private from = computed(() => {
+ const userFrom = this.userFrom();
+ if (userFrom) return userFrom;
+ const matches = this.matches();
+ return matches[matches.length - 1]?.fullPath;
+ });
private navigateOptions = computed(() => {
- const to = this.toOptions();
- if (typeof to === 'object') return to;
- return { to };
+ return { ...this.linkOptions(), from: this.from() };
});
- protected hostHref = computed(
- () => this.router.buildLocation(this.navigateOptions()).href
- );
+ private next = computed(() => {
+ const [options] = [this.navigateOptions(), this.currentSearch()];
+ return this.router.buildLocation(options);
+ });
+
+ private preload = computed(() => {
+ const userReloadDocument = this.userReloadDocument();
+ if (userReloadDocument) return false;
+ const userPreload = this.userPreload();
+ if (userPreload) return userPreload;
+ return this.router.options.defaultPreload;
+ });
+
+ private preloadDelay = computed(() => {
+ const userPreloadDelay = this.userPreloadDelay();
+ if (userPreloadDelay) return userPreloadDelay;
+ return this.router.options.defaultPreloadDelay;
+ });
+
+ protected hostHref = computed(() => {
+ const [type, to] = [this.type(), this.to()];
+ if (type === 'external') return to;
+
+ const disabled = this.disabled();
+ if (disabled) return undefined;
+
+ const next = this.next();
+ return next.maskedLocation
+ ? this.router.history.createHref(next.maskedLocation.href)
+ : this.router.history.createHref(next.href);
+ });
+
+ transitioning = signal(false);
+ isActive = computed(() => {
+ const [next, location, exact] = [
+ this.next(),
+ this.location(),
+ this.exactActiveOptions(),
+ ];
+ if (exact) {
+ const testExact = exactPathTest(
+ location.pathname,
+ next.pathname,
+ this.router.basepath
+ );
+ if (!testExact) return false;
+ } else {
+ const currentPathSplit = removeTrailingSlash(
+ location.pathname,
+ this.router.basepath
+ ).split('/');
+ const nextPathSplit = removeTrailingSlash(
+ next.pathname,
+ this.router.basepath
+ ).split('/');
+ const pathIsFuzzyEqual = nextPathSplit.every(
+ (d, i) => d === currentPathSplit[i]
+ );
+ if (!pathIsFuzzyEqual) {
+ return false;
+ }
+ }
+
+ const includeSearch = this.includeSearchActiveOptions() ?? true;
+
+ if (includeSearch) {
+ const searchTest = deepEqual(location.search, next.search, {
+ partial: !exact,
+ ignoreUndefined: !this.explicitUndefinedActiveOptions(),
+ });
+ if (!searchTest) {
+ return false;
+ }
+ }
+
+ const includeHash = this.includeHashActiveOptions();
+ if (includeHash) {
+ return location.hash === next.hash;
+ }
+
+ return true;
+ });
+
+ constructor() {
+ effect(() => {
+ const [disabled, preload] = [
+ untracked(this.disabled),
+ untracked(this.preload),
+ ];
+ if (!disabled && preload === 'render') {
+ this.doPreload();
+ }
+ });
+
+ effect((onCleanup) => {
+ const unsub = this.router.subscribe('onResolved', () => {
+ this.transitioning.set(false);
+ });
+ onCleanup(() => unsub());
+ });
+ }
+
+ protected handleClick(event: MouseEvent) {
+ const [disabled, target] = [
+ this.disabled(),
+ this.hostElement.nativeElement.target,
+ ];
+
+ if (
+ disabled ||
+ this.isCtrlEvent(event) ||
+ event.defaultPrevented ||
+ (target && target !== '_self') ||
+ event.button !== 0
+ ) {
+ return;
+ }
- navigate($event: Event) {
- $event.preventDefault();
+ event.preventDefault();
+ this.transitioning.set(true);
this.router.navigate(this.navigateOptions());
}
+
+ protected handleFocus() {
+ if (this.disabled()) return;
+ if (this.preload()) {
+ this.doPreload();
+ }
+ }
+
+ private preloadTimeout: ReturnType | null = null;
+ protected handleMouseEnter(event: MouseEvent) {
+ if (this.disabled() || !this.preload()) return;
+
+ this.preloadTimeout = setTimeout(() => {
+ this.preloadTimeout = null;
+ this.doPreload();
+ }, this.preloadDelay());
+ }
+
+ protected handleMouseLeave() {
+ if (this.disabled()) return;
+ if (this.preloadTimeout) {
+ clearTimeout(this.preloadTimeout);
+ this.preloadTimeout = null;
+ }
+ }
+
+ private doPreload() {
+ this.router.preloadRoute(this.navigateOptions()).catch((err) => {
+ console.warn(err);
+ console.warn(preloadWarning);
+ });
+ }
+
+ private isCtrlEvent(e: MouseEvent) {
+ return e.metaKey || e.altKey || e.ctrlKey || e.shiftKey;
+ }
}
diff --git a/src/router/outlet.ts b/src/router/outlet.ts
index f3db3ce..03dceaa 100644
--- a/src/router/outlet.ts
+++ b/src/router/outlet.ts
@@ -7,7 +7,11 @@ import {
Type,
ViewContainerRef,
} from '@angular/core';
-import { AnyRoute, RouterState } from '@tanstack/router-core';
+import {
+ AnyRoute,
+ getLocationChangeInfo,
+ RouterState,
+} from '@tanstack/router-core';
import { context } from './context';
import { injectRouteContext, injectRouter } from './router';
@@ -31,6 +35,11 @@ export class Outlet {
}
const matchesToRender = this.getMatch(routerState.matches.slice(1));
+
+ if (!matchesToRender) {
+ return;
+ }
+
const route: AnyRoute = this.router.getRouteById(matchesToRender.routeId);
const currentCmp = (
route && route.options.component ? route.options.component() : undefined
@@ -53,6 +62,10 @@ export class Outlet {
environmentInjector,
});
this.cmp = currentCmp;
+ this.router.emit({
+ type: 'onResolved',
+ ...getLocationChangeInfo(routerState),
+ });
} else {
this.cmpRef?.changeDetectorRef.markForCheck();
}
diff --git a/src/router/route.ts b/src/router/route.ts
index 79f4796..7776299 100644
--- a/src/router/route.ts
+++ b/src/router/route.ts
@@ -6,23 +6,103 @@ import {
runInInjectionContext,
Signal,
} from '@angular/core';
-import type {
+import {
AnyContext,
AnyRoute,
AnyRouter,
+ BaseRootRoute,
+ BaseRoute,
+ BaseRouteApi,
+ ConstrainLiteral,
RegisteredRouter,
ResolveFullPath,
ResolveId,
ResolveParams,
ResolveUseLoaderData,
+ ResolveUseParams,
RootRouteOptions,
RouteConstraints,
+ RouteIds,
RouteOptions,
- UseParamsResult,
} from '@tanstack/router-core';
-import { BaseRootRoute, BaseRoute } from '@tanstack/router-core';
import { injectRouteContext, injectRouter, RouterContext } from './router';
+function loaderData({ injector }: { injector?: Injector } = {}) {
+ !injector && assertInInjectionContext(loaderData);
+
+ if (!injector) {
+ injector = inject(Injector);
+ }
+
+ return runInInjectionContext(injector, () => {
+ const router = injectRouter();
+ const context = injectRouteContext();
+
+ return computed(() => {
+ const routerState = router.routerState();
+ const route = routerState.matches.find(
+ (match) => match.routeId === context!.id
+ );
+
+ return (route && route.loaderData) || {};
+ });
+ });
+}
+
+function routeParams({ injector }: { injector?: Injector } = {}) {
+ !injector && assertInInjectionContext(routeParams);
+
+ if (!injector) {
+ injector = inject(Injector);
+ }
+
+ return runInInjectionContext(injector, () => {
+ const router = injectRouter();
+ const context = injectRouteContext();
+
+ return computed(() => {
+ const routerState = router.routerState();
+ const route = routerState.matches.find(
+ (match) => match.routeId === context!.id
+ );
+
+ return (route && route.params) || {};
+ });
+ });
+}
+
+export function routeApi<
+ const TId,
+ TRouter extends AnyRouter = RegisteredRouter,
+>({
+ id,
+ injector,
+}: {
+ id: ConstrainLiteral>;
+ injector?: Injector;
+}): BaseRouteApi & {
+ loaderData: () => Signal>;
+ routeParams: () => Signal>;
+} {
+ !injector && assertInInjectionContext(routeApi);
+
+ if (!injector) {
+ injector = inject(Injector);
+ }
+
+ const _loaderData = loaderData.bind(null, { injector });
+ const _routeParams = routeParams.bind(null, { injector });
+
+ return runInInjectionContext(injector, () => {
+ const routeApi = new BaseRouteApi({ id });
+
+ return Object.assign(routeApi, {
+ loaderData: _loaderData,
+ routeParams: _routeParams,
+ });
+ });
+}
+
class Route<
in out TParentRoute extends RouteConstraints['TParentRoute'] = AnyRoute,
in out TPath extends RouteConstraints['TPath'] = '/',
@@ -86,54 +166,17 @@ class Route<
>({ injector }: { injector?: Injector } = {}): Signal<
ResolveUseLoaderData
> {
- !injector && assertInInjectionContext(this.loaderData);
-
- if (!injector) {
- injector = inject(Injector);
- }
-
- return runInInjectionContext(injector, () => {
- const router = injectRouter();
- const context = injectRouteContext();
-
- return computed(() => {
- const routerState = router.routerState();
- const route = routerState.matches.find(
- (match) => match.routeId === context!.id
- );
-
- return (route && route.loaderData) || {};
- });
- });
+ return loaderData({ injector });
}
routeParams<
TRouter extends AnyRouter = RegisteredRouter,
const TFrom extends string | undefined = undefined,
TStrict extends boolean = false,
- TSelected = unknown,
>({ injector }: { injector?: Injector } = {}): Signal<
- UseParamsResult
+ ResolveUseParams
> {
- !injector && assertInInjectionContext(this.routeParams);
-
- if (!injector) {
- injector = inject(Injector);
- }
-
- return runInInjectionContext(injector, () => {
- const router = injectRouter();
- const context = injectRouteContext();
-
- return computed(() => {
- const routerState = router.routerState();
- const route = routerState.matches.find(
- (match) => match.routeId === context!.id
- );
-
- return (route && route.params) || {};
- });
- });
+ return routeParams({ injector });
}
}