Skip to content

Commit 472cf92

Browse files
authored
fix: port Link from react-router (#10)
1 parent 18d5549 commit 472cf92

File tree

5 files changed

+373
-63
lines changed

5 files changed

+373
-63
lines changed

src/app/app.component.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ import { TanStackRouterDevtoolsComponent } from '../router/router-devtools';
77
imports: [Outlet, TanStackRouterDevtoolsComponent, Link],
88
template: `
99
<h1>Welcome to {{ title }}!</h1>
10-
11-
<a link="/">Home</a> | <a link="/about">About</a> |
12-
<a [link]="{ to: '/parent/$id', params: { id: '1' } }">Parent 1</a>
10+
<a link="/" class="chau">Home</a> | <a link="/about">About</a> |
11+
<a [link]="{ to: '/parent' }" [linkActive]="{ exact: false }">Parent 1</a>
1312
<hr />
1413
1514
<outlet />
@@ -22,7 +21,15 @@ import { TanStackRouterDevtoolsComponent } from '../router/router-devtools';
2221
/>
2322
}
2423
`,
25-
styles: [],
24+
styles: [
25+
`
26+
a[data-active='true'] {
27+
font-weight: bold;
28+
padding: 0.5rem;
29+
border: 1px solid;
30+
}
31+
`,
32+
],
2633
})
2734
export class AppComponent {
2835
title = 'tanstack-router-angular';

src/app/parent.component.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export const Route = createRoute({
3232
a {
3333
text-decoration: underline;
3434
}
35+
36+
a[data-active='true'] {
37+
font-weight: bold;
38+
padding: 0.5rem;
39+
border: 1px solid red;
40+
}
3541
`,
3642
],
3743
})

src/router/link.ts

Lines changed: 256 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,277 @@
1-
import { computed, Directive, input } from '@angular/core';
2-
import { NavigateOptions } from '@tanstack/router-core';
1+
import {
2+
computed,
3+
Directive,
4+
effect,
5+
ElementRef,
6+
inject,
7+
input,
8+
signal,
9+
untracked,
10+
} from '@angular/core';
11+
import {
12+
deepEqual,
13+
exactPathTest,
14+
LinkOptions,
15+
preloadWarning,
16+
removeTrailingSlash,
17+
} from '@tanstack/router-core';
318
import { injectRouter } from './router';
419

520
@Directive({
621
selector: 'a[link]',
722
exportAs: 'link',
823
host: {
9-
'(click)': 'navigate($event)',
24+
'(click)': 'type() === "internal" && handleClick($event)',
25+
'(focus)': 'type() === "internal" && handleFocus()',
26+
'(touchstart)': 'type() === "internal" && handleClick($event)',
27+
'(mouseenter)': 'type() === "internal" && handleMouseEnter($event)',
28+
'(mouseleave)': 'type() === "internal" && handleMouseLeave()',
29+
'[class]': '[isActive() ? activeClass() : ""]',
30+
'[attr.data-active]': 'isActive()',
31+
'[attr.data-type]': 'type()',
32+
'[attr.data-transitioning]':
33+
'transitioning() ? "transitioning" : undefined',
1034
'[attr.href]': 'hostHref()',
35+
'[attr.role]': 'disabled() ? "link" : undefined',
36+
'[attr.aria-disabled]': 'disabled()',
37+
'[attr.aria-current]': 'isActive() ? "page" : undefined',
1138
},
1239
})
1340
export class Link {
14-
toOptions = input.required<
15-
| (Omit<NavigateOptions, 'to'> & { to: NonNullable<NavigateOptions['to']> })
16-
| NonNullable<NavigateOptions['to']>
17-
>({ alias: 'link' });
41+
linkOptions = input.required({
42+
alias: 'link',
43+
transform: (
44+
value:
45+
| (Omit<LinkOptions, 'to' | 'activeOptions'> & {
46+
to: NonNullable<LinkOptions['to']>;
47+
})
48+
| NonNullable<LinkOptions['to']>
49+
) => {
50+
return (typeof value === 'object' ? value : { to: value }) as LinkOptions;
51+
},
52+
});
53+
linkActiveOptions = input(
54+
{ class: 'active' },
55+
{
56+
alias: 'linkActive',
57+
transform: (
58+
value: (LinkOptions['activeOptions'] & { class?: string }) | string
59+
) => {
60+
if (typeof value === 'string') return { class: value };
61+
62+
if (!value.class) value.class = 'active';
63+
return value;
64+
},
65+
}
66+
);
1867

1968
router = injectRouter();
69+
hostElement = inject<ElementRef<HTMLAnchorElement>>(ElementRef);
70+
71+
private location = computed(() => this.router.routerState().location);
72+
private matches = computed(() => this.router.routerState().matches);
73+
private currentSearch = computed(
74+
() => this.router.routerState().location.search
75+
);
76+
77+
protected disabled = computed(() => this.linkOptions().disabled);
78+
private to = computed(() => this.linkOptions().to);
79+
private userFrom = computed(() => this.linkOptions().from);
80+
private userReloadDocument = computed(
81+
() => this.linkOptions().reloadDocument
82+
);
83+
private userPreload = computed(() => this.linkOptions().preload);
84+
private userPreloadDelay = computed(() => this.linkOptions().preloadDelay);
85+
private exactActiveOptions = computed(() => this.linkActiveOptions().exact);
86+
private includeHashActiveOptions = computed(
87+
() => this.linkActiveOptions().includeHash
88+
);
89+
private includeSearchActiveOptions = computed(
90+
() => this.linkActiveOptions().includeSearch
91+
);
92+
private explicitUndefinedActiveOptions = computed(
93+
() => this.linkActiveOptions().explicitUndefined
94+
);
95+
protected activeClass = computed(() => this.linkActiveOptions().class);
96+
97+
protected type = computed(() => {
98+
const to = this.to();
99+
try {
100+
new URL(`${to}`);
101+
return 'external';
102+
} catch {
103+
return 'internal';
104+
}
105+
});
106+
107+
private from = computed(() => {
108+
const userFrom = this.userFrom();
109+
if (userFrom) return userFrom;
110+
const matches = this.matches();
111+
return matches[matches.length - 1]?.fullPath;
112+
});
20113

21114
private navigateOptions = computed(() => {
22-
const to = this.toOptions();
23-
if (typeof to === 'object') return to;
24-
return { to };
115+
return { ...this.linkOptions(), from: this.from() };
25116
});
26117

27-
protected hostHref = computed(
28-
() => this.router.buildLocation(this.navigateOptions()).href
29-
);
118+
private next = computed(() => {
119+
const [options] = [this.navigateOptions(), this.currentSearch()];
120+
return this.router.buildLocation(options);
121+
});
122+
123+
private preload = computed(() => {
124+
const userReloadDocument = this.userReloadDocument();
125+
if (userReloadDocument) return false;
126+
const userPreload = this.userPreload();
127+
if (userPreload) return userPreload;
128+
return this.router.options.defaultPreload;
129+
});
130+
131+
private preloadDelay = computed(() => {
132+
const userPreloadDelay = this.userPreloadDelay();
133+
if (userPreloadDelay) return userPreloadDelay;
134+
return this.router.options.defaultPreloadDelay;
135+
});
136+
137+
protected hostHref = computed(() => {
138+
const [type, to] = [this.type(), this.to()];
139+
if (type === 'external') return to;
140+
141+
const disabled = this.disabled();
142+
if (disabled) return undefined;
143+
144+
const next = this.next();
145+
return next.maskedLocation
146+
? this.router.history.createHref(next.maskedLocation.href)
147+
: this.router.history.createHref(next.href);
148+
});
149+
150+
transitioning = signal(false);
151+
isActive = computed(() => {
152+
const [next, location, exact] = [
153+
this.next(),
154+
this.location(),
155+
this.exactActiveOptions(),
156+
];
157+
if (exact) {
158+
const testExact = exactPathTest(
159+
location.pathname,
160+
next.pathname,
161+
this.router.basepath
162+
);
163+
if (!testExact) return false;
164+
} else {
165+
const currentPathSplit = removeTrailingSlash(
166+
location.pathname,
167+
this.router.basepath
168+
).split('/');
169+
const nextPathSplit = removeTrailingSlash(
170+
next.pathname,
171+
this.router.basepath
172+
).split('/');
173+
const pathIsFuzzyEqual = nextPathSplit.every(
174+
(d, i) => d === currentPathSplit[i]
175+
);
176+
if (!pathIsFuzzyEqual) {
177+
return false;
178+
}
179+
}
180+
181+
const includeSearch = this.includeSearchActiveOptions() ?? true;
182+
183+
if (includeSearch) {
184+
const searchTest = deepEqual(location.search, next.search, {
185+
partial: !exact,
186+
ignoreUndefined: !this.explicitUndefinedActiveOptions(),
187+
});
188+
if (!searchTest) {
189+
return false;
190+
}
191+
}
192+
193+
const includeHash = this.includeHashActiveOptions();
194+
if (includeHash) {
195+
return location.hash === next.hash;
196+
}
197+
198+
return true;
199+
});
200+
201+
constructor() {
202+
effect(() => {
203+
const [disabled, preload] = [
204+
untracked(this.disabled),
205+
untracked(this.preload),
206+
];
207+
if (!disabled && preload === 'render') {
208+
this.doPreload();
209+
}
210+
});
211+
212+
effect((onCleanup) => {
213+
const unsub = this.router.subscribe('onResolved', () => {
214+
this.transitioning.set(false);
215+
});
216+
onCleanup(() => unsub());
217+
});
218+
}
219+
220+
protected handleClick(event: MouseEvent) {
221+
const [disabled, target] = [
222+
this.disabled(),
223+
this.hostElement.nativeElement.target,
224+
];
225+
226+
if (
227+
disabled ||
228+
this.isCtrlEvent(event) ||
229+
event.defaultPrevented ||
230+
(target && target !== '_self') ||
231+
event.button !== 0
232+
) {
233+
return;
234+
}
30235

31-
navigate($event: Event) {
32-
$event.preventDefault();
236+
event.preventDefault();
237+
this.transitioning.set(true);
33238

34239
this.router.navigate(this.navigateOptions());
35240
}
241+
242+
protected handleFocus() {
243+
if (this.disabled()) return;
244+
if (this.preload()) {
245+
this.doPreload();
246+
}
247+
}
248+
249+
private preloadTimeout: ReturnType<typeof setTimeout> | null = null;
250+
protected handleMouseEnter(event: MouseEvent) {
251+
if (this.disabled() || !this.preload()) return;
252+
253+
this.preloadTimeout = setTimeout(() => {
254+
this.preloadTimeout = null;
255+
this.doPreload();
256+
}, this.preloadDelay());
257+
}
258+
259+
protected handleMouseLeave() {
260+
if (this.disabled()) return;
261+
if (this.preloadTimeout) {
262+
clearTimeout(this.preloadTimeout);
263+
this.preloadTimeout = null;
264+
}
265+
}
266+
267+
private doPreload() {
268+
this.router.preloadRoute(this.navigateOptions()).catch((err) => {
269+
console.warn(err);
270+
console.warn(preloadWarning);
271+
});
272+
}
273+
274+
private isCtrlEvent(e: MouseEvent) {
275+
return e.metaKey || e.altKey || e.ctrlKey || e.shiftKey;
276+
}
36277
}

src/router/outlet.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
Type,
88
ViewContainerRef,
99
} from '@angular/core';
10-
import { AnyRoute, RouterState } from '@tanstack/router-core';
10+
import {
11+
AnyRoute,
12+
getLocationChangeInfo,
13+
RouterState,
14+
} from '@tanstack/router-core';
1115

1216
import { context } from './context';
1317
import { injectRouteContext, injectRouter } from './router';
@@ -31,6 +35,11 @@ export class Outlet {
3135
}
3236

3337
const matchesToRender = this.getMatch(routerState.matches.slice(1));
38+
39+
if (!matchesToRender) {
40+
return;
41+
}
42+
3443
const route: AnyRoute = this.router.getRouteById(matchesToRender.routeId);
3544
const currentCmp = (
3645
route && route.options.component ? route.options.component() : undefined
@@ -53,6 +62,10 @@ export class Outlet {
5362
environmentInjector,
5463
});
5564
this.cmp = currentCmp;
65+
this.router.emit({
66+
type: 'onResolved',
67+
...getLocationChangeInfo(routerState),
68+
});
5669
} else {
5770
this.cmpRef?.changeDetectorRef.markForCheck();
5871
}

0 commit comments

Comments
 (0)