@very-simple/ajax 0.7.0 → 1.0.0-beta.1
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.
- package/README.md +220 -2
- package/dist/index.d.ts +69 -25
- package/dist/index.js +291 -89
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A very simple turbolinks inspired library based on [Idiomorph](https://github.com/bigskysoftware/idiomorph).
|
|
4
4
|
You can use it for progressive enhancements and to give your multi-page websites a SPA-like feel.
|
|
5
5
|
|
|
6
|
-
💾 only ~
|
|
6
|
+
💾 only ~6kb (minify and gzip)
|
|
7
7
|
|
|
8
8
|
## Installation
|
|
9
9
|
|
|
@@ -13,4 +13,222 @@ npm install @very-simple/ajax
|
|
|
13
13
|
|
|
14
14
|
## Usage
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
```ts
|
|
17
|
+
import { start } from '@very-simple/ajax'
|
|
18
|
+
start()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<a href="/about">About</a>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Clicking the link now fetches `/about`, morphs the browser history and
|
|
26
|
+
replaces the page content, without a full page reload.
|
|
27
|
+
|
|
28
|
+
Checkout the `/examples` folder for more examples.
|
|
29
|
+
|
|
30
|
+
### Attributes
|
|
31
|
+
|
|
32
|
+
Add these attributes to a link or form to tweak how its visit behaves,
|
|
33
|
+
without writing any JavaScript:
|
|
34
|
+
|
|
35
|
+
| Attribute | Description |
|
|
36
|
+
| ------------------- | ------------------------------------------------------------------------------------ |
|
|
37
|
+
| `data-ajax-reload` | Opt out of interception entirely; the browser performs a normal navigation/submit. |
|
|
38
|
+
| `data-ajax-regions` | Comma separated list of selectors to swap instead of the whole `<body>`. |
|
|
39
|
+
| `data-ajax-history` | History mode to use: `push`, `replace` or `none`. |
|
|
40
|
+
| `data-ajax-merge` | Merge strategy for the swapped content, e.g. `morph`, `replace`, `before`, `append`. |
|
|
41
|
+
| `data-ajax-state` | JSON encoded state to store in `history.state` for this visit. |
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<!-- only swap #content, preserving the merge strategy for that region -->
|
|
45
|
+
<a href="/about" data-ajax-regions="#content" data-ajax-merge="morph">About</a>
|
|
46
|
+
|
|
47
|
+
<!-- opt out of ajax handling for this link, e.g. to force a full reload -->
|
|
48
|
+
<a href="/logout" data-ajax-reload>Logout</a>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> The `data-ajax-` prefix can be customized via the `prefix` option in `start()`.
|
|
52
|
+
|
|
53
|
+
### `start(options?)`
|
|
54
|
+
|
|
55
|
+
Call this once when your app boots. It intercepts same-origin link clicks and
|
|
56
|
+
form submissions and turns them into ajax visits, and hooks into browser
|
|
57
|
+
back/forward navigation.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { start } from '@very-simple/ajax'
|
|
61
|
+
|
|
62
|
+
start({
|
|
63
|
+
// Strategy used to merge the swapped content into the current DOM.
|
|
64
|
+
merge: 'replace',
|
|
65
|
+
// Morph the `<head>` instead of leaving it untouched.
|
|
66
|
+
morphHeads: true,
|
|
67
|
+
// Re-execute `<script>` tags found in the swapped content.
|
|
68
|
+
executeScripts: true,
|
|
69
|
+
// Wrap navigations in the View Transitions API, if supported.
|
|
70
|
+
viewTransitions: false,
|
|
71
|
+
// Delay (ms) before showing a loading indicator on slow visits.
|
|
72
|
+
loadingDelay: 500,
|
|
73
|
+
// Delay (ms) before hiding the loading indicator again.
|
|
74
|
+
progressHideDelay: 500,
|
|
75
|
+
// Prefix used for the `data-ajax-*` attributes.
|
|
76
|
+
prefix: 'data-ajax-',
|
|
77
|
+
// Custom scroll handling, e.g. restoring position on back/forward visits.
|
|
78
|
+
scrollBehavior: ({ isBackForward, savedPosition }) =>
|
|
79
|
+
isBackForward ? savedPosition : { top: 0 },
|
|
80
|
+
// Called after an element is added to the DOM by a merge.
|
|
81
|
+
mount: (el) => {},
|
|
82
|
+
// Called before an element is removed from the DOM by a merge.
|
|
83
|
+
unmount: (el) => {},
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `visit(url, options?)`
|
|
88
|
+
|
|
89
|
+
A lower-level API for triggering a visit programmatically, e.g. in
|
|
90
|
+
response to a custom event or timer instead of a link click:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { visit } from '@very-simple/ajax'
|
|
94
|
+
|
|
95
|
+
visit('/about', {
|
|
96
|
+
// History mode to use: `push`, `replace` or `none`.
|
|
97
|
+
history: 'push',
|
|
98
|
+
// Selectors to swap instead of the whole `<body>`.
|
|
99
|
+
regions: ['#content'],
|
|
100
|
+
// State to store in `history.state` for this visit.
|
|
101
|
+
state: {},
|
|
102
|
+
// Options passed through to the underlying `fetch()` call.
|
|
103
|
+
request: {},
|
|
104
|
+
// Focus the first element with `autofocus` in the swapped content.
|
|
105
|
+
autoFocus: true,
|
|
106
|
+
// ... You can also overwrite any option set in `start()` an a per-visit bases here.
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Events
|
|
111
|
+
|
|
112
|
+
Every visit fires a series of `CustomEvent`s on `document`, each namespaced
|
|
113
|
+
with `ajax:`:
|
|
114
|
+
|
|
115
|
+
| Event | Description |
|
|
116
|
+
| ------------------- | -------------------------------------------------------------------------- |
|
|
117
|
+
| `ajax:before-visit` | Fired before the new page is fetched. |
|
|
118
|
+
| `ajax:before-swap` | Fired after the new document has been loaded, right before it's merged in. |
|
|
119
|
+
| `ajax:after-swap` | Fired right after the new content has been merged in. |
|
|
120
|
+
| `ajax:load` | Fired once the whole visit (including scroll/focus handling) has finished. |
|
|
121
|
+
|
|
122
|
+
#### Async listeners
|
|
123
|
+
|
|
124
|
+
Use `event.detail.waitUntil(promise)` on those same events to delay the
|
|
125
|
+
visit until the given promise settles, e.g. to run an animation before
|
|
126
|
+
continuing:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
document.addEventListener('ajax:before-swap', (e) => {
|
|
130
|
+
e.detail.waitUntil(animateIn())
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### Aborting
|
|
135
|
+
|
|
136
|
+
`before-visit`, `before-swap` and `after-swap` carry an `AbortSignal` in
|
|
137
|
+
`event.detail.signal`, which is aborted if the visit gets superseded by a
|
|
138
|
+
newer one (e.g. the user clicks another link while yours is still running).
|
|
139
|
+
If your listener does anything that isn't automatically cancelled, like a
|
|
140
|
+
manual animation loop, check `signal.aborted` (or listen for the signal's
|
|
141
|
+
`abort` event) to stop it early:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
document.addEventListener('ajax:before-swap', (e) => {
|
|
145
|
+
const { signal } = e.detail
|
|
146
|
+
const stopWhenAborted = () => (running = false)
|
|
147
|
+
signal.addEventListener('abort', stopWhenAborted)
|
|
148
|
+
|
|
149
|
+
let running = true
|
|
150
|
+
const loop = () => {
|
|
151
|
+
if (!running) return
|
|
152
|
+
// ...do some animation work...
|
|
153
|
+
requestAnimationFrame(loop)
|
|
154
|
+
}
|
|
155
|
+
loop()
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Common patterns
|
|
160
|
+
|
|
161
|
+
### Load more
|
|
162
|
+
|
|
163
|
+
Turn a "load more" link into an infinite-scroll-like list by appending the
|
|
164
|
+
new region into the existing one and replacing the link itself with the next
|
|
165
|
+
page's link:
|
|
166
|
+
|
|
167
|
+
```html
|
|
168
|
+
<div id="posts" data-ajax-merge="append">
|
|
169
|
+
<article>1. Lorem Ipsum</article>
|
|
170
|
+
<article>2. Lorem Ipsum</article>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<a
|
|
174
|
+
id="load-more"
|
|
175
|
+
href="/posts?page=2"
|
|
176
|
+
data-ajax-history="none"
|
|
177
|
+
data-ajax-regions="#posts, #load-more"
|
|
178
|
+
>
|
|
179
|
+
More
|
|
180
|
+
</a>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Since `#load-more` is one of the swapped regions, the response's `#load-more`
|
|
184
|
+
link (pointing to page 3) replaces this one, so clicking it again keeps
|
|
185
|
+
loading further pages.
|
|
186
|
+
|
|
187
|
+
### Loading content into a dialog
|
|
188
|
+
|
|
189
|
+
Regions aren't limited to the elements currently visible on the page. You can
|
|
190
|
+
target a hidden `<dialog>` and only swap its content, leaving the rest of the
|
|
191
|
+
page untouched:
|
|
192
|
+
|
|
193
|
+
```html
|
|
194
|
+
<dialog id="preview"></dialog>
|
|
195
|
+
|
|
196
|
+
<a href="/posts/1" data-ajax-regions="#preview" data-ajax-history="none">
|
|
197
|
+
Preview
|
|
198
|
+
</a>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The response for `/posts/1` provides its own `#preview` region and opens
|
|
202
|
+
itself via an inline script, since `executeScripts` re-runs `<script>` tags
|
|
203
|
+
found in the swapped content:
|
|
204
|
+
|
|
205
|
+
```html
|
|
206
|
+
<dialog id="preview">
|
|
207
|
+
<h2>Post 1</h2>
|
|
208
|
+
<script>
|
|
209
|
+
document.getElementById('preview').showModal()
|
|
210
|
+
</script>
|
|
211
|
+
</dialog>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Permanent elements
|
|
215
|
+
|
|
216
|
+
Mark an element with `data-ajax-permanent` (and give it a stable `id`) to
|
|
217
|
+
keep it alive across visits instead of replacing it, e.g. a currently
|
|
218
|
+
playing `<audio>`/`<video>` element or anything else with state that
|
|
219
|
+
shouldn't be reset:
|
|
220
|
+
|
|
221
|
+
```html
|
|
222
|
+
<div id="player" data-ajax-permanent>
|
|
223
|
+
<audio src="/song.mp3" controls></audio>
|
|
224
|
+
</div>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
As long as an element with the same `id` exists in both the old and the new
|
|
228
|
+
document, it's kept instead of being replaced.
|
|
229
|
+
|
|
230
|
+
> With the default `replace`/`update` merge strategies the permanent element
|
|
231
|
+
> is still moved to wherever it appears in the new content, which can cause a
|
|
232
|
+
> visible flash for things like playing animations. Use `merge: 'morph'`
|
|
233
|
+
> instead (globally or via `data-ajax-merge`) so the element is truly left
|
|
234
|
+
> untouched in place.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,49 +1,93 @@
|
|
|
1
|
-
interface
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
interface AjaxState extends Record<string, unknown> {
|
|
2
|
+
regions?: string[];
|
|
3
|
+
}
|
|
4
|
+
/** Also accepts all `Config` options, e.g. `merge`, `scrollBehavior`, etc. */
|
|
5
|
+
interface VisitOptions extends Config {
|
|
6
|
+
history?: 'push' | 'replace' | 'none';
|
|
4
7
|
isBackForward?: boolean;
|
|
5
|
-
merge?: MergeStrategy;
|
|
6
|
-
morphHeads?: boolean;
|
|
7
8
|
autoFocus?: boolean;
|
|
9
|
+
request?: RequestInit;
|
|
10
|
+
regions?: string[];
|
|
11
|
+
state?: Omit<AjaxState, 'regions'>;
|
|
12
|
+
}
|
|
13
|
+
type StartOptions = Config;
|
|
14
|
+
interface LoadingOptions {
|
|
8
15
|
loadingDelay?: number;
|
|
9
16
|
progressHideDelay?: number;
|
|
10
17
|
request?: RequestInit;
|
|
11
|
-
regions?: string[];
|
|
12
18
|
}
|
|
13
19
|
type MergeStrategy = 'replace' | 'morph' | 'before' | 'after' | 'prepend' | 'append' | 'update';
|
|
14
20
|
interface Config {
|
|
15
|
-
|
|
21
|
+
/** Strategy used to merge the swapped content into the current DOM. */
|
|
16
22
|
merge?: MergeStrategy;
|
|
23
|
+
/** Morph the `<head>` instead of leaving it untouched. */
|
|
17
24
|
morphHeads?: boolean;
|
|
25
|
+
/** Re-execute `<script>` tags found in the swapped content. */
|
|
26
|
+
executeScripts?: boolean;
|
|
27
|
+
/** Wrap navigations in the View Transitions API, if supported. */
|
|
28
|
+
viewTransitions?: boolean;
|
|
29
|
+
/** Called instead of the default merge to render the new document. */
|
|
30
|
+
render?: (newDocument: Document) => Promise<void> | void;
|
|
31
|
+
/** Custom scroll handling, e.g. restoring position on back/forward visits. */
|
|
32
|
+
scrollBehavior?: ScrollBehavior;
|
|
33
|
+
/** Delay (ms) before showing a loading indicator on slow visits. */
|
|
18
34
|
loadingDelay?: number;
|
|
35
|
+
/** Delay (ms) before hiding the loading indicator again. */
|
|
19
36
|
progressHideDelay?: number;
|
|
37
|
+
/** Prefix used for the `data-ajax-*` attributes. */
|
|
38
|
+
prefix?: string;
|
|
39
|
+
/** Called after an element is added to the DOM by a merge. */
|
|
40
|
+
mount?: (el: Element) => void;
|
|
41
|
+
/** Called before an element is removed from the DOM by a merge. */
|
|
42
|
+
unmount?: (el: Element) => void;
|
|
20
43
|
}
|
|
44
|
+
type ScrollPosition = {
|
|
45
|
+
top: number;
|
|
46
|
+
};
|
|
47
|
+
type ScrollBehavior = (info: {
|
|
48
|
+
from: PageState;
|
|
49
|
+
to: PageState;
|
|
50
|
+
isBackForward: boolean;
|
|
51
|
+
savedPosition?: ScrollPosition;
|
|
52
|
+
}) => ScrollPosition | false | undefined;
|
|
53
|
+
type WaitUntil = (promise: Promise<unknown>) => void;
|
|
54
|
+
type PageState = {
|
|
55
|
+
url: string;
|
|
56
|
+
state?: AjaxState;
|
|
57
|
+
document?: Document;
|
|
58
|
+
};
|
|
59
|
+
type Payload = {
|
|
60
|
+
from: PageState;
|
|
61
|
+
to: PageState;
|
|
62
|
+
isBackForward: boolean;
|
|
63
|
+
signal: AbortSignal;
|
|
64
|
+
waitUntil: WaitUntil;
|
|
65
|
+
};
|
|
21
66
|
type EventMap = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'before-visit': {
|
|
27
|
-
url: string;
|
|
28
|
-
prevUrl?: string;
|
|
29
|
-
};
|
|
30
|
-
'before-render': {
|
|
31
|
-
url: string;
|
|
32
|
-
prevUrl?: string;
|
|
33
|
-
newDocument: Document;
|
|
67
|
+
load: {
|
|
68
|
+
from: PageState;
|
|
69
|
+
to: PageState;
|
|
70
|
+
isBackForward: boolean;
|
|
34
71
|
};
|
|
72
|
+
'before-visit': Payload;
|
|
73
|
+
'before-swap': Payload;
|
|
74
|
+
'after-swap': Payload;
|
|
35
75
|
};
|
|
36
76
|
type DomEventMap = {
|
|
37
|
-
[K in keyof EventMap as `
|
|
77
|
+
[K in keyof EventMap as `ajax:${K & string}`]: CustomEvent<EventMap[K]>;
|
|
38
78
|
};
|
|
39
79
|
declare global {
|
|
40
80
|
interface DocumentEventMap extends DomEventMap {
|
|
41
81
|
}
|
|
42
82
|
}
|
|
43
83
|
|
|
44
|
-
declare const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
84
|
+
declare const start: (options?: StartOptions) => void;
|
|
85
|
+
|
|
86
|
+
declare const scrollPositions: Map<string, ScrollPosition>;
|
|
87
|
+
/**
|
|
88
|
+
* Load a new page, merge the regions/bodies and add a new history entry
|
|
89
|
+
* according to the history mode.
|
|
90
|
+
*/
|
|
91
|
+
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions, morphHeads, merge: mergeStrategy, executeScripts: shouldExecuteScripts, viewTransitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
|
|
48
92
|
|
|
49
|
-
export {
|
|
93
|
+
export { type AjaxState, type Config, type EventMap, type LoadingOptions, type MergeStrategy, type PageState, type ScrollBehavior, type ScrollPosition, type StartOptions, type VisitOptions, scrollPositions, start, visit };
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// src/config.ts
|
|
2
|
-
var
|
|
3
|
-
watchHistory: true,
|
|
2
|
+
var config = {
|
|
4
3
|
morphHeads: true,
|
|
5
4
|
merge: "replace",
|
|
5
|
+
executeScripts: true,
|
|
6
|
+
viewTransitions: false,
|
|
6
7
|
loadingDelay: 500,
|
|
7
|
-
progressHideDelay: 500
|
|
8
|
+
progressHideDelay: 500,
|
|
9
|
+
prefix: "data-ajax-"
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
// src/load.ts
|
|
@@ -12,6 +14,7 @@ var parser = new DOMParser();
|
|
|
12
14
|
var currentLoadController;
|
|
13
15
|
var progress = 0;
|
|
14
16
|
var trickleInterval;
|
|
17
|
+
var cache = /* @__PURE__ */ new Map();
|
|
15
18
|
var load = async (url, regions, options) => {
|
|
16
19
|
let progressDelayTimeout;
|
|
17
20
|
try {
|
|
@@ -27,14 +30,15 @@ var load = async (url, regions, options) => {
|
|
|
27
30
|
...options.request,
|
|
28
31
|
headers: {
|
|
29
32
|
...options.request?.headers,
|
|
30
|
-
"X-Very-Simple-
|
|
31
|
-
"X-Very-Simple-Regions": regions.join(" ")
|
|
33
|
+
"X-Very-Simple-Ajax": "true",
|
|
34
|
+
"X-Very-Simple-Ajax-Regions": regions.join(" ")
|
|
32
35
|
},
|
|
33
36
|
signal: currentLoadController.signal
|
|
34
37
|
});
|
|
35
38
|
const html = await response.text();
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
if (response.ok) cache.set(url, html);
|
|
40
|
+
const document2 = parseHtml(html);
|
|
41
|
+
return { document: document2, response };
|
|
38
42
|
} catch (e) {
|
|
39
43
|
setProgress(0);
|
|
40
44
|
const isAbortError = e instanceof DOMException && e.name === "AbortError";
|
|
@@ -49,6 +53,7 @@ var load = async (url, regions, options) => {
|
|
|
49
53
|
setTimeout(() => toggleProgress(false), options.progressHideDelay);
|
|
50
54
|
}
|
|
51
55
|
};
|
|
56
|
+
var parseHtml = (html) => parser.parseFromString(html, "text/html");
|
|
52
57
|
var trickle = () => {
|
|
53
58
|
const amount = -0.095 * progress + 0.1;
|
|
54
59
|
setProgress(progress + Math.random() * amount);
|
|
@@ -58,167 +63,364 @@ var stopTrickle = () => clearInterval(trickleInterval);
|
|
|
58
63
|
var setProgress = (value) => {
|
|
59
64
|
progress = value;
|
|
60
65
|
document.documentElement.style.setProperty(
|
|
61
|
-
"--
|
|
66
|
+
"--ajax-progress",
|
|
62
67
|
`${Math.round(value * 1e4) / 100}%`
|
|
63
68
|
);
|
|
64
69
|
};
|
|
65
|
-
var toggleProgress = (state) => document.documentElement.toggleAttribute(
|
|
66
|
-
var toggleLoading = (state) => document.documentElement.toggleAttribute(
|
|
70
|
+
var toggleProgress = (state) => document.documentElement.toggleAttribute(`${config.prefix}progress`, state);
|
|
71
|
+
var toggleLoading = (state) => document.documentElement.toggleAttribute(`${config.prefix}loading`, state);
|
|
67
72
|
|
|
68
73
|
// src/merge.ts
|
|
69
74
|
import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js";
|
|
70
75
|
var merge = (region, newRegion, strategy) => {
|
|
71
76
|
let autoFocusEl;
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
const { prefix, mount, unmount } = config;
|
|
78
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
79
|
+
if (strategy === "replace" || strategy === "update") {
|
|
80
|
+
const permanents = /* @__PURE__ */ new Map();
|
|
81
|
+
Array.from(
|
|
82
|
+
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`)
|
|
83
|
+
).forEach((el) => {
|
|
84
|
+
const original = region.querySelector(`#${el.id}`);
|
|
85
|
+
if (original) {
|
|
86
|
+
permanents.set(el.id, original);
|
|
87
|
+
original.remove();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
unmount?.(region);
|
|
91
|
+
if (strategy === "replace") region.replaceWith(newRegion);
|
|
92
|
+
else region.replaceChildren(...Array.from(newRegion.children));
|
|
93
|
+
Array.from(
|
|
94
|
+
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`)
|
|
95
|
+
).forEach((el) => {
|
|
96
|
+
const original = permanents.get(el.id);
|
|
97
|
+
if (original) el.replaceWith(original);
|
|
98
|
+
});
|
|
99
|
+
mount?.(strategy === "replace" ? newRegion : region);
|
|
74
100
|
} else if (strategy === "before") {
|
|
75
101
|
region.insertAdjacentElement("beforebegin", newRegion);
|
|
102
|
+
mount?.(newRegion);
|
|
76
103
|
} else if (strategy === "after") {
|
|
77
104
|
region.insertAdjacentElement("afterend", newRegion);
|
|
105
|
+
mount?.(newRegion);
|
|
78
106
|
} else if (strategy === "prepend") {
|
|
79
107
|
region.insertAdjacentElement("afterbegin", newRegion);
|
|
108
|
+
mount?.(newRegion);
|
|
80
109
|
} else if (strategy === "append") {
|
|
81
110
|
region.insertAdjacentElement("beforeend", newRegion);
|
|
82
|
-
|
|
83
|
-
region.replaceChildren(...Array.from(newRegion.children));
|
|
111
|
+
mount?.(newRegion);
|
|
84
112
|
} else if (strategy === "morph") {
|
|
85
113
|
const result = morph(region, newRegion);
|
|
86
114
|
autoFocusEl = result.autoFocusEl;
|
|
87
115
|
}
|
|
88
|
-
if (
|
|
89
|
-
newRegion.querySelectorAll("[data-simple-permanent][id]").forEach((el) => {
|
|
90
|
-
const originalEL = region.querySelector(`#${el.id}`);
|
|
91
|
-
if (originalEL) el.replaceWith(originalEL);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
if (newRegion.hasAttribute("data-simple-autofocus") || newRegion.autofocus) {
|
|
116
|
+
if (newRegion.hasAttribute(`${prefix}autofocus`) || newRegion.autofocus) {
|
|
95
117
|
autoFocusEl ??= newRegion;
|
|
96
118
|
}
|
|
97
|
-
autoFocusEl ??= newRegion.querySelector(
|
|
119
|
+
autoFocusEl ??= newRegion.querySelector(`[${escapedPrefix}autofocus]`) ?? newRegion.querySelector("[autofocus]") ?? void 0;
|
|
98
120
|
return { autoFocusEl };
|
|
99
121
|
};
|
|
100
122
|
var morph = (container, newContainer) => {
|
|
123
|
+
const { prefix, mount, unmount } = config;
|
|
101
124
|
const manualReplacements = [];
|
|
102
125
|
let autoFocusEls = [];
|
|
103
126
|
let currentNodeKeepAttributes = [];
|
|
104
127
|
Idiomorph.morph(container, newContainer, {
|
|
105
128
|
callbacks: {
|
|
106
129
|
beforeNodeMorphed(oldNode, newNode) {
|
|
107
|
-
if (!(oldNode instanceof
|
|
108
|
-
if (!(newNode instanceof
|
|
109
|
-
currentNodeKeepAttributes = oldNode.
|
|
110
|
-
if (oldNode.hasAttribute(
|
|
130
|
+
if (!(oldNode instanceof Element)) return true;
|
|
131
|
+
if (!(newNode instanceof Element)) return true;
|
|
132
|
+
currentNodeKeepAttributes = oldNode.getAttribute(`${prefix}keep-attributes`)?.split(" ") ?? [];
|
|
133
|
+
if (oldNode.hasAttribute(`${prefix}permanent`) && oldNode.id === newNode.id)
|
|
111
134
|
return false;
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
135
|
+
const oldIsComponent = oldNode.hasAttribute(`${prefix}component`);
|
|
136
|
+
const newIsComponent = newNode.hasAttribute(`${prefix}component`);
|
|
137
|
+
const isSameComponent = oldIsComponent && newIsComponent && oldNode.getAttribute(`${prefix}component`) === newNode.getAttribute(`${prefix}component`);
|
|
138
|
+
if (!oldIsComponent || isSameComponent) return true;
|
|
115
139
|
manualReplacements.push([oldNode, newNode]);
|
|
116
140
|
},
|
|
117
141
|
beforeAttributeUpdated: (name) => {
|
|
118
142
|
return !currentNodeKeepAttributes.includes(name);
|
|
119
143
|
},
|
|
144
|
+
beforeNodeRemoved(node) {
|
|
145
|
+
if (node instanceof Element && !node.hasAttribute(`${prefix}permanent`)) {
|
|
146
|
+
unmount?.(node);
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
},
|
|
150
|
+
afterNodeAdded(node) {
|
|
151
|
+
if (node instanceof Element && node.hasAttribute(`${prefix}component`)) {
|
|
152
|
+
mount?.(node);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
120
155
|
afterNodeMorphed(_, newNode) {
|
|
121
|
-
if (newNode instanceof HTMLElement && (newNode.autofocus || newNode.
|
|
156
|
+
if (newNode instanceof HTMLElement && (newNode.autofocus || newNode.hasAttribute(`${prefix}autofocus`))) {
|
|
122
157
|
autoFocusEls.push(newNode);
|
|
123
158
|
}
|
|
124
159
|
}
|
|
125
160
|
}
|
|
126
161
|
});
|
|
127
|
-
for (const [oldEl, newEl] of manualReplacements)
|
|
128
|
-
|
|
162
|
+
for (const [oldEl, newEl] of manualReplacements) {
|
|
163
|
+
unmount?.(oldEl);
|
|
164
|
+
oldEl.replaceWith(newEl);
|
|
165
|
+
mount?.(newEl);
|
|
166
|
+
}
|
|
167
|
+
const autoFocusEl = autoFocusEls.find((el) => el.hasAttribute(`${prefix}autofocus`)) ?? autoFocusEls.find((el) => el.autofocus);
|
|
129
168
|
return { autoFocusEl };
|
|
130
169
|
};
|
|
131
170
|
|
|
171
|
+
// src/scripts.ts
|
|
172
|
+
var executeScripts = (container) => {
|
|
173
|
+
const marker = `${config.prefix}script-executed`;
|
|
174
|
+
const waiting = [];
|
|
175
|
+
for (const script of container.querySelectorAll("script")) {
|
|
176
|
+
if (script.hasAttribute(marker)) continue;
|
|
177
|
+
const type = script.getAttribute("type");
|
|
178
|
+
if (type && type !== "module" && type !== "text/javascript") continue;
|
|
179
|
+
const newScript = document.createElement("script");
|
|
180
|
+
newScript.textContent = script.textContent;
|
|
181
|
+
for (const attr of script.attributes) {
|
|
182
|
+
newScript.setAttribute(attr.name, attr.value);
|
|
183
|
+
}
|
|
184
|
+
newScript.setAttribute(marker, "");
|
|
185
|
+
if (newScript.src) {
|
|
186
|
+
waiting.push(
|
|
187
|
+
new Promise((resolve) => {
|
|
188
|
+
newScript.onload = newScript.onerror = resolve;
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
script.replaceWith(newScript);
|
|
193
|
+
}
|
|
194
|
+
return Promise.all(waiting);
|
|
195
|
+
};
|
|
196
|
+
|
|
132
197
|
// src/visit.ts
|
|
133
198
|
import { Idiomorph as Idiomorph2 } from "idiomorph/dist/idiomorph.esm.js";
|
|
134
|
-
var
|
|
135
|
-
|
|
136
|
-
|
|
199
|
+
var normalizeUrl = (url) => {
|
|
200
|
+
try {
|
|
201
|
+
const urlObj = new URL(url, window.location.origin);
|
|
202
|
+
return urlObj.pathname + urlObj.search;
|
|
203
|
+
} catch {
|
|
204
|
+
return url;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
var emit = (type, payload) => {
|
|
208
|
+
const waiting = [];
|
|
209
|
+
const waitUntil = (promise) => waiting.push(Promise.resolve(promise));
|
|
210
|
+
payload = { ...payload, waitUntil };
|
|
211
|
+
const event = new CustomEvent(`ajax:${type}`, { detail: payload });
|
|
137
212
|
document.dispatchEvent(event);
|
|
213
|
+
if (waiting.length) return Promise.allSettled(waiting);
|
|
138
214
|
};
|
|
139
215
|
var currentUrl = window.location.pathname;
|
|
140
|
-
var
|
|
216
|
+
var currentState = history.state;
|
|
217
|
+
var currentVisitController;
|
|
218
|
+
var scrollPositions = /* @__PURE__ */ new Map();
|
|
141
219
|
var visit = async (url, {
|
|
142
|
-
|
|
143
|
-
emitEvents = true,
|
|
220
|
+
history: historyMode = "none",
|
|
144
221
|
isBackForward = false,
|
|
145
|
-
morphHeads = globalConfig.morphHeads,
|
|
146
|
-
merge: mergeStrategy = globalConfig.merge,
|
|
147
|
-
loadingDelay = globalConfig.loadingDelay,
|
|
148
|
-
progressHideDelay = globalConfig.progressHideDelay,
|
|
149
222
|
autoFocus = true,
|
|
150
223
|
request,
|
|
151
|
-
|
|
224
|
+
state,
|
|
225
|
+
regions = [],
|
|
226
|
+
morphHeads = config.morphHeads,
|
|
227
|
+
merge: mergeStrategy = config.merge,
|
|
228
|
+
executeScripts: shouldExecuteScripts = config.executeScripts,
|
|
229
|
+
viewTransitions = config.viewTransitions,
|
|
230
|
+
loadingDelay = config.loadingDelay,
|
|
231
|
+
progressHideDelay = config.progressHideDelay
|
|
152
232
|
} = {}) => {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
233
|
+
url = normalizeUrl(url);
|
|
234
|
+
const fromUrl = currentUrl;
|
|
235
|
+
const fromState = currentState;
|
|
236
|
+
state = { ...state, regions };
|
|
237
|
+
currentVisitController?.abort();
|
|
238
|
+
currentVisitController = new AbortController();
|
|
239
|
+
const { signal } = currentVisitController;
|
|
240
|
+
if (url === currentUrl) historyMode = "none";
|
|
241
|
+
await emit("before-visit", {
|
|
242
|
+
from: { url: fromUrl, state: fromState, document },
|
|
243
|
+
to: { url, state },
|
|
244
|
+
signal,
|
|
245
|
+
isBackForward
|
|
246
|
+
});
|
|
247
|
+
if (signal.aborted) return;
|
|
156
248
|
let newDocument;
|
|
157
249
|
if (isBackForward) {
|
|
158
|
-
|
|
250
|
+
const html = cache.get(url);
|
|
251
|
+
if (html) newDocument = parseHtml(html);
|
|
159
252
|
}
|
|
160
253
|
if (!newDocument) {
|
|
161
254
|
const options = { loadingDelay, progressHideDelay, request };
|
|
162
255
|
const result = await load(url, regions, options);
|
|
163
|
-
if (result)
|
|
256
|
+
if (result) {
|
|
257
|
+
newDocument = result.document;
|
|
258
|
+
url = result.response.redirected ? result.response.url : url;
|
|
259
|
+
}
|
|
164
260
|
}
|
|
165
261
|
if (!newDocument) return;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
262
|
+
currentUrl = url;
|
|
263
|
+
currentState = state;
|
|
264
|
+
if (historyMode === "replace") history.replaceState(state, "", url);
|
|
265
|
+
else if (historyMode === "push") history.pushState(state, "", url);
|
|
266
|
+
const from = { url: fromUrl, state: fromState, document };
|
|
267
|
+
const to = { url, state, document: newDocument };
|
|
268
|
+
await emit("before-swap", { from, to, isBackForward, signal });
|
|
269
|
+
if (signal.aborted) return;
|
|
171
270
|
if (morphHeads) Idiomorph2.morph(document.head, newDocument.head);
|
|
172
|
-
const getMergeStrategy = (oldEl, newEl) => newEl.
|
|
271
|
+
const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
|
|
173
272
|
let autoFocusEl;
|
|
174
273
|
const hasMatchingRegions = regions?.some(
|
|
175
274
|
(selector) => document.querySelector(selector) && newDocument.querySelector(selector)
|
|
176
275
|
);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
276
|
+
const mergeRegions = async () => {
|
|
277
|
+
const scriptPromises = [];
|
|
278
|
+
if (hasMatchingRegions) {
|
|
279
|
+
for (const id of regions) {
|
|
280
|
+
const region = document.querySelector(id);
|
|
281
|
+
const newRegion = newDocument.querySelector(id);
|
|
282
|
+
if (!region || !newRegion) continue;
|
|
283
|
+
const strategy = getMergeStrategy(region, newRegion);
|
|
284
|
+
const result = merge(region, newRegion, strategy);
|
|
285
|
+
autoFocusEl ??= result.autoFocusEl;
|
|
286
|
+
if (shouldExecuteScripts) {
|
|
287
|
+
const finalRegion = document.querySelector(id);
|
|
288
|
+
if (finalRegion) scriptPromises.push(executeScripts(finalRegion));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const region = document.body;
|
|
293
|
+
const newRegion = newDocument.body;
|
|
182
294
|
const strategy = getMergeStrategy(region, newRegion);
|
|
183
295
|
const result = merge(region, newRegion, strategy);
|
|
184
|
-
autoFocusEl
|
|
296
|
+
autoFocusEl = result.autoFocusEl;
|
|
297
|
+
if (shouldExecuteScripts) {
|
|
298
|
+
scriptPromises.push(executeScripts(document.body));
|
|
299
|
+
}
|
|
185
300
|
}
|
|
301
|
+
if (scriptPromises.length) await Promise.all(scriptPromises);
|
|
302
|
+
};
|
|
303
|
+
if (fromUrl) scrollPositions.set(fromUrl, { top: window.scrollY });
|
|
304
|
+
const applyScrollBehavior = () => {
|
|
305
|
+
if (!config.scrollBehavior) return;
|
|
306
|
+
const savedPosition = isBackForward ? scrollPositions.get(url) : void 0;
|
|
307
|
+
const position = config.scrollBehavior({
|
|
308
|
+
from,
|
|
309
|
+
to,
|
|
310
|
+
isBackForward,
|
|
311
|
+
savedPosition
|
|
312
|
+
});
|
|
313
|
+
if (position) window.scrollTo(position);
|
|
314
|
+
};
|
|
315
|
+
if (config.render) {
|
|
316
|
+
await config.render(newDocument);
|
|
317
|
+
} else if (viewTransitions && document.startViewTransition) {
|
|
318
|
+
await document.startViewTransition(async () => {
|
|
319
|
+
await mergeRegions();
|
|
320
|
+
await emit("after-swap", { from, to, isBackForward, signal });
|
|
321
|
+
applyScrollBehavior();
|
|
322
|
+
}).ready;
|
|
186
323
|
} else {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const result = merge(region, newRegion, strategy);
|
|
191
|
-
autoFocusEl = result.autoFocusEl;
|
|
324
|
+
await mergeRegions();
|
|
325
|
+
await emit("after-swap", { from, to, isBackForward, signal });
|
|
326
|
+
applyScrollBehavior();
|
|
192
327
|
}
|
|
193
|
-
if (
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
if (emitEvents) emit("visit", { url, prevUrl });
|
|
328
|
+
if (signal.aborted) return;
|
|
329
|
+
if (autoFocus) autoFocusEl?.focus({ preventScroll: true });
|
|
330
|
+
emit("load", { from, to, isBackForward });
|
|
197
331
|
};
|
|
198
332
|
|
|
199
|
-
// src/
|
|
200
|
-
var
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
333
|
+
// src/intercept.ts
|
|
334
|
+
var interceptLinks = () => {
|
|
335
|
+
document.addEventListener("click", (e) => {
|
|
336
|
+
if (e.defaultPrevented || isModifiedClick(e)) return;
|
|
337
|
+
const target = e.composedPath ? e.composedPath()[0] : e.target;
|
|
338
|
+
const link = target?.closest?.("a");
|
|
339
|
+
if (!link || !link.href) return;
|
|
340
|
+
const isOptOut = link.hasAttribute(`${config.prefix}reload`);
|
|
341
|
+
const isExternalTarget = link.target && link.target !== "_self";
|
|
342
|
+
if (isOptOut || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href))
|
|
343
|
+
return;
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
visit(link.href, { history: "push", ...getVisitOptions(link) });
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
var interceptForms = () => {
|
|
349
|
+
document.addEventListener("submit", (e) => {
|
|
350
|
+
const form = e.target;
|
|
351
|
+
if (e.defaultPrevented || !(form instanceof HTMLFormElement) || form.hasAttribute(`${config.prefix}reload`))
|
|
352
|
+
return;
|
|
353
|
+
const submitter = e.submitter;
|
|
354
|
+
const formAction = typeof form.action === "string" ? form.action : form.getAttribute("action");
|
|
355
|
+
const formMethod = typeof form.method === "string" ? form.method : form.getAttribute("method");
|
|
356
|
+
const method = (submitter?.getAttribute("formmethod") ?? formMethod ?? "get").toLowerCase();
|
|
357
|
+
if (method === "dialog") return;
|
|
358
|
+
const action = submitter?.getAttribute("formaction") ?? formAction ?? location.href;
|
|
359
|
+
if (!isSameOrigin(action)) return;
|
|
360
|
+
const formData = new FormData(form, submitter ?? void 0);
|
|
361
|
+
let url = action;
|
|
362
|
+
const request = { method };
|
|
363
|
+
if (method === "get") {
|
|
364
|
+
const urlObj = new URL(action, location.href);
|
|
365
|
+
urlObj.search = new URLSearchParams(formData).toString();
|
|
366
|
+
url = urlObj.toString();
|
|
367
|
+
} else {
|
|
368
|
+
request.body = formData;
|
|
369
|
+
}
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
visit(url, { history: "push", ...getVisitOptions(form), request });
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
var interceptHistory = () => {
|
|
375
|
+
window.addEventListener("popstate", (event) => {
|
|
376
|
+
if (normalizeUrl(window.location.href) === normalizeUrl(currentUrl)) return;
|
|
377
|
+
visit(window.location.href, {
|
|
378
|
+
history: "none",
|
|
379
|
+
isBackForward: true,
|
|
380
|
+
regions: currentState?.regions ?? [],
|
|
381
|
+
state: event.state
|
|
216
382
|
});
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
var isSameOrigin = (url) => new URL(url, location.href).origin === location.origin;
|
|
386
|
+
var isModifiedClick = (e) => e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
|
|
387
|
+
var getVisitOptions = (el) => {
|
|
388
|
+
const prefix = config.prefix;
|
|
389
|
+
const options = {};
|
|
390
|
+
const historyAttr = el.getAttribute(`${prefix}history`);
|
|
391
|
+
if (historyAttr) options.history = historyAttr;
|
|
392
|
+
const regions = el.getAttribute(`${prefix}regions`);
|
|
393
|
+
if (regions) {
|
|
394
|
+
options.regions = regions.split(",").map((region) => region.trim()).filter(Boolean);
|
|
217
395
|
}
|
|
396
|
+
const state = el.getAttribute(`${prefix}state`);
|
|
397
|
+
if (state) {
|
|
398
|
+
try {
|
|
399
|
+
options.state = JSON.parse(state);
|
|
400
|
+
} catch {
|
|
401
|
+
console.error(`Invalid ${prefix}state on`, el);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const merge2 = el.getAttribute(`${prefix}merge`);
|
|
405
|
+
if (merge2) options.merge = merge2;
|
|
406
|
+
return options;
|
|
218
407
|
};
|
|
219
408
|
|
|
220
|
-
// src/
|
|
221
|
-
var
|
|
409
|
+
// src/start.ts
|
|
410
|
+
var start = (options = {}) => {
|
|
411
|
+
Object.assign(config, options);
|
|
412
|
+
if (config.scrollBehavior) history.scrollRestoration = "manual";
|
|
413
|
+
interceptLinks();
|
|
414
|
+
interceptForms();
|
|
415
|
+
interceptHistory();
|
|
416
|
+
if (import.meta.env.DEV) {
|
|
417
|
+
document.addEventListener("ajax:before-visit", () => {
|
|
418
|
+
document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
};
|
|
222
422
|
export {
|
|
223
|
-
|
|
423
|
+
scrollPositions,
|
|
424
|
+
start,
|
|
425
|
+
visit
|
|
224
426
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@very-simple/ajax",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A very simple turbolinks inspired library",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "A very simple turbolinks inspired ajax library",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
],
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "https://github.com/very-simple
|
|
13
|
+
"url": "https://github.com/arnoson/very-simple-ajax.git"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsup src/index.ts --dts --format esm",
|
|
17
17
|
"dev": "vite",
|
|
18
18
|
"test": "playwright test",
|
|
19
|
+
"test:types": "tsc --noEmit",
|
|
20
|
+
"size": "esbuild src/index.ts --bundle --minify --format=esm | gzip -c | wc -c",
|
|
19
21
|
"release": "npm run build && npm run test && bumpp"
|
|
20
22
|
},
|
|
21
23
|
"author": "arnoson",
|
|
@@ -23,12 +25,11 @@
|
|
|
23
25
|
"devDependencies": {
|
|
24
26
|
"@picocss/pico": "^2.0.6",
|
|
25
27
|
"@playwright/test": "^1.48.2",
|
|
26
|
-
"@types/node": "^
|
|
27
|
-
"@very-simple/components": "^0.6.0",
|
|
28
|
+
"@types/node": "^24",
|
|
28
29
|
"bumpp": "^10.0.0",
|
|
29
30
|
"tsup": "8",
|
|
30
|
-
"typescript": "^
|
|
31
|
-
"vite": "^
|
|
31
|
+
"typescript": "^6",
|
|
32
|
+
"vite": "^8"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"idiomorph": "^0.7.0"
|