Skip to content

Commit 8d67481

Browse files
committed
feat(action): refactor action to prep attachment
1 parent fd0f9ff commit 8d67481

File tree

16 files changed

+334
-333
lines changed

16 files changed

+334
-333
lines changed

demo/routers/PathSelector.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
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';
69
import { NavigationCancelledError } from '~/models/error.model.js';
7-
import { active } from '~/router/active.svelte.js';
810
import { useNavigate, useRouter } from '~/router/hooks.svelte.js';
9-
import { link } from '~/router/link.svelte.js';
10-
import { links } from '~/router/links.svelte.js';
1111
1212
const {
1313
resolve = false,

package.json

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
"types": "./dist/router/index.d.ts",
4747
"import": "./dist/router/index.js"
4848
},
49+
"./action": {
50+
"types": "./dist/action/index.d.ts",
51+
"import": "./dist/action/index.js"
52+
},
4953
"./components": {
5054
"types": "./dist/components/index.d.ts",
5155
"import": "./dist/components/index.js"
@@ -115,7 +119,7 @@
115119
"dependencies": {
116120
"@dvcol/common-utils": "^1.31.1",
117121
"@dvcol/svelte-utils": "^1.17.1",
118-
"svelte": "^5.27.0"
122+
"svelte": "^5.32.1"
119123
},
120124
"devDependencies": {
121125
"@commitlint/cli": "^19.8.0",
@@ -124,12 +128,12 @@
124128
"@dvcol/neo-svelte": "^1.1.1",
125129
"@dvcol/stylelint-plugin-presets": "^2.1.2",
126130
"@prettier/plugin-xml": "^3.4.1",
127-
"@sveltejs/adapter-auto": "^6.0.0",
128-
"@sveltejs/kit": "^2.20.7",
131+
"@sveltejs/adapter-auto": "^6.0.1",
132+
"@sveltejs/kit": "^2.21.1",
129133
"@sveltejs/package": "^2.3.11",
130134
"@sveltejs/vite-plugin-svelte": "^5.0.3",
131135
"@testing-library/jest-dom": "^6.6.3",
132-
"@testing-library/svelte": "^5.2.7",
136+
"@testing-library/svelte": "^5.2.8",
133137
"@testing-library/user-event": "^14.6.1",
134138
"@tsconfig/node22": "^22.0.1",
135139
"@tsconfig/svelte": "^5.0.4",
@@ -138,7 +142,7 @@
138142
"@vitest/coverage-v8": "^3.1.1",
139143
"eslint": "^9.24.0",
140144
"eslint-plugin-format": "^1.0.1",
141-
"eslint-plugin-svelte": "^3.5.1",
145+
"eslint-plugin-svelte": "^3.8.2",
142146
"extract-changelog-release": "^1.0.2",
143147
"husky": "^9.1.7",
144148
"jsdom": "^26.1.0",
@@ -148,12 +152,12 @@
148152
"postcss": "^8.5.3",
149153
"postcss-syntax": "^0.36.2",
150154
"prettier": "^3.5.3",
151-
"prettier-plugin-svelte": "^3.3.3",
155+
"prettier-plugin-svelte": "^3.4.0",
152156
"publint": "^0.3.12",
153157
"sass": "^1.86.3",
154158
"standard-version": "^9.5.0",
155159
"stylelint": "^16.18.0",
156-
"svelte-check": "^4.1.6",
160+
"svelte-check": "^4.2.1",
157161
"svelte-preprocess": "^6.0.3",
158162
"typescript": "^5.8.3",
159163
"vite": "^6.2.6",

pnpm-lock.yaml

Lines changed: 132 additions & 161 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/action/action.model.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Matcher } from '~/models/index.js';
2+
import type { ParsedRoute, RouteName } from '~/models/route.model.js';
3+
import type { IRouter } from '~/models/router.model.js';
4+
5+
import { Logger } from '~/utils/logger.utils.js';
6+
7+
export interface ActiveOptions<Name extends RouteName = RouteName> {
8+
/**
9+
* Route name to match against.
10+
* This takes precedence over the path option.
11+
*/
12+
name?: Name;
13+
/**
14+
* Route path to match against.
15+
* This is ignored if the name option is provided.
16+
*/
17+
path?: string;
18+
/**
19+
* Inline class to apply when the route is active
20+
*/
21+
class?: string;
22+
/**
23+
* Inline styles to apply when the route is active
24+
*/
25+
style?: Partial<CSSStyleDeclaration>;
26+
/**
27+
* Match the route path exactly
28+
*
29+
* @default false
30+
* @see {@link RouterOptions.strict}
31+
*/
32+
exact?: boolean;
33+
/**
34+
* Coerce the route name to lowercase before comparing.
35+
* Note: Symbols and numbers will be coerced to strings. Be sure to register your routes names accordingly.
36+
*
37+
* @default router if set, else false
38+
* @see {@link RouterOptions.caseSensitive}
39+
*/
40+
caseSensitive?: boolean;
41+
}
42+
43+
export function ensureRouter(element: Element, router?: IRouter): router is IRouter {
44+
if (router) return true;
45+
Logger.warn('Router not found. Make sure you are using the active action within a Router context.', { element });
46+
element.setAttribute('data-error', 'Router not found.');
47+
return false;
48+
}
49+
50+
export function ensurePathName(element: Element, params: { path?: string | null; name?: RouteName | null } = {}): params is { path: string; name: RouteName } {
51+
if (!params.path && !params.name) {
52+
Logger.warn('No path or name found. Make sure you are using the active action with the proper parameters.', { element, ...params });
53+
element.setAttribute('data-error', 'No path or name found.');
54+
return false;
55+
}
56+
element.removeAttribute('data-error');
57+
return true;
58+
}
59+
60+
// recursively extract route.parent.name
61+
function getParentName(route?: ParsedRoute, names: ParsedRoute['name'][] = []) {
62+
if (route?.name) names.push(route.name);
63+
if (!route?.parent) return names;
64+
return getParentName(route.parent, names);
65+
}
66+
67+
export function doNameMatch(route?: ParsedRoute, name?: RouteName | null, { exact, caseSensitive }: { exact?: boolean; caseSensitive?: boolean } = {}): boolean {
68+
if (!name) return false;
69+
if (!route?.name) return false;
70+
const names = exact ? [route.name] : getParentName(route);
71+
if (caseSensitive) return names.includes(name);
72+
return names.map(n => String(n)?.toLowerCase()).includes(String(name)?.toLowerCase());
73+
}
74+
75+
export function doPathMatch(matcher?: Matcher, name?: RouteName | null, location?: string, exact?: boolean): boolean {
76+
if (name) return false;
77+
if (!matcher) return false;
78+
if (!location) return false;
79+
if (exact) return matcher.match(location, true);
80+
return matcher.match(location);
81+
}
82+
83+
type Styles = CSSStyleDeclaration[keyof CSSStyleDeclaration];
84+
85+
export function getOriginalStyle(element: Element, style: Partial<CSSStyleDeclaration> = {}): Record<string, Styles> | undefined {
86+
if (!(element instanceof HTMLElement)) return;
87+
return Object.fromEntries(Object.keys(style).map(key => [key, element.style[key as keyof CSSStyleDeclaration]]));
88+
}
89+
90+
export function activeStyles(element: Element, options?: ActiveOptions) {
91+
if (!(element instanceof HTMLElement)) return;
92+
element.setAttribute('data-active', 'true');
93+
if (options?.class) element.classList.add(options.class);
94+
if (!options?.style) return;
95+
Object.assign(element.style, options.style);
96+
}
97+
98+
export function restoreStyles(element: Element, original?: Record<string, Styles>, options?: ActiveOptions) {
99+
if (!(element instanceof HTMLElement)) return;
100+
element.removeAttribute('data-active');
101+
if (options?.class) element.classList.remove(options.class);
102+
if (!options?.style) return;
103+
Object.keys(options.style).forEach(key => element.style.removeProperty(key));
104+
Object.assign(element.style, original);
105+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Action } from 'svelte/action';
2+
3+
import type { ActiveOptions } from '~/action/action.model.js';
4+
import type { RouteName } from '~/models/route.model.js';
5+
6+
import { activeStyles, doNameMatch, doPathMatch, ensurePathName, ensureRouter, getOriginalStyle, restoreStyles } from '~/action/action.model.js';
7+
import { Matcher } from '~/models/index.js';
8+
import { getRouter } from '~/router/context.svelte.js';
9+
10+
/**
11+
* A svelte action to add an active state (class, style or attribute) to an element when the route matches.
12+
*
13+
* Additionally:
14+
* - If attached to an anchor element, it will attempt to match the href attribute.
15+
* - If path or name options are provided, they will take precedence over the element attributes.
16+
* - Name always takes precedence over path.
17+
* - When the route un-matches, the original style will be restored.
18+
*
19+
* Note: The action requires the router context to be present in the component tree.
20+
*
21+
* @param node - The element to add the active state to
22+
* @param options - The options to use for the active state
23+
*
24+
* @see {@link RouterView}
25+
*
26+
* @example
27+
* ```html
28+
* <a href="/path" use:active>simple link</a>
29+
* <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>
32+
* ```
33+
*/
34+
export const active: Action<HTMLElement, ActiveOptions | undefined> = (node: HTMLElement, options: ActiveOptions | undefined = {}) => {
35+
const router = getRouter();
36+
if (!ensureRouter(node, router)) return {};
37+
38+
let _options = $state(options);
39+
let _path: string | null = $state(options?.path || node.getAttribute('data-path') || node.getAttribute('href'));
40+
let _name: RouteName | null = $state(options?.name || node.getAttribute('data-name'));
41+
42+
const update = (newOptions: ActiveOptions | undefined = {}) => {
43+
_options = newOptions;
44+
_path = newOptions?.path || node.getAttribute('data-path') || node.getAttribute('href');
45+
_name = newOptions?.name || node.getAttribute('data-name');
46+
47+
ensurePathName(node, { path: _path, name: _name });
48+
};
49+
50+
const caseSensitive = $derived(_options?.caseSensitive ?? router.options?.caseSensitive);
51+
const matchName = $derived(doNameMatch(router.route, _name, { caseSensitive, exact: _options?.exact }));
52+
53+
const location = $derived(router.location?.path);
54+
const matcher = $derived(_path ? new Matcher(_path) : undefined);
55+
const matchPath = $derived(doPathMatch(matcher, _name, location, _options?.exact));
56+
57+
const match = $derived(matchName || matchPath);
58+
59+
const originalStyle = $derived(getOriginalStyle(node, _options?.style));
60+
61+
$effect(() => {
62+
if (match) activeStyles(node, _options);
63+
else restoreStyles(node, originalStyle, _options);
64+
});
65+
66+
$effect(() => {
67+
ensurePathName(node, { path: _path, name: _name });
68+
});
69+
return { update };
70+
};

src/lib/action/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './active.action.svelte.js';
2+
export * from './link.action.svelte.js';
3+
export * from './links.action.svelte.js';

0 commit comments

Comments
 (0)