@very-simple/ajax 0.7.1 → 1.0.0-beta.10
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 +244 -2
- package/dist/index.d.ts +99 -25
- package/dist/index.js +482 -117
- 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,246 @@ 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
|
+
| `data-ajax-prefetch` | Prefetch the link before it's clicked: `hover`, `mousedown` or `false` to opt out. Without a value the `prefetch` option is used, falling back to `hover`. |
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<!-- only swap #content, preserving the merge strategy for that region -->
|
|
46
|
+
<a href="/about" data-ajax-regions="#content" data-ajax-merge="morph">About</a>
|
|
47
|
+
|
|
48
|
+
<!-- opt out of ajax handling for this link, e.g. to force a full reload -->
|
|
49
|
+
<a href="/logout" data-ajax-reload>Logout</a>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> The `data-ajax-` prefix can be customized via the `prefix` option in `start()`.
|
|
53
|
+
|
|
54
|
+
### `start(options?)`
|
|
55
|
+
|
|
56
|
+
Call this once when your app boots. It intercepts same-origin link clicks and
|
|
57
|
+
form submissions and turns them into ajax visits, and hooks into browser
|
|
58
|
+
back/forward navigation.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { start } from '@very-simple/ajax'
|
|
62
|
+
|
|
63
|
+
start({
|
|
64
|
+
// Strategy used to merge the swapped content into the current DOM.
|
|
65
|
+
merge: 'replace',
|
|
66
|
+
// How to update the `<head>`: 'merge', 'append', 'morph' or 'none'.
|
|
67
|
+
head: 'merge',
|
|
68
|
+
// Re-execute `<script>` tags found in the swapped content.
|
|
69
|
+
executeScripts: true,
|
|
70
|
+
// `'view'` wraps the swap in the View Transitions API (if supported), `'css'`
|
|
71
|
+
// keeps the old and new region in the DOM and sets `data-ajax-leaving` /
|
|
72
|
+
// `data-ajax-entering` for CSS transitions (only for the `replace` merge).
|
|
73
|
+
transitions: false,
|
|
74
|
+
// Delay (ms) before showing a loading indicator on slow visits.
|
|
75
|
+
loadingDelay: 500,
|
|
76
|
+
// Delay (ms) before hiding the loading indicator again.
|
|
77
|
+
progressHideDelay: 500,
|
|
78
|
+
// Prefix used for the `data-ajax-*` attributes.
|
|
79
|
+
prefix: 'data-ajax-',
|
|
80
|
+
// Default selectors to swap instead of the whole `<body>`, used for visits
|
|
81
|
+
// that don't specify their own `regions` (e.g. via `data-ajax-regions`).
|
|
82
|
+
regions: [],
|
|
83
|
+
// Default prefetch strategy for links: false, 'hover' (hover/focus) or
|
|
84
|
+
// 'mousedown' (press). Touch always prefetches on touchstart.
|
|
85
|
+
prefetch: false,
|
|
86
|
+
// How long (ms) a prefetched response stays usable.
|
|
87
|
+
prefetchTtl: 30000,
|
|
88
|
+
// Custom scroll handling, e.g. restoring position on back/forward visits.
|
|
89
|
+
scrollBehavior: ({ isBackForward, savedPosition }) =>
|
|
90
|
+
isBackForward ? savedPosition : { top: 0 },
|
|
91
|
+
// Called after an element is added to the DOM by a merge.
|
|
92
|
+
mount: (el) => {},
|
|
93
|
+
// Called before an element is removed from the DOM by a merge.
|
|
94
|
+
unmount: (el) => {},
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`head`, `executeScripts` and `transitions` also accept a callback
|
|
99
|
+
receiving `{ from, to, isBackForward }`. Combined with `data-ajax-state` this
|
|
100
|
+
allows deciding per link, e.g. to only add new `<head>` elements (scripts,
|
|
101
|
+
styles) without removing existing ones when loading a partial:
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<a href="/partial" data-ajax-state='{"isPartial": true}'>Partial</a>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
start({ head: ({ to }) => (to.state?.isPartial ? 'append' : 'merge') })
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `visit(url, options?)`
|
|
112
|
+
|
|
113
|
+
A lower-level API for triggering a visit programmatically, e.g. in
|
|
114
|
+
response to a custom event or timer instead of a link click:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { visit } from '@very-simple/ajax'
|
|
118
|
+
|
|
119
|
+
visit('/about', {
|
|
120
|
+
// History mode to use: `push`, `replace` or `none`.
|
|
121
|
+
history: 'push',
|
|
122
|
+
// Selectors to swap instead of the whole `<body>`.
|
|
123
|
+
regions: ['#content'],
|
|
124
|
+
// State to store in `history.state` for this visit.
|
|
125
|
+
state: {},
|
|
126
|
+
// Options passed through to the underlying `fetch()` call.
|
|
127
|
+
request: {},
|
|
128
|
+
// Focus the first element with `autofocus` in the swapped content.
|
|
129
|
+
autoFocus: true,
|
|
130
|
+
// ... You can also overwrite any option set in `start()` an a per-visit bases here.
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Events
|
|
135
|
+
|
|
136
|
+
Every visit fires a series of `CustomEvent`s on `document`, each namespaced
|
|
137
|
+
with `ajax:`:
|
|
138
|
+
|
|
139
|
+
| Event | Description |
|
|
140
|
+
| ------------------- | -------------------------------------------------------------------------- |
|
|
141
|
+
| `ajax:before-visit` | Fired before the new page is fetched. |
|
|
142
|
+
| `ajax:before-swap` | Fired after the new document has been loaded, right before it's merged in. |
|
|
143
|
+
| `ajax:after-swap` | Fired right after the new content has been merged in. |
|
|
144
|
+
| `ajax:load` | Fired once the whole visit (including scroll/focus handling) has finished. |
|
|
145
|
+
|
|
146
|
+
#### Async listeners
|
|
147
|
+
|
|
148
|
+
Use `event.detail.waitUntil(promise)` on those same events to delay the
|
|
149
|
+
visit until the given promise settles, e.g. to run an animation before
|
|
150
|
+
continuing:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
document.addEventListener('ajax:before-swap', (e) => {
|
|
154
|
+
e.detail.waitUntil(animateIn())
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
#### Aborting
|
|
159
|
+
|
|
160
|
+
`before-visit`, `before-swap` and `after-swap` carry an `AbortSignal` in
|
|
161
|
+
`event.detail.signal`, which is aborted if the visit gets superseded by a
|
|
162
|
+
newer one (e.g. the user clicks another link while yours is still running).
|
|
163
|
+
If your listener does anything that isn't automatically cancelled, like a
|
|
164
|
+
manual animation loop, check `signal.aborted` (or listen for the signal's
|
|
165
|
+
`abort` event) to stop it early:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
document.addEventListener('ajax:before-swap', (e) => {
|
|
169
|
+
const { signal } = e.detail
|
|
170
|
+
const stopWhenAborted = () => (running = false)
|
|
171
|
+
signal.addEventListener('abort', stopWhenAborted)
|
|
172
|
+
|
|
173
|
+
let running = true
|
|
174
|
+
const loop = () => {
|
|
175
|
+
if (!running) return
|
|
176
|
+
// ...do some animation work...
|
|
177
|
+
requestAnimationFrame(loop)
|
|
178
|
+
}
|
|
179
|
+
loop()
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Common patterns
|
|
184
|
+
|
|
185
|
+
### Load more
|
|
186
|
+
|
|
187
|
+
Turn a "load more" link into an infinite-scroll-like list by appending the
|
|
188
|
+
new region into the existing one and replacing the link itself with the next
|
|
189
|
+
page's link:
|
|
190
|
+
|
|
191
|
+
```html
|
|
192
|
+
<div id="posts" data-ajax-merge="append">
|
|
193
|
+
<article>1. Lorem Ipsum</article>
|
|
194
|
+
<article>2. Lorem Ipsum</article>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<a
|
|
198
|
+
id="load-more"
|
|
199
|
+
href="/posts?page=2"
|
|
200
|
+
data-ajax-history="none"
|
|
201
|
+
data-ajax-regions="#posts, #load-more"
|
|
202
|
+
>
|
|
203
|
+
More
|
|
204
|
+
</a>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Since `#load-more` is one of the swapped regions, the response's `#load-more`
|
|
208
|
+
link (pointing to page 3) replaces this one, so clicking it again keeps
|
|
209
|
+
loading further pages.
|
|
210
|
+
|
|
211
|
+
### Loading content into a dialog
|
|
212
|
+
|
|
213
|
+
Regions aren't limited to the elements currently visible on the page. You can
|
|
214
|
+
target a hidden `<dialog>` and only swap its content, leaving the rest of the
|
|
215
|
+
page untouched:
|
|
216
|
+
|
|
217
|
+
```html
|
|
218
|
+
<dialog id="preview"></dialog>
|
|
219
|
+
|
|
220
|
+
<a href="/posts/1" data-ajax-regions="#preview" data-ajax-history="none">
|
|
221
|
+
Preview
|
|
222
|
+
</a>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The response for `/posts/1` provides its own `#preview` region and opens
|
|
226
|
+
itself via an inline script, since `executeScripts` re-runs `<script>` tags
|
|
227
|
+
found in the swapped content:
|
|
228
|
+
|
|
229
|
+
```html
|
|
230
|
+
<dialog id="preview">
|
|
231
|
+
<h2>Post 1</h2>
|
|
232
|
+
<script>
|
|
233
|
+
document.getElementById('preview').showModal()
|
|
234
|
+
</script>
|
|
235
|
+
</dialog>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Permanent elements
|
|
239
|
+
|
|
240
|
+
Mark an element with `data-ajax-permanent` (and give it a stable `id`) to
|
|
241
|
+
keep it alive across visits instead of replacing it, e.g. a currently
|
|
242
|
+
playing `<audio>`/`<video>` element or anything else with state that
|
|
243
|
+
shouldn't be reset:
|
|
244
|
+
|
|
245
|
+
```html
|
|
246
|
+
<div id="player" data-ajax-permanent>
|
|
247
|
+
<audio src="/song.mp3" controls></audio>
|
|
248
|
+
</div>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
As long as an element with the same `id` exists in both the old and the new
|
|
252
|
+
document, it's kept instead of being replaced.
|
|
253
|
+
|
|
254
|
+
> With the default `replace`/`update` merge strategies the permanent element
|
|
255
|
+
> is still moved to wherever it appears in the new content, which can cause a
|
|
256
|
+
> visible flash for things like playing animations. Use `merge: 'morph'`
|
|
257
|
+
> instead (globally or via `data-ajax-merge`) so the element is truly left
|
|
258
|
+
> untouched in place.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,49 +1,123 @@
|
|
|
1
|
-
interface
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
interface AjaxState extends Record<string, unknown> {
|
|
2
|
+
regions?: string[];
|
|
3
|
+
index?: number;
|
|
4
|
+
}
|
|
5
|
+
/** Also accepts all `Config` options, e.g. `merge`, `scrollBehavior`, etc. */
|
|
6
|
+
interface VisitOptions extends Config {
|
|
7
|
+
history?: 'push' | 'replace' | 'none';
|
|
4
8
|
isBackForward?: boolean;
|
|
5
|
-
merge?: MergeStrategy;
|
|
6
|
-
morphHeads?: boolean;
|
|
7
9
|
autoFocus?: boolean;
|
|
10
|
+
request?: RequestInit;
|
|
11
|
+
regions?: string[];
|
|
12
|
+
state?: Omit<AjaxState, 'regions' | 'index'>;
|
|
13
|
+
}
|
|
14
|
+
type StartOptions = Config;
|
|
15
|
+
interface LoadingOptions {
|
|
8
16
|
loadingDelay?: number;
|
|
9
17
|
progressHideDelay?: number;
|
|
10
18
|
request?: RequestInit;
|
|
11
|
-
regions?: string[];
|
|
12
19
|
}
|
|
13
20
|
type MergeStrategy = 'replace' | 'morph' | 'before' | 'after' | 'prepend' | 'append' | 'update';
|
|
21
|
+
/** `true` is `'merge'`, `false` is `'none'`. */
|
|
22
|
+
type HeadMode = 'merge' | 'append' | 'morph' | 'none' | boolean;
|
|
14
23
|
interface Config {
|
|
15
|
-
|
|
24
|
+
/** Strategy used to merge the swapped content into the current DOM. */
|
|
16
25
|
merge?: MergeStrategy;
|
|
17
|
-
|
|
26
|
+
/**
|
|
27
|
+
* How to update the `<head>`: `'merge'` adds new and removes stale elements,
|
|
28
|
+
* `'append'` only adds new ones, `'morph'` morphs it like any element and
|
|
29
|
+
* `'none'` leaves it untouched. A callback decides per visit.
|
|
30
|
+
*/
|
|
31
|
+
head?: Resolvable<HeadMode>;
|
|
32
|
+
/** Re-execute `<script>` tags found in the swapped content. A callback decides per visit. */
|
|
33
|
+
executeScripts?: Resolvable<boolean>;
|
|
34
|
+
/**
|
|
35
|
+
* `'view'` wraps the swap in the View Transitions API (if supported), `'css'`
|
|
36
|
+
* keeps the old and new region in the DOM and sets `data-ajax-leaving` /
|
|
37
|
+
* `data-ajax-entering` so CSS can animate them (only for `replace`). A
|
|
38
|
+
* callback decides per visit.
|
|
39
|
+
*/
|
|
40
|
+
transitions?: Resolvable<false | 'view' | 'css'>;
|
|
41
|
+
/** Custom scroll handling, e.g. restoring position on back/forward visits. */
|
|
42
|
+
scrollBehavior?: ScrollBehavior;
|
|
43
|
+
/** Delay (ms) before showing a loading indicator on slow visits. */
|
|
18
44
|
loadingDelay?: number;
|
|
45
|
+
/** Delay (ms) before hiding the loading indicator again. */
|
|
19
46
|
progressHideDelay?: number;
|
|
47
|
+
/** Prefix used for the `data-ajax-*` attributes. */
|
|
48
|
+
prefix?: string;
|
|
49
|
+
/** Default regions used for visits that don't specify their own. */
|
|
50
|
+
regions?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Default prefetch strategy for links: `'hover'` on hover/focus, `'mousedown'`
|
|
53
|
+
* on press. Links can override it with `data-ajax-prefetch`.
|
|
54
|
+
*/
|
|
55
|
+
prefetch?: false | 'hover' | 'mousedown';
|
|
56
|
+
/** How long (ms) a prefetched response stays usable. */
|
|
57
|
+
prefetchTtl?: number;
|
|
58
|
+
/** Called after an element is added to the DOM by a merge. */
|
|
59
|
+
mount?: (el: Element) => void;
|
|
60
|
+
/** Called before an element is removed from the DOM by a merge. */
|
|
61
|
+
unmount?: (el: Element) => void;
|
|
20
62
|
}
|
|
63
|
+
/** A value, or a callback resolving it right before the swap. */
|
|
64
|
+
type Resolvable<T> = T | ((info: {
|
|
65
|
+
from: PageState;
|
|
66
|
+
to: PageState;
|
|
67
|
+
isBackForward: boolean;
|
|
68
|
+
}) => T);
|
|
69
|
+
type ScrollPosition = {
|
|
70
|
+
top: number;
|
|
71
|
+
};
|
|
72
|
+
type ScrollBehavior = (info: {
|
|
73
|
+
from: PageState;
|
|
74
|
+
to: PageState;
|
|
75
|
+
isBackForward: boolean;
|
|
76
|
+
savedPosition?: ScrollPosition;
|
|
77
|
+
}) => ScrollPosition | false | undefined;
|
|
78
|
+
type WaitUntil = (promise: Promise<unknown>) => void;
|
|
79
|
+
type PageState = {
|
|
80
|
+
url: string;
|
|
81
|
+
state?: AjaxState;
|
|
82
|
+
document?: Document;
|
|
83
|
+
};
|
|
84
|
+
type Payload = {
|
|
85
|
+
from: PageState;
|
|
86
|
+
to: PageState;
|
|
87
|
+
isBackForward: boolean;
|
|
88
|
+
signal: AbortSignal;
|
|
89
|
+
waitUntil: WaitUntil;
|
|
90
|
+
};
|
|
21
91
|
type EventMap = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
92
|
+
load: {
|
|
93
|
+
from: PageState;
|
|
94
|
+
to: PageState;
|
|
95
|
+
isBackForward: boolean;
|
|
25
96
|
};
|
|
26
|
-
'before-visit':
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
prevUrl?: string;
|
|
33
|
-
newDocument: Document;
|
|
97
|
+
'before-visit': Payload;
|
|
98
|
+
'before-swap': Payload;
|
|
99
|
+
'after-swap': Payload;
|
|
100
|
+
'progress-start': Record<string, never>;
|
|
101
|
+
progress: {
|
|
102
|
+
progress: number;
|
|
34
103
|
};
|
|
104
|
+
'progress-end': Record<string, never>;
|
|
35
105
|
};
|
|
36
106
|
type DomEventMap = {
|
|
37
|
-
[K in keyof EventMap as `
|
|
107
|
+
[K in keyof EventMap as `ajax:${K & string}`]: CustomEvent<EventMap[K]>;
|
|
38
108
|
};
|
|
39
109
|
declare global {
|
|
40
110
|
interface DocumentEventMap extends DomEventMap {
|
|
41
111
|
}
|
|
42
112
|
}
|
|
43
113
|
|
|
44
|
-
declare const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
114
|
+
declare const start: (options?: StartOptions) => void;
|
|
115
|
+
|
|
116
|
+
declare const scrollPositions: Map<string, ScrollPosition>;
|
|
117
|
+
/**
|
|
118
|
+
* Load a new page, merge the regions/bodies and add a new history entry
|
|
119
|
+
* according to the history mode.
|
|
120
|
+
*/
|
|
121
|
+
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions, head, merge: mergeStrategy, executeScripts: executeScriptsOption, transitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
|
|
48
122
|
|
|
49
|
-
export {
|
|
123
|
+
export { type AjaxState, type Config, type EventMap, type HeadMode, type LoadingOptions, type MergeStrategy, type PageState, type Resolvable, type ScrollBehavior, type ScrollPosition, type StartOptions, type VisitOptions, scrollPositions, start, visit };
|
package/dist/index.js
CHANGED
|
@@ -1,159 +1,337 @@
|
|
|
1
1
|
// src/config.ts
|
|
2
|
-
var
|
|
3
|
-
|
|
4
|
-
morphHeads: true,
|
|
2
|
+
var config = {
|
|
3
|
+
head: "merge",
|
|
5
4
|
merge: "replace",
|
|
5
|
+
executeScripts: true,
|
|
6
|
+
transitions: false,
|
|
6
7
|
loadingDelay: 500,
|
|
7
|
-
progressHideDelay: 500
|
|
8
|
+
progressHideDelay: 500,
|
|
9
|
+
prefix: "data-ajax-",
|
|
10
|
+
regions: [],
|
|
11
|
+
prefetch: false,
|
|
12
|
+
prefetchTtl: 3e4
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/prefetch.ts
|
|
16
|
+
var store = /* @__PURE__ */ new Map();
|
|
17
|
+
var keyOf = (url, regions) => `${url}
|
|
18
|
+
${regions.join(" ")}`;
|
|
19
|
+
var isFresh = (time) => Date.now() - time < config.prefetchTtl;
|
|
20
|
+
var prefetch = (url, regions) => {
|
|
21
|
+
for (const [key2, entry] of store) if (!isFresh(entry.time)) store.delete(key2);
|
|
22
|
+
const key = keyOf(url, regions);
|
|
23
|
+
if (store.has(key)) return;
|
|
24
|
+
const promise = fetch(url, {
|
|
25
|
+
headers: {
|
|
26
|
+
"X-Very-Simple-Ajax": "true",
|
|
27
|
+
"X-Very-Simple-Ajax-Regions": regions.join(" ")
|
|
28
|
+
}
|
|
29
|
+
}).then(async (response) => {
|
|
30
|
+
if (!response.ok) return void 0;
|
|
31
|
+
return { html: await response.text(), response };
|
|
32
|
+
}).catch(() => void 0).then((result) => {
|
|
33
|
+
if (!result) store.delete(key);
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
36
|
+
store.set(key, { time: Date.now(), promise });
|
|
37
|
+
};
|
|
38
|
+
var takePrefetched = (url, regions) => {
|
|
39
|
+
const key = keyOf(url, regions);
|
|
40
|
+
const entry = store.get(key);
|
|
41
|
+
store.delete(key);
|
|
42
|
+
return entry && isFresh(entry.time) ? entry.promise : void 0;
|
|
8
43
|
};
|
|
9
44
|
|
|
10
45
|
// src/load.ts
|
|
11
46
|
var parser = new DOMParser();
|
|
12
47
|
var currentLoadController;
|
|
48
|
+
var progressDelayTimeout;
|
|
13
49
|
var progress = 0;
|
|
14
50
|
var trickleInterval;
|
|
51
|
+
var emit = (type, payload) => document.dispatchEvent(new CustomEvent(`ajax:${type}`, { detail: payload }));
|
|
52
|
+
var cache = /* @__PURE__ */ new Map();
|
|
15
53
|
var load = async (url, regions, options) => {
|
|
16
|
-
let
|
|
54
|
+
let aborted = false;
|
|
17
55
|
try {
|
|
18
56
|
currentLoadController?.abort();
|
|
57
|
+
clearTimeout(progressDelayTimeout);
|
|
19
58
|
currentLoadController = new AbortController();
|
|
20
59
|
setProgress(0);
|
|
21
60
|
progressDelayTimeout = window.setTimeout(() => {
|
|
22
|
-
|
|
23
|
-
toggleProgress(true);
|
|
61
|
+
emit("progress-start", {});
|
|
24
62
|
startTrickle();
|
|
25
63
|
}, options.loadingDelay);
|
|
26
|
-
const
|
|
64
|
+
const prefetched = await takePrefetched(url, regions);
|
|
65
|
+
const response = prefetched?.response ?? await fetch(url, {
|
|
27
66
|
...options.request,
|
|
28
67
|
headers: {
|
|
29
68
|
...options.request?.headers,
|
|
30
|
-
"X-Very-Simple-
|
|
31
|
-
"X-Very-Simple-Regions": regions.join(" ")
|
|
69
|
+
"X-Very-Simple-Ajax": "true",
|
|
70
|
+
"X-Very-Simple-Ajax-Regions": regions.join(" ")
|
|
32
71
|
},
|
|
33
72
|
signal: currentLoadController.signal
|
|
34
73
|
});
|
|
35
|
-
const html = await response.text();
|
|
36
|
-
|
|
74
|
+
const html = prefetched?.html ?? await response.text();
|
|
75
|
+
if (response.ok) cache.set(url, html);
|
|
76
|
+
const document2 = parseHtml(html);
|
|
37
77
|
return { document: document2, response };
|
|
38
78
|
} catch (e) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
79
|
+
aborted = e instanceof DOMException && e.name === "AbortError";
|
|
80
|
+
if (!aborted) {
|
|
81
|
+
setProgress(0);
|
|
42
82
|
window.location.reload();
|
|
43
83
|
}
|
|
44
84
|
} finally {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
85
|
+
if (!aborted) {
|
|
86
|
+
clearTimeout(progressDelayTimeout);
|
|
87
|
+
stopTrickle();
|
|
88
|
+
setProgress(1);
|
|
89
|
+
setTimeout(() => emit("progress-end", {}), options.progressHideDelay);
|
|
90
|
+
}
|
|
50
91
|
}
|
|
51
92
|
};
|
|
93
|
+
var parseHtml = (html) => parser.parseFromString(html, "text/html");
|
|
52
94
|
var trickle = () => {
|
|
53
|
-
|
|
54
|
-
|
|
95
|
+
setProgress(progress + Math.random() * 0.1 * (1 - progress));
|
|
96
|
+
};
|
|
97
|
+
var startTrickle = () => {
|
|
98
|
+
stopTrickle();
|
|
99
|
+
trickleInterval = window.setInterval(trickle, 300);
|
|
100
|
+
};
|
|
101
|
+
var stopTrickle = () => {
|
|
102
|
+
clearInterval(trickleInterval);
|
|
103
|
+
trickleInterval = void 0;
|
|
55
104
|
};
|
|
56
|
-
var startTrickle = () => trickleInterval = window.setInterval(trickle, 300);
|
|
57
|
-
var stopTrickle = () => clearInterval(trickleInterval);
|
|
58
105
|
var setProgress = (value) => {
|
|
59
106
|
progress = value;
|
|
60
|
-
|
|
61
|
-
"--simple-progress",
|
|
62
|
-
`${Math.round(value * 1e4) / 100}%`
|
|
63
|
-
);
|
|
107
|
+
emit("progress", { progress: value });
|
|
64
108
|
};
|
|
65
|
-
var toggleProgress = (state) => document.documentElement.toggleAttribute("data-simple-progress", state);
|
|
66
|
-
var toggleLoading = (state) => document.documentElement.toggleAttribute("data-simple-loading", state);
|
|
67
109
|
|
|
68
110
|
// src/merge.ts
|
|
69
111
|
import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js";
|
|
70
|
-
var merge = (region, newRegion, strategy) => {
|
|
112
|
+
var merge = async (region, newRegion, strategy, signal, useCssTransition = false) => {
|
|
113
|
+
if (useCssTransition && strategy === "replace") {
|
|
114
|
+
return replaceWithTransition(region, newRegion, signal);
|
|
115
|
+
}
|
|
116
|
+
return mergeBuiltIn(region, newRegion, strategy);
|
|
117
|
+
};
|
|
118
|
+
var replaceWithTransition = async (region, newRegion, signal) => {
|
|
119
|
+
const { prefix, mount, unmount } = config;
|
|
120
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
121
|
+
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`).forEach((el) => {
|
|
122
|
+
const original = region.querySelector(`#${CSS.escape(el.id)}`);
|
|
123
|
+
if (original) el.replaceWith(original);
|
|
124
|
+
});
|
|
125
|
+
newRegion.setAttribute(`${prefix}entering`, "");
|
|
126
|
+
region.before(newRegion);
|
|
127
|
+
mount?.(newRegion);
|
|
128
|
+
newRegion.getBoundingClientRect();
|
|
129
|
+
newRegion.removeAttribute(`${prefix}entering`);
|
|
130
|
+
region.setAttribute(`${prefix}leaving`, "");
|
|
131
|
+
region.inert = true;
|
|
132
|
+
const animations = [region, newRegion].flatMap(
|
|
133
|
+
(el) => el.getAnimations({ subtree: true })
|
|
134
|
+
);
|
|
135
|
+
const finish = () => animations.forEach((a) => a.finish());
|
|
136
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
137
|
+
await Promise.allSettled(animations.map((a) => a.finished));
|
|
138
|
+
signal.removeEventListener("abort", finish);
|
|
139
|
+
unmount?.(region);
|
|
140
|
+
region.remove();
|
|
141
|
+
return { autoFocusEl: findAutoFocus(newRegion) };
|
|
142
|
+
};
|
|
143
|
+
var findAutoFocus = (newRegion) => {
|
|
144
|
+
const { prefix } = config;
|
|
145
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
146
|
+
if (newRegion.hasAttribute(`${prefix}autofocus`) || newRegion.autofocus) {
|
|
147
|
+
return newRegion;
|
|
148
|
+
}
|
|
149
|
+
return newRegion.querySelector(`[${escapedPrefix}autofocus]`) ?? newRegion.querySelector("[autofocus]") ?? void 0;
|
|
150
|
+
};
|
|
151
|
+
var mergeBuiltIn = (region, newRegion, strategy) => {
|
|
71
152
|
let autoFocusEl;
|
|
72
|
-
|
|
73
|
-
|
|
153
|
+
const { prefix, mount, unmount } = config;
|
|
154
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
155
|
+
if (strategy === "replace" || strategy === "update") {
|
|
156
|
+
const permanents = /* @__PURE__ */ new Map();
|
|
157
|
+
Array.from(
|
|
158
|
+
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`)
|
|
159
|
+
).forEach((el) => {
|
|
160
|
+
const original = region.querySelector(`#${CSS.escape(el.id)}`);
|
|
161
|
+
if (original) {
|
|
162
|
+
permanents.set(el.id, original);
|
|
163
|
+
original.remove();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
unmount?.(region);
|
|
167
|
+
if (strategy === "replace") region.replaceWith(newRegion);
|
|
168
|
+
else region.replaceChildren(...Array.from(newRegion.children));
|
|
169
|
+
const target = strategy === "replace" ? newRegion : region;
|
|
170
|
+
target.querySelectorAll(`[${escapedPrefix}permanent][id]`).forEach((el) => {
|
|
171
|
+
const original = permanents.get(el.id);
|
|
172
|
+
if (original) el.replaceWith(original);
|
|
173
|
+
});
|
|
174
|
+
mount?.(target);
|
|
74
175
|
} else if (strategy === "before") {
|
|
75
176
|
region.insertAdjacentElement("beforebegin", newRegion);
|
|
177
|
+
mount?.(newRegion);
|
|
76
178
|
} else if (strategy === "after") {
|
|
77
179
|
region.insertAdjacentElement("afterend", newRegion);
|
|
180
|
+
mount?.(newRegion);
|
|
78
181
|
} else if (strategy === "prepend") {
|
|
79
182
|
region.insertAdjacentElement("afterbegin", newRegion);
|
|
183
|
+
mount?.(newRegion);
|
|
80
184
|
} else if (strategy === "append") {
|
|
81
185
|
region.insertAdjacentElement("beforeend", newRegion);
|
|
82
|
-
|
|
83
|
-
region.replaceChildren(...Array.from(newRegion.children));
|
|
186
|
+
mount?.(newRegion);
|
|
84
187
|
} else if (strategy === "morph") {
|
|
85
188
|
const result = morph(region, newRegion);
|
|
86
189
|
autoFocusEl = result.autoFocusEl;
|
|
87
190
|
}
|
|
88
|
-
if (strategy
|
|
89
|
-
|
|
90
|
-
const originalEL = region.querySelector(`#${el.id}`);
|
|
91
|
-
if (originalEL) el.replaceWith(originalEL);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
if (newRegion.hasAttribute("data-simple-autofocus") || newRegion.autofocus) {
|
|
95
|
-
autoFocusEl ??= newRegion;
|
|
96
|
-
}
|
|
97
|
-
autoFocusEl ??= newRegion.querySelector("[data-simple-autofocus]") ?? newRegion.querySelector("[autofocus]") ?? void 0;
|
|
98
|
-
return { autoFocusEl };
|
|
191
|
+
if (strategy === "morph") return { autoFocusEl };
|
|
192
|
+
return { autoFocusEl: findAutoFocus(newRegion) };
|
|
99
193
|
};
|
|
100
194
|
var morph = (container, newContainer) => {
|
|
195
|
+
const { prefix, mount, unmount } = config;
|
|
101
196
|
const manualReplacements = [];
|
|
102
|
-
|
|
197
|
+
const autoFocusEls = [];
|
|
103
198
|
let currentNodeKeepAttributes = [];
|
|
104
199
|
Idiomorph.morph(container, newContainer, {
|
|
105
200
|
callbacks: {
|
|
106
201
|
beforeNodeMorphed(oldNode, newNode) {
|
|
107
|
-
if (!(oldNode instanceof
|
|
108
|
-
if (!(newNode instanceof
|
|
109
|
-
currentNodeKeepAttributes = oldNode.
|
|
110
|
-
if (oldNode.hasAttribute(
|
|
202
|
+
if (!(oldNode instanceof Element)) return true;
|
|
203
|
+
if (!(newNode instanceof Element)) return true;
|
|
204
|
+
currentNodeKeepAttributes = oldNode.getAttribute(`${prefix}keep-attributes`)?.split(" ") ?? [];
|
|
205
|
+
if (oldNode.hasAttribute(`${prefix}permanent`) && oldNode.id === newNode.id)
|
|
111
206
|
return false;
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
207
|
+
const oldIsComponent = oldNode.hasAttribute(`${prefix}component`);
|
|
208
|
+
const newIsComponent = newNode.hasAttribute(`${prefix}component`);
|
|
209
|
+
const isSameComponent = oldIsComponent && newIsComponent && oldNode.getAttribute(`${prefix}component`) === newNode.getAttribute(`${prefix}component`);
|
|
210
|
+
if (!oldIsComponent || isSameComponent) return true;
|
|
115
211
|
manualReplacements.push([oldNode, newNode]);
|
|
212
|
+
return false;
|
|
116
213
|
},
|
|
117
214
|
beforeAttributeUpdated: (name) => {
|
|
118
215
|
return !currentNodeKeepAttributes.includes(name);
|
|
119
216
|
},
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
217
|
+
beforeNodeRemoved(node) {
|
|
218
|
+
if (node instanceof Element && !node.hasAttribute(`${prefix}permanent`)) {
|
|
219
|
+
unmount?.(node);
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
afterNodeAdded(node) {
|
|
224
|
+
if (node instanceof Element && node.hasAttribute(`${prefix}component`)) {
|
|
225
|
+
mount?.(node);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
afterNodeMorphed(oldNode) {
|
|
229
|
+
if (oldNode instanceof HTMLElement && (oldNode.autofocus || oldNode.hasAttribute(`${prefix}autofocus`))) {
|
|
230
|
+
autoFocusEls.push(oldNode);
|
|
123
231
|
}
|
|
124
232
|
}
|
|
125
233
|
}
|
|
126
234
|
});
|
|
127
|
-
for (const [oldEl, newEl] of manualReplacements)
|
|
128
|
-
|
|
235
|
+
for (const [oldEl, newEl] of manualReplacements) {
|
|
236
|
+
unmount?.(oldEl);
|
|
237
|
+
oldEl.replaceWith(newEl);
|
|
238
|
+
mount?.(newEl);
|
|
239
|
+
}
|
|
240
|
+
const autoFocusEl = autoFocusEls.find((el) => el.hasAttribute(`${prefix}autofocus`)) ?? autoFocusEls.find((el) => el.autofocus);
|
|
129
241
|
return { autoFocusEl };
|
|
130
242
|
};
|
|
131
243
|
|
|
244
|
+
// src/scripts.ts
|
|
245
|
+
var executeScripts = (container) => {
|
|
246
|
+
const marker = `${config.prefix}script-executed`;
|
|
247
|
+
const waiting = [];
|
|
248
|
+
for (const script of container.querySelectorAll("script")) {
|
|
249
|
+
if (script.hasAttribute(marker)) continue;
|
|
250
|
+
const type = script.getAttribute("type");
|
|
251
|
+
if (type && type !== "module" && type !== "text/javascript") continue;
|
|
252
|
+
const newScript = document.createElement("script");
|
|
253
|
+
newScript.textContent = script.textContent;
|
|
254
|
+
for (const attr of script.attributes) {
|
|
255
|
+
newScript.setAttribute(attr.name, attr.value);
|
|
256
|
+
}
|
|
257
|
+
newScript.setAttribute(marker, "");
|
|
258
|
+
if (newScript.src) {
|
|
259
|
+
waiting.push(
|
|
260
|
+
new Promise((resolve) => {
|
|
261
|
+
newScript.onload = newScript.onerror = resolve;
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
script.replaceWith(newScript);
|
|
266
|
+
}
|
|
267
|
+
return Promise.all(waiting);
|
|
268
|
+
};
|
|
269
|
+
|
|
132
270
|
// src/visit.ts
|
|
133
271
|
import { Idiomorph as Idiomorph2 } from "idiomorph/dist/idiomorph.esm.js";
|
|
134
|
-
var
|
|
135
|
-
|
|
136
|
-
|
|
272
|
+
var normalizeUrl = (url) => {
|
|
273
|
+
try {
|
|
274
|
+
const urlObj = new URL(url, window.location.origin);
|
|
275
|
+
return urlObj.pathname + urlObj.search;
|
|
276
|
+
} catch {
|
|
277
|
+
return url;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
var emit2 = (type, payload) => {
|
|
281
|
+
const waiting = [];
|
|
282
|
+
const waitUntil = (promise) => waiting.push(Promise.resolve(promise));
|
|
283
|
+
payload = { ...payload, waitUntil };
|
|
284
|
+
const event = new CustomEvent(`ajax:${type}`, { detail: payload });
|
|
137
285
|
document.dispatchEvent(event);
|
|
286
|
+
if (waiting.length) return Promise.allSettled(waiting);
|
|
138
287
|
};
|
|
139
|
-
var currentUrl = window.location.pathname;
|
|
140
|
-
var
|
|
288
|
+
var currentUrl = window.location.pathname + window.location.search;
|
|
289
|
+
var currentState = history.state;
|
|
290
|
+
var currentVisitController;
|
|
291
|
+
var scrollPositions = /* @__PURE__ */ new Map();
|
|
141
292
|
var visit = async (url, {
|
|
142
|
-
|
|
143
|
-
emitEvents = true,
|
|
293
|
+
history: historyMode = "none",
|
|
144
294
|
isBackForward = false,
|
|
145
|
-
morphHeads = globalConfig.morphHeads,
|
|
146
|
-
merge: mergeStrategy = globalConfig.merge,
|
|
147
|
-
loadingDelay = globalConfig.loadingDelay,
|
|
148
|
-
progressHideDelay = globalConfig.progressHideDelay,
|
|
149
295
|
autoFocus = true,
|
|
150
296
|
request,
|
|
151
|
-
|
|
297
|
+
state,
|
|
298
|
+
regions = config.regions,
|
|
299
|
+
head = config.head,
|
|
300
|
+
merge: mergeStrategy = config.merge,
|
|
301
|
+
executeScripts: executeScriptsOption = config.executeScripts,
|
|
302
|
+
transitions = config.transitions,
|
|
303
|
+
loadingDelay = config.loadingDelay,
|
|
304
|
+
progressHideDelay = config.progressHideDelay
|
|
152
305
|
} = {}) => {
|
|
153
|
-
|
|
306
|
+
url = normalizeUrl(url);
|
|
307
|
+
const fromUrl = currentUrl;
|
|
308
|
+
const fromState = currentState;
|
|
309
|
+
if (!(isBackForward && state)) {
|
|
310
|
+
const currentIndex = currentState?.index ?? 0;
|
|
311
|
+
const index = historyMode === "push" ? currentIndex + 1 : currentIndex;
|
|
312
|
+
state = { ...state, regions, index };
|
|
313
|
+
}
|
|
314
|
+
currentVisitController?.abort();
|
|
315
|
+
currentVisitController = new AbortController();
|
|
316
|
+
const { signal } = currentVisitController;
|
|
317
|
+
if (url === currentUrl) historyMode = "none";
|
|
318
|
+
await emit2("before-visit", {
|
|
319
|
+
from: { url: fromUrl, state: fromState, document },
|
|
320
|
+
to: { url, state },
|
|
321
|
+
signal,
|
|
322
|
+
isBackForward
|
|
323
|
+
});
|
|
324
|
+
if (signal.aborted) return;
|
|
154
325
|
let newDocument;
|
|
155
326
|
if (isBackForward) {
|
|
156
|
-
|
|
327
|
+
const html = cache.get(url);
|
|
328
|
+
if (html) {
|
|
329
|
+
const cached = parseHtml(html);
|
|
330
|
+
const isComplete = regions.every(
|
|
331
|
+
(selector) => !document.querySelector(selector) || cached.querySelector(selector)
|
|
332
|
+
);
|
|
333
|
+
if (isComplete) newDocument = cached;
|
|
334
|
+
}
|
|
157
335
|
}
|
|
158
336
|
if (!newDocument) {
|
|
159
337
|
const options = { loadingDelay, progressHideDelay, request };
|
|
@@ -164,64 +342,251 @@ var visit = async (url, {
|
|
|
164
342
|
}
|
|
165
343
|
}
|
|
166
344
|
if (!newDocument) return;
|
|
167
|
-
|
|
345
|
+
if (signal.aborted) return;
|
|
168
346
|
currentUrl = url;
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
347
|
+
currentState = state;
|
|
348
|
+
if (historyMode === "replace") history.replaceState(state, "", url);
|
|
349
|
+
else if (historyMode === "push") history.pushState(state, "", url);
|
|
350
|
+
const from = { url: fromUrl, state: fromState, document };
|
|
351
|
+
const to = { url, state, document: newDocument };
|
|
352
|
+
await emit2("before-swap", { from, to, isBackForward, signal });
|
|
353
|
+
if (signal.aborted) return;
|
|
354
|
+
const resolve = (option) => typeof option === "function" ? option({ from, to, isBackForward }) : option;
|
|
355
|
+
const resolvedHead = resolve(head);
|
|
356
|
+
let headStyle = resolvedHead;
|
|
357
|
+
if (headStyle === true) headStyle = "merge";
|
|
358
|
+
else if (headStyle === false) headStyle = "none";
|
|
359
|
+
const updateHead = () => {
|
|
360
|
+
if (headStyle === "none") return;
|
|
361
|
+
Idiomorph2.morph(document.head, newDocument.head, {
|
|
362
|
+
head: { style: headStyle }
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
const shouldExecuteScripts = resolve(executeScriptsOption);
|
|
366
|
+
const transition = resolve(transitions);
|
|
367
|
+
const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
|
|
176
368
|
let autoFocusEl;
|
|
177
369
|
const hasMatchingRegions = regions?.some(
|
|
178
370
|
(selector) => document.querySelector(selector) && newDocument.querySelector(selector)
|
|
179
371
|
);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
372
|
+
const mergeRegions = async () => {
|
|
373
|
+
const scriptPromises = [];
|
|
374
|
+
if (hasMatchingRegions) {
|
|
375
|
+
for (const id of regions) {
|
|
376
|
+
const region = document.querySelector(id);
|
|
377
|
+
const parsedRegion = newDocument.querySelector(id);
|
|
378
|
+
if (!region || !parsedRegion) continue;
|
|
379
|
+
const newRegion = document.importNode(parsedRegion, true);
|
|
380
|
+
const strategy = getMergeStrategy(region, newRegion);
|
|
381
|
+
const result = await merge(
|
|
382
|
+
region,
|
|
383
|
+
newRegion,
|
|
384
|
+
strategy,
|
|
385
|
+
signal,
|
|
386
|
+
transition === "css"
|
|
387
|
+
);
|
|
388
|
+
autoFocusEl ??= result.autoFocusEl;
|
|
389
|
+
if (shouldExecuteScripts) {
|
|
390
|
+
const finalRegion = document.querySelector(id);
|
|
391
|
+
if (finalRegion) scriptPromises.push(executeScripts(finalRegion));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
const region = document.body;
|
|
396
|
+
const newRegion = document.importNode(newDocument.body, true);
|
|
185
397
|
const strategy = getMergeStrategy(region, newRegion);
|
|
186
|
-
const result = merge(region, newRegion, strategy);
|
|
187
|
-
autoFocusEl
|
|
398
|
+
const result = await merge(region, newRegion, strategy, signal);
|
|
399
|
+
autoFocusEl = result.autoFocusEl;
|
|
400
|
+
if (shouldExecuteScripts) {
|
|
401
|
+
scriptPromises.push(executeScripts(document.body));
|
|
402
|
+
}
|
|
188
403
|
}
|
|
404
|
+
if (scriptPromises.length) await Promise.all(scriptPromises);
|
|
405
|
+
};
|
|
406
|
+
if (fromUrl) scrollPositions.set(fromUrl, { top: window.scrollY });
|
|
407
|
+
const applyScrollBehavior = () => {
|
|
408
|
+
if (!config.scrollBehavior) return;
|
|
409
|
+
const savedPosition = isBackForward ? scrollPositions.get(url) : void 0;
|
|
410
|
+
const position = config.scrollBehavior({
|
|
411
|
+
from,
|
|
412
|
+
to,
|
|
413
|
+
isBackForward,
|
|
414
|
+
savedPosition
|
|
415
|
+
});
|
|
416
|
+
if (position) window.scrollTo(position);
|
|
417
|
+
};
|
|
418
|
+
if (transition === "view" && document.startViewTransition) {
|
|
419
|
+
await document.startViewTransition(async () => {
|
|
420
|
+
updateHead();
|
|
421
|
+
await mergeRegions();
|
|
422
|
+
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
423
|
+
applyScrollBehavior();
|
|
424
|
+
}).updateCallbackDone;
|
|
189
425
|
} else {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
autoFocusEl = result.autoFocusEl;
|
|
426
|
+
updateHead();
|
|
427
|
+
await mergeRegions();
|
|
428
|
+
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
429
|
+
applyScrollBehavior();
|
|
195
430
|
}
|
|
196
|
-
if (
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
if (emitEvents) emit("visit", { url, prevUrl });
|
|
431
|
+
if (signal.aborted) return;
|
|
432
|
+
if (autoFocus) autoFocusEl?.focus({ preventScroll: true });
|
|
433
|
+
emit2("load", { from, to, isBackForward });
|
|
200
434
|
};
|
|
201
435
|
|
|
202
|
-
// src/
|
|
203
|
-
var
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
436
|
+
// src/intercept.ts
|
|
437
|
+
var interceptLinks = () => {
|
|
438
|
+
document.addEventListener("click", (e) => {
|
|
439
|
+
if (e.defaultPrevented || isModifiedClick(e)) return;
|
|
440
|
+
const target = e.composedPath ? e.composedPath()[0] : e.target;
|
|
441
|
+
const link = target?.closest?.("a");
|
|
442
|
+
if (!link || !isInterceptable(link)) return;
|
|
443
|
+
e.preventDefault();
|
|
444
|
+
visit(link.href, { history: "push", ...getVisitOptions(link) });
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
var isInterceptable = (link) => {
|
|
448
|
+
if (!link.href) return false;
|
|
449
|
+
const isExternalTarget = link.target && link.target !== "_self";
|
|
450
|
+
return !(link.hasAttribute(`${config.prefix}reload`) || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href));
|
|
451
|
+
};
|
|
452
|
+
var hoverDelay = 100;
|
|
453
|
+
var getPrefetchStrategy = (link) => {
|
|
454
|
+
const attr = link.getAttribute(`${config.prefix}prefetch`);
|
|
455
|
+
if (attr === null) return config.prefetch;
|
|
456
|
+
if (attr === "false") return false;
|
|
457
|
+
if (attr === "hover" || attr === "mousedown") return attr;
|
|
458
|
+
return config.prefetch || "hover";
|
|
459
|
+
};
|
|
460
|
+
var interceptPrefetch = () => {
|
|
461
|
+
let hoverTimeout;
|
|
462
|
+
let hoverLink;
|
|
463
|
+
const run = (link) => {
|
|
464
|
+
if (navigator.connection?.saveData) return;
|
|
465
|
+
const url = normalizeUrl(link.href);
|
|
466
|
+
if (url === currentUrl) return;
|
|
467
|
+
const regions = getVisitOptions(link).regions ?? config.regions;
|
|
468
|
+
prefetch(url, regions);
|
|
469
|
+
};
|
|
470
|
+
const findLink = (e) => {
|
|
471
|
+
const link = e.target?.closest?.("a");
|
|
472
|
+
if (!link || !getPrefetchStrategy(link) || !isInterceptable(link)) return;
|
|
473
|
+
return link;
|
|
474
|
+
};
|
|
475
|
+
const clearHover = () => {
|
|
476
|
+
clearTimeout(hoverTimeout);
|
|
477
|
+
hoverLink = void 0;
|
|
478
|
+
};
|
|
479
|
+
document.addEventListener("mouseover", (e) => {
|
|
480
|
+
const link = findLink(e);
|
|
481
|
+
if (!link || link === hoverLink || getPrefetchStrategy(link) !== "hover")
|
|
482
|
+
return;
|
|
483
|
+
clearHover();
|
|
484
|
+
hoverLink = link;
|
|
485
|
+
hoverTimeout = window.setTimeout(() => run(link), hoverDelay);
|
|
486
|
+
});
|
|
487
|
+
document.addEventListener("mouseout", (e) => {
|
|
488
|
+
const to = e.relatedTarget;
|
|
489
|
+
if (hoverLink && !(to && hoverLink.contains(to))) clearHover();
|
|
490
|
+
});
|
|
491
|
+
document.addEventListener("focusin", (e) => {
|
|
492
|
+
const link = findLink(e);
|
|
493
|
+
if (link && getPrefetchStrategy(link) === "hover") run(link);
|
|
494
|
+
});
|
|
495
|
+
document.addEventListener(
|
|
496
|
+
"touchstart",
|
|
497
|
+
(e) => {
|
|
498
|
+
const link = findLink(e);
|
|
499
|
+
if (link) run(link);
|
|
500
|
+
},
|
|
501
|
+
{ passive: true }
|
|
502
|
+
);
|
|
503
|
+
document.addEventListener("mousedown", (e) => {
|
|
504
|
+
if (e.button !== 0) return;
|
|
505
|
+
const link = findLink(e);
|
|
506
|
+
if (link) run(link);
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
var interceptForms = () => {
|
|
510
|
+
document.addEventListener("submit", (e) => {
|
|
511
|
+
const form = e.target;
|
|
512
|
+
if (e.defaultPrevented || !(form instanceof HTMLFormElement) || form.hasAttribute(`${config.prefix}reload`))
|
|
513
|
+
return;
|
|
514
|
+
const submitter = e.submitter;
|
|
515
|
+
const formAction = typeof form.action === "string" ? form.action : form.getAttribute("action");
|
|
516
|
+
const formMethod = typeof form.method === "string" ? form.method : form.getAttribute("method");
|
|
517
|
+
const method = (submitter?.getAttribute("formmethod") ?? formMethod ?? "get").toLowerCase();
|
|
518
|
+
if (method === "dialog") return;
|
|
519
|
+
const action = submitter?.getAttribute("formaction") ?? formAction ?? location.href;
|
|
520
|
+
if (!isSameOrigin(action)) return;
|
|
521
|
+
const formData = new FormData(form, submitter ?? void 0);
|
|
522
|
+
let url = action;
|
|
523
|
+
const request = { method };
|
|
524
|
+
if (method === "get") {
|
|
525
|
+
const urlObj = new URL(action, location.href);
|
|
526
|
+
urlObj.search = new URLSearchParams(formData).toString();
|
|
527
|
+
url = urlObj.toString();
|
|
528
|
+
} else {
|
|
529
|
+
request.body = formData;
|
|
530
|
+
}
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
visit(url, { history: "push", ...getVisitOptions(form), request });
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
var interceptHistory = () => {
|
|
536
|
+
window.addEventListener("popstate", (event) => {
|
|
537
|
+
if (normalizeUrl(window.location.href) === normalizeUrl(currentUrl)) return;
|
|
538
|
+
const isBack = (event.state?.index ?? 0) < (currentState?.index ?? 0);
|
|
539
|
+
const regions = isBack ? currentState?.regions : event.state?.regions;
|
|
540
|
+
visit(window.location.href, {
|
|
541
|
+
history: "none",
|
|
542
|
+
isBackForward: true,
|
|
543
|
+
regions: regions ?? config.regions,
|
|
544
|
+
state: event.state
|
|
219
545
|
});
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
var isSameOrigin = (url) => new URL(url, location.href).origin === location.origin;
|
|
549
|
+
var isModifiedClick = (e) => e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
|
|
550
|
+
var getVisitOptions = (el) => {
|
|
551
|
+
const prefix = config.prefix;
|
|
552
|
+
const options = {};
|
|
553
|
+
const historyAttr = el.getAttribute(`${prefix}history`);
|
|
554
|
+
if (historyAttr) options.history = historyAttr;
|
|
555
|
+
const regions = el.getAttribute(`${prefix}regions`);
|
|
556
|
+
if (regions) {
|
|
557
|
+
options.regions = regions.split(",").map((region) => region.trim()).filter(Boolean);
|
|
558
|
+
}
|
|
559
|
+
const state = el.getAttribute(`${prefix}state`);
|
|
560
|
+
if (state) {
|
|
561
|
+
try {
|
|
562
|
+
options.state = JSON.parse(state);
|
|
563
|
+
} catch {
|
|
564
|
+
console.error(`Invalid ${prefix}state on`, el);
|
|
565
|
+
}
|
|
220
566
|
}
|
|
567
|
+
const merge2 = el.getAttribute(`${prefix}merge`);
|
|
568
|
+
if (merge2) options.merge = merge2;
|
|
569
|
+
return options;
|
|
221
570
|
};
|
|
222
571
|
|
|
223
|
-
// src/
|
|
224
|
-
var
|
|
572
|
+
// src/start.ts
|
|
573
|
+
var start = (options = {}) => {
|
|
574
|
+
Object.assign(config, options);
|
|
575
|
+
const url = normalizeUrl(window.location.href);
|
|
576
|
+
if (!cache.has(url)) cache.set(url, document.documentElement.outerHTML);
|
|
577
|
+
if (config.scrollBehavior) history.scrollRestoration = "manual";
|
|
578
|
+
interceptLinks();
|
|
579
|
+
interceptForms();
|
|
580
|
+
interceptHistory();
|
|
581
|
+
interceptPrefetch();
|
|
582
|
+
if (import.meta.env.DEV) {
|
|
583
|
+
document.addEventListener("ajax:before-visit", () => {
|
|
584
|
+
document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
};
|
|
225
588
|
export {
|
|
226
|
-
|
|
589
|
+
scrollPositions,
|
|
590
|
+
start,
|
|
591
|
+
visit
|
|
227
592
|
};
|
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.10",
|
|
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"
|