Skip to content

Commit fa9a39a

Browse files
committed
feat(attachment): rework link action and add link attachment
1 parent c31017b commit fa9a39a

File tree

13 files changed

+563
-356
lines changed

13 files changed

+563
-356
lines changed

src/lib/action/active.action.svelte.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
import type { Action } from 'svelte/action';
1+
import type { ActionReturn } from 'svelte/action';
22

33
import type { ActiveOptions } from '~/models/action.model.js';
44
import type { RouteName } from '~/models/route.model.js';
55

6-
import { activeStyles, doNameMatch, doPathMatch, ensurePathName, ensureRouter, getOriginalStyle, restoreStyles } from '~/models/action.model.js';
6+
import {
7+
activeStyles,
8+
doNameMatch,
9+
doPathMatch,
10+
ensureActionRouter,
11+
ensurePathName,
12+
getOriginalStyle,
13+
restoreStyles,
14+
} from '~/models/action.model.js';
715
import { Matcher } from '~/models/index.js';
816
import { getRouter } from '~/router/context.svelte.js';
917

@@ -16,7 +24,7 @@ import { getRouter } from '~/router/context.svelte.js';
1624
* - Name always takes precedence over path.
1725
* - When the route un-matches, the original style will be restored.
1826
*
19-
* Note: The action requires the router context to be present in the component tree.
27+
* Note: The action requires a router instance or the router context to be present in the component tree.
2028
*
2129
* @param node - The element to add the active state to
2230
* @param options - The options to use for the active state
@@ -27,13 +35,13 @@ import { getRouter } from '~/router/context.svelte.js';
2735
* ```html
2836
* <a href="/path" use:active>simple link</a>
2937
* <a href="/path" data-name="route-name" use:active>named link</a>
30-
* <button :use:active="{ path: '/path' }">button link</button>
31-
* <div :use:active="{ name: 'route-name' }">div link</div>
38+
* <button use:active="{ path: '/path' }">button link</button>
39+
* <div use:active="{ name: 'route-name' }">div link</div>
3240
* ```
3341
*/
34-
export const active: Action<HTMLElement, ActiveOptions | undefined> = (node: HTMLElement, options: ActiveOptions | undefined = {}) => {
42+
export function active(node: HTMLElement, options: ActiveOptions | undefined = {}): ActionReturn<ActiveOptions | undefined> {
3543
const router = options?.router || getRouter();
36-
if (!ensureRouter(node, router)) return {};
44+
if (!ensureActionRouter(node, router)) return {};
3745

3846
let _options = $state(options);
3947
let _path: string | null = $state(options?.path || node.getAttribute('data-path') || node.getAttribute('href'));
@@ -67,4 +75,4 @@ export const active: Action<HTMLElement, ActiveOptions | undefined> = (node: HTM
6775
ensurePathName(node, { path: _path, name: _name });
6876
});
6977
return { update };
70-
};
78+
}

src/lib/action/link.action.svelte.ts

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import type { Action } from 'svelte/action';
1+
import type { ActionReturn } from 'svelte/action';
22

3-
import type { RouteName } from '~/models/index.js';
43
import type { LinkNavigateFunction, LinkNavigateOptions } from '~/models/link.model.js';
54

6-
import { getLinkNavigateFunction, getResolveFunction, normalizeLinkAttributes } from '~/models/link.model.js';
7-
import { Logger } from '~/utils/logger.utils.js';
8-
9-
export type LinkActionOptions<Name extends RouteName = RouteName, Path extends string = string> = LinkNavigateOptions<Name, Path>;
5+
import { ensureLinkRouter, getNavigateFunction, getResolveFunction, normalizeLinkAttributes } from '~/models/link.model.js';
6+
import { getRouter } from '~/router/context.svelte.js';
107

118
/**
129
* A svelte action to add to an element to navigate to a new location using the router.
@@ -26,7 +23,7 @@ export type LinkActionOptions<Name extends RouteName = RouteName, Path extends s
2623
* - Name takes precedence over path.
2724
* - If the host is not an anchor element, the role and tabindex attributes will be set.
2825
*
29-
* Note: The action requires the router context to be present in the component tree.
26+
* Note: The action requires a router instance or the router context to be present in the component tree.
3027
*
3128
* @param node - The element to add the link action to
3229
* @param options - The options to use for the navigation
@@ -41,38 +38,26 @@ export type LinkActionOptions<Name extends RouteName = RouteName, Path extends s
4138
* <button href='/path/:param' use:link="{ params: { param: 'value' } }">button link</button>
4239
* ```
4340
*/
44-
export const link: Action<HTMLElement, LinkActionOptions | undefined> = (node: HTMLElement, options: LinkActionOptions | undefined = {}) => {
45-
normalizeLinkAttributes(node, options);
46-
47-
let _options = $state(options);
48-
const update = (newOptions: LinkNavigateOptions | undefined = {}) => {
49-
_options = newOptions;
50-
};
41+
export function link(node: HTMLElement, options: LinkNavigateOptions | undefined = {}): ActionReturn<LinkNavigateOptions | undefined> {
42+
const router = options?.router || getRouter();
43+
if (!ensureLinkRouter(node, router)) return {};
5144

52-
const navigate = $derived.by<LinkNavigateFunction | undefined>(() => {
53-
try {
54-
const fn = getLinkNavigateFunction(_options);
55-
node.removeAttribute('data-error');
56-
return fn;
57-
} catch (error) {
58-
Logger.warn('Router not found. Make sure you are using the link(s) action within a Router context.', { node, options, error });
59-
node.setAttribute('data-error', 'Router not found.');
60-
}
61-
});
45+
let _options = $state(normalizeLinkAttributes(node, options));
6246

47+
const navigate = $derived<LinkNavigateFunction | undefined>(getNavigateFunction(router, options));
6348
const navigateHandler = async (event: MouseEvent | KeyboardEvent) => navigate?.(event, node);
6449

65-
// Add resolve on hover option && view params
6650
const resolve = $derived(getResolveFunction(navigate, _options));
67-
6851
const resolveHandler = async (event: FocusEvent | PointerEvent) => resolve(event, node);
6952

7053
node.addEventListener('click', navigateHandler);
7154
node.addEventListener('keydown', navigateHandler);
7255
node.addEventListener('pointerenter', resolveHandler);
7356
node.addEventListener('focus', resolveHandler);
7457
return {
75-
update,
58+
update(newOptions: LinkNavigateOptions | undefined = {}) {
59+
_options = newOptions;
60+
},
7661
destroy() {
7762
node.removeEventListener('click', navigateHandler);
7863
node.removeEventListener('keydown', navigateHandler);

src/lib/action/links.action.svelte.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type { Action } from 'svelte/action';
33
import type { RouteName } from '~/models/index.js';
44
import type { LinkNavigateFunction, LinkNavigateOptions } from '~/models/link.model.js';
55

6-
import { getLinkNavigateFunction, getResolveFunction, parseBooleanAttribute } from '~/models/link.model.js';
7-
import { Logger } from '~/utils/logger.utils.js';
6+
import { ensureLinkRouter, getNavigateFunction, getResolveFunction, parseBooleanAttribute } from '~/models/link.model.js';
7+
import { getRouter } from '~/router/context.svelte.js';
88

99
export type NodeConditionFn = (node: HTMLElement) => boolean;
1010
export interface LinksActionOptions<Name extends RouteName = RouteName, Path extends string = string> {
@@ -89,21 +89,12 @@ function findLinkNode(node: HTMLElement, { apply, boundary, host }: InternalLink
8989
* ```
9090
*/
9191
export const links: Action<HTMLElement, LinksActionOptions | undefined> = (node: HTMLElement, options: LinksActionOptions | undefined = {}) => {
92+
const router = options?.navigate?.router || getRouter();
93+
if (!ensureLinkRouter(node, router)) return {};
94+
9295
let _options: InternalLinksActionOptions = $state({ ...options, host: node });
93-
const update = (newOptions: LinksActionOptions | undefined = {}) => {
94-
_options = { ...newOptions, host: node };
95-
};
9696

97-
const navigate = $derived.by<LinkNavigateFunction | undefined>(() => {
98-
try {
99-
const fn = getLinkNavigateFunction(_options.navigate);
100-
node.removeAttribute('data-error');
101-
return fn;
102-
} catch (error) {
103-
Logger.warn('Router not found. Make sure you are using the link(s) action within a Router context.', { node, options, error });
104-
node.setAttribute('data-error', 'Router not found.');
105-
}
106-
});
97+
const navigate = $derived<LinkNavigateFunction | undefined>(getNavigateFunction(router, _options.navigate));
10798

10899
const navigateHandler = async (event: MouseEvent | KeyboardEvent) => {
109100
const { target } = event;
@@ -133,7 +124,9 @@ export const links: Action<HTMLElement, LinksActionOptions | undefined> = (node:
133124
node.addEventListener('pointerover', resolveHandler);
134125
node.addEventListener('focusin', resolveHandler);
135126
return {
136-
update,
127+
update(newOptions: LinksActionOptions | undefined = {}) {
128+
_options = { ...newOptions, host: node };
129+
},
137130
destroy() {
138131
node.removeEventListener('click', navigateHandler);
139132
node.removeEventListener('keydown', navigateHandler);

src/lib/attachment/active.attachment.svelte.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,46 @@ import type { Attachment } from 'svelte/attachments';
33
import type { ActiveOptions } from '~/models/action.model.js';
44
import type { RouteName } from '~/models/route.model.js';
55

6-
import { activeStyles, doNameMatch, doPathMatch, ensurePathName, ensureRouter, getOriginalStyle, restoreStyles } from '~/models/action.model.js';
6+
import {
7+
activeStyles,
8+
doNameMatch,
9+
doPathMatch,
10+
ensureActionRouter,
11+
ensurePathName,
12+
getOriginalStyle,
13+
restoreStyles,
14+
} from '~/models/action.model.js';
715
import { Matcher } from '~/models/index.js';
816
import { getRouter } from '~/router/context.svelte.js';
917

10-
export function useActive(options: ActiveOptions): Attachment {
18+
/**
19+
* A svelte attachment to add an active state (class, style or attribute) to an element when the route matches.
20+
*
21+
* Additionally:
22+
* - If attached to an anchor element, it will attempt to match the href attribute.
23+
* - If path or name options are provided, they will take precedence over the element attributes.
24+
* - Name always takes precedence over path.
25+
* - When the route un-matches, the original style will be restored.
26+
*
27+
* Note: The attachment requires a router instance or the router context to be present in the component tree.
28+
*
29+
* @param options - The options to use for the active state
30+
*
31+
* @see {@link RouterView}
32+
*
33+
* @example
34+
* ```html
35+
* <a href="/path" {@attach useActive()}>simple link</a>
36+
* <a href="/path" data-name="route-name" {@attach useActive()}>named link</a>
37+
* <button {@attach useActive({ path: '/path' })}>button link</button>
38+
* <div {@attach useActive({ name: 'route-name' })}>div link</div>
39+
* ```
40+
*/
41+
export function useActive(options: ActiveOptions = {}): Attachment<HTMLElement> {
1142
const router = options?.router || getRouter();
1243

1344
return (element) => {
14-
if (!ensureRouter(element, router)) return;
45+
if (!ensureActionRouter(element, router)) return;
1546

1647
const _options = $derived(options);
1748
const _path: string | null = $derived(options?.path || element.getAttribute('data-path') || element.getAttribute('href'));

src/lib/attachment/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './active.attachment.svelte.js';
2+
export * from './link.attachment.svelte.js';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Attachment } from 'svelte/attachments';
2+
3+
import type { LinkNavigateFunction, LinkNavigateOptions } from '~/models/link.model.js';
4+
5+
import { ensureLinkRouter, getNavigateFunction, getResolveFunction, normalizeLinkAttributes } from '~/models/link.model.js';
6+
import { getRouter } from '~/router/context.svelte.js';
7+
8+
/**
9+
* A svelte attachment to add to an element to navigate to a new location using the router.
10+
*
11+
* The link attachment will prevent the default behavior and use the router only if the following conditions are met:
12+
* - The element is within a router context
13+
* - The event is a left click or enter key
14+
* - The event does not have a modifier key
15+
* - The target is not an external link (for anchor elements)
16+
* - The target is not a new tab or window (for anchor elements)
17+
*
18+
* Additionally:
19+
* - The attachment merge data-attributes with the options passed as argument.
20+
* - Passed options have precedence over data-attributes.
21+
* - If attribute expects a JSON object, it will be parsed.
22+
* - If a name or path parameter are provided, they will be used to navigate and href will be ignored.
23+
* - Name takes precedence over path.
24+
* - If the host is not an anchor element, the role and tabindex attributes will be set.
25+
*
26+
* Note: The attachment requires a router instance or the router context to be present in the component tree.
27+
*
28+
* @param options - The options to use for the navigation
29+
*
30+
* @Example
31+
* ```html
32+
* <a href="/path/:param?query=value" {@attach useLink()}>simple link</a>
33+
* <a href='goodbye' name {@attach useLink()}>named link</a>
34+
* <a href='/path/:param' data-query='{"query":"value"}' {@attach useLink()}>link with query</a>
35+
* <a href='/path/:param' {@attach useLink({ params: { param: 'value' } })}>link with params</a>
36+
* <div href='/path/:param' {@attach useLink({ params: { param: 'value' } })}>div link</div>
37+
* <button href='/path/:param' {@attach useLink({ params: { param: 'value' } })}>button link</button>
38+
* ```
39+
*/
40+
export function useLink(options: LinkNavigateOptions = {}): Attachment<HTMLElement> {
41+
return (element) => {
42+
const _options = $state(normalizeLinkAttributes(element, options));
43+
44+
const router = _options?.router || getRouter();
45+
if (!ensureLinkRouter(element, router)) return;
46+
47+
const navigate = $derived<LinkNavigateFunction | undefined>(getNavigateFunction(router, options));
48+
const navigateHandler = async (event: MouseEvent | KeyboardEvent) => navigate?.(event, element);
49+
50+
const resolve = $derived(getResolveFunction(navigate, _options));
51+
const resolveHandler = async (event: FocusEvent | PointerEvent) => resolve(event, element);
52+
53+
element.addEventListener('click', navigateHandler);
54+
element.addEventListener('keydown', navigateHandler);
55+
element.addEventListener('pointerenter', resolveHandler);
56+
element.addEventListener('focus', resolveHandler);
57+
return () => {
58+
element.removeEventListener('click', navigateHandler);
59+
element.removeEventListener('keydown', navigateHandler);
60+
element.removeEventListener('pointerenter', resolveHandler);
61+
element.removeEventListener('focus', resolveHandler);
62+
};
63+
};
64+
}

src/lib/models/action.model.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export interface ActiveOptions<Name extends RouteName = RouteName> {
4545
caseSensitive?: boolean;
4646
}
4747

48-
export function ensureRouter(element: Element, router?: IRouter): router is IRouter {
48+
export function ensureActionRouter(element: Element, router?: IRouter): router is IRouter {
4949
if (router) return true;
5050
Logger.warn('Router not found. Make sure you are using the active action within a Router context.', { element });
5151
element.setAttribute('data-error', 'Router not found.');
@@ -87,21 +87,18 @@ export function doPathMatch(matcher?: Matcher, name?: RouteName | null, location
8787

8888
type Styles = CSSStyleDeclaration[keyof CSSStyleDeclaration];
8989

90-
export function getOriginalStyle(element: Element, style: Partial<CSSStyleDeclaration> = {}): Record<string, Styles> | undefined {
91-
if (!(element instanceof HTMLElement)) return;
90+
export function getOriginalStyle(element: HTMLElement, style: Partial<CSSStyleDeclaration> = {}): Record<string, Styles> | undefined {
9291
return Object.fromEntries(Object.keys(style).map(key => [key, element.style[key as keyof CSSStyleDeclaration]]));
9392
}
9493

95-
export function activeStyles(element: Element, options?: ActiveOptions) {
96-
if (!(element instanceof HTMLElement)) return;
94+
export function activeStyles(element: HTMLElement, options?: ActiveOptions) {
9795
element.setAttribute('data-active', 'true');
9896
if (options?.class) element.classList.add(options.class);
9997
if (!options?.style) return;
10098
Object.assign(element.style, options.style);
10199
}
102100

103-
export function restoreStyles(element: Element, original?: Record<string, Styles>, options?: ActiveOptions) {
104-
if (!(element instanceof HTMLElement)) return;
101+
export function restoreStyles(element: HTMLElement, original?: Record<string, Styles>, options?: ActiveOptions) {
105102
element.removeAttribute('data-active');
106103
if (options?.class) element.classList.remove(options.class);
107104
if (!options?.style) return;

src/lib/models/link.model.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import type { IRouter, ResolvedRouterLocationSnapshot, RouterNavigationOptions }
33

44
import { resolveComponent } from '@dvcol/svelte-utils/component';
55

6-
import { MissingRouterContextError, NavigationCancelledError } from '~/models/index.js';
7-
import { getRouter } from '~/router/context.svelte.js';
6+
import { NavigationCancelledError } from '~/models/index.js';
87
import { Logger, LoggerKey } from '~/utils/logger.utils.js';
98

109
function isAnchorTarget(target: EventTarget | null): target is HTMLAnchorElement {
@@ -74,14 +73,12 @@ export type LinkNavigateFunction = <Action extends 'replace' | 'push' | 'resolve
7473

7574
/**
7675
* Get the router link navigation function for the given options.
77-
* @param options
76+
* @param router - The router instance to use for matching.
77+
* @param options - The options to use for the navigation.
7878
*
7979
* @throws {MissingRouterContextError} - If the router context is not found
8080
*/
81-
export function getLinkNavigateFunction(options: LinkNavigateOptions = {}): LinkNavigateFunction {
82-
const router = options?.router || getRouter();
83-
if (!router) throw new MissingRouterContextError();
84-
81+
export function getNavigateFunction(router: IRouter, options: LinkNavigateOptions = {}): LinkNavigateFunction {
8582
return async (event, node, action) => {
8683
// if the element is disabled, we return
8784
if (options?.disabled) return;
@@ -137,6 +134,16 @@ export function getLinkNavigateFunction(options: LinkNavigateOptions = {}): Link
137134
};
138135
}
139136

137+
export function ensureLinkRouter(element: Element, router?: IRouter): router is IRouter {
138+
if (router) {
139+
element.removeAttribute('data-error');
140+
return true;
141+
}
142+
Logger.warn('Router not found. Make sure you are using the link(s) action within a Router context.', { element });
143+
element.setAttribute('data-error', 'Router not found.');
144+
return false;
145+
}
146+
140147
/**
141148
* Normalize the link attributes and options.
142149
* If the host is not an anchor element, the role and tabindex attributes will be set.
@@ -145,14 +152,14 @@ export function getLinkNavigateFunction(options: LinkNavigateOptions = {}): Link
145152
* @param node
146153
* @param options
147154
*/
148-
export function normalizeLinkAttributes(node: HTMLElement, options: LinkNavigateOptions) {
155+
export function normalizeLinkAttributes(node: Element, options: LinkNavigateOptions) {
149156
if (!isAnchorTarget(node)) {
150157
if (!node.hasAttribute('role')) node.setAttribute('role', 'link');
151158
if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '0');
152159
} else if (!node.hasAttribute('href') && options?.path) {
153160
node.setAttribute('href', options.path);
154161
}
155-
return { node, options };
162+
return options;
156163
}
157164

158165
export interface LinkNavigateOptions<Name extends RouteName = RouteName, Path extends string = string> extends CommonRouteNavigation<Path>,

test/action/active/active.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ describe('active', () => {
7070
spyRouter.mockReturnValueOnce(undefined);
7171

7272
if (title === 'action') {
73-
active(mockNode, {});
73+
active(mockNode);
7474
} else {
75-
useActive({})(mockNode);
75+
useActive()(mockNode);
7676
}
7777

7878
expect(spyRouter).toHaveBeenCalledWith();
File renamed without changes.

0 commit comments

Comments
 (0)