@excom/routable-element 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/.rush/temp/chunked-rush-logs/routable-element.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/routable-element.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/routable-element.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +6 -0
  15. package/index.ts +96 -0
  16. package/package.json +45 -0
  17. package/rush-logs/routable-element.apply-exports.cache.log +1 -0
  18. package/rush-logs/routable-element.apply-exports.log +1 -0
  19. package/rush-logs/routable-element.build_docs.cache.log +1 -0
  20. package/rush-logs/routable-element.build_docs.log +1 -0
  21. package/rush-logs/routable-element.build_package-metas.cache.log +1 -0
  22. package/rush-logs/routable-element.build_package-metas.log +1 -0
  23. package/support/custom-elements.json +100 -0
  24. package/support/dist-docs/routable-element.md +99 -0
  25. package/support/docs/README.md +69 -0
  26. package/support/package-meta.json +73 -0
  27. package/support/tests/routable-element-routing.test.ts +243 -0
  28. package/support/tests/routable-element.test.ts +42 -0
  29. package/tsconfig.json +5 -0
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 36.16796899999986
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 43.255107000000066
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 92.0411489999999
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "../../packages/routable-element": "../../packages/routable-element:OaWXFD2rsxm0UKuzafmWeu6jw1vWZUo3lv+Y44mly+A=:"
3
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
3
+ "rigPackageName": "@excom/heft-rig",
4
+ "rigProfile": "default"
5
+ }
6
+
package/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { KitRoute, kitRouter } from "@excom/kit-router";
2
+ import { Neutron } from "@excom/neutron";
3
+
4
+ /**
5
+ * Composition base that makes an element route-aware. Subclasses implement `routeChanged` to react when the URL matches. Used by `<spa-route>`, `<spa-a>`, and `<spa-manager>`.
6
+ *
7
+ * @summary URL matching for Neutron elements.
8
+ */
9
+ export const RoutableElement = Neutron({
10
+ tag: "noop-tag",
11
+ props: {
12
+ /**
13
+ * @option
14
+ * URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards. Set this or `route-regex`.
15
+ * @values <path pattern>
16
+ */
17
+ routeHref: String,
18
+ /**
19
+ * @option
20
+ * RegExp source string matched against the current URL — alternative to `route-href` for catch-alls / advanced patterns.
21
+ * @values <RegExp source>
22
+ */
23
+ routeRegex: String,
24
+ /**
25
+ * @option
26
+ * Also match nested paths of `route-href` (e.g. `/users` stays active on `/users/42`). Essential for layout routes and nested SPAs.
27
+ */
28
+ matchNested: Boolean,
29
+ routeInstance: KitRoute,
30
+ },
31
+ })
32
+ .defineMethods({
33
+ routeChanged: () => {
34
+ throw new Error("routeChanged must be implemented");
35
+ },
36
+ })
37
+ .onConnected(
38
+ ({
39
+ routeHref,
40
+ routeRegex,
41
+ routeChanged,
42
+ matchNested,
43
+ isMoving,
44
+ wasMounted,
45
+ }) => {
46
+ if (isMoving) return;
47
+ return (
48
+ wasMounted &&
49
+ (routeHref || routeRegex) && {
50
+ routeInstance: new KitRoute(
51
+ (routeHref || new RegExp(routeRegex!))!,
52
+ routeChanged,
53
+ {
54
+ matchNested,
55
+ }
56
+ ),
57
+ _usedRouteProp: "routeHref",
58
+ }
59
+ );
60
+ }
61
+ )
62
+ .onDisconnected(({ routeHref, isMoving }) => {
63
+ if (isMoving) return;
64
+ return (
65
+ routeHref && {
66
+ routeInstance: null,
67
+ }
68
+ );
69
+ })
70
+ .onPropChanged(
71
+ ["routeHref", "routeRegex"],
72
+ ({ routeHref, routeRegex, routeChanged, matchNested }, previous) => {
73
+ let routeInstance;
74
+ if (previous?.routeHref || previous?.routeRegex) {
75
+ // if previous routeInstance was set, remove it
76
+ routeInstance = null;
77
+ }
78
+ if (routeHref || routeRegex) {
79
+ // if new route, set a new routeInstance, which will also remove the previous one
80
+ routeInstance = new KitRoute(
81
+ (routeHref || new RegExp(routeRegex!))!,
82
+ routeChanged,
83
+ { matchNested }
84
+ );
85
+ }
86
+ return { routeInstance };
87
+ }
88
+ )
89
+ .onPropChanged("routeInstance", ({ routeInstance }, previous) => {
90
+ if (previous?.routeInstance) {
91
+ kitRouter.off(previous.routeInstance);
92
+ }
93
+ if (routeInstance) {
94
+ kitRouter.on(routeInstance);
95
+ }
96
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@excom/routable-element",
3
+ "version": "0.1.0",
4
+ "description": "RoutableElement base for Neutron elements",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=24.13.0"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "@excom/kit-router": "^0.1.0",
12
+ "@excom/neutron": "^0.1.0"
13
+ },
14
+ "peerDependencies": {},
15
+ "devDependencies": {
16
+ "@excom/heft-rig": "^0.1.0"
17
+ },
18
+ "repository": {
19
+ "url": "excom-dev/nucleus",
20
+ "directory": "packages/routable-element"
21
+ },
22
+ "homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/routable-element/support/docs/README.md",
23
+ "bugs": "https://github.com/excom-dev/nucleus/issues",
24
+ "keywords": [
25
+ "RoutableElement",
26
+ "routable-element",
27
+ "neutron",
28
+ "kit-element-base",
29
+ "custom-elements"
30
+ ],
31
+ "excom": {
32
+ "packageType": "element-base"
33
+ },
34
+ "scripts": {
35
+ "build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
36
+ "build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
37
+ "format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
38
+ "test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
39
+ "coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs",
40
+ "dev": "node node_modules/@excom/heft-rig/scripts/vite-dev.mjs",
41
+ "preview": "node node_modules/@excom/heft-rig/scripts/vite-preview.mjs",
42
+ "build:package-metas": "node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs",
43
+ "build:docs": "node node_modules/@excom/heft-rig/scripts/build-docs.mjs"
44
+ }
45
+ }
@@ -0,0 +1 @@
1
+ Caching has been disabled for this project's "apply-exports" command.
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:docs" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:package-metas" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
@@ -0,0 +1,100 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "modules": [
4
+ {
5
+ "kind": "javascript-module",
6
+ "path": "index.ts",
7
+ "declarations": [
8
+ {
9
+ "kind": "mixin",
10
+ "name": "RoutableElement",
11
+ "summary": "URL matching for Neutron elements.",
12
+ "description": "Composition base that makes an element route-aware. Subclasses implement `routeChanged` to react when the URL matches. Used by `<spa-route>`, `<spa-a>`, and `<spa-manager>`.",
13
+ "attributes": [
14
+ {
15
+ "name": "route-href",
16
+ "type": {
17
+ "text": "string"
18
+ },
19
+ "description": "URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards. Set this or `route-regex`.",
20
+ "fieldName": "routeHref",
21
+ "values": [
22
+ "<path pattern>"
23
+ ]
24
+ },
25
+ {
26
+ "name": "route-regex",
27
+ "type": {
28
+ "text": "string"
29
+ },
30
+ "description": "RegExp source string matched against the current URL — alternative to `route-href` for catch-alls / advanced patterns.",
31
+ "fieldName": "routeRegex",
32
+ "values": [
33
+ "<RegExp source>"
34
+ ]
35
+ },
36
+ {
37
+ "name": "match-nested",
38
+ "type": {
39
+ "text": "boolean"
40
+ },
41
+ "description": "Also match nested paths of `route-href` (e.g. `/users` stays active on `/users/42`). Essential for layout routes and nested SPAs.",
42
+ "fieldName": "matchNested"
43
+ }
44
+ ],
45
+ "members": [
46
+ {
47
+ "kind": "field",
48
+ "name": "routeHref",
49
+ "type": {
50
+ "text": "string"
51
+ },
52
+ "privacy": "public",
53
+ "readonly": false,
54
+ "description": "URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards. Set this or `route-regex`.",
55
+ "_neutron": {
56
+ "surface": "option"
57
+ }
58
+ },
59
+ {
60
+ "kind": "field",
61
+ "name": "routeRegex",
62
+ "type": {
63
+ "text": "string"
64
+ },
65
+ "privacy": "public",
66
+ "readonly": false,
67
+ "description": "RegExp source string matched against the current URL — alternative to `route-href` for catch-alls / advanced patterns.",
68
+ "_neutron": {
69
+ "surface": "option"
70
+ }
71
+ },
72
+ {
73
+ "kind": "field",
74
+ "name": "matchNested",
75
+ "type": {
76
+ "text": "boolean"
77
+ },
78
+ "privacy": "public",
79
+ "readonly": false,
80
+ "description": "Also match nested paths of `route-href` (e.g. `/users` stays active on `/users/42`). Essential for layout routes and nested SPAs.",
81
+ "_neutron": {
82
+ "surface": "option"
83
+ }
84
+ }
85
+ ]
86
+ }
87
+ ],
88
+ "exports": [
89
+ {
90
+ "kind": "js",
91
+ "name": "RoutableElement",
92
+ "declaration": {
93
+ "name": "RoutableElement",
94
+ "module": "index.ts"
95
+ }
96
+ }
97
+ ]
98
+ }
99
+ ]
100
+ }
@@ -0,0 +1,99 @@
1
+ # routable-element
2
+
3
+ URL matching for custom elements — give any Neutron class a
4
+ `route-href` and react when the address bar changes.
5
+
6
+ ## Features
7
+
8
+ - **Path patterns** Named params (`/users/:id`) and wildcards
9
+ - **Regex routes** Full control via `route-regex`
10
+ - **Nested match** Keep layouts active under child paths
11
+ - **Live updates** Rematch when `route-href` / `route-regex` change
12
+
13
+ ## Installation
14
+
15
+
16
+ `@excom/routable-element` v0.1.0
17
+
18
+ ```bash
19
+ pnpm add @excom/routable-element
20
+ ```
21
+
22
+ ```bash
23
+ npm install @excom/routable-element
24
+ ```
25
+
26
+ ```bash
27
+ yarn add @excom/routable-element
28
+ ```
29
+
30
+ ### Import
31
+
32
+ ```ts
33
+ import { /* … */ } from "@excom/routable-element";
34
+ ```
35
+
36
+
37
+
38
+ ## Usage
39
+
40
+ Compose `RoutableElement` and implement `routeChanged`. Concrete
41
+ consumers include `<spa-route>`, `<spa-a>`, and `<spa-manager>`.
42
+
43
+ ```ts
44
+ import { Neutron } from "@excom/neutron";
45
+ import { RoutableElement } from "@excom/routable-element";
46
+
47
+ export const PathAware = Neutron.compose([
48
+ RoutableElement,
49
+ Neutron({
50
+ tag: "path-aware",
51
+ props: {
52
+ isActive: Boolean,
53
+ },
54
+ }),
55
+ ])
56
+ .defineMethods({
57
+ routeChanged: (_el, { match }) => ({ isActive: !!match }),
58
+ });
59
+
60
+ PathAware.define();
61
+ ```
62
+
63
+ ```html
64
+ <path-aware route-href="/dashboard" match-nested></path-aware>
65
+ ```
66
+
67
+ ### API Reference
68
+
69
+
70
+ #### Attributes
71
+
72
+ | Name | Surface | Type | Default | Values | Description |
73
+ | --- | --- | --- | --- | --- | --- |
74
+ | `route-href` | option | `string` | | `<path pattern>` | URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards. Set this or `route-regex`. |
75
+ | `route-regex` | option | `string` | | `<RegExp source>` | RegExp source string matched against the current URL — alternative to `route-href` for catch-alls / advanced patterns. |
76
+ | `match-nested` | option | `boolean` | | | Also match nested paths of `route-href` (e.g. `/users` stays active on `/users/42`). Essential for layout routes and nested SPAs. |
77
+
78
+
79
+
80
+ ### Examples
81
+
82
+ #### Path match & nested layouts
83
+
84
+ `route-href` activates on exact match; `match-nested` keeps a layout
85
+ mounted under child paths.
86
+
87
+ ```html
88
+ <spa-route route-href="/users" match-nested>
89
+ <template>…layout…</template>
90
+ </spa-route>
91
+ ```
92
+
93
+ #### Catch-all via regex
94
+
95
+ ```html
96
+ <spa-route route-regex=".*" is-fallback>
97
+ <template>404</template>
98
+ </spa-route>
99
+ ```
@@ -0,0 +1,69 @@
1
+ # routable-element
2
+
3
+ URL matching for custom elements — give any Neutron class a
4
+ `route-href` and react when the address bar changes.
5
+
6
+ ## Features
7
+
8
+ - **Path patterns** Named params (`/users/:id`) and wildcards
9
+ - **Regex routes** Full control via `route-regex`
10
+ - **Nested match** Keep layouts active under child paths
11
+ - **Live updates** Rematch when `route-href` / `route-regex` change
12
+
13
+ ## Installation
14
+
15
+ <include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
16
+
17
+ ## Usage
18
+
19
+ Compose `RoutableElement` and implement `routeChanged`. Concrete
20
+ consumers include `<spa-route>`, `<spa-a>`, and `<spa-manager>`.
21
+
22
+ ```ts
23
+ import { Neutron } from "@excom/neutron";
24
+ import { RoutableElement } from "@excom/routable-element";
25
+
26
+ export const PathAware = Neutron.compose([
27
+ RoutableElement,
28
+ Neutron({
29
+ tag: "path-aware",
30
+ props: {
31
+ isActive: Boolean,
32
+ },
33
+ }),
34
+ ])
35
+ .defineMethods({
36
+ routeChanged: (_el, { match }) => ({ isActive: !!match }),
37
+ });
38
+
39
+ PathAware.define();
40
+ ```
41
+
42
+ ```html
43
+ <path-aware route-href="/dashboard" match-nested></path-aware>
44
+ ```
45
+
46
+ ### API Reference
47
+
48
+ <include-content is-active template-ref="/views/api-reference/api-reference.html"></include-content>
49
+
50
+ ### Examples
51
+
52
+ #### Path match & nested layouts
53
+
54
+ `route-href` activates on exact match; `match-nested` keeps a layout
55
+ mounted under child paths.
56
+
57
+ ```html
58
+ <spa-route route-href="/users" match-nested>
59
+ <template>…layout…</template>
60
+ </spa-route>
61
+ ```
62
+
63
+ #### Catch-all via regex
64
+
65
+ ```html
66
+ <spa-route route-regex=".*" is-fallback>
67
+ <template>404</template>
68
+ </spa-route>
69
+ ```
@@ -0,0 +1,73 @@
1
+ {
2
+ "shortName": "routable-element",
3
+ "package": {
4
+ "name": "@excom/routable-element",
5
+ "version": "0.1.0",
6
+ "description": "RoutableElement base for Neutron elements",
7
+ "peerDependencies": {},
8
+ "excom": {
9
+ "packageType": "element-base"
10
+ }
11
+ },
12
+ "demos": {},
13
+ "readme": "<h1 id=\"md-routable-element\">routable-element</h1>\n<p>URL matching for custom elements — give any Neutron class a\n<code>route-href</code> and react when the address bar changes.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Path patterns</strong> Named params (<code>/users/:id</code>) and wildcards</li>\n<li><strong>Regex routes</strong> Full control via <code>route-regex</code></li>\n<li><strong>Nested match</strong> Keep layouts active under child paths</li>\n<li><strong>Live updates</strong> Rematch when <code>route-href</code> / <code>route-regex</code> change</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Compose <code>RoutableElement</code> and implement <code>routeChanged</code>. Concrete\nconsumers include <code>&lt;spa-route&gt;</code>, <code>&lt;spa-a&gt;</code>, and <code>&lt;spa-manager&gt;</code>.</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\nimport { RoutableElement } from \"@excom/routable-element\";\n\nexport const PathAware = Neutron.compose([\n RoutableElement,\n Neutron({\n tag: \"path-aware\",\n props: {\n isActive: Boolean,\n },\n }),\n])\n .defineMethods({\n routeChanged: (_el, { match }) =&gt; ({ isActive: !!match }),\n });\n\nPathAware.define();</template></include-content>\n<include-content data-language=\"html\"><template>&lt;path-aware route-href=\"/dashboard\" match-nested&gt;&lt;/path-aware&gt;</template></include-content>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-path-match-amp-nested-layouts\">Path match &amp; nested layouts</h4>\n<p><code>route-href</code> activates on exact match; <code>match-nested</code> keeps a layout\nmounted under child paths.</p>\n<include-content data-language=\"html\"><template>&lt;spa-route route-href=\"/users\" match-nested&gt;\n &lt;template&gt;…layout…&lt;/template&gt;\n&lt;/spa-route&gt;</template></include-content>\n<h4 id=\"md-catch-all-via-regex\">Catch-all via regex</h4>\n<include-content data-language=\"html\"><template>&lt;spa-route route-regex=\".*\" is-fallback&gt;\n &lt;template&gt;404&lt;/template&gt;\n&lt;/spa-route&gt;</template></include-content>\n",
14
+ "docs": {
15
+ "readme": "<h1 id=\"md-routable-element\">routable-element</h1>\n<p>URL matching for custom elements — give any Neutron class a\n<code>route-href</code> and react when the address bar changes.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Path patterns</strong> Named params (<code>/users/:id</code>) and wildcards</li>\n<li><strong>Regex routes</strong> Full control via <code>route-regex</code></li>\n<li><strong>Nested match</strong> Keep layouts active under child paths</li>\n<li><strong>Live updates</strong> Rematch when <code>route-href</code> / <code>route-regex</code> change</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Compose <code>RoutableElement</code> and implement <code>routeChanged</code>. Concrete\nconsumers include <code>&lt;spa-route&gt;</code>, <code>&lt;spa-a&gt;</code>, and <code>&lt;spa-manager&gt;</code>.</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\nimport { RoutableElement } from \"@excom/routable-element\";\n\nexport const PathAware = Neutron.compose([\n RoutableElement,\n Neutron({\n tag: \"path-aware\",\n props: {\n isActive: Boolean,\n },\n }),\n])\n .defineMethods({\n routeChanged: (_el, { match }) =&gt; ({ isActive: !!match }),\n });\n\nPathAware.define();</template></include-content>\n<include-content data-language=\"html\"><template>&lt;path-aware route-href=\"/dashboard\" match-nested&gt;&lt;/path-aware&gt;</template></include-content>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-path-match-amp-nested-layouts\">Path match &amp; nested layouts</h4>\n<p><code>route-href</code> activates on exact match; <code>match-nested</code> keeps a layout\nmounted under child paths.</p>\n<include-content data-language=\"html\"><template>&lt;spa-route route-href=\"/users\" match-nested&gt;\n &lt;template&gt;…layout…&lt;/template&gt;\n&lt;/spa-route&gt;</template></include-content>\n<h4 id=\"md-catch-all-via-regex\">Catch-all via regex</h4>\n<include-content data-language=\"html\"><template>&lt;spa-route route-regex=\".*\" is-fallback&gt;\n &lt;template&gt;404&lt;/template&gt;\n&lt;/spa-route&gt;</template></include-content>\n"
16
+ },
17
+ "installation": {
18
+ "name": "@excom/routable-element",
19
+ "shortName": "routable-element",
20
+ "version": "0.1.0",
21
+ "description": "RoutableElement base for Neutron elements",
22
+ "packageType": "element-base",
23
+ "install": {
24
+ "npm": "npm install @excom/routable-element"
25
+ },
26
+ "imports": {
27
+ "js": "import { /* … */ } from \"@excom/routable-element\";"
28
+ },
29
+ "peerDependencies": []
30
+ },
31
+ "elementApis": [
32
+ {
33
+ "summary": "URL matching for Neutron elements.",
34
+ "kind": "mixin",
35
+ "attributes": [
36
+ {
37
+ "name": "match-nested",
38
+ "type": "boolean",
39
+ "description": "Also match nested paths of <code>route-href</code> (e.g. <code>/users</code> stays active on <code>/users/42</code>). Essential for layout routes and nested SPAs.",
40
+ "fieldName": "matchNested",
41
+ "surface": "option"
42
+ },
43
+ {
44
+ "name": "route-href",
45
+ "type": "string",
46
+ "description": "URL pattern to match. Supports named placeholders (<code>/users/:id</code>) and wildcards. Set this or <code>route-regex</code>.",
47
+ "fieldName": "routeHref",
48
+ "surface": "option",
49
+ "values": "<path pattern>"
50
+ },
51
+ {
52
+ "name": "route-regex",
53
+ "type": "string",
54
+ "description": "RegExp source string matched against the current URL — alternative to <code>route-href</code> for catch-alls / advanced patterns.",
55
+ "fieldName": "routeRegex",
56
+ "surface": "option",
57
+ "values": "<RegExp source>"
58
+ }
59
+ ],
60
+ "events": [],
61
+ "slots": [],
62
+ "cssProperties": [],
63
+ "cssClasses": [],
64
+ "cssAliases": [],
65
+ "listens": [],
66
+ "commands": [],
67
+ "defaultActions": [],
68
+ "expectedChildren": [],
69
+ "provisions": []
70
+ }
71
+ ],
72
+ "exportedFiles": {}
73
+ }
@@ -0,0 +1,243 @@
1
+ import { RoutableElement } from "../../index";
2
+ import { Neutron } from "@excom/neutron";
3
+ import { KitRoute, kitRouter } from "@excom/kit-router";
4
+ import {
5
+ afterEach,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ fixture,
10
+ it,
11
+ vi,
12
+ wait,
13
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
14
+
15
+ const TAG = "routable-routing-test";
16
+
17
+ // Records every `routeChanged` call so tests can assert on router payloads.
18
+ const routeChangedSpy = vi.fn();
19
+
20
+ const PathAware = Neutron.compose([
21
+ RoutableElement,
22
+ Neutron({
23
+ tag: TAG,
24
+ props: {
25
+ isActive: Boolean,
26
+ },
27
+ }),
28
+ ]).defineMethods({
29
+ routeChanged: (element, data) => {
30
+ routeChangedSpy(element, data);
31
+ return { isActive: !!data.match };
32
+ },
33
+ });
34
+
35
+ if (!customElements.get(TAG)) {
36
+ PathAware.define();
37
+ }
38
+
39
+ type TestElement = HTMLElement & {
40
+ routeHref: string | null;
41
+ routeRegex: string | null;
42
+ matchNested: boolean;
43
+ routeInstance: KitRoute | null;
44
+ isActive: boolean;
45
+ isMoving: boolean;
46
+ wasMounted: boolean;
47
+ };
48
+
49
+ describe("RoutableElement routing", () => {
50
+ let onSpy: ReturnType<typeof vi.spyOn>;
51
+ let offSpy: ReturnType<typeof vi.spyOn>;
52
+
53
+ beforeEach(() => {
54
+ history.replaceState(null, "", "/");
55
+ routeChangedSpy.mockClear();
56
+ onSpy = vi.spyOn(kitRouter, "on");
57
+ offSpy = vi.spyOn(kitRouter, "off");
58
+ });
59
+
60
+ afterEach(() => {
61
+ document.body.innerHTML = "";
62
+ vi.restoreAllMocks();
63
+ history.replaceState(null, "", "/");
64
+ });
65
+
66
+ it("registers a path route on connect and reacts to URL changes", async () => {
67
+ const el = fixture<TestElement>(
68
+ `<${TAG} route-href="/users/:id"></${TAG}>`
69
+ );
70
+ await wait(0);
71
+ expect(el.routeInstance).toBeInstanceOf(KitRoute);
72
+ expect(el.routeInstance!.key).toBe("/users/:id");
73
+ expect(onSpy).toHaveBeenCalledWith(el.routeInstance);
74
+ // registering immediately reports the current (non-matching) URL
75
+ expect(routeChangedSpy).toHaveBeenLastCalledWith(
76
+ el,
77
+ expect.objectContaining({ match: null, params: null })
78
+ );
79
+ expect(el.isActive).toBe(false);
80
+
81
+ kitRouter.pushState({ url: "/users/7" });
82
+ expect(routeChangedSpy).toHaveBeenLastCalledWith(
83
+ el,
84
+ expect.objectContaining({
85
+ params: { id: "7" },
86
+ match: expect.arrayContaining(["/users/7", "7"]),
87
+ move: "push",
88
+ })
89
+ );
90
+ expect(el.isActive).toBe(true);
91
+
92
+ kitRouter.pushState({ url: "/elsewhere" });
93
+ expect(el.isActive).toBe(false);
94
+ });
95
+
96
+ it("does not register a route when neither route-href nor route-regex is set", async () => {
97
+ const el = fixture<TestElement>(`<${TAG}></${TAG}>`);
98
+ await wait(0);
99
+ expect(el.routeInstance ?? null).toBeNull();
100
+ expect(onSpy).not.toHaveBeenCalled();
101
+ expect(routeChangedSpy).not.toHaveBeenCalled();
102
+ el.remove();
103
+ await wait(0);
104
+ expect(offSpy).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("registers a regex route", async () => {
108
+ const el = fixture<TestElement>(
109
+ `<${TAG} route-regex="^/docs(/.*)?$"></${TAG}>`
110
+ );
111
+ await wait(0);
112
+ expect(el.routeInstance!.key).toBeInstanceOf(RegExp);
113
+ expect(el.routeInstance!.regex.source).toBe(
114
+ new RegExp("^/docs(/.*)?$").source
115
+ );
116
+ expect(el.isActive).toBe(false);
117
+ kitRouter.pushState({ url: "/docs/intro" });
118
+ expect(el.isActive).toBe(true);
119
+ });
120
+
121
+ it("honours match-nested", async () => {
122
+ const nested = fixture<TestElement>(
123
+ `<${TAG} route-href="/docs" match-nested></${TAG}>`
124
+ );
125
+ const exact = fixture<TestElement>(`<${TAG} route-href="/docs"></${TAG}>`);
126
+ await wait(0);
127
+ expect(nested.routeInstance!.opts).toEqual({ matchNested: true });
128
+ expect(exact.routeInstance!.opts).toEqual({ matchNested: false });
129
+ kitRouter.pushState({ url: "/docs/intro" });
130
+ expect(nested.isActive).toBe(true);
131
+ expect(exact.isActive).toBe(false);
132
+ });
133
+
134
+ it("swaps the router registration when route-href changes", async () => {
135
+ const el = fixture<TestElement>(`<${TAG} route-href="/a"></${TAG}>`);
136
+ await wait(0);
137
+ const first = el.routeInstance!;
138
+ expect(first.key).toBe("/a");
139
+
140
+ el.routeHref = "/b";
141
+ await wait(0);
142
+ const second = el.routeInstance!;
143
+ expect(second).not.toBe(first);
144
+ expect(second.key).toBe("/b");
145
+ expect(offSpy).toHaveBeenCalledWith(first);
146
+ expect(onSpy).toHaveBeenCalledWith(second);
147
+ });
148
+
149
+ it("switches from a path route to a regex route", async () => {
150
+ const el = fixture<TestElement>(`<${TAG} route-href="/a"></${TAG}>`);
151
+ await wait(0);
152
+ const pathRoute = el.routeInstance!;
153
+
154
+ el.routeHref = null;
155
+ el.routeRegex = "^/z";
156
+ await wait(0);
157
+ expect(offSpy).toHaveBeenCalledWith(pathRoute);
158
+ expect(el.routeInstance!.key).toBeInstanceOf(RegExp);
159
+ expect((el.routeInstance!.key as RegExp).source).toBe(
160
+ new RegExp("^/z").source
161
+ );
162
+ });
163
+
164
+ it("unregisters when route-href is cleared", async () => {
165
+ const el = fixture<TestElement>(`<${TAG} route-href="/a"></${TAG}>`);
166
+ await wait(0);
167
+ const route = el.routeInstance!;
168
+ onSpy.mockClear();
169
+
170
+ el.routeHref = null;
171
+ await wait(0);
172
+ expect(el.routeInstance).toBeNull();
173
+ expect(offSpy).toHaveBeenCalledWith(route);
174
+ expect(onSpy).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("unregisters on disconnect and re-registers on reconnect", async () => {
178
+ const el = fixture<TestElement>(`<${TAG} route-href="/a"></${TAG}>`);
179
+ await wait(0);
180
+ const first = el.routeInstance!;
181
+
182
+ el.remove();
183
+ await wait(0);
184
+ expect(el.routeInstance).toBeNull();
185
+ expect(offSpy).toHaveBeenCalledWith(first);
186
+
187
+ onSpy.mockClear();
188
+ document.body.appendChild(el);
189
+ await wait(0);
190
+ expect(el.wasMounted).toBe(true);
191
+ const second = el.routeInstance!;
192
+ expect(second).toBeInstanceOf(KitRoute);
193
+ expect(second).not.toBe(first);
194
+ expect(onSpy).toHaveBeenCalledWith(second);
195
+ });
196
+
197
+ it("re-registers a regex route on reconnect", async () => {
198
+ const el = fixture<TestElement>(`<${TAG} route-regex="^/r"></${TAG}>`);
199
+ await wait(0);
200
+ const first = el.routeInstance!;
201
+ expect(first).toBeInstanceOf(KitRoute);
202
+
203
+ el.remove();
204
+ await wait(0);
205
+ onSpy.mockClear();
206
+ document.body.appendChild(el);
207
+ await wait(0);
208
+ const second = el.routeInstance!;
209
+ expect(second).not.toBe(first);
210
+ expect(second.key).toBeInstanceOf(RegExp);
211
+ expect(onSpy).toHaveBeenCalledWith(second);
212
+ });
213
+
214
+ it("does nothing on reconnect when no pattern is set", async () => {
215
+ const el = fixture<TestElement>(`<${TAG}></${TAG}>`);
216
+ await wait(0);
217
+ el.remove();
218
+ await wait(0);
219
+ document.body.appendChild(el);
220
+ await wait(0);
221
+ expect(el.routeInstance ?? null).toBeNull();
222
+ expect(onSpy).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it("keeps the registration when the element is moved synchronously", async () => {
226
+ const el = fixture<TestElement>(`<${TAG} route-href="/a"></${TAG}>`);
227
+ await wait(0);
228
+ const route = el.routeInstance!;
229
+ offSpy.mockClear();
230
+ onSpy.mockClear();
231
+
232
+ const holder = document.createElement("div");
233
+ document.body.appendChild(holder);
234
+ // remove + append in the same task is a "move" (isMoving), not a
235
+ // disconnect: the route must stay registered exactly once
236
+ el.remove();
237
+ holder.appendChild(el);
238
+ await wait(0);
239
+ expect(el.routeInstance).toBe(route);
240
+ expect(offSpy).not.toHaveBeenCalled();
241
+ expect(onSpy).not.toHaveBeenCalled();
242
+ });
243
+ });
@@ -0,0 +1,42 @@
1
+ import { RoutableElement } from "../../index";
2
+ import {
3
+ afterEach,
4
+ describe,
5
+ expect,
6
+ fixture,
7
+ it,
8
+ vi,
9
+ wait,
10
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
11
+
12
+ const TAG = "routable-element-test";
13
+ if (!customElements.get(TAG)) {
14
+ RoutableElement.define(TAG);
15
+ }
16
+
17
+ describe("RoutableElement", () => {
18
+ afterEach(() => {
19
+ document.body.innerHTML = "";
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ it("throws when routeChanged is not implemented", () => {
24
+ const el = fixture<any>(`<${TAG}></${TAG}>`);
25
+ expect(() => el.routeChanged()).toThrowError(/routeChanged/);
26
+ });
27
+
28
+ it("has default props", () => {
29
+ const el = fixture<any>(`<${TAG}></${TAG}>`);
30
+ expect(el).dom.to.equalTag(`<${TAG}></${TAG}>`);
31
+ });
32
+
33
+ it("sets routeInstance to null on disconnect when routeHref is set", async () => {
34
+ const el = fixture<any>(`<${TAG}></${TAG}>`);
35
+ el.routeChanged = vi.fn();
36
+ el.routeHref = "/test";
37
+ await wait(0);
38
+ el.remove();
39
+ await wait(0);
40
+ expect(el.routeInstance).toBeNull();
41
+ });
42
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "@excom/heft-rig/profiles/default/config/tsconfig.json",
3
+ "include": ["./*.ts"],
4
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
5
+ }