@excom/spa-route 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 (38) hide show
  1. package/.rush/temp/chunked-rush-logs/spa-route.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/spa-route.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/spa-route.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 +5 -0
  15. package/index.css +5 -0
  16. package/index.ts +29 -0
  17. package/package.json +48 -0
  18. package/rush-logs/spa-route.apply-exports.cache.log +1 -0
  19. package/rush-logs/spa-route.apply-exports.log +1 -0
  20. package/rush-logs/spa-route.build_docs.cache.log +1 -0
  21. package/rush-logs/spa-route.build_docs.log +1 -0
  22. package/rush-logs/spa-route.build_package-metas.cache.log +1 -0
  23. package/rush-logs/spa-route.build_package-metas.log +1 -0
  24. package/spa-a.ts +124 -0
  25. package/spa-manager.ts +605 -0
  26. package/spa-route.ts +322 -0
  27. package/src/spa-route.css +24 -0
  28. package/src/utils.ts +31 -0
  29. package/support/custom-elements.json +953 -0
  30. package/support/dist-docs/spa-a.md +40 -0
  31. package/support/dist-docs/spa-manager.md +66 -0
  32. package/support/dist-docs/spa-route.md +408 -0
  33. package/support/docs/README.md +314 -0
  34. package/support/package-meta.json +789 -0
  35. package/support/tests/spa-navigation.test.ts +1076 -0
  36. package/support/tests/spa-route.test.ts +766 -0
  37. package/support/tests/spa-title.test.ts +225 -0
  38. package/tsconfig.json +5 -0
@@ -0,0 +1,40 @@
1
+ # `<spa-a>`
2
+
3
+ > Declarative SPA link with active-state styling.
4
+
5
+ **Tag:** `<spa-a>`
6
+
7
+ ## API
8
+
9
+ ### Attributes
10
+
11
+ | Name | Surface | Type | Default | Values | Description | Inherited from |
12
+ | --- | --- | --- | --- | --- | --- | --- |
13
+ | `route-action` | option | `string` | `"push"` | `"push"` \| `"replace"` \| `"back"` \| `"forward"` | Navigation mode when activated (default click). | |
14
+ | `document-title` | option | `string` | | | Sets `document.title` after navigation. | |
15
+ | `transition-types` | option | `tokenlist` | | `<token>…` | Space-separated View Transition types for this navigation (CSS View Transitions API). Useful for shared-element morphs like card expansion. | |
16
+ | `match-hash` | option | `boolean` | | | Require the URL hash to match when setting `is-active`. | |
17
+ | `is-active` | state | `boolean` | | | Current URL matches `route-href`. Style with `spa-a[is-active]`. | |
18
+ | `was-active` | state | `boolean` | | | Previous URL matched `route-href`. Style outgoing links / card-expansion exits with `spa-a[was-active]`. | |
19
+ | `host-ref` | option | `string` | | `<CSS Selector>` \| `"window"` \| `"document"` \| `"html"` \| `"body"` \| `"head"` | Listen on another element / `window` / `document` — e.g. Escape to dismiss a dialog from a global `keydown`. Defaults to `:scope`. Used with `listen-for`. Not compatible with `listen-for-lifecycle`. The selector MUST resolve when `host-ref` is set — it will not wait for a match to appear. | `@excom/listenable-element` |
20
+ | `listen-for` | option | `tokenlist` | | `<EventName>…` | Space-separated event names to listen for. Defaults to `click` when unset (and no lifecycle list is set). | `@excom/listenable-element` |
21
+ | `listen-for-lifecycle` | option | `tokenlist` | | `"connected"` \| `"disconnected"` \| `"adopted"` | Space-separated element lifecycles to handle. | `@excom/listenable-element` |
22
+ | `listen-once` | option | `boolean` | | | Handle each distinct event name / lifecycle at most once. | `@excom/listenable-element` |
23
+ | `selector-filter` | option | `string` | | `<CSS Selector>` | Only handle events whose `event.target` matches this CSS selector. Does not support `:scope` in the selector. | `@excom/listenable-element` |
24
+ | `keycode-filter` | option | `tokenlist` | | `<key` \| `mod+key>…` | Space-separated key filters (OR). Join modifiers with `+` (AND, any order): `shift+k tab` → Shift+K or Tab. Modifiers: `shift`, `alt`, `ctrl`/`control`, `meta`. Case-insensitive. | `@excom/listenable-element` |
25
+ | `pathname-filter` | option | `tokenlist` | | `<pathname>…` | Only handle when `location.pathname` is one of these values — route-aware behaviors without a separate router element. | `@excom/listenable-element` |
26
+ | `prevent-default` | option | `boolean` | | | Call `preventDefault()` on matched events (ignored for lifecycles). | `@excom/listenable-element` |
27
+ | `stop-propagation` | option | `boolean` | | | Call `stopPropagation()` on matched events (ignored for lifecycles). | `@excom/listenable-element` |
28
+ | `stop-immediate-propagation` | option | `boolean` | | | Call `stopImmediatePropagation()` on matched events (ignored for lifecycles). | `@excom/listenable-element` |
29
+ | `vibrate-ms` | option | `number` | `"20 (when attribute is present with no value)"` | | Vibrate on handle (`navigator.vibrate`). Empty / `0` uses a 20ms pulse. | `@excom/listenable-element` |
30
+ | `delay-ms` | option | `number` | | | Delay handling by this many milliseconds. | `@excom/listenable-element` |
31
+ | `is-debounced` | option | `boolean` | | | With `delay-ms`, coalesce bursts into one trailing call (debounce). | `@excom/listenable-element` |
32
+ | `route-href` | option | `string` | | `<path pattern>` | URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards. Set this or `route-regex`. | `@excom/routable-element` |
33
+ | `route-regex` | option | `string` | | `<RegExp source>` | RegExp source string matched against the current URL — alternative to `route-href` for catch-alls / advanced patterns. | `@excom/routable-element` |
34
+ | `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. | `@excom/routable-element` |
35
+
36
+ ### CSS Aliases
37
+
38
+ | Alias | Kind | Matches | Description |
39
+ | --- | --- | --- | --- |
40
+ | `:--spa-a` | element | `spa-a`, `.tag-spa-a` | |
@@ -0,0 +1,66 @@
1
+ # `<spa-manager>`
2
+
3
+ > SPA shell — view transitions, swipe nav, route orchestration.
4
+
5
+ **Tag:** `<spa-manager>`
6
+
7
+ ## API
8
+
9
+ ### Attributes
10
+
11
+ | Name | Surface | Type | Default | Values | Description | Inherited from |
12
+ | --- | --- | --- | --- | --- | --- | --- |
13
+ | `transition-first-render` | option | `boolean` | | | Animate the very first activation (cold load) with a View Transition. Off by default so the initial paint is instant. | |
14
+ | `transition-delay` | option | `number` | | | Delay (ms) before starting the batched View Transition. Gives late sibling render/unrender events time to queue, or room for last-second DOM work. Unset / `null` starts synchronously. | |
15
+ | `no-transition` | option | `boolean` | | | Disable View Transitions for every child route. | |
16
+ | `render-timeout` | option | `number` | `2000` | | Max wait (ms) for child render promises before forcing the transition to resolve. Raise for slow remote templates. | |
17
+ | `overscroll-behavior-x` | option | `string` | | `"none"` \| `"navigate"` | Touch edge-swipe on touch devices. `none` blocks horizontal overscroll; `navigate` also calls back / forward past the threshold. | |
18
+ | `overscroll-x-threshold` | option | `number` | `40` | | Edge inset (px) where a touch start counts as an edge swipe. | |
19
+ | `max-states` | option | `number` | | | Cap retained router history states (scroll positions, transition types, etc.). | |
20
+ | `last-move` | state | `string` | | `"push"` \| `"replace"` \| `"back"` \| `"forward"` | Last navigation direction (`push` / `replace` / `back` / `forward`). Pick CSS transition styles from this. | |
21
+ | `active-url` | state | `string` | | | URL of the currently active route. | |
22
+ | `is-transitioning` | state | `boolean` | | | A View Transition is in flight. | |
23
+ | `has-rendered` | state | `boolean` | | | At least one render has committed. Gates `transition-first-render`; useful for hiding loading shells. | |
24
+ | `route-href` | option | `string` | | `<path pattern>` | URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards. Set this or `route-regex`. | `@excom/routable-element` |
25
+ | `route-regex` | option | `string` | | `<RegExp source>` | RegExp source string matched against the current URL — alternative to `route-href` for catch-alls / advanced patterns. | `@excom/routable-element` |
26
+ | `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. | `@excom/routable-element` |
27
+
28
+ ### Provision
29
+
30
+ | Name | Type | Description |
31
+ | --- | --- | --- |
32
+ | `provision` | `KitRouteData` (`{ previous: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; active: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; }; next: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; all: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; }[]; params: Record<string, string> \| null; match: Array<any> \| null; move: null \| "push" \| "replace" \| "back" \| "forward"; event?: { hasUAVisualTransition: boolean; }; route: KitRoute; }`) | Current route payload. Not reflected as an attribute. |
33
+
34
+ ### Recognized Elements
35
+
36
+ | Relationship | Selector | Required | Description |
37
+ | --- | --- | --- | --- |
38
+ | `spa-route` | descendant | yes | Routes this manager coordinates. Their render lifecycle is intercepted and batched into one view transition per navigation. |
39
+
40
+ ### Fires
41
+
42
+ | Name | Type | Description |
43
+ | --- | --- | --- |
44
+ | `spa-manager-will-transition` | `SpaManagerWillTransitionEvent` (`CustomEvent & { type: "spa-manager-will-transition"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Once per navigation, just before the View Transition starts. `preventDefault()` skips the transition for this navigation. |
45
+ | `spa-manager-transition` | `SpaManagerTransitionEvent` (`CustomEvent & { type: "spa-manager-transition"; detail: { transition: ViewTransition; }; bubbles: true; cancelable: true; composed: true }`) | After `document.startViewTransition()` is called. |
46
+ | `spa-manager-rendered` | `SpaManagerRenderedEvent` (`CustomEvent & { type: "spa-manager-rendered"; detail: void; bubbles: true; cancelable: true; composed: true }`) | After the transition (or synchronous fallback) finishes and routes are settled. |
47
+ | `spa-manager-error` | `SpaManagerErrorEvent` (`CustomEvent & { type: "spa-manager-error"; detail: unknown; bubbles: true; cancelable: true; composed: true }`) | Re-emitted when a child fires `spa-route-error`. `event.detail` mirrors the source error. |
48
+ | `spa-manager-push` | `SpaManagerPushEvent` (`CustomEvent & { type: "spa-manager-push"; detail: void; bubbles: true; cancelable: true; composed: true }`) | On `pushState` navigations. |
49
+ | `spa-manager-replace` | `SpaManagerReplaceEvent` (`CustomEvent & { type: "spa-manager-replace"; detail: void; bubbles: true; cancelable: true; composed: true }`) | On `replaceState` navigations. |
50
+ | `spa-manager-back` | `SpaManagerBackEvent` (`CustomEvent & { type: "spa-manager-back"; detail: void; bubbles: true; cancelable: true; composed: true }`) | On `back` navigations. |
51
+ | `spa-manager-forward` | `SpaManagerForwardEvent` (`CustomEvent & { type: "spa-manager-forward"; detail: void; bubbles: true; cancelable: true; composed: true }`) | On `forward` navigations. |
52
+
53
+ ### Listens for
54
+
55
+ | Name | Type | Description |
56
+ | --- | --- | --- |
57
+ | `spa-route-render` | `RenderableRenderEvent` (`CustomEvent & { type: "{tag}-render"; detail: () => Promise<void>; bubbles: true; cancelable: true; composed: true }`) | Queues the child's render callback for the next batched transition. |
58
+ | `spa-route-unrender` | `RenderableUnrenderEvent` (`CustomEvent & { type: "{tag}-unrender"; detail: () => void; bubbles: true; cancelable: true; composed: true }`) | Queues the child's unrender callback (runs before renders so the outgoing route leaves first). |
59
+ | `spa-route-provision` | `SpaRouteProvisionEvent` (`CustomEvent & { type: "spa-route-provision"; detail: () => void; bubbles: true; cancelable: true; composed: true }`) | Queues a same-route param update (reuse) into the next batched transition. |
60
+ | `spa-route-error` | `RenderableErrorEvent` (`CustomEvent & { type: "{tag}-error"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Falls back to a non-transitioned update and re-emits as `spa-manager-error`. |
61
+
62
+ ### Default actions
63
+
64
+ | Event | Default behavior (unless preventDefault() is called) |
65
+ | --- | --- |
66
+ | `spa-manager-will-transition` | Starts the batched View Transition (or synchronous update when transitions are unavailable / opted out). |
@@ -0,0 +1,408 @@
1
+ # spa-route
2
+
3
+ Build a full SPA from HTML alone — screens, links, and view transitions.
4
+
5
+ > This site is a live demo... inspect its HTML! Other live demos of spa-route coming soon.
6
+
7
+ ```html
8
+ <spa-manager>
9
+ <spa-route route-href="/" template-ref="/views/home.html"></spa-route>
10
+ <spa-route route-href="/about" template-ref="/views/about.html"></spa-route>
11
+ </spa-manager>
12
+
13
+ <nav>
14
+ <spa-a route-href="/">Home</spa-a>
15
+ <spa-a route-href="/about">About</spa-a>
16
+ </nav>
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Pure CSS View Transitions** Write CSS, get beautiful animations between routes
22
+ - **Active / was-active** Style current and outgoing links & screens (nav chrome, card expansion)
23
+ - **Same-route reuse / refresh** Keep or rebuild the view when only params change
24
+ - **Scroll reset / restore** Per-axis control for push, replace, back, forward
25
+ - **Nested layouts** Keep a parent route mounted under child paths
26
+ - **404 fallbacks** Catch-alls that only fire when nothing else matched
27
+ - **Per-route document title** `document.title` follows the active route
28
+ - **History actions** Push, replace, back, forward from a link
29
+
30
+ ## Installation
31
+
32
+
33
+ `@excom/spa-route` v0.1.0
34
+
35
+ ```bash
36
+ pnpm add @excom/spa-route
37
+ ```
38
+
39
+ ```bash
40
+ npm install @excom/spa-route
41
+ ```
42
+
43
+ ```bash
44
+ yarn add @excom/spa-route
45
+ ```
46
+
47
+ ### Import
48
+
49
+ ```ts
50
+ import "@excom/spa-route";
51
+ ```
52
+
53
+
54
+
55
+ ## Usage
56
+
57
+ Wrap screens in `<spa-manager>`, give each `<spa-route>` a `route-href`, and link with `<spa-a>`.
58
+
59
+ ```html
60
+ <!-- Optional SPA manager for View Transitions and batched router config -->
61
+ <spa-manager>
62
+ <!-- SPA routing -->
63
+ <spa-route route-href="/" template-ref="/views/home.html"></spa-route>
64
+ <spa-route route-href="/about" template-ref="/views/about.html"></spa-route>
65
+ </spa-manager>
66
+ <nav>
67
+ <!-- SPA links -->
68
+ <spa-a route-href="/">Home</spa-a>
69
+ <spa-a route-href="/about">About</spa-a>
70
+ </nav>
71
+ ```
72
+
73
+ ### API Reference
74
+
75
+
76
+ #### Attributes
77
+
78
+ | Name | Surface | Type | Default | Values | Description | Inherited from |
79
+ | --- | --- | --- | --- | --- | --- | --- |
80
+ | `same-route` | option | `string` | `"reuse"` | `"reuse"` \| `"refresh"` | When this route matches while already active, `reuse` keeps the rendered tree and updates route data; `refresh` tears down and re-renders. Use `refresh` for param-driven screens (e.g. `/users/:id` → `/users/2`); `reuse` when only route data should change (e.g. `/logs/:view`). Pair with `scroll-set-disabled` to leave the viewport untouched. | |
81
+ | `no-transition` | option | `boolean` | | | Opt this route out of the parent `<spa-manager>` View Transition. Still renders/unrenders — just without the cross-fade. | |
82
+ | `scroll-reset-behavior` | option | `string` | `"instant"` | `"auto"` \| `"instant"` \| `"smooth"` | `window.scrollTo` behavior when this route applies a scroll reset / restore. | |
83
+ | `scroll-reset-x` | option | `tokenlist` | `"push replace"` | `"push"` \| `"replace"` \| `"back"` \| `"forward"` | Navigation moves that reset scroll X to `0`. Moves omitted here restore the saved X for that history entry instead. | |
84
+ | `scroll-reset-y` | option | `tokenlist` | `"push replace"` | `"push"` \| `"replace"` \| `"back"` \| `"forward"` | Navigation moves that reset scroll Y to `0`. Moves omitted here restore the saved Y for that history entry instead. | |
85
+ | `scroll-set-disabled` | option | `boolean` | | | Disable all scroll reset / restore for this route. | |
86
+ | `is-fallback` | option | `boolean` | | | Only activate when this route matches *and* no earlier sibling `<spa-route>` is already active. Pair with a permissive `route-regex` (e.g. `.*`) for 404 catch-alls. | |
87
+ | `document-title` | option | `string` | | | `document.title` while this route is active. The outermost `<spa-manager>` applies the last active route carrying one — so a nested route beats its ancestor — and restores the page's own `<title>` once no active route has one. Cold loads and back / forward retitle too: it keys off activation, not clicks. | |
88
+ | `was-active` | state | `boolean` | | | Set briefly while navigating away. Style outgoing screens / card-expansion exits with `spa-route[was-active]`. | |
89
+ | `template-ref` | option | `string` | `":scope > template"` | `<CSS Selector>` \| `<URL>` | Source `<template>` — in-document selector or remote URL. Changing mid-flight aborts and reloads. Can use `:scope` to relatively select elements: e.g. `main:has(:scope) > template` | `@excom/renderable-element` |
90
+ | `bypass-cache` | option | `boolean` | | | Skip the in-memory response cache (URL `template-ref` only). | `@excom/renderable-element` |
91
+ | `pre-fetch` | option | `string` | `"lazy"` | `""` \| `"eager"` \| `"idle"` \| `"lazy"` | When to fetch the template, independent of when it renders. `""` aliases `eager`. | `@excom/renderable-element` |
92
+ | `persist-content` | option | `boolean` | | | Reuse the same live nodes across unrender / render (held on `_persistedTree`) so form values, scroll position, and subtree state survive toggles. | `@excom/renderable-element` |
93
+ | `host-ref` | option | `string` | | `"shadow"` \| `"iframe"` \| `<CSS Selector>` | Where rendered children land. Unset = this element's light DOM. `shadow` attaches an open shadow root. `iframe` paints into a child `<iframe data-render-host>` body (you supply the iframe — useful for sandboxed / third-party document isolation). Any other value is a portal selector. | `@excom/renderable-element` |
94
+ | `ready-on` | option | `string` | | `<Event Name>` | Event name that marks rendered children "ready". Until it fires, `delaying-ready` is set so CSS can hide the host for a coordinated paint / view transition. | `@excom/renderable-element` |
95
+ | `is-active` | hybrid | `boolean` | | | Master switch. Set to load (if needed) and render; unset to unrender. Drive from visibility, route match, hover, etc. | `@excom/renderable-element` |
96
+ | `is-loading` | state | `boolean` | | | Template fetch in flight. | `@excom/renderable-element` |
97
+ | `did-load` | state | `boolean` | | | Template resolved at least once. Stays set across `is-active` toggles so consumers know later paints are warm (URL refs reuse the shared fetch cache in kit-utils). Cleared when `template-ref` changes or `--reload` forces a fresh resolve. | `@excom/renderable-element` |
98
+ | `is-error` | state | `boolean` | | | Latest template fetch rejected (excluding abort). Fires with the `error` event. | `@excom/renderable-element` |
99
+ | `delaying-ready` | state | `boolean` | | | Between `render` and the matching `ready-on` event. Hook with CSS for coordinated paints / view transitions. | `@excom/renderable-element` |
100
+ | `route-href` | option | `string` | | `<path pattern>` | URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards. Set this or `route-regex`. | `@excom/routable-element` |
101
+ | `route-regex` | option | `string` | | `<RegExp source>` | RegExp source string matched against the current URL — alternative to `route-href` for catch-alls / advanced patterns. | `@excom/routable-element` |
102
+ | `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. | `@excom/routable-element` |
103
+
104
+ #### Provision
105
+
106
+ | Name | Type | Description |
107
+ | --- | --- | --- |
108
+ | `provision` | `SpaRouteProvision` (`{ routeHref: string; matchNested: boolean; scrollResetY: string[]; scrollResetX: string[]; scrollResetBehavior: ScrollBehavior; noTransition: boolean; active: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; }; event: { hasUAVisualTransition: boolean; }; match: Array<any> \| null; move: null \| "push" \| "replace" \| "back" \| "forward"; next: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; previous: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; params: Record<string, string> \| null; }`) | Active route payload for this activation (`null` when inactive). Not reflected as an attribute. |
109
+
110
+ #### Recognized Elements
111
+
112
+ | Relationship | Selector | Required | Description | Inherited from |
113
+ | --- | --- | --- | --- | --- |
114
+ | `template` | child | yes | Screen content. Cloned (or reused with `persist-content`) on activation. | |
115
+ | `iframe[data-render-host]` | child | no | Required when `host-ref="iframe"`. Content paints into `iframe.contentDocument.body`. Provide your own iframe (e.g. with `srcdoc`); the element will not create one. | `@excom/renderable-element` |
116
+
117
+ #### Fires
118
+
119
+ | Name | Type | Description | Inherited from |
120
+ | --- | --- | --- | --- |
121
+ | `spa-route-provision` | `SpaRouteProvisionEvent` (`CustomEvent & { type: "spa-route-provision"; detail: () => void; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Same route stayed active but params changed (`same-route="reuse"`). `event.detail` is a thunk that updates route data and resolves the ready promise. `<spa-manager>` batches this into the View Transition like render/unrender. | |
122
+ | `spa-route-render` | `RenderableRenderEvent` (`CustomEvent & { type: "{tag}-render"; detail: () => Promise<void>; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Dispatched when the element becomes active and is about to place template content into the host. `event.detail` is a thunk that performs the load (if not already loaded) and renders the children, returning a Promise that resolves once the corresponding `ready-on` event fires (or immediately if `ready-on` is unset). The promise rejects if the element is torn down mid-flight (`startTeardown` while loading / `delaying-ready`). Call `preventDefault()` to defer rendering and invoke `event.detail()` later. | `@excom/renderable-element` |
123
+ | `spa-route-unrender` | `RenderableUnrenderEvent` (`CustomEvent & { type: "{tag}-unrender"; detail: () => void; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Dispatched when the element becomes inactive and content is already painted. `event.detail` is a thunk that removes the rendered children. Call `preventDefault()` to defer the removal. Not fired when teardown cancels an in-flight load — that path emits `aborted` instead. | `@excom/renderable-element` |
124
+ | `spa-route-did-render` | `RenderableDidRenderEvent` (`CustomEvent & { type: "{tag}-did-render"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched after the template content has actually been placed into the host. | `@excom/renderable-element` |
125
+ | `spa-route-did-unrender` | `RenderableDidUnrenderEvent` (`CustomEvent & { type: "{tag}-did-unrender"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched after rendered children have been removed from the host. | `@excom/renderable-element` |
126
+ | `spa-route-error` | `RenderableErrorEvent` (`CustomEvent & { type: "{tag}-error"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched when the template promise rejects with anything other than an `AbortError`. | `@excom/renderable-element` |
127
+ | `spa-route-aborted` | `RenderableAbortedEvent` (`CustomEvent & { type: "{tag}-aborted"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched when an in-flight load / ready wait is canceled because `is-active` was unset (via `startTeardown`). | `@excom/renderable-element` |
128
+
129
+ #### Commands
130
+
131
+ | Command | Action | Inherited from |
132
+ | --- | --- | --- |
133
+ | `--reload` | Aborts any in-flight fetch and re-resolves the template, bypassing the cache for URL refs (useful after remote content changes). | `@excom/renderable-element` |
134
+
135
+ #### Default actions
136
+
137
+ | Event | Default behavior (unless preventDefault() is called) | Inherited from |
138
+ | --- | --- | --- |
139
+ | `spa-route-provision` | Invokes `event.detail()` to apply the new provision. | |
140
+ | `spa-route-render` | Invokes `event.detail()` to load (if needed) and render the template into the host. | `@excom/renderable-element` |
141
+ | `spa-route-unrender` | Invokes `event.detail()` to remove rendered children from the host. | `@excom/renderable-element` |
142
+
143
+ #### CSS Aliases
144
+
145
+ | Alias | Kind | Matches | Description |
146
+ | --- | --- | --- | --- |
147
+ | `:--spa-route` | element | `spa-route`, `.tag-spa-route` | |
148
+
149
+
150
+
151
+ ### Examples
152
+
153
+ #### Minimal SPA
154
+
155
+ Three routes, three links. Active links style via `spa-a[is-active]`.
156
+
157
+ ```html
158
+ <spa-manager>
159
+ <nav>
160
+ <spa-a route-href="/home">Home</spa-a>
161
+ <spa-a route-href="/users">Users</spa-a>
162
+ <spa-a route-href="/about">About</spa-a>
163
+ <spa-a route-href="/contact">Contact</spa-a>
164
+ </nav>
165
+ <spa-route route-href="/home">
166
+ <template>
167
+ <h3>Welcome</h3>
168
+ <p>Mounted because the URL matched <code>/home</code>.</p>
169
+ </template>
170
+ </spa-route>
171
+ <spa-route route-href="/users">
172
+ <template>
173
+ <h3>Users</h3>
174
+ <ul>
175
+ <li>Adam</li>
176
+ <li>Linus</li>
177
+ <li>Grace</li>
178
+ </ul>
179
+ </template>
180
+ </spa-route>
181
+ <spa-route route-href="/about" template-ref="/this/view/is/remote.html"></spa-route>
182
+ <spa-route route-href="/contact" template-ref="#this-view-is-dom-selected"></spa-route>
183
+ </spa-manager>
184
+
185
+ <template id="this-view-is-dom-selected">foo@bar.com</template>
186
+ ```
187
+
188
+ ```css
189
+ spa-a[is-active] {
190
+ font-weight: bold;
191
+ pointer-events: none;
192
+ text-decoration: none;
193
+ }
194
+ ```
195
+
196
+ #### Nested layout & 404
197
+
198
+ `match-nested` keeps a layout mounted under child paths. `is-fallback` with `route-regex=".*"` is a 404 that only activates when no preceding sibling matched.
199
+
200
+ ```html
201
+ <spa-manager>
202
+ <nav>
203
+ <spa-a route-href="/users">Users list</spa-a>
204
+ <spa-a route-href="/users/42">User 42</spa-a>
205
+ <spa-a route-href="/missing">Missing page</spa-a>
206
+ </nav>
207
+ <spa-route route-href="/users" match-nested>
208
+ <template>
209
+ <section>
210
+ <h3>Users layout</h3>
211
+ <spa-manager>
212
+ <spa-route route-href="/users">
213
+ <template><p>List of users.</p></template>
214
+ </spa-route>
215
+ <spa-route route-href="/users/:id">
216
+ <template><p>Detail for a single user.</p></template>
217
+ </spa-route>
218
+ </spa-manager>
219
+ </section>
220
+ </template>
221
+ </spa-route>
222
+ <spa-route route-regex=".*?view=admin.*" template-ref="/views/admin-sidebar.html"></spa-route>
223
+ <spa-route route-regex=".*" is-fallback>
224
+ <template>
225
+ <section>
226
+ <h3>404</h3>
227
+ <p>Catch-all — only when no preceding sibling matched.</p>
228
+ </section>
229
+ </template>
230
+ </spa-route>
231
+ </spa-manager>
232
+ ```
233
+
234
+ #### Document title
235
+
236
+ `document-title` sets `document.title` while its route is active. It keys off
237
+ activation, not clicks, so cold loads and back / forward retitle too. The
238
+ outermost `<spa-manager>` applies the last active route carrying one — a
239
+ nested route beats its ancestor — and restores the page's own `<title>` once
240
+ no active route has a title.
241
+
242
+ ```html
243
+ <title>Nucleus · docs</title>
244
+
245
+ <spa-manager>
246
+ <spa-route route-href="/" document-title="My company">
247
+ <template><p>The company page.</p></template>
248
+ </spa-route>
249
+ <!-- untitled: the page's own <title> comes back -->
250
+ <spa-route route-href="/docs">
251
+ <template><p>The docs.</p></template>
252
+ </spa-route>
253
+ </spa-manager>
254
+ ```
255
+
256
+ #### History actions
257
+
258
+ `route-action="back"` / `"forward"` walk history; `"replace"` swaps the current entry instead of pushing.
259
+
260
+ ```html
261
+ <spa-manager>
262
+ <nav>
263
+ <spa-a route-action="back">‹ Back</spa-a>
264
+ <spa-a route-action="forward">Forward ›</spa-a>
265
+ <spa-a route-href="/one">Push /one</spa-a>
266
+ <spa-a route-href="/two">Push /two</spa-a>
267
+ <spa-a route-href="/login" route-action="replace">
268
+ Replace with /login
269
+ </spa-a>
270
+ </nav>
271
+ <spa-route route-href="/one">
272
+ <template><p>You're on <code>/one</code>.</p></template>
273
+ </spa-route>
274
+ <spa-route route-href="/two">
275
+ <template><p>You're on <code>/two</code>.</p></template>
276
+ </spa-route>
277
+ <spa-route route-href="/login">
278
+ <template>
279
+ <p>You're on <code>/login</code> — this entry replaced the
280
+ previous one in history.</p>
281
+ </template>
282
+ </spa-route>
283
+ </spa-manager>
284
+ ```
285
+
286
+ #### View Transitions
287
+
288
+ `<spa-manager>` wraps each navigation in `document.startViewTransition()` (when supported). Style with `::view-transition-*`; set per-link types via `transition-types` (e.g. card expansion); opt a route out with `no-transition`.
289
+
290
+ ```css
291
+ ::view-transition-old(root),
292
+ ::view-transition-new(root) {
293
+ animation-duration: 0.25s;
294
+ }
295
+ ```
296
+
297
+ #### View Transitions - Localized
298
+ If you had a list of cards, and clicking on one expanded it to the detail view (and vice versa, contracting), you would achieve it similarly to the code example below. This technique relies on styling the `<spa-a>` with its `[is-active]` (incoming view) and `[was-active]` (outgoing view).
299
+
300
+ ```html
301
+ <spa-manager>
302
+ <spa-route id="route-list" route-href="/list">
303
+ <template>
304
+ <spa-a route-href="/detail/123" transition-types="card-morph" class="mini-card">
305
+ Go to detail
306
+ </spa-a>
307
+ </template>
308
+ </spa-route>
309
+ <spa-route id="route-detail" route-href="/detail/:id">
310
+ <template>
311
+ <article id="detail-card" class="card">
312
+ <!-- other content here -->
313
+ </article>
314
+ </template>
315
+ </spa-route>
316
+ </spa-manager>
317
+ ```
318
+
319
+ ```css
320
+ html:active-view-transition-type(card-morph) {
321
+ #route-list spa-a[transition-types="card-morph"][was-active], /* outgoing list card (forward) */
322
+ #route-list spa-a[transition-types="card-morph"][is-active], /* incoming list card (back) */
323
+ #detail-card /* detail card (forward & back) */ {
324
+ contain: layout;
325
+ height: fit-content;
326
+ view-transition-name: card-morph;
327
+ }
328
+ }
329
+ ::view-transition-old(card-morph),
330
+ ::view-transition-new(card-morph) {
331
+ mix-blend-mode: normal;
332
+ height: 100%;
333
+ width: 100%;
334
+ will-change: opacity;
335
+ animation-fill-mode: both;
336
+ }
337
+ ::view-transition-old(card-morph) {
338
+ animation-name: fade-out 1s ease;
339
+ }
340
+ ::view-transition-new(card-morph) {
341
+ animation-name: fade-in 1s ease;
342
+ }
343
+ @keyframes fade-in {
344
+ from { opacity: 0; }
345
+ to { opacity: 1; }
346
+ }
347
+ @keyframes fade-out {
348
+ from { opacity: 1; }
349
+ to { opacity: 0; }
350
+ }
351
+ ```
352
+
353
+ #### Touch edge-swipe
354
+ On touch devices, horizontal drags from within `overscroll-x-threshold` of an edge trigger back / forward. Use `"none"` to block overscroll without navigating. Useful for preventing native swipes in Safari, which visually break SPAs.
355
+
356
+ ```html
357
+ <spa-manager overscroll-behavior-x="navigate"></spa-manager>
358
+ ```
359
+
360
+
361
+ #### Scroll reset / restore
362
+
363
+ By default, `<spa-route>`:
364
+ - resets scroll to top-left on `push` / `replace`
365
+ - restores the saved scroll position on `back` / `forward`
366
+
367
+ Override per axis with `scroll-reset-y` / `scroll-reset-x` — space-separated moves that should reset to `0` (omitted moves restore instead):
368
+
369
+ ```html
370
+ <!-- also reset Y when the user hits back -->
371
+ <spa-route
372
+ route-href="/article/:id"
373
+ scroll-reset-y="push replace back"
374
+ ></spa-route>
375
+ ```
376
+
377
+ Animate with `scroll-reset-behavior="smooth"`. Disable all scroll handling with `scroll-set-disabled`.
378
+
379
+ #### Same-route params
380
+
381
+ When the matched route stays the same but params change (e.g. `/users/1` → `/users/2`):
382
+
383
+ - `same-route="reuse"` (default) — keep the rendered tree, update route data, and let `<spa-manager>` run a View Transition
384
+ - `same-route="refresh"` — tear down and re-render the view
385
+
386
+ ```html
387
+ <spa-route route-href="/users/:id" same-route="refresh">
388
+ <template><!-- fresh tree per user id --></template>
389
+ </spa-route>
390
+
391
+ <spa-route
392
+ route-href="/logs/:view"
393
+ same-route="reuse"
394
+ scroll-set-disabled
395
+ >
396
+ <template><!-- preserve content + scroll across view tabs --></template>
397
+ </spa-route>
398
+ ```
399
+
400
+ #### Transition delay
401
+
402
+ `transition-delay` on `<spa-manager>` waits N ms before starting the batched View Transition — useful when sibling routes need a beat to queue their render/unrender callbacks, or for last-second DOM work.
403
+
404
+ ```html
405
+ <spa-manager transition-delay="50">
406
+ <!-- routes -->
407
+ </spa-manager>
408
+ ```