@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.19.1",
3
+ "version": "1.19.2",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -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.getElementById(id);
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.getElementById(decodedId);
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
+ }
@@ -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 stationary layer. Show only
109
- // the incoming snapshot so disabled defaults and stale hover states cannot blend.
110
- .page-bottom {
111
- view-transition-name: page-bottom;
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:
@@ -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
 
@@ -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
- document.head.appendChild(link.cloneNode(true));
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;
@@ -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';