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 }); } }