Skip to content

Commit 4030f85

Browse files
committed
feat(attachment): adds links
1 parent 5d5b521 commit 4030f85

File tree

9 files changed

+381
-201
lines changed

9 files changed

+381
-201
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,25 @@ Note: The action requires the router context to be present in the component tree
316316
<button href='/path/:param' use:link="{ params: { param: 'value' } }">button link</button>
317317
```
318318

319+
#### Link attachment
320+
321+
The `link attachment` intercepts click events on dom elements and triggers a router navigation instead.
322+
323+
Similar to the `link action`, see the [link action](#link-action) for more information.
324+
325+
```svelte
326+
<script lang="ts">
327+
import { useLink } from '@dvcol/svelte-simple-router/attachment';
328+
</script>
329+
330+
<a href="/path/:param?query=value" {@attach useLink()}>simple link</a>
331+
<a href='goodbye' name {@attach useLink()}>named link</a>
332+
<a href='/path/:param' data-query='{"query":"value"}' {@attach useLink()}>link with query</a>
333+
<a href='/path/:param' {@attach useLink({ params: { param: 'value' } })}>link with params</a>
334+
<div href='/path/:param' {@attach useLink({ params: { param: 'value' } })}>div link</div>
335+
<button href='/path/:param' {@attach useLink({ params: { param: 'value' } })}>button link</button>
336+
```
337+
319338
#### Links action
320339

321340
The `links action` intercepts click events on dom elements and upwardly navigate the dom tree until it reaches a link element and triggers a router navigation instead.
@@ -351,6 +370,27 @@ Note: Unlike use:link, use:links does not normalize link attributes (role, tabin
351370
</div>
352371
```
353372

373+
#### Links attachment
374+
375+
The `links attachment` intercepts click events on dom elements and upwardly navigate the dom tree until it reaches a link element and triggers a router navigation instead.
376+
377+
Similar to the `links action`, see the [links action](#links-action) for more information.
378+
379+
```svelte
380+
<script lang="ts">
381+
import { useLinks } from '@dvcol/svelte-simple-router/attachment';
382+
</script>
383+
384+
<div {@attach useLinks()}>
385+
<div>
386+
<a href="/path/:param?query=value">simple link</a>
387+
</div>
388+
<div data-router-link data-name="Hello">
389+
<span>simple span</span>
390+
</div>
391+
</div>
392+
```
393+
354394
#### Active action
355395

356396
The `active action` adds an active state (class, style or attribute) to an element when the route matches.

demo/routers/PathSelector.svelte

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
44
import { NeoButton, NeoCard, NeoInput } from '@dvcol/neo-svelte';
55
6-
import { active } from '~/action/active.action.svelte.js';
7-
import { link } from '~/action/link.action.svelte.js';
8-
import { links } from '~/action/links.action.svelte.js';
6+
import { useActive } from '~/attachment/active.attachment.svelte.js';
7+
import { useLink } from '~/attachment/link.attachment.svelte.js';
8+
import { useLinks } from '~/attachment/links.attachment.svelte.js';
99
import { NavigationCancelledError } from '~/models/error.model.js';
1010
import { useNavigate, useRouter } from '~/router/hooks.svelte.js';
1111
@@ -77,7 +77,7 @@
7777
<NeoCard rounded>
7878
<div id="route-selector" class="container">
7979
<h4>Routes</h4>
80-
<table class="routes" use:links>
80+
<table class="routes" {@attach useLinks()}>
8181
<thead>
8282
<tr>
8383
<th>Name</th>
@@ -89,7 +89,7 @@
8989
</thead>
9090
<tbody>
9191
{#each routes as { name, path, redirect, meta } (name ?? path)}
92-
<tr use:link={{ name, path, ...navOptions }} use:active={{ name, path, class: 'active', exact: true }}>
92+
<tr {@attach useLink({ name, path, ...navOptions })} {@attach useActive({ name, path, class: 'active', exact: true })}>
9393
<td>{name}</td>
9494
<td>{path}</td>
9595
<td>{redirect?.name ?? redirect?.path ?? meta?.redirect ?? '-'}</td>

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

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

3-
import type { RouteName } from '~/models/index.js';
4-
import type { LinkNavigateFunction, LinkNavigateOptions } from '~/models/link.model.js';
3+
import type { LinkNavigateFunction } from '~/models/link.model.js';
4+
import type { InternalLinksActionOptions, LinksActionOptions } from '~/models/links.model.js';
55

6-
import { ensureLinkRouter, getNavigateFunction, getResolveFunction, parseBooleanAttribute } from '~/models/link.model.js';
6+
import { ensureLinkRouter, getNavigateFunction, getResolveFunction } from '~/models/link.model.js';
7+
import { getNavigateHandler, getResolveHandler } from '~/models/links.model.js';
78
import { getRouter } from '~/router/context.svelte.js';
89

9-
export type NodeConditionFn = (node: HTMLElement) => boolean;
10-
export interface LinksActionOptions<Name extends RouteName = RouteName, Path extends string = string> {
11-
/**
12-
* Whether the target node should be considered a link.
13-
*/
14-
apply?: NodeConditionFn;
15-
/**
16-
* The element to act as a boundary for the upward link search.
17-
*/
18-
boundary?: HTMLElement | NodeConditionFn;
19-
/**
20-
* The navigate options to use for the navigation.
21-
*/
22-
navigate?: LinkNavigateOptions<Name, Path>;
23-
}
24-
25-
function isLinkNode(node: HTMLElement, apply?: LinksActionOptions['apply']): boolean {
26-
if (node instanceof HTMLAnchorElement) return true;
27-
if (parseBooleanAttribute(node, 'router-link')) return true;
28-
return apply?.(node) ?? false;
29-
}
30-
31-
function isBoundaryNode(node: HTMLElement, boundary: HTMLElement | NodeConditionFn): boolean {
32-
if (typeof boundary === 'function') return boundary(node);
33-
return node === boundary;
34-
}
35-
36-
function isWithinBoundary(node: HTMLElement, boundary: HTMLElement | NodeConditionFn): boolean {
37-
if (isBoundaryNode(node, boundary)) return true;
38-
let _node: HTMLElement = node;
39-
while (_node?.parentElement) {
40-
_node = _node.parentElement;
41-
if (isBoundaryNode(_node, boundary)) return true;
42-
}
43-
return false;
44-
}
45-
46-
type InternalLinksActionOptions = LinksActionOptions & { host: HTMLElement };
47-
function findLinkNode(node: HTMLElement, { apply, boundary, host }: InternalLinksActionOptions): HTMLElement | undefined {
48-
if (boundary && !isWithinBoundary(node, boundary ?? host)) return;
49-
if (isLinkNode(node, apply)) return node;
50-
let link: HTMLElement = node;
51-
while (link?.parentElement) {
52-
link = link.parentElement;
53-
54-
if (isBoundaryNode(link, boundary ?? host)) return;
55-
if (isLinkNode(link, apply)) return link;
56-
}
57-
}
58-
5910
/**
6011
* The `links action` intercepts click events on dom elements and upwardly navigate the dom tree until it reaches a link element and triggers a router navigation instead.
6112
*
@@ -70,7 +21,7 @@ function findLinkNode(node: HTMLElement, { apply, boundary, host }: InternalLink
7021
* - The action requires either valid href or data-attributes to navigate.
7122
* - Once the action reaches the host element or the `boundary` element (or selector function), it will stop evaluating the dom tree.
7223
*
73-
* Note: The action requires the router context to be present in the component tree.
24+
* Note: The action requires a router instance or the router context to be present in the component tree.
7425
* Note: Unlike use:link, use:links does not normalize link attributes (role, tabindex, href).
7526
*
7627
* @param node
@@ -88,36 +39,17 @@ function findLinkNode(node: HTMLElement, { apply, boundary, host }: InternalLink
8839
* </div>
8940
* ```
9041
*/
91-
export const links: Action<HTMLElement, LinksActionOptions | undefined> = (node: HTMLElement, options: LinksActionOptions | undefined = {}) => {
42+
export function links(node: HTMLElement, options: LinksActionOptions | undefined = {}): ActionReturn<LinksActionOptions | undefined> {
9243
const router = options?.navigate?.router || getRouter();
9344
if (!ensureLinkRouter(node, router)) return {};
9445

95-
let _options: InternalLinksActionOptions = $state({ ...options, host: node });
46+
let _options: InternalLinksActionOptions = $derived({ ...options, host: node });
9647

9748
const navigate = $derived<LinkNavigateFunction | undefined>(getNavigateFunction(router, _options.navigate));
49+
const navigateHandler = getNavigateHandler(_options, navigate);
9850

99-
const navigateHandler = async (event: MouseEvent | KeyboardEvent) => {
100-
const { target } = event;
101-
if (!(target instanceof HTMLElement)) return;
102-
if (!navigate) return;
103-
104-
const nodeLink = findLinkNode(target, _options);
105-
if (!nodeLink) return;
106-
return navigate(event, nodeLink);
107-
};
108-
109-
// Add resolve on hover option && view params
11051
const resolve = $derived(getResolveFunction(navigate, _options.navigate));
111-
112-
const resolveHandler = async (event: FocusEvent | PointerEvent) => {
113-
const { target } = event;
114-
if (!(target instanceof HTMLElement)) return;
115-
if (!resolve) return;
116-
117-
const nodeLink = findLinkNode(target, _options);
118-
if (!nodeLink) return;
119-
return resolve(event, nodeLink);
120-
};
52+
const resolveHandler = getResolveHandler(_options, resolve);
12153

12254
node.addEventListener('click', navigateHandler);
12355
node.addEventListener('keydown', navigateHandler);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Attachment } from 'svelte/attachments';
2+
3+
import type { LinksActionOptions } from '~/models/links.model.js';
4+
5+
import { links } from '~/action/links.action.svelte.js';
6+
7+
/**
8+
* The `links attachment` intercepts click events on dom elements and upwardly navigate the dom tree until it reaches a link element and triggers a router navigation instead.
9+
*
10+
* The links attachment will recognize a parent node as a router link if it satisfies any of the following conditions:
11+
* - The element is an anchor element
12+
* - The element has a `data-router-link` attribute
13+
* - The element satisfies the `apply` selector function passed as argument
14+
*
15+
* When a node is recognized as a router link, the attachment will behave as the `link` attachment (all restrictions apply).
16+
*
17+
* Additionally:
18+
* - The attachment requires either valid href or data-attributes to navigate.
19+
* - Once the attachment reaches the host element or the `boundary` element (or selector function), it will stop evaluating the dom tree.
20+
*
21+
* Note: The attachment requires a router instance or the router context to be present in the component tree.
22+
* Note: Unlike use:link, use:links does not normalize link attributes (role, tabindex, href).
23+
*
24+
* @param options
25+
*
26+
* @Example
27+
* ```html
28+
* <div {@attach useLinks()}>
29+
* <div>
30+
* <a href="/path/:param?query=value">simple link</a>
31+
* </div>
32+
* <div data-router-link data-name="Hello">
33+
* <span>simple span</span>
34+
* </div>
35+
* </div>
36+
* ```
37+
*/
38+
export function useLinks(options: LinksActionOptions = {}): Attachment<HTMLElement> {
39+
return element => links(element, options).destroy;
40+
}

src/lib/models/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export type * from './action.model.js';
1+
export type { ActiveOptions } from './action.model.js';
2+
export type { LinkNavigateOptions } from './link.model.js';
3+
24
export * from './component.model.js';
35
export * from './error.model.js';
46
export * from './matcher.model.js';

src/lib/models/links.model.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { LinkNavigateFunction, LinkNavigateOptions, LinkResolveFunction } from '~/models/link.model.js';
2+
import type { RouteName } from '~/models/route.model.js';
3+
4+
import { parseBooleanAttribute } from '~/models/link.model.js';
5+
6+
export type NodeConditionFn = (node: HTMLElement) => boolean;
7+
export interface LinksActionOptions<Name extends RouteName = RouteName, Path extends string = string> {
8+
/**
9+
* Whether the target node should be considered a link.
10+
*/
11+
apply?: NodeConditionFn;
12+
/**
13+
* The element to act as a boundary for the upward link search.
14+
*/
15+
boundary?: HTMLElement | NodeConditionFn;
16+
/**
17+
* The navigate options to use for the navigation.
18+
*/
19+
navigate?: LinkNavigateOptions<Name, Path>;
20+
}
21+
22+
function isLinkNode(node: HTMLElement, apply?: LinksActionOptions['apply']): boolean {
23+
if (node instanceof HTMLAnchorElement) return true;
24+
if (parseBooleanAttribute(node, 'router-link')) return true;
25+
return apply?.(node) ?? false;
26+
}
27+
28+
function isBoundaryNode(node: HTMLElement, boundary: HTMLElement | NodeConditionFn): boolean {
29+
if (typeof boundary === 'function') return boundary(node);
30+
return node === boundary;
31+
}
32+
33+
function isWithinBoundary(node: HTMLElement, boundary: HTMLElement | NodeConditionFn): boolean {
34+
if (isBoundaryNode(node, boundary)) return true;
35+
let _node: HTMLElement = node;
36+
while (_node?.parentElement) {
37+
_node = _node.parentElement;
38+
if (isBoundaryNode(_node, boundary)) return true;
39+
}
40+
return false;
41+
}
42+
43+
export type InternalLinksActionOptions = LinksActionOptions & { host: HTMLElement };
44+
function findLinkNode(node: HTMLElement, { apply, boundary, host }: InternalLinksActionOptions): HTMLElement | undefined {
45+
if (boundary && !isWithinBoundary(node, boundary ?? host)) return;
46+
if (isLinkNode(node, apply)) return node;
47+
let link: HTMLElement = node;
48+
while (link?.parentElement) {
49+
link = link.parentElement;
50+
51+
if (isBoundaryNode(link, boundary ?? host)) return;
52+
if (isLinkNode(link, apply)) return link;
53+
}
54+
}
55+
56+
export function getNavigateHandler(options: InternalLinksActionOptions, navigate?: LinkNavigateFunction) {
57+
return async (event: MouseEvent | KeyboardEvent) => {
58+
const { target } = event;
59+
if (!(target instanceof HTMLElement)) return;
60+
if (!navigate) return;
61+
62+
const nodeLink = findLinkNode(target, options);
63+
if (!nodeLink) return;
64+
return navigate(event, nodeLink);
65+
};
66+
}
67+
68+
export function getResolveHandler(options: InternalLinksActionOptions, resolve?: LinkResolveFunction) {
69+
return async (event: FocusEvent | PointerEvent) => {
70+
const { target } = event;
71+
if (!(target instanceof HTMLElement)) return;
72+
if (!resolve) return;
73+
74+
const nodeLink = findLinkNode(target, options);
75+
if (!nodeLink) return;
76+
return resolve(event, nodeLink);
77+
};
78+
}
File renamed without changes.

0 commit comments

Comments
 (0)