@very-simple/ajax 0.7.1 → 1.0.0-beta.2

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 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 ~3kb (minify and gzip)
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
- Checkout the `/examples` folder for more examples. Better documentation coming soon...
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 VisitOptions {
2
- action?: 'push' | 'replace' | 'none';
3
- emitEvents?: boolean;
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
- watchHistory?: boolean;
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
- visit: {
23
- url: string;
24
- prevUrl: string;
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 `simple-ajax:${K & string}`]: CustomEvent<EventMap[K]>;
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 _default: {
45
- start: ({ watchHistory, merge, morphHeads, loadingDelay, }?: Config) => void;
46
- visit: (url: string, { action, emitEvents, isBackForward, morphHeads, merge: mergeStrategy, loadingDelay, progressHideDelay, autoFocus, request, regions, }?: VisitOptions) => Promise<void>;
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 { _default as default };
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 globalConfig = {
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,13 +30,14 @@ var load = async (url, regions, options) => {
27
30
  ...options.request,
28
31
  headers: {
29
32
  ...options.request?.headers,
30
- "X-Very-Simple-Request": "true",
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
- const document2 = parser.parseFromString(html, "text/html");
39
+ if (response.ok) cache.set(url, html);
40
+ const document2 = parseHtml(html);
37
41
  return { document: document2, response };
38
42
  } catch (e) {
39
43
  setProgress(0);
@@ -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,102 +63,192 @@ var stopTrickle = () => clearInterval(trickleInterval);
58
63
  var setProgress = (value) => {
59
64
  progress = value;
60
65
  document.documentElement.style.setProperty(
61
- "--simple-progress",
66
+ "--ajax-progress",
62
67
  `${Math.round(value * 1e4) / 100}%`
63
68
  );
64
69
  };
65
- var toggleProgress = (state) => document.documentElement.toggleAttribute("data-simple-progress", state);
66
- var toggleLoading = (state) => document.documentElement.toggleAttribute("data-simple-loading", state);
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
- if (strategy === "replace") {
73
- region.replaceWith(newRegion);
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
- } else if (strategy === "update") {
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 (strategy !== "morph") {
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("[data-simple-autofocus]") ?? newRegion.querySelector("[autofocus]") ?? void 0;
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 HTMLElement)) return true;
108
- if (!(newNode instanceof HTMLElement)) return true;
109
- currentNodeKeepAttributes = oldNode.dataset.simpleKeepAttributes?.split(" ") ?? [];
110
- if (oldNode.hasAttribute("data-simple-permanent") && oldNode.id === newNode.id)
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 oldComponent = oldNode.dataset.simpleComponent;
113
- const newComponent = newNode.dataset.simpleComponent;
114
- if (!oldComponent || oldComponent === newComponent) return true;
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.dataset.simpleAutofocus)) {
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) oldEl.replaceWith(newEl);
128
- const autoFocusEl = autoFocusEls.find((el) => el.dataset.simpleAutofocus) ?? autoFocusEls.find((el) => el.autofocus);
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 cache = /* @__PURE__ */ new Map();
135
- var emit = async (type, payload) => {
136
- const event = new CustomEvent(`simple-ajax:${type}`, { detail: payload });
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 prevUrl;
216
+ var currentState = history.state;
217
+ var currentVisitController;
218
+ var scrollPositions = /* @__PURE__ */ new Map();
141
219
  var visit = async (url, {
142
- action = "none",
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
- regions = []
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
- if (emitEvents) emit("before-visit", { url, prevUrl });
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;
154
248
  let newDocument;
155
249
  if (isBackForward) {
156
- newDocument = cache.get(url)?.cloneNode(true);
250
+ const html = cache.get(url);
251
+ if (html) newDocument = parseHtml(html);
157
252
  }
158
253
  if (!newDocument) {
159
254
  const options = { loadingDelay, progressHideDelay, request };
@@ -164,64 +259,170 @@ var visit = async (url, {
164
259
  }
165
260
  }
166
261
  if (!newDocument) return;
167
- prevUrl = currentUrl;
168
262
  currentUrl = url;
169
- if (emitEvents) emit("before-render", { url, prevUrl, newDocument });
170
- if (!isBackForward) {
171
- const cacheId = window.location.pathname + window.location.search;
172
- cache.set(cacheId, document.cloneNode(true));
173
- }
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;
174
270
  if (morphHeads) Idiomorph2.morph(document.head, newDocument.head);
175
- const getMergeStrategy = (oldEl, newEl) => newEl.dataset.simpleMerge || oldEl.dataset.simpleMerge || mergeStrategy;
271
+ const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
176
272
  let autoFocusEl;
177
273
  const hasMatchingRegions = regions?.some(
178
274
  (selector) => document.querySelector(selector) && newDocument.querySelector(selector)
179
275
  );
180
- if (hasMatchingRegions) {
181
- for (const id of regions) {
182
- const region = document.querySelector(id);
183
- const newRegion = newDocument.querySelector(id);
184
- if (!region || !newRegion) continue;
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;
185
294
  const strategy = getMergeStrategy(region, newRegion);
186
295
  const result = merge(region, newRegion, strategy);
187
- autoFocusEl ??= result.autoFocusEl;
296
+ autoFocusEl = result.autoFocusEl;
297
+ if (shouldExecuteScripts) {
298
+ scriptPromises.push(executeScripts(document.body));
299
+ }
188
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;
189
323
  } else {
190
- const region = document.body;
191
- const newRegion = newDocument.body;
192
- const strategy = getMergeStrategy(region, newRegion);
193
- const result = merge(region, newRegion, strategy);
194
- autoFocusEl = result.autoFocusEl;
324
+ await mergeRegions();
325
+ await emit("after-swap", { from, to, isBackForward, signal });
326
+ applyScrollBehavior();
195
327
  }
196
- if (autoFocus) autoFocusEl?.focus();
197
- if (action === "replace") history.replaceState(null, "", url);
198
- else if (action === "push") history.pushState(null, "", url);
199
- if (emitEvents) emit("visit", { url, prevUrl });
328
+ if (signal.aborted) return;
329
+ if (autoFocus) autoFocusEl?.focus({ preventScroll: true });
330
+ emit("load", { from, to, isBackForward });
200
331
  };
201
332
 
202
- // src/start.ts
203
- var start = ({
204
- watchHistory = true,
205
- merge: merge2 = "replace",
206
- morphHeads = true,
207
- loadingDelay = 500
208
- } = {}) => {
209
- globalConfig.merge = merge2;
210
- globalConfig.watchHistory = watchHistory;
211
- globalConfig.morphHeads = morphHeads;
212
- globalConfig.loadingDelay = loadingDelay;
213
- if (watchHistory) {
214
- window.addEventListener("popstate", () => {
215
- visit(window.location.pathname + window.location.search, {
216
- action: "none",
217
- isBackForward: true
218
- });
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
219
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);
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
+ }
220
403
  }
404
+ const merge2 = el.getAttribute(`${prefix}merge`);
405
+ if (merge2) options.merge = merge2;
406
+ return options;
221
407
  };
222
408
 
223
- // src/index.ts
224
- var index_default = { start, visit };
409
+ // src/start.ts
410
+ var start = (options = {}) => {
411
+ Object.assign(config, options);
412
+ const url = normalizeUrl(window.location.href);
413
+ if (!cache.has(url)) cache.set(url, document.documentElement.outerHTML);
414
+ if (config.scrollBehavior) history.scrollRestoration = "manual";
415
+ interceptLinks();
416
+ interceptForms();
417
+ interceptHistory();
418
+ if (import.meta.env.DEV) {
419
+ document.addEventListener("ajax:before-visit", () => {
420
+ document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
421
+ });
422
+ }
423
+ };
225
424
  export {
226
- index_default as default
425
+ scrollPositions,
426
+ start,
427
+ visit
227
428
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@very-simple/ajax",
3
- "version": "0.7.1",
4
- "description": "A very simple turbolinks inspired library",
3
+ "version": "1.0.0-beta.2",
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/ajax.git"
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": "^22.0",
27
- "@very-simple/components": "^0.6.0",
28
+ "@types/node": "^24",
28
29
  "bumpp": "^10.0.0",
29
30
  "tsup": "8",
30
- "typescript": "^5",
31
- "vite": "^6"
31
+ "typescript": "^6",
32
+ "vite": "^8"
32
33
  },
33
34
  "dependencies": {
34
35
  "idiomorph": "^0.7.0"