@ultimat3/render 26.0.0 → 26.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -2
- package/README.md +39 -1
- package/package.json +7 -6
- package/src/client-sync-tags.ts +1 -1
- package/src/client.ts +14 -0
- package/src/errors.ts +12 -3
- package/src/index.ts +19 -0
- package/src/navigation-api.ts +84 -0
- package/src/navigation-dom.ts +54 -2
- package/src/navigation-errors.ts +21 -0
- package/src/navigation-fetch.ts +52 -1
- package/src/navigation-history.ts +8 -1
- package/src/navigation-modal-rules.ts +110 -0
- package/src/navigation-modal.ts +233 -0
- package/src/navigation-press.ts +59 -0
- package/src/navigation-swap.ts +9 -2
- package/src/navigation-tags.ts +16 -3
- package/src/navigation.ts +137 -143
- package/src/route.ts +11 -3
package/src/navigation.ts
CHANGED
|
@@ -10,25 +10,29 @@
|
|
|
10
10
|
* over instead of letting `fetch` follow it, and a POST is never re-submitted — after a failed
|
|
11
11
|
* one, only the server knows what landed.
|
|
12
12
|
*
|
|
13
|
-
* Loaded as one deferred classic script (`@ultimat3/cli` builds it to
|
|
14
|
-
* on documents whose surface opted in (`navigation: { client:
|
|
13
|
+
* Loaded as one deferred classic script (`@ultimat3/cli` builds it to
|
|
14
|
+
* `/_x/assets/navigation/<hash>.js`) on documents whose surface opted in (`navigation: { client:
|
|
15
|
+
* [...] }` in `app.config.ts`).
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
18
18
|
import { navigationCache } from './navigation-cache';
|
|
19
19
|
import {
|
|
20
20
|
anchorOf,
|
|
21
21
|
announcer,
|
|
22
22
|
focusMain,
|
|
23
|
-
formAction,
|
|
24
|
-
formFields,
|
|
25
|
-
formPairs,
|
|
26
23
|
handOver,
|
|
24
|
+
intentUrl,
|
|
27
25
|
linkFacts,
|
|
28
|
-
|
|
26
|
+
submitFacts,
|
|
29
27
|
transition,
|
|
30
28
|
} from './navigation-dom';
|
|
31
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
type Answer,
|
|
31
|
+
fetchDocument,
|
|
32
|
+
heldOrFetched,
|
|
33
|
+
metaOf,
|
|
34
|
+
responseFactsOf,
|
|
35
|
+
} from './navigation-fetch';
|
|
32
36
|
import {
|
|
33
37
|
type EntryState,
|
|
34
38
|
entryOf,
|
|
@@ -36,25 +40,22 @@ import {
|
|
|
36
40
|
scrollAfter,
|
|
37
41
|
withoutFragment,
|
|
38
42
|
} from './navigation-history';
|
|
43
|
+
import { modalController } from './navigation-modal';
|
|
44
|
+
import { addressOf, modalAddress, modalHistory } from './navigation-modal-rules';
|
|
45
|
+
import { pressTracker } from './navigation-press';
|
|
39
46
|
import {
|
|
40
47
|
answerMovesTab,
|
|
41
|
-
type FormFacts,
|
|
42
48
|
formVerdict,
|
|
43
49
|
linkVerdict,
|
|
44
|
-
mayPrefetch,
|
|
45
50
|
NAVIGATE_EVENT,
|
|
46
51
|
NAVIGATED_EVENT,
|
|
47
52
|
NAVIGATING_ATTRIBUTE,
|
|
48
53
|
NAVIGATION_ERROR_EVENT,
|
|
49
54
|
NAVIGATION_MAX_HOPS,
|
|
50
55
|
NAVIGATION_META,
|
|
51
|
-
NAVIGATION_NO_PREFETCH_ATTRIBUTE,
|
|
52
56
|
NAVIGATION_PREFETCH_DELAY_MS,
|
|
53
57
|
NAVIGATION_PROGRESS_DELAY_MS,
|
|
54
|
-
NAVIGATION_RELOAD_ATTRIBUTE,
|
|
55
|
-
type ResponseFacts,
|
|
56
58
|
responseVerdict,
|
|
57
|
-
reusable,
|
|
58
59
|
} from './navigation-rules';
|
|
59
60
|
import { scrollKeeper } from './navigation-scroll';
|
|
60
61
|
import {
|
|
@@ -74,11 +75,19 @@ export interface NavigateOptions {
|
|
|
74
75
|
readonly history?: 'push' | 'replace' | 'none';
|
|
75
76
|
/** Redirects already followed for this navigation. */
|
|
76
77
|
readonly hops?: number;
|
|
78
|
+
/** The URL's hash addressed it: a modal, or nothing — never a page (`presentation`). */
|
|
79
|
+
readonly fromHash?: boolean;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
export interface NavigationRouter {
|
|
80
83
|
navigate(url: string, options?: NavigateOptions): Promise<void>;
|
|
81
84
|
prefetch(url: string): void;
|
|
85
|
+
/** The page on screen, fetched again and swapped in where it is scrolled. */
|
|
86
|
+
refresh(): Promise<void>;
|
|
87
|
+
/** `path` as the modal over this page (`#<path>`); anything but a modal of this tab: nothing. */
|
|
88
|
+
openModal(path: string): Promise<void>;
|
|
89
|
+
/** As Escape: Back through the router's own entry, else the hash dropped in place. */
|
|
90
|
+
closeModal(): void;
|
|
82
91
|
stop(): void;
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -104,15 +113,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
104
113
|
let rendered = withoutFragment(win.location.href);
|
|
105
114
|
/** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
|
|
106
115
|
let navigatingTo: string | undefined;
|
|
107
|
-
|
|
108
|
-
let animating: RunningTransition | undefined;
|
|
109
|
-
/** Between a press and its click: the focus the press gives a link is not a hover. */
|
|
110
|
-
let pressed = false;
|
|
111
|
-
/** The press landed while a transition was painting over the page (see `onClick`). */
|
|
112
|
-
let pressedOverTransition = false;
|
|
116
|
+
const press = pressTracker(doc);
|
|
113
117
|
/** The pending prefetch of a hover, cancelled by anything that navigates. */
|
|
114
118
|
let intent: number | undefined;
|
|
115
119
|
let untrusted = false;
|
|
120
|
+
let backing = false; // a Back this router started is traversing: the URL is still the one left
|
|
116
121
|
let inflight: AbortController | undefined;
|
|
117
122
|
const live = announcer(doc);
|
|
118
123
|
const owned = documentHead(doc);
|
|
@@ -120,6 +125,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
120
125
|
const tabs = tabSync(win, () => cache.clear());
|
|
121
126
|
const forget = tabs.forget;
|
|
122
127
|
const scroll = scrollKeeper(win, () => rendered);
|
|
128
|
+
const modal = modalController(win, () => rendered, ran, owned);
|
|
123
129
|
|
|
124
130
|
const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
|
|
125
131
|
fetchDocument(win, doc, url, purpose, init);
|
|
@@ -138,7 +144,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
138
144
|
const failed = (url: string, method: string, reason: string): void => {
|
|
139
145
|
const detail = { url, method, reason };
|
|
140
146
|
const event = new CustomEvent(NAVIGATION_ERROR_EVENT, { detail, cancelable: true });
|
|
141
|
-
if (doc.dispatchEvent(event))
|
|
147
|
+
if (!doc.dispatchEvent(event)) return;
|
|
148
|
+
// Under a modal the URL differs only by its hash, which `assign` merely scrolls to: reloaded,
|
|
149
|
+
// the page and its modal are both asked for again (the entry is the router's own GET).
|
|
150
|
+
if (modal.address === undefined) win.location.assign(win.location.href);
|
|
151
|
+
else win.location.reload();
|
|
142
152
|
};
|
|
143
153
|
|
|
144
154
|
const answerFor = (
|
|
@@ -154,22 +164,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
154
164
|
const body = options.body === undefined ? {} : { body: options.body };
|
|
155
165
|
return request(url, 'soft', { method, ...body, signal }).finally(forget);
|
|
156
166
|
}
|
|
157
|
-
|
|
158
|
-
cache.delete(url);
|
|
159
|
-
if (held === undefined) return request(url, 'soft', { signal });
|
|
160
|
-
return held.value.then(
|
|
161
|
-
(answer) =>
|
|
162
|
-
reusable({
|
|
163
|
-
status: answer.status,
|
|
164
|
-
html: answer.html !== null,
|
|
165
|
-
location: answer.location,
|
|
166
|
-
noStore: answer.noStore,
|
|
167
|
-
ageMs: held.ageMs,
|
|
168
|
-
})
|
|
169
|
-
? answer
|
|
170
|
-
: request(url, 'soft', { signal }),
|
|
171
|
-
() => request(url, 'soft', { signal }),
|
|
172
|
-
);
|
|
167
|
+
return heldOrFetched(cache, url, () => request(url, 'soft', { signal }));
|
|
173
168
|
};
|
|
174
169
|
|
|
175
170
|
const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
|
|
@@ -183,7 +178,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
183
178
|
// After an answer for another principal or build was shown in place, this tab is no longer
|
|
184
179
|
// trusted to swap: every navigation is a real load (`answerMovesTab`).
|
|
185
180
|
if (untrusted && method === 'GET') {
|
|
186
|
-
|
|
181
|
+
if (options.fromHash === true) modal.clear();
|
|
182
|
+
else win.location.assign(url);
|
|
187
183
|
return;
|
|
188
184
|
}
|
|
189
185
|
const detail = { url, method };
|
|
@@ -212,22 +208,20 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
212
208
|
if (mine.signal.aborted) return;
|
|
213
209
|
const next =
|
|
214
210
|
answer.html === null ? null : new DOMParser().parseFromString(answer.html, 'text/html');
|
|
215
|
-
const facts:
|
|
211
|
+
const facts = responseFactsOf(doc, next, answer, { method, requested: url, hops });
|
|
212
|
+
const verdict = responseVerdict(facts);
|
|
213
|
+
const landed = `${withoutFragment(url)}${new URL(url).hash}`;
|
|
214
|
+
const shown = modal.presentationOf(next, landed, {
|
|
215
|
+
fromHash: options.fromHash === true,
|
|
216
216
|
method,
|
|
217
|
-
|
|
217
|
+
verdict: verdict.kind,
|
|
218
218
|
status: answer.status,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
build: metaOf(doc, CLIENT_BUILD_META),
|
|
226
|
-
nextBuild: answer.build ?? (next === null ? null : metaOf(next, CLIENT_BUILD_META)),
|
|
227
|
-
scope: metaOf(doc, CLIENT_SCOPE_META),
|
|
228
|
-
nextScope: next === null ? null : metaOf(next, CLIENT_SCOPE_META),
|
|
229
|
-
};
|
|
230
|
-
const verdict = responseVerdict(facts);
|
|
219
|
+
movesTab: answerMovesTab(facts),
|
|
220
|
+
});
|
|
221
|
+
if (shown === 'degrade') {
|
|
222
|
+
modal.clear();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
231
225
|
switch (verdict.kind) {
|
|
232
226
|
case 'stay':
|
|
233
227
|
return;
|
|
@@ -242,7 +236,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
242
236
|
return;
|
|
243
237
|
case 'follow':
|
|
244
238
|
await navigate(verdict.url, {
|
|
245
|
-
|
|
239
|
+
// Out of a modal, a redirect's target takes the modal's entry: Back never reopens it.
|
|
240
|
+
history: options.history === 'none' || modal.address !== undefined ? 'replace' : 'push',
|
|
246
241
|
hops: hops + 1,
|
|
247
242
|
});
|
|
248
243
|
return;
|
|
@@ -253,18 +248,26 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
253
248
|
break;
|
|
254
249
|
}
|
|
255
250
|
if (next === null) return;
|
|
256
|
-
|
|
251
|
+
// A page answered while a modal is open: the modal goes, and so does its history entry.
|
|
252
|
+
const leave = shown === 'page' ? modal.leaveFor(options.history, landed) : undefined;
|
|
253
|
+
const history = leave === undefined ? options.history : leave === 'back' ? 'none' : leave;
|
|
257
254
|
try {
|
|
258
255
|
// Owned from the moment they are appended: a navigation aborted after this point left its
|
|
259
256
|
// sheets in the head for good, styling every page after it — the next swap retires an
|
|
260
257
|
// owned sheet its page does not link.
|
|
261
258
|
await loadStylesheets(doc, missingStylesheets(doc, next), owned);
|
|
262
259
|
if (mine.signal.aborted) return;
|
|
260
|
+
if (shown === 'modal') {
|
|
261
|
+
await modal.present(next, addressOf(landed), landed, modalHistory(method, history));
|
|
262
|
+
doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (leave === 'back') {
|
|
266
|
+
backing = true;
|
|
267
|
+
win.history.back();
|
|
268
|
+
}
|
|
269
|
+
modal.dismiss();
|
|
263
270
|
let swapped: ReturnType<typeof swapDocument> | undefined;
|
|
264
|
-
const track = (running: RunningTransition, finished: boolean): void => {
|
|
265
|
-
if (!finished) animating = running;
|
|
266
|
-
else if (animating === running) animating = undefined;
|
|
267
|
-
};
|
|
268
271
|
await transition(
|
|
269
272
|
win,
|
|
270
273
|
() => {
|
|
@@ -275,16 +278,16 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
275
278
|
live.textContent = '';
|
|
276
279
|
swapped.body.append(live);
|
|
277
280
|
rendered = withoutFragment(landed);
|
|
278
|
-
if (
|
|
279
|
-
const same = landed === win.location.href ||
|
|
281
|
+
if (history !== 'none') {
|
|
282
|
+
const same = landed === win.location.href || history === 'replace';
|
|
280
283
|
const state = { [STATE_KEY]: { scroll: [0, 0], doc: rendered } satisfies EntryState };
|
|
281
284
|
if (same) win.history.replaceState(state, '', landed);
|
|
282
285
|
else win.history.pushState(state, '', landed);
|
|
283
286
|
}
|
|
284
287
|
// Inside the swap, so a view transition's "after" frame is already where the page lands.
|
|
285
|
-
scrollAfter(win, doc, landed,
|
|
288
|
+
scrollAfter(win, doc, landed, history);
|
|
286
289
|
},
|
|
287
|
-
track,
|
|
290
|
+
press.track,
|
|
288
291
|
);
|
|
289
292
|
const done = swapped;
|
|
290
293
|
if (done === undefined) return;
|
|
@@ -302,6 +305,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
302
305
|
focusMain(doc);
|
|
303
306
|
live.textContent = doc.title;
|
|
304
307
|
doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
|
|
308
|
+
reconcile();
|
|
305
309
|
} catch {
|
|
306
310
|
// A swap that failed part-way leaves a page nobody rendered: a GET is loaded for real; a
|
|
307
311
|
// POST's answer cannot be asked for again, so the visitor is told and shown this page.
|
|
@@ -319,64 +323,30 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
319
323
|
};
|
|
320
324
|
|
|
321
325
|
const onPress = (): void => {
|
|
322
|
-
|
|
323
|
-
pressedOverTransition = animating !== undefined;
|
|
326
|
+
press.onPress();
|
|
324
327
|
// A press is not a hover: the click it becomes navigates, and a guess on its heels is a
|
|
325
|
-
// second request for the same page.
|
|
326
|
-
// snapped every named element to its end mid-glide (#621); only the click below may.
|
|
328
|
+
// second request for the same page.
|
|
327
329
|
win.clearTimeout(intent);
|
|
328
330
|
};
|
|
329
|
-
const onRelease = (): void => {
|
|
330
|
-
pressed = false;
|
|
331
|
-
};
|
|
332
331
|
|
|
333
332
|
const onClick = (event: MouseEvent): void => {
|
|
334
333
|
win.clearTimeout(intent);
|
|
335
|
-
|
|
336
|
-
// so whatever `pointer-events` the `::view-transition` overlay has — so the click reached
|
|
337
|
-
// nothing: the visitor's first click after a swap was lost. It is given to the element under
|
|
338
|
-
// the pointer, whatever it is: a link, a submit button, an island's own control. Only a
|
|
339
|
-
// skipped transition hit-tests the page, so the skip happens HERE, at the click, never at
|
|
340
|
-
// the press; when the animation already ended between the two, there is nothing to skip.
|
|
341
|
-
if (pressedOverTransition && event.target === doc.documentElement) {
|
|
342
|
-
pressedOverTransition = false;
|
|
343
|
-
animating?.skipTransition?.();
|
|
344
|
-
const hit = doc.elementFromPoint(event.clientX, event.clientY);
|
|
345
|
-
if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
334
|
+
if (press.overlayClick(event)) return;
|
|
348
335
|
const anchor = anchorOf(event.target);
|
|
349
336
|
if (anchor === null) return;
|
|
350
337
|
const verdict = linkVerdict(linkFacts(win, anchor, event));
|
|
351
338
|
if (verdict.kind !== 'soft') return;
|
|
352
339
|
event.preventDefault();
|
|
353
|
-
|
|
340
|
+
// A link to the page beneath an open modal (its Cancel) closes the modal: nothing is fetched.
|
|
341
|
+
if (modal.address !== undefined && withoutFragment(verdict.url) === rendered) modal.close();
|
|
342
|
+
else void navigate(verdict.url);
|
|
354
343
|
};
|
|
355
344
|
|
|
356
345
|
const onSubmit = (event: SubmitEvent): void => {
|
|
357
346
|
const form = event.target;
|
|
358
347
|
// An untrusted tab (`answerMovesTab`) leaves every form to the browser.
|
|
359
348
|
if (!(form instanceof HTMLFormElement) || untrusted) return;
|
|
360
|
-
const
|
|
361
|
-
const fields = formFields(form, submitter);
|
|
362
|
-
const say = (attr: string, fallback: string): string =>
|
|
363
|
-
submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
|
|
364
|
-
const facts: FormFacts = {
|
|
365
|
-
action: formAction(say('action', ''), win.location.href, doc.baseURI),
|
|
366
|
-
current: win.location.href,
|
|
367
|
-
method: say('method', 'get').toLowerCase(),
|
|
368
|
-
enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
|
|
369
|
-
target: say('target', ''),
|
|
370
|
-
defaultPrevented: event.defaultPrevented,
|
|
371
|
-
reload:
|
|
372
|
-
form.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) ||
|
|
373
|
-
submitter?.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) === true,
|
|
374
|
-
// `unknown`: a browser yields a `File` for a file input, whatever a server-side type says.
|
|
375
|
-
hasFile: [...fields.values()].some(
|
|
376
|
-
(value: unknown) => value instanceof File && value.name !== '',
|
|
377
|
-
),
|
|
378
|
-
fields: formPairs(fields),
|
|
379
|
-
};
|
|
349
|
+
const { facts, fields } = submitFacts(win, form, event.submitter, event.defaultPrevented);
|
|
380
350
|
const verdict = formVerdict(facts);
|
|
381
351
|
if (verdict.kind === 'native') return;
|
|
382
352
|
event.preventDefault();
|
|
@@ -393,27 +363,12 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
393
363
|
|
|
394
364
|
const onIntent = (event: Event): void => {
|
|
395
365
|
// The focus a press gives a link: its click is a moment away, and it navigates.
|
|
396
|
-
if (event.type === 'focusin' && pressed) return;
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
const verdict = linkVerdict(linkFacts(win, anchor));
|
|
400
|
-
const connection = (
|
|
401
|
-
win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
|
|
402
|
-
).connection;
|
|
403
|
-
if (
|
|
404
|
-
verdict.kind !== 'soft' ||
|
|
405
|
-
!mayPrefetch({
|
|
406
|
-
url: verdict.url,
|
|
407
|
-
noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
|
|
408
|
-
saveData: connection?.saveData,
|
|
409
|
-
effectiveType: connection?.effectiveType,
|
|
410
|
-
})
|
|
411
|
-
) {
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
366
|
+
if (event.type === 'focusin' && press.pressed()) return;
|
|
367
|
+
const url = intentUrl(win, event.target);
|
|
368
|
+
if (url === undefined) return;
|
|
414
369
|
win.clearTimeout(intent);
|
|
415
370
|
const delay = event.type === 'pointerover' ? NAVIGATION_PREFETCH_DELAY_MS : 0;
|
|
416
|
-
intent = win.setTimeout(() => prefetch(
|
|
371
|
+
intent = win.setTimeout(() => prefetch(url), delay);
|
|
417
372
|
};
|
|
418
373
|
const onLeave = (): void => win.clearTimeout(intent);
|
|
419
374
|
|
|
@@ -424,7 +379,10 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
424
379
|
*/
|
|
425
380
|
const onPop = (event: PopStateEvent): void => {
|
|
426
381
|
scroll.cancel();
|
|
427
|
-
|
|
382
|
+
backing = false;
|
|
383
|
+
// The page is the PATH: a modal's hash is the modal's (`reconcile`), never the page's fragment.
|
|
384
|
+
const at = win.location.href;
|
|
385
|
+
const url = modalAddress(new URL(at).hash, at) === null ? at : withoutFragment(at);
|
|
428
386
|
const entry = entryOf(event.state);
|
|
429
387
|
const wanted = entry?.doc ?? withoutFragment(url);
|
|
430
388
|
const same =
|
|
@@ -442,40 +400,76 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
442
400
|
behavior: 'instant' as ScrollBehavior,
|
|
443
401
|
});
|
|
444
402
|
}
|
|
403
|
+
reconcile();
|
|
445
404
|
};
|
|
446
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The modal follows the URL's hash, once the page on screen is the URL's: opened (fetched, as a
|
|
408
|
+
* hash address, so a stale one degrades), kept, or dismissed. On start, on Back/Forward, on a
|
|
409
|
+
* typed hash, and after every page swap — a Back onto a modal's entry swaps its page first.
|
|
410
|
+
*/
|
|
411
|
+
function reconcile(): void {
|
|
412
|
+
// Until that Back lands, the hash still names the modal just left: it would be opened again.
|
|
413
|
+
if (backing) return;
|
|
414
|
+
const at = win.location.href;
|
|
415
|
+
if (withoutFragment(at) !== rendered) return;
|
|
416
|
+
const wanted = modalAddress(new URL(at).hash, at);
|
|
417
|
+
if (wanted === null) modal.dismiss();
|
|
418
|
+
else if (wanted !== modal.address) {
|
|
419
|
+
void navigate(new URL(wanted, at).href, { history: 'none', fromHash: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
447
423
|
// `window`, bubble phase: after every handler on the page — Solid delegates to `document` — has
|
|
448
424
|
// had its chance to `preventDefault`, which the rules then honour.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
425
|
+
const passive = { passive: true };
|
|
426
|
+
const pressing = { capture: true, passive: true };
|
|
427
|
+
const listeners: readonly (readonly [EventTarget, string, EventListener, object?])[] = [
|
|
428
|
+
[win, 'click', onClick as EventListener],
|
|
429
|
+
[win, 'submit', onSubmit as EventListener],
|
|
430
|
+
[doc, 'pointerover', onIntent, passive],
|
|
431
|
+
[doc, 'pointerout', onLeave, passive],
|
|
432
|
+
[doc, 'focusin', onIntent],
|
|
433
|
+
[doc, 'touchstart', onIntent, passive],
|
|
434
|
+
[win, 'popstate', onPop as EventListener],
|
|
435
|
+
[win, 'hashchange', reconcile],
|
|
436
|
+
[doc, 'pointerdown', onPress, pressing],
|
|
437
|
+
[doc, 'pointerup', press.onRelease, pressing],
|
|
438
|
+
[doc, 'pointercancel', press.onRelease, pressing],
|
|
439
|
+
];
|
|
440
|
+
for (const [target, type, listener, options] of listeners) {
|
|
441
|
+
target.addEventListener(type, listener, options);
|
|
442
|
+
}
|
|
459
443
|
|
|
460
444
|
const router: NavigationRouter = {
|
|
461
445
|
navigate,
|
|
462
446
|
prefetch,
|
|
447
|
+
refresh() {
|
|
448
|
+
// A fresh answer for the page on screen, landing where the visitor is scrolled.
|
|
449
|
+
cache.delete(rendered);
|
|
450
|
+
scroll.save();
|
|
451
|
+
return navigate(rendered, { history: 'none' });
|
|
452
|
+
},
|
|
453
|
+
openModal(path) {
|
|
454
|
+
const address = modalAddress(`#${path}`, win.location.href);
|
|
455
|
+
if (address === null) return Promise.resolve();
|
|
456
|
+
return navigate(new URL(address, win.location.href).href, {
|
|
457
|
+
fromHash: true,
|
|
458
|
+
history: 'push',
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
closeModal: () => modal.close(),
|
|
463
462
|
stop() {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
doc.removeEventListener('focusin', onIntent);
|
|
469
|
-
doc.removeEventListener('touchstart', onIntent);
|
|
470
|
-
win.removeEventListener('popstate', onPop);
|
|
471
|
-
doc.removeEventListener('pointerdown', onPress, { capture: true });
|
|
472
|
-
doc.removeEventListener('pointerup', onRelease, { capture: true });
|
|
473
|
-
doc.removeEventListener('pointercancel', onRelease, { capture: true });
|
|
463
|
+
for (const [target, type, listener, options] of listeners) {
|
|
464
|
+
target.removeEventListener(type, listener, options);
|
|
465
|
+
}
|
|
466
|
+
modal.dismiss();
|
|
474
467
|
scroll.stop();
|
|
475
468
|
tabs.stop();
|
|
476
469
|
delete win.__xNavigation;
|
|
477
470
|
},
|
|
478
471
|
};
|
|
479
472
|
win.__xNavigation = router;
|
|
473
|
+
reconcile();
|
|
480
474
|
return router;
|
|
481
475
|
}
|
package/src/route.ts
CHANGED
|
@@ -191,11 +191,19 @@ export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
|
|
|
191
191
|
* in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
|
|
192
192
|
* page whose GET does nothing but render. `'document'`: always a real document load, the route run
|
|
193
193
|
* once by the browser — for a GET that records something (an open, a download, a token consumed).
|
|
194
|
-
*
|
|
194
|
+
* `'modal'`: presented OVER the page a visitor came from, addressed by the hash
|
|
195
|
+
* (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
|
|
196
|
+
* whole page. Never fetched before a click. The server enforces all three before `load`
|
|
197
|
+
* (`@ultimat3/http`'s navigation gate) and names a modal in its document
|
|
198
|
+
* (`NAVIGATION_PRESENTATION_META`).
|
|
195
199
|
*/
|
|
196
|
-
export type RouteNavigationMode = 'prefetch' | 'document';
|
|
200
|
+
export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
|
|
197
201
|
|
|
198
|
-
export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
|
|
202
|
+
export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
|
|
203
|
+
'prefetch',
|
|
204
|
+
'document',
|
|
205
|
+
'modal',
|
|
206
|
+
];
|
|
199
207
|
|
|
200
208
|
/** The input shape of `defineRoute` — exactly the contract's twelve keys, nothing else. */
|
|
201
209
|
export interface RouteDefinition<TData = RouteData> {
|