@hyperspan/framework 1.0.27 → 1.0.28
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/package.json +1 -1
- package/src/client/_hs/hyperspan-actions.client.ts +131 -16
- package/src/types.ts +67 -0
package/package.json
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
import { Idiomorph } from './idiomorph';
|
|
2
2
|
import { lazyLoadScripts } from './hyperspan-scripts.client';
|
|
3
|
+
import type { Hyperspan as HS } from '../../types';
|
|
4
|
+
|
|
5
|
+
const HS_ACTION_BEFORE_FETCH: HS.ActionEventName = 'hs:action:before-fetch';
|
|
6
|
+
const HS_ACTION_AFTER_FETCH: HS.ActionEventName = 'hs:action:after-fetch';
|
|
7
|
+
const HS_ACTION_BEFORE_SWAP: HS.ActionEventName = 'hs:action:before-swap';
|
|
8
|
+
const HS_ACTION_AFTER_SWAP: HS.ActionEventName = 'hs:action:after-swap';
|
|
9
|
+
const HS_ACTION_BEFORE_NAVIGATE: HS.ActionEventName = 'hs:action:before-navigate';
|
|
10
|
+
|
|
11
|
+
function dispatchActionEvent<T>(
|
|
12
|
+
target: EventTarget,
|
|
13
|
+
name: HS.ActionEventName,
|
|
14
|
+
detail: T,
|
|
15
|
+
cancelable = false
|
|
16
|
+
): boolean {
|
|
17
|
+
return target.dispatchEvent(
|
|
18
|
+
new CustomEvent(name, {
|
|
19
|
+
detail,
|
|
20
|
+
bubbles: true,
|
|
21
|
+
cancelable,
|
|
22
|
+
composed: true,
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** No box by default (`display: contents`) — style `:has(hs-action-loading)` for loading UI. */
|
|
28
|
+
function ensureActionLoadingStyles() {
|
|
29
|
+
if (document.getElementById('hs-action-loading-style')) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const style = document.createElement('style');
|
|
33
|
+
style.id = 'hs-action-loading-style';
|
|
34
|
+
style.textContent = 'hs-action-loading{display:contents}';
|
|
35
|
+
document.head.appendChild(style);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class HSActionLoading extends HTMLElement {
|
|
39
|
+
connectedCallback() {
|
|
40
|
+
ensureActionLoadingStyles();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!customElements.get('hs-action-loading')) {
|
|
45
|
+
customElements.define('hs-action-loading', HSActionLoading);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setActionLoading(hsActionTag: HTMLElement | null, loading: boolean) {
|
|
49
|
+
if (!hsActionTag) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const existing = hsActionTag.querySelector('hs-action-loading');
|
|
53
|
+
if (loading) {
|
|
54
|
+
if (!existing) {
|
|
55
|
+
hsActionTag.appendChild(document.createElement('hs-action-loading'));
|
|
56
|
+
}
|
|
57
|
+
} else if (existing) {
|
|
58
|
+
existing.remove();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
3
61
|
|
|
4
62
|
const actionFormObserver = new MutationObserver((list) => {
|
|
5
63
|
list.forEach((mutation) => {
|
|
@@ -71,7 +129,24 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
71
129
|
}
|
|
72
130
|
}
|
|
73
131
|
|
|
74
|
-
const hsActionTag = form.closest('hs-action');
|
|
132
|
+
const hsActionTag = form.closest('hs-action') as HTMLElement | null;
|
|
133
|
+
const eventTarget: EventTarget = hsActionTag || document;
|
|
134
|
+
const fetchDetail: HS.ActionFetchDetail = {
|
|
135
|
+
form,
|
|
136
|
+
action: hsActionTag,
|
|
137
|
+
url: formUrl,
|
|
138
|
+
method,
|
|
139
|
+
loadingElement: true,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (!dispatchActionEvent(eventTarget, HS_ACTION_BEFORE_FETCH, fetchDetail, true)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (fetchDetail.loadingElement) {
|
|
147
|
+
setActionLoading(hsActionTag, true);
|
|
148
|
+
}
|
|
149
|
+
|
|
75
150
|
const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
|
|
76
151
|
if (submitBtn) {
|
|
77
152
|
submitBtn.setAttribute('disabled', 'disabled');
|
|
@@ -82,6 +157,18 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
82
157
|
if (isFullDocument) {
|
|
83
158
|
html = html.replace(/^[\s\uFEFF]*<!DOCTYPE[^>]*>/i, '');
|
|
84
159
|
}
|
|
160
|
+
|
|
161
|
+
const swapDetail: HS.ActionSwapDetail = {
|
|
162
|
+
form,
|
|
163
|
+
action: hsActionTag,
|
|
164
|
+
html,
|
|
165
|
+
fullDocument: isFullDocument,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (!dispatchActionEvent(eventTarget, HS_ACTION_BEFORE_SWAP, swapDetail, true)) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
85
172
|
const target = isFullDocument ? window.document : hsActionTag || form;
|
|
86
173
|
const options = isFullDocument ? undefined : { morphStyle: 'innerHTML' };
|
|
87
174
|
|
|
@@ -96,10 +183,42 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
96
183
|
|
|
97
184
|
activateScriptsIn(isFullDocument ? document.body : (target as ParentNode));
|
|
98
185
|
|
|
186
|
+
dispatchActionEvent(eventTarget, HS_ACTION_AFTER_SWAP, swapDetail);
|
|
187
|
+
|
|
99
188
|
opts.afterResponse && opts.afterResponse();
|
|
100
189
|
lazyLoadScripts();
|
|
101
190
|
}
|
|
102
191
|
|
|
192
|
+
async function navigateTo(url: string, preferHard: boolean) {
|
|
193
|
+
const navigateDetail: HS.ActionNavigateDetail = {
|
|
194
|
+
form,
|
|
195
|
+
action: hsActionTag,
|
|
196
|
+
url,
|
|
197
|
+
hard: preferHard,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (!dispatchActionEvent(eventTarget, HS_ACTION_BEFORE_NAVIGATE, navigateDetail, true)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (navigateDetail.hard) {
|
|
205
|
+
window.location.assign(navigateDetail.url);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Soft morph requires same-origin (CORS / Idiomorph are not safe across domains).
|
|
210
|
+
const resolved = new URL(navigateDetail.url, window.location.href);
|
|
211
|
+
if (resolved.origin !== window.location.origin) {
|
|
212
|
+
window.location.assign(navigateDetail.url);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const pageRes = await fetch(navigateDetail.url, {
|
|
217
|
+
headers: { Accept: 'text/html' },
|
|
218
|
+
});
|
|
219
|
+
await consumeStreamingHtmlResponse(pageRes, applyResponseHtml);
|
|
220
|
+
}
|
|
221
|
+
|
|
103
222
|
fetch(formUrl, { body: formData, method, headers })
|
|
104
223
|
.then(async (res: Response) => {
|
|
105
224
|
// Look for special header that indicates a redirect.
|
|
@@ -109,21 +228,13 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
109
228
|
if (newUrl) {
|
|
110
229
|
const resolved = new URL(newUrl, window.location.href);
|
|
111
230
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
resolved.origin
|
|
116
|
-
resolved.pathname
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
headers: { Accept: 'text/html' },
|
|
120
|
-
});
|
|
121
|
-
await consumeStreamingHtmlResponse(pageRes, applyResponseHtml);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// If the new URL is different, we need to redirect the user to the new URL
|
|
126
|
-
window.location.assign(newUrl);
|
|
231
|
+
// Default: same-origin + same path → soft morph; anything else → hard navigation.
|
|
232
|
+
// Listeners can flip detail.hard on hs:action:before-navigate to override.
|
|
233
|
+
const preferHard =
|
|
234
|
+
resolved.origin !== window.location.origin ||
|
|
235
|
+
resolved.pathname !== window.location.pathname;
|
|
236
|
+
|
|
237
|
+
await navigateTo(newUrl, preferHard);
|
|
127
238
|
}
|
|
128
239
|
return;
|
|
129
240
|
}
|
|
@@ -132,6 +243,10 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
132
243
|
})
|
|
133
244
|
.catch((error) => {
|
|
134
245
|
console.error('[Hyperspan] Error submitting form action:', error);
|
|
246
|
+
})
|
|
247
|
+
.finally(() => {
|
|
248
|
+
setActionLoading(hsActionTag, false);
|
|
249
|
+
dispatchActionEvent(eventTarget, HS_ACTION_AFTER_FETCH, fetchDetail);
|
|
135
250
|
});
|
|
136
251
|
}
|
|
137
252
|
|
package/src/types.ts
CHANGED
|
@@ -299,6 +299,56 @@ export namespace Hyperspan {
|
|
|
299
299
|
fetch: (request: Request) => Promise<Response>;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Client-side action lifecycle events (dispatched from `<hs-action>`, bubbles to document).
|
|
304
|
+
*
|
|
305
|
+
* - `hs:action:before-fetch` — before the action request starts (cancelable).
|
|
306
|
+
* - `hs:action:after-fetch` — after the action request finishes (success or error).
|
|
307
|
+
* - `hs:action:before-swap` — before HTML morph (cancelable). Close modals here.
|
|
308
|
+
* - `hs:action:after-swap` — after HTML morph.
|
|
309
|
+
* - `hs:action:before-navigate` — before redirect soft/hard navigation (cancelable).
|
|
310
|
+
* Set `detail.hard = true` for a full page load, or `false` to fetch+morph in place.
|
|
311
|
+
*/
|
|
312
|
+
export type ActionEventName =
|
|
313
|
+
| 'hs:action:before-fetch'
|
|
314
|
+
| 'hs:action:after-fetch'
|
|
315
|
+
| 'hs:action:before-swap'
|
|
316
|
+
| 'hs:action:after-swap'
|
|
317
|
+
| 'hs:action:before-navigate';
|
|
318
|
+
|
|
319
|
+
export type ActionFetchDetail = {
|
|
320
|
+
form: HTMLFormElement;
|
|
321
|
+
/** The current `<hs-action>` element, if present. */
|
|
322
|
+
action: HTMLElement | null;
|
|
323
|
+
url: string;
|
|
324
|
+
method: string;
|
|
325
|
+
/**
|
|
326
|
+
* Mutable. When `true` (default), appends `<hs-action-loading>` inside `<hs-action>` for the
|
|
327
|
+
* duration of the request. Set to `false` in `hs:action:before-fetch` to skip it.
|
|
328
|
+
*/
|
|
329
|
+
loadingElement: boolean;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export type ActionSwapDetail = {
|
|
333
|
+
form: HTMLFormElement;
|
|
334
|
+
/** The current `<hs-action>` element, if present. */
|
|
335
|
+
action: HTMLElement | null;
|
|
336
|
+
html: string;
|
|
337
|
+
fullDocument: boolean;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export type ActionNavigateDetail = {
|
|
341
|
+
form: HTMLFormElement;
|
|
342
|
+
/** The current `<hs-action>` element, if present. */
|
|
343
|
+
action: HTMLElement | null;
|
|
344
|
+
url: string;
|
|
345
|
+
/**
|
|
346
|
+
* Mutable. Default is soft (false) for same-origin+same-path redirects, hard (true) otherwise.
|
|
347
|
+
* Set to `true` for `window.location.assign`, or `false` to fetch + morph in place.
|
|
348
|
+
*/
|
|
349
|
+
hard: boolean;
|
|
350
|
+
};
|
|
351
|
+
|
|
302
352
|
/**
|
|
303
353
|
* Client JS Module = ESM Module + Public Path + Render Script Tag
|
|
304
354
|
*/
|
|
@@ -324,3 +374,20 @@ export namespace Hyperspan {
|
|
|
324
374
|
formErrors: unknown[];
|
|
325
375
|
}
|
|
326
376
|
}
|
|
377
|
+
|
|
378
|
+
declare global {
|
|
379
|
+
interface DocumentEventMap {
|
|
380
|
+
'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
|
|
381
|
+
'hs:action:after-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
|
|
382
|
+
'hs:action:before-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
|
|
383
|
+
'hs:action:after-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
|
|
384
|
+
'hs:action:before-navigate': CustomEvent<Hyperspan.ActionNavigateDetail>;
|
|
385
|
+
}
|
|
386
|
+
interface HTMLElementEventMap {
|
|
387
|
+
'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
|
|
388
|
+
'hs:action:after-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
|
|
389
|
+
'hs:action:before-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
|
|
390
|
+
'hs:action:after-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
|
|
391
|
+
'hs:action:before-navigate': CustomEvent<Hyperspan.ActionNavigateDetail>;
|
|
392
|
+
}
|
|
393
|
+
}
|