@abreen/tada 1.19.1 → 1.19.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/package.json +1 -1
- package/src/hash-target.ts +21 -2
- package/src/navigate/_index.scss +17 -6
- package/src/navigate/index.ts +23 -12
- package/src/navigate/runtime.ts +98 -1
- package/templates/_theme.scss +1 -1
package/package.json
CHANGED
package/src/hash-target.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Tada also allows raw HTML, so an author can create a decoded ID like
|
|
6
6
|
* <h2 id="hello world">Hello</h2>. A link to that target becomes
|
|
7
7
|
* #hello%20world, and getElementById('hello%20world') would not find it.
|
|
8
|
+
*
|
|
9
|
+
* Like the browser, fall back to a legacy <a name="..."> anchor when no
|
|
10
|
+
* element has the ID.
|
|
8
11
|
*/
|
|
9
12
|
export function getHashTarget(
|
|
10
13
|
document: Document,
|
|
@@ -15,15 +18,31 @@ export function getHashTarget(
|
|
|
15
18
|
return null;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
const rawTarget = document
|
|
21
|
+
const rawTarget = findIndicatedElement(document, id);
|
|
19
22
|
if (rawTarget) {
|
|
20
23
|
return rawTarget;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
try {
|
|
24
27
|
const decodedId = decodeURIComponent(id);
|
|
25
|
-
return decodedId === id ? null : document
|
|
28
|
+
return decodedId === id ? null : findIndicatedElement(document, decodedId);
|
|
26
29
|
} catch {
|
|
27
30
|
return null;
|
|
28
31
|
}
|
|
29
32
|
}
|
|
33
|
+
|
|
34
|
+
function findIndicatedElement(
|
|
35
|
+
document: Document,
|
|
36
|
+
fragment: string,
|
|
37
|
+
): HTMLElement | null {
|
|
38
|
+
const byId = document.getElementById(fragment);
|
|
39
|
+
if (byId) {
|
|
40
|
+
return byId;
|
|
41
|
+
}
|
|
42
|
+
for (const element of document.getElementsByName(fragment)) {
|
|
43
|
+
if (element.localName === 'a') {
|
|
44
|
+
return element;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
package/src/navigate/_index.scss
CHANGED
|
@@ -105,12 +105,10 @@ html.navigation-loading * {
|
|
|
105
105
|
mix-blend-mode: normal;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// The footer and appearance controls get their own
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
108
|
+
// The footer and appearance controls get their own layer while they are in the
|
|
109
|
+
// viewport. view-transition-name is set via inline style by the navigator. On
|
|
110
|
+
// screen on both pages, the layer stays put and shows only the incoming
|
|
111
|
+
// snapshot so disabled defaults and stale hover states cannot blend.
|
|
114
112
|
::view-transition-group(page-bottom),
|
|
115
113
|
::view-transition-old(page-bottom),
|
|
116
114
|
::view-transition-new(page-bottom) {
|
|
@@ -125,6 +123,19 @@ html.navigation-loading * {
|
|
|
125
123
|
mix-blend-mode: normal;
|
|
126
124
|
}
|
|
127
125
|
|
|
126
|
+
// On screen on only one page, such as between a short page and the top of a
|
|
127
|
+
// long page: fade out or in where they are, in step with the page. Fill both
|
|
128
|
+
// ways, like the UA default, so the faded-out snapshot cannot reappear in the
|
|
129
|
+
// transition's last frame.
|
|
130
|
+
::view-transition-old(page-bottom):only-child {
|
|
131
|
+
display: block;
|
|
132
|
+
animation: page-fade-out $duration ease-in both;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
::view-transition-new(page-bottom):only-child {
|
|
136
|
+
animation: page-fade-in $duration ease-out both;
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
// Forward navigation: old content slides left, new content slides in from right
|
|
129
140
|
.nav-forward::view-transition-old(root) {
|
|
130
141
|
animation:
|
package/src/navigate/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isEligibleLink } from './eligible';
|
|
2
2
|
import {
|
|
3
|
+
applyFragmentTarget,
|
|
3
4
|
clearSearch,
|
|
4
5
|
closeHeaderDetails,
|
|
5
6
|
getCurrentPath,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
getHistoryIndex,
|
|
8
9
|
getSavedScroll,
|
|
9
10
|
initNavigation,
|
|
11
|
+
isApplyingFragment,
|
|
10
12
|
navigateToUrl,
|
|
11
13
|
saveScrollPosition,
|
|
12
14
|
setCurrentPath,
|
|
@@ -134,6 +136,10 @@ export default function mountNavigate(window: Window): () => void {
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
function handlePopState(event: PopStateEvent) {
|
|
139
|
+
if (isApplyingFragment()) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
137
143
|
if (clearingFragment) {
|
|
138
144
|
clearingFragment = false;
|
|
139
145
|
if (!window.location.hash) {
|
|
@@ -145,19 +151,24 @@ export default function mountNavigate(window: Window): () => void {
|
|
|
145
151
|
if (newPath === getCurrentPath()) {
|
|
146
152
|
const locationKey = newPath + window.location.hash;
|
|
147
153
|
const savedY = getSavedLocationScroll(locationKey);
|
|
154
|
+
// Fragment traversal can update :target and apply its native target
|
|
155
|
+
// scroll after popstate. Correct both in the next task so the URL and our
|
|
156
|
+
// saved position win. Capture savedY now because the native scroll event
|
|
157
|
+
// can update the location map first.
|
|
158
|
+
window.setTimeout(() => {
|
|
159
|
+
const currentLocationKey =
|
|
160
|
+
window.location.pathname +
|
|
161
|
+
window.location.search +
|
|
162
|
+
window.location.hash;
|
|
163
|
+
if (currentLocationKey !== locationKey) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
applyFragmentTarget(window);
|
|
167
|
+
if (typeof savedY === 'number') {
|
|
168
|
+
window.scrollTo({ top: savedY });
|
|
169
|
+
}
|
|
170
|
+
}, 0);
|
|
148
171
|
if (typeof savedY === 'number') {
|
|
149
|
-
// Fragment traversal can apply its native target scroll after popstate.
|
|
150
|
-
// Restore in the next task so our saved position wins. Capture savedY
|
|
151
|
-
// now because the native scroll event can update the location map first.
|
|
152
|
-
window.setTimeout(() => {
|
|
153
|
-
const currentLocationKey =
|
|
154
|
-
window.location.pathname +
|
|
155
|
-
window.location.search +
|
|
156
|
-
window.location.hash;
|
|
157
|
-
if (currentLocationKey === locationKey) {
|
|
158
|
-
window.scrollTo({ top: savedY });
|
|
159
|
-
}
|
|
160
|
-
}, 0);
|
|
161
172
|
return;
|
|
162
173
|
}
|
|
163
174
|
|
package/src/navigate/runtime.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
teardownPerPageComponents,
|
|
6
6
|
} from './lifecycle';
|
|
7
7
|
import { globals } from '../globals';
|
|
8
|
+
import { getHashTarget } from '../hash-target';
|
|
8
9
|
import { swapHeaderTitle } from '../header';
|
|
9
10
|
|
|
10
11
|
export const NAVIGATION_EVENT = 'tada:navigation';
|
|
@@ -14,9 +15,14 @@ const LOADING_CURSOR_DELAY = 400;
|
|
|
14
15
|
let currentAbortController: AbortController | null = null;
|
|
15
16
|
let historyIndex = 0;
|
|
16
17
|
let currentPath = '';
|
|
18
|
+
let applyingFragment = false;
|
|
19
|
+
let fragmentTargetRequest = 0;
|
|
17
20
|
|
|
18
21
|
const scrollByIndex = new Map<number, number>();
|
|
19
22
|
const scrollByLocation = new Map<string, number>();
|
|
23
|
+
// Stylesheets the navigator added that have not loaded or failed yet. Those in
|
|
24
|
+
// the initial document have settled before the deferred client script runs.
|
|
25
|
+
const pendingStylesheets = new Map<Element, Promise<void>>();
|
|
20
26
|
|
|
21
27
|
type Direction = 'forward' | 'back';
|
|
22
28
|
|
|
@@ -65,7 +71,19 @@ function updateHead(document: Document, newDoc: Document): void {
|
|
|
65
71
|
|
|
66
72
|
for (const link of newDoc.querySelectorAll('link[rel="stylesheet"]')) {
|
|
67
73
|
if (!existingHrefs.has(link.getAttribute('href'))) {
|
|
68
|
-
|
|
74
|
+
const clone = link.cloneNode(true) as Element;
|
|
75
|
+
pendingStylesheets.set(
|
|
76
|
+
clone,
|
|
77
|
+
new Promise(resolve => {
|
|
78
|
+
const settle = () => {
|
|
79
|
+
pendingStylesheets.delete(clone);
|
|
80
|
+
resolve();
|
|
81
|
+
};
|
|
82
|
+
clone.addEventListener('load', settle, { once: true });
|
|
83
|
+
clone.addEventListener('error', settle, { once: true });
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
document.head.appendChild(clone);
|
|
69
87
|
}
|
|
70
88
|
}
|
|
71
89
|
}
|
|
@@ -138,11 +156,29 @@ function setTitleTransitionNames(titleEl: Element, enabled: boolean): void {
|
|
|
138
156
|
}
|
|
139
157
|
}
|
|
140
158
|
|
|
159
|
+
// The footer and appearance pickers get their own layer only while they are in
|
|
160
|
+
// the viewport, so the layer stays put when they are on screen on both pages
|
|
161
|
+
// and fades on its own when they are on screen on only one.
|
|
162
|
+
function setPageBottomTransitionName(window: Window): void {
|
|
163
|
+
const bottom = window.document.querySelector<HTMLElement>('.page-bottom');
|
|
164
|
+
if (!bottom) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const rect = bottom.getBoundingClientRect();
|
|
168
|
+
if (rect.bottom > 0 && rect.top < window.innerHeight) {
|
|
169
|
+
bottom.style.viewTransitionName = 'page-bottom';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
141
173
|
function cleanupViewTransitionNames(document: Document): void {
|
|
142
174
|
const titleEl = document.querySelector('.title-and-info');
|
|
143
175
|
if (titleEl) {
|
|
144
176
|
setTitleTransitionNames(titleEl, false);
|
|
145
177
|
}
|
|
178
|
+
const bottom = document.querySelector<HTMLElement>('.page-bottom');
|
|
179
|
+
if (bottom) {
|
|
180
|
+
bottom.style.viewTransitionName = '';
|
|
181
|
+
}
|
|
146
182
|
}
|
|
147
183
|
|
|
148
184
|
function getLocationKey(window: Window): string {
|
|
@@ -213,6 +249,60 @@ export function getSavedLocationScroll(
|
|
|
213
249
|
return scrollByLocation.get(locationKey);
|
|
214
250
|
}
|
|
215
251
|
|
|
252
|
+
// Browsers fire popstate synchronously during applyFragmentTarget
|
|
253
|
+
export function isApplyingFragment(): boolean {
|
|
254
|
+
return applyingFragment;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Restoring a history entry can leave :target stale. With manual scroll
|
|
258
|
+
// restoration, WebKit skips fragment processing on Back/Forward, and swapped
|
|
259
|
+
// content loses it in every browser. A fragment navigation sets it in every
|
|
260
|
+
// browser, but it can reset the entry's history state and scroll position, so
|
|
261
|
+
// restore both.
|
|
262
|
+
//
|
|
263
|
+
// WebKit defers fragment scrolling until pending stylesheets load, which would
|
|
264
|
+
// later move the page away from the restored scroll position. Wait for them
|
|
265
|
+
// first, and skip the update if another request or navigation replaced it.
|
|
266
|
+
export function applyFragmentTarget(window: Window): void {
|
|
267
|
+
const request = ++fragmentTargetRequest;
|
|
268
|
+
const { href } = window.location;
|
|
269
|
+
const apply = (): void => {
|
|
270
|
+
if (request !== fragmentTargetRequest || window.location.href !== href) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const pending = Array.from(pendingStylesheets.values());
|
|
274
|
+
if (pending.length > 0) {
|
|
275
|
+
void Promise.all(pending).then(apply);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
replaceFragmentTarget(window);
|
|
279
|
+
};
|
|
280
|
+
apply();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function replaceFragmentTarget(window: Window): void {
|
|
284
|
+
const { document, history, location } = window;
|
|
285
|
+
if (
|
|
286
|
+
document.querySelector(':target') === getHashTarget(document, location.hash)
|
|
287
|
+
) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const { href } = location;
|
|
291
|
+
const state = history.state;
|
|
292
|
+
const { scrollX, scrollY } = window;
|
|
293
|
+
applyingFragment = true;
|
|
294
|
+
try {
|
|
295
|
+
globals.replaceLocation(
|
|
296
|
+
window,
|
|
297
|
+
location.pathname + location.search + (location.hash || '#'),
|
|
298
|
+
);
|
|
299
|
+
} finally {
|
|
300
|
+
applyingFragment = false;
|
|
301
|
+
}
|
|
302
|
+
history.replaceState(state, '', href);
|
|
303
|
+
window.scrollTo({ left: scrollX, top: scrollY });
|
|
304
|
+
}
|
|
305
|
+
|
|
216
306
|
export async function navigateToUrl(
|
|
217
307
|
window: Window,
|
|
218
308
|
options: NavigationOptions,
|
|
@@ -323,6 +413,11 @@ export async function navigateToUrl(
|
|
|
323
413
|
|
|
324
414
|
teardownPerPageComponents();
|
|
325
415
|
swapContent(document, newDoc);
|
|
416
|
+
if (!pushHistory) {
|
|
417
|
+
// Restore the entry's :target before updateHead adds stylesheets, so it
|
|
418
|
+
// applies right away unless an earlier stylesheet is still loading
|
|
419
|
+
applyFragmentTarget(window);
|
|
420
|
+
}
|
|
326
421
|
updateHead(document, newDoc);
|
|
327
422
|
mountAppearancePickerForPage(window);
|
|
328
423
|
currentPath = parsed.pathname + parsed.search;
|
|
@@ -369,6 +464,7 @@ export async function navigateToUrl(
|
|
|
369
464
|
if (titleVisible) {
|
|
370
465
|
setTitleTransitionNames(titleEl, true);
|
|
371
466
|
}
|
|
467
|
+
setPageBottomTransitionName(window);
|
|
372
468
|
|
|
373
469
|
document.documentElement.classList.add(
|
|
374
470
|
direction === 'forward' ? 'nav-forward' : 'nav-back',
|
|
@@ -389,6 +485,7 @@ export async function navigateToUrl(
|
|
|
389
485
|
if (newTitleVisible && newTitleEl) {
|
|
390
486
|
setTitleTransitionNames(newTitleEl, true);
|
|
391
487
|
}
|
|
488
|
+
setPageBottomTransitionName(window);
|
|
392
489
|
});
|
|
393
490
|
|
|
394
491
|
await transition.finished;
|
package/templates/_theme.scss
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
'Segoe UI Symbol';
|
|
55
55
|
--sans-mono-font: 'Google Sans Code', 'Consolas', 'Courier', monospace;
|
|
56
56
|
--serif-font: <%= serifFontStack %>;
|
|
57
|
-
--serif-preview-font: 'Times New Roman', 'Times', serif;
|
|
57
|
+
--serif-preview-font: 'Georgia', 'Times New Roman', 'Times', serif;
|
|
58
58
|
--serif-mono-font: <%= serifMonoFontStack %>;
|
|
59
59
|
--sans-font-feature-settings:
|
|
60
60
|
'liga', 'calt', 'ss07', 'ss08', 'cv01', 'cv08', 'cv10';
|