Skip to content

Commit a5f112f

Browse files
webJoseCopilot
andauthored
feat(LinkContext)!: Add activeState property (#94)
* feat(LinkContext)!: Add `activeState` property * Update src/lib/Link/Link.svelte Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 8ab949a commit a5f112f

File tree

10 files changed

+426
-520
lines changed

10 files changed

+426
-520
lines changed

src/lib/Link/Link.svelte

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,9 @@
5555
*/
5656
state?: any;
5757
/**
58-
* Sets the various options that are used to automatically style the anchor tag whenever a particular route
59-
* becomes active.
60-
*
61-
* **IMPORTANT**: This only works if the component is within a `Router` component.
58+
* Sets the route key that the link will use to determine if it should render as active.
6259
*/
63-
activeState?: ActiveState;
60+
activeFor?: string;
6461
/**
6562
* Renders the children of the component.
6663
* @param state The state object stored in in the window's History API for the universe the link is
@@ -76,6 +73,7 @@
7673
href,
7774
replace,
7875
state,
76+
activeFor,
7977
activeState,
8078
class: cssClass,
8179
style,
@@ -93,7 +91,20 @@
9391
const calcReplace = $derived(replace ?? linkContext?.replace ?? false);
9492
const calcPreserveQuery = $derived(preserveQuery ?? linkContext?.preserveQuery ?? false);
9593
const calcPrependBasePath = $derived(prependBasePath ?? linkContext?.prependBasePath ?? false);
96-
const isActive = $derived(isRouteActive(router, activeState?.key));
94+
const calcActiveState = $derived.by(() => {
95+
if (!activeState) {
96+
return linkContext?.activeState;
97+
}
98+
const result = { ...activeState };
99+
result.class ??= linkContext?.activeState?.class;
100+
result.style ??= linkContext?.activeState?.style;
101+
result.aria = {
102+
...linkContext?.activeState?.aria,
103+
...activeState.aria
104+
};
105+
return result;
106+
});
107+
const isActive = $derived(isRouteActive(router, activeFor));
97108
const calcHref = $derived(
98109
calculateHref(
99110
{
@@ -116,10 +127,10 @@
116127

117128
<a
118129
href={calcHref}
119-
class={[cssClass, (isActive && activeState?.class) || undefined]}
120-
style={isActive ? joinStyles(style, activeState?.style) : style}
130+
class={[cssClass, (isActive && calcActiveState?.class) || undefined]}
131+
style={isActive ? joinStyles(style, calcActiveState?.style) : style}
121132
onclick={handleClick}
122-
{...(isActive ? activeState?.aria : undefined)}
133+
{...(isActive ? calcActiveState?.aria : undefined)}
123134
{...restProps}
124135
>
125136
{@render children?.(location.getState(resolvedHash), router?.routeStatus)}

src/lib/Link/Link.svelte.test.ts

Lines changed: 157 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { location } from "$lib/core/Location.js";
33
import { describe, test, expect, beforeAll, afterAll, beforeEach, vi, afterEach } from "vitest";
44
import { render, fireEvent } from "@testing-library/svelte";
55
import Link from "./Link.svelte";
6-
import { createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js";
6+
import { createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES, ALL_HASHES, createWindowMock, setupBrowserMocks, type RoutingUniverse, addMatchingRoute } from "../../testing/test-utils.js";
77
import { flushSync } from "svelte";
88
import { resetRoutingOptions, setRoutingOptions } from "$lib/core/options.js";
99
import type { ExtendedRoutingOptions } from "$lib/types.js";
10+
import { linkCtxKey, type ILinkContext } from "$lib/LinkContext/LinkContext.svelte";
11+
import { calculateHref } from "$lib/core/calculateHref.js";
1012

1113
function basicLinkTests(setup: ReturnType<typeof createRouterTestSetup>) {
1214
const linkText = "Test Link";
@@ -192,7 +194,8 @@ function activeStateTests(setup: ReturnType<typeof createRouterTestSetup>) {
192194
props: {
193195
hash,
194196
href,
195-
activeState: { key: activeKey, class: "active-link" },
197+
activeFor: activeKey,
198+
activeState: { class: "active-link" },
196199
children: content
197200
},
198201
context
@@ -222,7 +225,8 @@ function activeStateTests(setup: ReturnType<typeof createRouterTestSetup>) {
222225
props: {
223226
hash,
224227
href,
225-
activeState: { key: activeKey, style: "color: red;" },
228+
activeFor: activeKey,
229+
activeState: { style: "color: red;" },
226230
children: content
227231
},
228232
context
@@ -252,8 +256,8 @@ function activeStateTests(setup: ReturnType<typeof createRouterTestSetup>) {
252256
props: {
253257
hash,
254258
href,
259+
activeFor: activeKey,
255260
activeState: {
256-
key: activeKey,
257261
aria: {
258262
'aria-selected': 'true',
259263
'aria-current': 'page'
@@ -289,8 +293,8 @@ function activeStateTests(setup: ReturnType<typeof createRouterTestSetup>) {
289293
props: {
290294
hash,
291295
href,
296+
activeFor: activeKey,
292297
activeState: {
293-
key: activeKey,
294298
aria: {
295299
'aria-selected': 'true',
296300
'aria-current': 'page'
@@ -326,7 +330,8 @@ function activeStateTests(setup: ReturnType<typeof createRouterTestSetup>) {
326330
props: {
327331
hash,
328332
href,
329-
activeState: { key: activeKey, class: "active-link" },
333+
activeFor: activeKey,
334+
activeState: { class: "active-link" },
330335
children: content
331336
},
332337
context
@@ -508,11 +513,17 @@ function reactivityTests(setup: ReturnType<typeof createRouterTestSetup>) {
508513
});
509514
}
510515

511-
const initialActiveState = { key: activeKey, class: "initial-active" };
512-
const updatedActiveState = { key: activeKey, class: "updated-active" };
516+
const initialActiveState = { class: "initial-active" };
517+
const updatedActiveState = { class: "updated-active" };
513518

514519
const { container, rerender } = render(Link, {
515-
props: { hash, href, activeState: initialActiveState, children: content },
520+
props: {
521+
hash,
522+
href,
523+
activeFor: activeKey,
524+
activeState: initialActiveState,
525+
children: content
526+
},
516527
context
517528
});
518529
const anchor = container.querySelector('a');
@@ -546,7 +557,8 @@ function reactivityTests(setup: ReturnType<typeof createRouterTestSetup>) {
546557
props: {
547558
hash,
548559
href,
549-
get activeState() { return { key: activeKey, class: activeClass }; },
560+
activeFor: activeKey,
561+
get activeState() { return { class: activeClass }; },
550562
children: content
551563
},
552564
context
@@ -682,6 +694,125 @@ function reactivityTests(setup: ReturnType<typeof createRouterTestSetup>) {
682694
});
683695
}
684696

697+
function linkContextTests(ru: RoutingUniverse) {
698+
let browserMocks: ReturnType<typeof setupBrowserMocks>;
699+
let setup: ReturnType<typeof createRouterTestSetup>;
700+
701+
beforeEach(() => {
702+
browserMocks = setupBrowserMocks('http://example.com/', location);
703+
setup = createRouterTestSetup(ru.hash);
704+
setup.init();
705+
});
706+
707+
afterAll(() => {
708+
setup.dispose();
709+
});
710+
711+
test("Should prepend the parent router's base path when link context demands it.", () => {
712+
// Arrange.
713+
const { router, context } = setup;
714+
router.basePath = '/base';
715+
const linkCtx: ILinkContext = {
716+
prependBasePath: true,
717+
};
718+
context.set(linkCtxKey, linkCtx);
719+
720+
// Act.
721+
const { container } = render(Link, {
722+
props: {
723+
hash: ru.hash,
724+
href: "/test",
725+
},
726+
context,
727+
});
728+
729+
// Assert.
730+
const anchor = container.querySelector('a');
731+
expect(anchor?.getAttribute('href')).toEqual(calculateHref({ hash: ru.hash }, '/base/test'));
732+
});
733+
734+
test("Should preserve the query string when link context demands it.", () => {
735+
// Arrange.
736+
const { router, context } = setup;
737+
const queryString = '?a=1&b=2';
738+
browserMocks.setUrl(`http://example.com/${queryString}`);
739+
const linkCtx: ILinkContext = {
740+
preserveQuery: true,
741+
};
742+
context.set(linkCtxKey, linkCtx);
743+
744+
// Act.
745+
const { container } = render(Link, {
746+
props: {
747+
hash: ru.hash,
748+
href: "/test",
749+
},
750+
context,
751+
});
752+
753+
// Assert.
754+
const anchor = container.querySelector('a');
755+
expect(anchor?.getAttribute('href')).toContain(queryString);
756+
});
757+
758+
test("Should apply activeState from link context when link context demands it.", () => {
759+
// Arrange.
760+
const { router, context } = setup;
761+
const linkCtx: ILinkContext = {
762+
activeState: {
763+
class: 'context-active',
764+
aria: { 'aria-current': 'page' }
765+
}
766+
};
767+
const activeFor = 'test-route';
768+
addMatchingRoute(router, { name: activeFor });
769+
context.set(linkCtxKey, linkCtx);
770+
771+
// Act.
772+
const { container } = render(Link, {
773+
props: {
774+
hash: ru.hash,
775+
href: "/test",
776+
activeFor,
777+
},
778+
context,
779+
});
780+
781+
// Assert.
782+
const anchor = container.querySelector('a');
783+
expect(anchor?.className).toContain('context-active');
784+
expect(anchor?.getAttribute('aria-current')).toBe('page');
785+
});
786+
787+
test.each<{
788+
replace: boolean;
789+
fnName: 'pushState' | 'replaceState';
790+
}>([
791+
{ replace: false, fnName: 'pushState' },
792+
{ replace: true, fnName: 'replaceState' }
793+
])("Should call $fnName when link context demands it.", async ({ replace, fnName }) => {
794+
// Arrange.
795+
const { router, context } = setup;
796+
const linkCtx: ILinkContext = {
797+
replace,
798+
};
799+
context.set(linkCtxKey, linkCtx);
800+
const { container } = render(Link, {
801+
props: {
802+
hash: ru.hash,
803+
href: "/test",
804+
},
805+
context,
806+
});
807+
const anchor = container.querySelector('a');
808+
809+
// Act.
810+
await fireEvent.click(anchor!);
811+
812+
expect(browserMocks.history[fnName]).toHaveBeenCalledOnce();
813+
});
814+
}
815+
685816
describe("Routing Mode Assertions", () => {
686817
const linkText = "Test Link";
687818
const content = createTestSnippet(linkText);
@@ -736,7 +867,6 @@ describe("Routing Mode Assertions", () => {
736867
});
737868
});
738869

739-
740870
ROUTING_UNIVERSES.forEach(ru => {
741871
describe(`Link - ${ru.text}`, () => {
742872
const setup = createRouterTestSetup(ru.hash);
@@ -774,3 +904,19 @@ ROUTING_UNIVERSES.forEach(ru => {
774904
});
775905
});
776906
});
907+
908+
ROUTING_UNIVERSES.forEach(ru => {
909+
describe(`Link Context - ${ru.text}`, () => {
910+
let cleanup: () => void;
911+
beforeAll(() => {
912+
cleanup = init({
913+
implicitMode: ru.implicitMode,
914+
hashMode: ru.hashMode,
915+
});
916+
});
917+
afterAll(() => {
918+
cleanup?.();
919+
});
920+
linkContextTests(ru);
921+
});
922+
});

src/lib/LinkContext/LinkContext.svelte

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts" module>
2-
import type { PreserveQuery } from '$lib/types.js';
2+
import type { ActiveState, PreserveQuery } from '$lib/types.js';
33
import { getContext, setContext, type Snippet } from 'svelte';
44
55
export type ILinkContext = {
@@ -33,25 +33,30 @@
3333
* Set to a string or an array of strings to preserve only the specified query string values.
3434
*/
3535
preserveQuery?: PreserveQuery;
36+
/**
37+
* Sets the various options that are used to automatically style the anchor tag whenever a particular route
38+
* becomes active.
39+
*
40+
* **IMPORTANT**: This only works if the component is within a `Router` component.
41+
*/
42+
activeState?: ActiveState;
3643
};
3744
3845
class _LinkContext implements ILinkContext {
3946
replace;
4047
prependBasePath;
4148
preserveQuery;
49+
activeState;
4250
43-
constructor(
44-
replace: boolean,
45-
prependBasePath: boolean,
46-
preserveQuery: PreserveQuery
47-
) {
51+
constructor(replace: boolean | undefined, prependBasePath: boolean | undefined, preserveQuery: PreserveQuery | undefined, activeState: ActiveState | undefined) {
4852
this.replace = $state(replace);
4953
this.prependBasePath = $state(prependBasePath);
5054
this.preserveQuery = $state(preserveQuery);
55+
this.activeState = $state(activeState);
5156
}
5257
}
5358
54-
const linkCtxKey = Symbol();
59+
export const linkCtxKey = Symbol();
5560
5661
export function getLinkContext() {
5762
return getContext<ILinkContext | undefined>(linkCtxKey);
@@ -67,25 +72,28 @@
6772
};
6873
6974
let {
70-
replace = false,
71-
prependBasePath = false,
72-
preserveQuery = false,
73-
children,
75+
replace,
76+
prependBasePath,
77+
preserveQuery,
78+
activeState,
79+
children
7480
}: Props = $props();
7581
7682
const parentContext = getLinkContext();
7783
const context = new _LinkContext(
78-
parentContext?.replace ?? replace,
79-
parentContext?.prependBasePath ?? prependBasePath,
80-
parentContext?.preserveQuery ?? preserveQuery
84+
replace ?? parentContext?.replace,
85+
prependBasePath ?? parentContext?.prependBasePath,
86+
preserveQuery ?? parentContext?.preserveQuery,
87+
activeState ?? parentContext?.activeState
8188
);
8289
8390
setContext(linkCtxKey, context);
8491
8592
$effect.pre(() => {
86-
context.prependBasePath = prependBasePath;
87-
context.replace = replace;
88-
context.preserveQuery = preserveQuery;
93+
context.prependBasePath = prependBasePath ?? parentContext?.prependBasePath;
94+
context.replace = replace ?? parentContext?.replace;
95+
context.preserveQuery = preserveQuery ?? parentContext?.preserveQuery;
96+
context.activeState = activeState ?? parentContext?.activeState;
8997
});
9098
</script>
9199

0 commit comments

Comments
 (0)