@ultimat3/render 22.8.0 → 22.8.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "22.8.
|
|
3
|
+
"version": "22.8.1",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,11 +38,11 @@
|
|
|
38
38
|
"test": "bun test"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@ultimat3/cache": "22.8.
|
|
42
|
-
"@ultimat3/core": "22.8.
|
|
43
|
-
"@ultimat3/http": "22.8.
|
|
44
|
-
"@ultimat3/i18n": "22.8.
|
|
45
|
-
"@ultimat3/seo": "22.8.
|
|
41
|
+
"@ultimat3/cache": "22.8.1",
|
|
42
|
+
"@ultimat3/core": "22.8.1",
|
|
43
|
+
"@ultimat3/http": "22.8.1",
|
|
44
|
+
"@ultimat3/i18n": "22.8.1",
|
|
45
|
+
"@ultimat3/seo": "22.8.1",
|
|
46
46
|
"sass": "1.104.0"
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The controller's test harness beside the fake DOM: a page with the links every test follows, a
|
|
3
|
+
* tab whose `fetch` the test answers (every call recorded), an event dispatched the way a browser
|
|
4
|
+
* does, and the router the test started — stopped after each by `stopRouter()`.
|
|
5
|
+
*/
|
|
6
|
+
import { startNavigation } from './navigation';
|
|
7
|
+
import {
|
|
8
|
+
FakeDocument,
|
|
9
|
+
type FakeElement,
|
|
10
|
+
type FakeWindow,
|
|
11
|
+
fakeWindow,
|
|
12
|
+
h,
|
|
13
|
+
htmlAnswer,
|
|
14
|
+
routerHead,
|
|
15
|
+
} from './navigation-dom-fixture';
|
|
16
|
+
|
|
17
|
+
export type Handler = (init: RequestInit | undefined) => Response | Promise<Response>;
|
|
18
|
+
|
|
19
|
+
export const page = (
|
|
20
|
+
title: string,
|
|
21
|
+
meta: { scope?: string; build?: string } = {},
|
|
22
|
+
body: FakeElement[] = [],
|
|
23
|
+
) =>
|
|
24
|
+
new FakeDocument(routerHead(title, [], meta), [
|
|
25
|
+
h('main', {}, [
|
|
26
|
+
h('h1', {}, title),
|
|
27
|
+
h('a', { id: 'to-b', href: '/b' }, 'b'),
|
|
28
|
+
h('a', { id: 'to-c', href: '/c' }, 'c'),
|
|
29
|
+
h('a', { id: 'to-slow', href: '/slow' }, 'slow'),
|
|
30
|
+
h('a', { id: 'plain', href: '/b', 'data-x-no-prefetch': '' }, 'plain'),
|
|
31
|
+
...body,
|
|
32
|
+
]),
|
|
33
|
+
h('script', {}, `hydrate(${title})`),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** The router the current test started; `stopRouter()` in an `afterEach` lets it go. */
|
|
37
|
+
let router: ReturnType<typeof startNavigation>;
|
|
38
|
+
export const currentRouter = (): ReturnType<typeof startNavigation> => router;
|
|
39
|
+
export function stopRouter(): void {
|
|
40
|
+
router?.stop();
|
|
41
|
+
router = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A tab on `/a` whose `fetch` answers from `routes`; `calls` records `METHOD path purpose`. */
|
|
45
|
+
export function tab(routes: Record<string, Handler>, doc = page('A')) {
|
|
46
|
+
const calls: string[] = [];
|
|
47
|
+
const fetch = (url: string, init?: RequestInit): Promise<Response> => {
|
|
48
|
+
const u = new URL(url);
|
|
49
|
+
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
50
|
+
calls.push(
|
|
51
|
+
`${init?.method ?? 'GET'} ${u.pathname}${u.search} ${headers['x-ultimate-navigation']}`,
|
|
52
|
+
);
|
|
53
|
+
const handler = routes[u.pathname];
|
|
54
|
+
if (handler === undefined) return Promise.reject(new TypeError('network'));
|
|
55
|
+
return Promise.resolve(handler(init));
|
|
56
|
+
};
|
|
57
|
+
const win = fakeWindow(doc, 'https://app.test/a', fetch);
|
|
58
|
+
router = startNavigation(win as unknown as Window);
|
|
59
|
+
return { win, doc, calls };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An event the way a browser dispatches it: cancelable, with its target and fields. */
|
|
63
|
+
export function fire(
|
|
64
|
+
on: EventTarget,
|
|
65
|
+
type: string,
|
|
66
|
+
target: unknown,
|
|
67
|
+
fields: Record<string, unknown> = {},
|
|
68
|
+
): Event {
|
|
69
|
+
const event = new Event(type, { cancelable: true, bubbles: true });
|
|
70
|
+
Object.defineProperty(event, 'target', { value: target });
|
|
71
|
+
for (const [key, value] of Object.entries({
|
|
72
|
+
button: 0,
|
|
73
|
+
metaKey: false,
|
|
74
|
+
ctrlKey: false,
|
|
75
|
+
shiftKey: false,
|
|
76
|
+
altKey: false,
|
|
77
|
+
...fields,
|
|
78
|
+
})) {
|
|
79
|
+
Object.defineProperty(event, key, { value });
|
|
80
|
+
}
|
|
81
|
+
on.dispatchEvent(event);
|
|
82
|
+
return event;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const click = (win: FakeWindow, id: string, fields?: Record<string, unknown>) =>
|
|
86
|
+
fire(win, 'click', win.document.getElementById(id), fields);
|
|
87
|
+
|
|
88
|
+
export const answers = {
|
|
89
|
+
b: () => htmlAnswer('page:B', page('B')),
|
|
90
|
+
c: () => htmlAnswer('page:C', page('C')),
|
|
91
|
+
};
|
|
@@ -257,6 +257,11 @@ export class FakeDocument extends EventTarget {
|
|
|
257
257
|
node.adopt(this);
|
|
258
258
|
return node;
|
|
259
259
|
}
|
|
260
|
+
/** What a test says is under the pointer, for a click the browser aimed at `<html>`. */
|
|
261
|
+
underPointer: FakeElement | null = null;
|
|
262
|
+
elementFromPoint(): FakeElement | null {
|
|
263
|
+
return this.underPointer;
|
|
264
|
+
}
|
|
260
265
|
getElementById(id: string): FakeElement | null {
|
|
261
266
|
return this.documentElement.querySelectorAll('[id]').find((el) => el.id === id) ?? null;
|
|
262
267
|
}
|
|
@@ -375,6 +380,7 @@ export function fakeWindow(doc: FakeDocument, url: string, fetch: FakeWindow['fe
|
|
|
375
380
|
globals['DOMParser'] = FakeParser;
|
|
376
381
|
globals['HTMLFormElement'] = FakeForm;
|
|
377
382
|
globals['FormData'] = FakeFormData;
|
|
383
|
+
globals['HTMLElement'] = FakeElement;
|
|
378
384
|
return self;
|
|
379
385
|
}
|
|
380
386
|
|
package/src/navigation-dom.ts
CHANGED
|
@@ -56,13 +56,13 @@ const hush = (): void => undefined;
|
|
|
56
56
|
* leaves an unhandled rejection behind. A throw inside `apply` rejects `updateCallbackDone`, which
|
|
57
57
|
* the caller awaits and turns into a full load.
|
|
58
58
|
*/
|
|
59
|
-
export async function transition(
|
|
59
|
+
export async function transition(
|
|
60
|
+
win: Window,
|
|
61
|
+
apply: () => void,
|
|
62
|
+
track?: (running: RunningTransition | undefined) => void,
|
|
63
|
+
): Promise<void> {
|
|
60
64
|
const doc = win.document as Document & {
|
|
61
|
-
startViewTransition?: (update: () => void) =>
|
|
62
|
-
updateCallbackDone: Promise<void>;
|
|
63
|
-
ready: Promise<void>;
|
|
64
|
-
finished: Promise<void>;
|
|
65
|
-
};
|
|
65
|
+
startViewTransition?: (update: () => void) => RunningTransition;
|
|
66
66
|
};
|
|
67
67
|
const still = win.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
|
|
68
68
|
if (doc.startViewTransition === undefined || still) {
|
|
@@ -70,11 +70,23 @@ export async function transition(win: Window, apply: () => void): Promise<void>
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
const running = doc.startViewTransition(apply);
|
|
73
|
+
track?.(running);
|
|
73
74
|
running.ready.catch(hush);
|
|
74
|
-
running.finished.
|
|
75
|
+
running.finished.then(
|
|
76
|
+
() => track?.(undefined),
|
|
77
|
+
() => track?.(undefined),
|
|
78
|
+
);
|
|
75
79
|
await running.updateCallbackDone;
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/** The part of a `ViewTransition` the router uses. */
|
|
83
|
+
export interface RunningTransition {
|
|
84
|
+
readonly updateCallbackDone: Promise<void>;
|
|
85
|
+
readonly ready: Promise<void>;
|
|
86
|
+
readonly finished: Promise<void>;
|
|
87
|
+
skipTransition?(): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
/** Where a keyboard or screen-reader user lands: the page's `<main>`, else its first heading. */
|
|
79
91
|
export function focusMain(doc: Document): void {
|
|
80
92
|
const target = doc.querySelector<HTMLElement>('main') ?? doc.querySelector<HTMLElement>('h1');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client router's history entries: its own slot in `history.state` (the scroll offset and the
|
|
3
|
+
* document the entry shows), how an entry is read back, how the scroll is saved into the entry of
|
|
4
|
+
* the page ON SCREEN, and where a page lands. Split from `navigation.ts` so that file is the
|
|
5
|
+
* controller and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The router's own slot in `history.state`, beside whatever an app keeps there. */
|
|
9
|
+
export const STATE_KEY = '__x';
|
|
10
|
+
|
|
11
|
+
export interface EntryState {
|
|
12
|
+
readonly scroll: readonly [number, number];
|
|
13
|
+
/** The document this entry shows — what back/forward compares with the one on screen. */
|
|
14
|
+
readonly doc: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Back/forward, as the router passes it: `none` restores; the others start at the top. */
|
|
18
|
+
export type HistoryMode = 'push' | 'replace' | 'none' | undefined;
|
|
19
|
+
|
|
20
|
+
export const withoutFragment = (url: string): string => url.split('#')[0] ?? url;
|
|
21
|
+
|
|
22
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
23
|
+
typeof value === 'object' && value !== null;
|
|
24
|
+
|
|
25
|
+
/** The router's entry in a `history.state`, or `undefined` for an entry an app pushed. */
|
|
26
|
+
export const entryOf = (state: unknown): EntryState | undefined => {
|
|
27
|
+
const entry = isRecord(state) ? state[STATE_KEY] : undefined;
|
|
28
|
+
return isRecord(entry) && typeof entry['doc'] === 'string'
|
|
29
|
+
? (entry as unknown as EntryState)
|
|
30
|
+
: undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The scroll offset, into the entry of the document `rendered` — never into another's: between a
|
|
35
|
+
* back/forward and its swap, the current entry is the next page's, and this offset is not its.
|
|
36
|
+
*/
|
|
37
|
+
export function saveEntryScroll(win: Window, rendered: string): void {
|
|
38
|
+
const state = (win.history.state ?? {}) as Record<string, unknown>;
|
|
39
|
+
const held = entryOf(state)?.doc;
|
|
40
|
+
if (held !== undefined && held !== rendered) return;
|
|
41
|
+
const entry: EntryState = { scroll: [win.scrollX, win.scrollY], doc: held ?? rendered };
|
|
42
|
+
win.history.replaceState({ ...state, [STATE_KEY]: entry }, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where the new page lands: the saved offset on back/forward, the fragment's element, else the
|
|
47
|
+
* top. `instant`, whatever the page's `scroll-behavior` says — `@ultimat3/ui`'s reset makes it
|
|
48
|
+
* `smooth`, and a new page is a new place, which a full load jumps to rather than glides to.
|
|
49
|
+
*/
|
|
50
|
+
export function scrollAfter(win: Window, doc: Document, url: string, mode: HistoryMode): void {
|
|
51
|
+
const hash = new URL(url).hash;
|
|
52
|
+
const target = hash.length > 1 ? doc.getElementById(decodeURIComponent(hash.slice(1))) : null;
|
|
53
|
+
const saved = entryOf(win.history.state);
|
|
54
|
+
const to = (left: number, top: number): void =>
|
|
55
|
+
win.scrollTo({ left, top, behavior: 'instant' as ScrollBehavior });
|
|
56
|
+
if (mode === 'none' && saved !== undefined) to(saved.scroll[0], saved.scroll[1]);
|
|
57
|
+
else if (target !== null) target.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
|
|
58
|
+
else to(0, 0);
|
|
59
|
+
}
|
package/src/navigation.ts
CHANGED
|
@@ -29,9 +29,18 @@ import {
|
|
|
29
29
|
formFields,
|
|
30
30
|
handOver,
|
|
31
31
|
linkFacts,
|
|
32
|
+
type RunningTransition,
|
|
32
33
|
transition,
|
|
33
34
|
} from './navigation-dom';
|
|
34
35
|
import { type Answer, fetchDocument, metaOf } from './navigation-fetch';
|
|
36
|
+
import {
|
|
37
|
+
type EntryState,
|
|
38
|
+
entryOf,
|
|
39
|
+
STATE_KEY,
|
|
40
|
+
saveEntryScroll,
|
|
41
|
+
scrollAfter,
|
|
42
|
+
withoutFragment,
|
|
43
|
+
} from './navigation-history';
|
|
35
44
|
import {
|
|
36
45
|
answerMovesTab,
|
|
37
46
|
type FormFacts,
|
|
@@ -75,29 +84,9 @@ export interface NavigationRouter {
|
|
|
75
84
|
stop(): void;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
|
-
/** The router's own slot in `history.state`, beside whatever an app keeps there. */
|
|
79
|
-
const STATE_KEY = '__x';
|
|
80
|
-
interface EntryState {
|
|
81
|
-
readonly scroll: readonly [number, number];
|
|
82
|
-
/** The document this entry shows — what back/forward compares with the one on screen. */
|
|
83
|
-
readonly doc: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
87
|
/** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
|
|
87
88
|
const CHANNEL = 'ultimate:navigation';
|
|
88
89
|
|
|
89
|
-
const withoutFragment = (url: string): string => url.split('#')[0] ?? url;
|
|
90
|
-
|
|
91
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
92
|
-
typeof value === 'object' && value !== null;
|
|
93
|
-
|
|
94
|
-
const entryOf = (state: unknown): EntryState | undefined => {
|
|
95
|
-
const entry = isRecord(state) ? state[STATE_KEY] : undefined;
|
|
96
|
-
return isRecord(entry) && typeof entry['doc'] === 'string'
|
|
97
|
-
? (entry as unknown as EntryState)
|
|
98
|
-
: undefined;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
90
|
interface RouterWindow extends Window {
|
|
102
91
|
__xNavigation?: NavigationRouter;
|
|
103
92
|
}
|
|
@@ -118,6 +107,16 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
118
107
|
),
|
|
119
108
|
);
|
|
120
109
|
let rendered = withoutFragment(win.location.href);
|
|
110
|
+
/** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
|
|
111
|
+
let navigatingTo: string | undefined;
|
|
112
|
+
/** The view transition still animating, which a press skips to the new page. */
|
|
113
|
+
let animating: RunningTransition | undefined;
|
|
114
|
+
/** Between a press and its click: the focus the press gives a link is not a hover. */
|
|
115
|
+
let pressed = false;
|
|
116
|
+
/** The press landed while a transition was painting over the page (see `onClick`). */
|
|
117
|
+
let pressedOverTransition = false;
|
|
118
|
+
/** The pending prefetch of a hover, cancelled by anything that navigates. */
|
|
119
|
+
let intent: number | undefined;
|
|
121
120
|
let untrusted = false;
|
|
122
121
|
let inflight: AbortController | undefined;
|
|
123
122
|
const live = announcer(doc);
|
|
@@ -135,15 +134,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
135
134
|
const offWrite = onClientWrite(forget);
|
|
136
135
|
const offRescope = onRescope(forget);
|
|
137
136
|
|
|
138
|
-
const saveScroll = (): void =>
|
|
139
|
-
|
|
140
|
-
const held = entryOf(state)?.doc;
|
|
141
|
-
// Only into the entry of the document ON SCREEN: between a back/forward and its swap, the
|
|
142
|
-
// current entry is the next page's, and this scroll offset is not its to keep.
|
|
143
|
-
if (held !== undefined && held !== rendered) return;
|
|
144
|
-
const entry: EntryState = { scroll: [win.scrollX, win.scrollY], doc: held ?? rendered };
|
|
145
|
-
win.history.replaceState({ ...state, [STATE_KEY]: entry }, '');
|
|
146
|
-
};
|
|
137
|
+
const saveScroll = (): void => saveEntryScroll(win, rendered);
|
|
138
|
+
|
|
147
139
|
saveScroll();
|
|
148
140
|
// Saved as the visitor scrolls, once the scroll settles — so FORWARD restores too, and a
|
|
149
141
|
// browser's cap on `replaceState` calls is never approached.
|
|
@@ -157,7 +149,10 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
157
149
|
fetchDocument(win, doc, url, purpose, init);
|
|
158
150
|
|
|
159
151
|
const prefetch = (url: string): void => {
|
|
160
|
-
|
|
152
|
+
const key = withoutFragment(url);
|
|
153
|
+
if (untrusted || key === rendered || key === navigatingTo || cache.get(url) !== undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
161
156
|
// Cached whatever it answers: an empty `204` (the route did not opt in) is remembered too, so
|
|
162
157
|
// a second hover does not ask again. Only a reusable page ever answers a click (`reusable`).
|
|
163
158
|
cache.set(url, request(url, 'prefetch'));
|
|
@@ -170,22 +165,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
170
165
|
if (doc.dispatchEvent(event)) win.location.assign(win.location.href);
|
|
171
166
|
};
|
|
172
167
|
|
|
173
|
-
/**
|
|
174
|
-
* Where the new page lands: the saved offset on back/forward, the fragment's element, else the
|
|
175
|
-
* top. `instant`, whatever the page's `scroll-behavior` says — `@ultimat3/ui`'s reset makes it
|
|
176
|
-
* `smooth`, and a new page is a new place, which a full load jumps to rather than glides to.
|
|
177
|
-
*/
|
|
178
|
-
const scrollAfter = (url: string, mode: NavigateOptions['history']): void => {
|
|
179
|
-
const hash = new URL(url).hash;
|
|
180
|
-
const target = hash.length > 1 ? doc.getElementById(decodeURIComponent(hash.slice(1))) : null;
|
|
181
|
-
const saved = entryOf(win.history.state);
|
|
182
|
-
const to = (left: number, top: number): void =>
|
|
183
|
-
win.scrollTo({ left, top, behavior: 'instant' as ScrollBehavior });
|
|
184
|
-
if (mode === 'none' && saved !== undefined) to(saved.scroll[0], saved.scroll[1]);
|
|
185
|
-
else if (target !== null) target.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
|
|
186
|
-
else to(0, 0);
|
|
187
|
-
};
|
|
188
|
-
|
|
189
168
|
const answerFor = (
|
|
190
169
|
url: string,
|
|
191
170
|
method: 'GET' | 'POST',
|
|
@@ -231,6 +210,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
231
210
|
inflight?.abort();
|
|
232
211
|
const mine = new AbortController();
|
|
233
212
|
inflight = mine;
|
|
213
|
+
win.clearTimeout(intent);
|
|
214
|
+
navigatingTo = withoutFragment(url);
|
|
234
215
|
if (options.history !== 'none') saveScroll();
|
|
235
216
|
const progress = win.setTimeout(
|
|
236
217
|
() => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
|
|
@@ -296,23 +277,30 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
296
277
|
await loadStylesheets(doc, missingStylesheets(doc, next));
|
|
297
278
|
if (mine.signal.aborted) return;
|
|
298
279
|
let swapped: ReturnType<typeof swapDocument> | undefined;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
280
|
+
const track = (running: RunningTransition | undefined): void => {
|
|
281
|
+
animating = running;
|
|
282
|
+
};
|
|
283
|
+
await transition(
|
|
284
|
+
win,
|
|
285
|
+
() => {
|
|
286
|
+
// A newer navigation started while the sheets loaded: this answer is no longer asked for.
|
|
287
|
+
if (mine.signal.aborted) return;
|
|
288
|
+
swapped = swapDocument(doc, next, owned);
|
|
289
|
+
// Emptied here and filled after the scripts, so a page with the same title still reads out.
|
|
290
|
+
live.textContent = '';
|
|
291
|
+
swapped.body.append(live);
|
|
292
|
+
rendered = withoutFragment(landed);
|
|
293
|
+
if (options.history !== 'none') {
|
|
294
|
+
const same = landed === win.location.href || options.history === 'replace';
|
|
295
|
+
const state = { [STATE_KEY]: { scroll: [0, 0], doc: rendered } satisfies EntryState };
|
|
296
|
+
if (same) win.history.replaceState(state, '', landed);
|
|
297
|
+
else win.history.pushState(state, '', landed);
|
|
298
|
+
}
|
|
299
|
+
// Inside the swap, so a view transition's "after" frame is already where the page lands.
|
|
300
|
+
scrollAfter(win, doc, landed, options.history);
|
|
301
|
+
},
|
|
302
|
+
track,
|
|
303
|
+
);
|
|
316
304
|
const done = swapped;
|
|
317
305
|
if (done === undefined) return;
|
|
318
306
|
if (method === 'POST' && answerMovesTab(facts)) {
|
|
@@ -339,12 +327,37 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
339
327
|
win.clearTimeout(progress);
|
|
340
328
|
if (inflight === mine) {
|
|
341
329
|
inflight = undefined;
|
|
330
|
+
navigatingTo = undefined;
|
|
342
331
|
doc.documentElement.removeAttribute(NAVIGATING_ATTRIBUTE);
|
|
343
332
|
}
|
|
344
333
|
}
|
|
345
334
|
};
|
|
346
335
|
|
|
336
|
+
const onPress = (): void => {
|
|
337
|
+
pressed = true;
|
|
338
|
+
pressedOverTransition = animating !== undefined;
|
|
339
|
+
// A press is not a hover: the click it becomes navigates, and a guess on its heels is a
|
|
340
|
+
// second request for the same page. And a press during the animation skips to the new page.
|
|
341
|
+
win.clearTimeout(intent);
|
|
342
|
+
animating?.skipTransition?.();
|
|
343
|
+
};
|
|
344
|
+
const onRelease = (): void => {
|
|
345
|
+
pressed = false;
|
|
346
|
+
};
|
|
347
|
+
|
|
347
348
|
const onClick = (event: MouseEvent): void => {
|
|
349
|
+
win.clearTimeout(intent);
|
|
350
|
+
// While a view transition paints, the browser hit-tests every press to `<html>`: the press
|
|
351
|
+
// skipped the animation (`onPress`), the release landed on the real element, and the click —
|
|
352
|
+
// aimed at their common ancestor — reached nothing. The visitor's first click after a swap was
|
|
353
|
+
// lost. It is given to the element under the pointer NOW, whatever it is: a link, a submit
|
|
354
|
+
// button, an island's own control.
|
|
355
|
+
if (pressedOverTransition && event.target === doc.documentElement) {
|
|
356
|
+
pressedOverTransition = false;
|
|
357
|
+
const hit = doc.elementFromPoint(event.clientX, event.clientY);
|
|
358
|
+
if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
348
361
|
const anchor = anchorOf(event.target);
|
|
349
362
|
if (anchor === null) return;
|
|
350
363
|
const verdict = linkVerdict(linkFacts(win, anchor, event));
|
|
@@ -393,8 +406,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
393
406
|
void navigate(verdict.url, { method: 'POST', body });
|
|
394
407
|
};
|
|
395
408
|
|
|
396
|
-
let intent: number | undefined;
|
|
397
409
|
const onIntent = (event: Event): void => {
|
|
410
|
+
// The focus a press gives a link: its click is a moment away, and it navigates.
|
|
411
|
+
if (event.type === 'focusin' && pressed) return;
|
|
398
412
|
const anchor = anchorOf(event.target);
|
|
399
413
|
if (anchor === null) return;
|
|
400
414
|
const verdict = linkVerdict(linkFacts(win, anchor));
|
|
@@ -456,6 +470,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
456
470
|
doc.addEventListener('focusin', onIntent);
|
|
457
471
|
doc.addEventListener('touchstart', onIntent, { passive: true });
|
|
458
472
|
win.addEventListener('popstate', onPop);
|
|
473
|
+
doc.addEventListener('pointerdown', onPress, { capture: true, passive: true });
|
|
474
|
+
doc.addEventListener('pointerup', onRelease, { capture: true, passive: true });
|
|
475
|
+
doc.addEventListener('pointercancel', onRelease, { capture: true, passive: true });
|
|
459
476
|
|
|
460
477
|
const router: NavigationRouter = {
|
|
461
478
|
navigate,
|
|
@@ -469,6 +486,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
469
486
|
doc.removeEventListener('focusin', onIntent);
|
|
470
487
|
doc.removeEventListener('touchstart', onIntent);
|
|
471
488
|
win.removeEventListener('popstate', onPop);
|
|
489
|
+
doc.removeEventListener('pointerdown', onPress, { capture: true });
|
|
490
|
+
doc.removeEventListener('pointerup', onRelease, { capture: true });
|
|
491
|
+
doc.removeEventListener('pointercancel', onRelease, { capture: true });
|
|
472
492
|
offWrite();
|
|
473
493
|
offRescope();
|
|
474
494
|
channel?.close();
|