@abreen/tada 1.19.0 → 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/README.md CHANGED
@@ -71,9 +71,11 @@ A static site generator. The successor to Presto.
71
71
  * Special heading subtitles with `## Heading # A subtitle here`
72
72
  * `{{{ _partial.md }}}` syntax for including partials
73
73
 
74
- - Extremely fast client-side navigation
75
- * A miniature SPA without the bloat of a framework
76
- * Highly efficient, thoroughly covered by cross-platform browser tests
74
+ - Extremely efficient client-side navigation
75
+ * A miniature SPA that uses internal links tagged at build time
76
+ * Get the benefits of DOM tree replacement without an entire bloated framework
77
+ * Thoroughly covered by browser tests across multiple engines
78
+ * Clear loading indicators for slow requests
77
79
  * Also detects a new version of the current page and supplies a reload button
78
80
 
79
81
  ### Experimental features
package/bin/tada.ts CHANGED
@@ -50,7 +50,7 @@ function parsePortArg(name: string): number | undefined {
50
50
  const idx = process.argv.indexOf(name);
51
51
  if (idx !== -1 && process.argv[idx + 1]) {
52
52
  const val = parseInt(process.argv[idx + 1], 10);
53
- if (val > 0 && val < 65536) {
53
+ if (val >= 0 && val < 65536) {
54
54
  return val;
55
55
  }
56
56
  }
@@ -2,59 +2,24 @@ import path from 'path';
2
2
  import { normalizeOutputPath } from './util';
3
3
  import type { HtmlOutputAnalysis } from './types';
4
4
 
5
- function normalizeInternalTarget(target: string): string {
6
- return normalizeOutputPath(target);
7
- }
8
-
5
+ /** HTML candidates for an already normalized target without a non-HTML extension. */
9
6
  function toCandidateHtmlAssetPaths(target: string): string[] {
10
- const normalizedTarget = normalizeInternalTarget(target);
11
-
12
- if (normalizedTarget === '/') {
7
+ if (target === '/') {
13
8
  return ['index.html'];
14
9
  }
15
10
 
16
- if (normalizedTarget.endsWith('.html')) {
17
- return [normalizedTarget.slice(1)];
18
- }
19
-
20
- if (path.posix.extname(normalizedTarget)) {
21
- return [];
11
+ if (target.endsWith('.html')) {
12
+ return [target.slice(1)];
22
13
  }
23
14
 
24
- const withoutLeadingSlash = normalizedTarget.slice(1);
25
- if (normalizedTarget.endsWith('/')) {
15
+ const withoutLeadingSlash = target.slice(1);
16
+ if (target.endsWith('/')) {
26
17
  return [`${withoutLeadingSlash}index.html`];
27
18
  }
28
19
 
29
20
  return [`${withoutLeadingSlash}/index.html`, `${withoutLeadingSlash}.html`];
30
21
  }
31
22
 
32
- function resolveTargetToAssetTarget(target: string): string | null {
33
- const normalizedTarget = normalizeInternalTarget(target);
34
- if (normalizedTarget === '/' || normalizedTarget.endsWith('.html')) {
35
- return null;
36
- }
37
- return path.posix.extname(normalizedTarget) ? normalizedTarget : null;
38
- }
39
-
40
- function collectOutgoingHtmlAssetPaths(
41
- analysis: HtmlOutputAnalysis,
42
- knownHtmlAssetPaths: Set<string>,
43
- ): string[] {
44
- const htmlAssetPaths = new Set<string>();
45
-
46
- for (const target of analysis.outgoingTargets) {
47
- for (const candidate of toCandidateHtmlAssetPaths(target)) {
48
- if (knownHtmlAssetPaths.has(candidate)) {
49
- htmlAssetPaths.add(candidate);
50
- break;
51
- }
52
- }
53
- }
54
-
55
- return [...htmlAssetPaths].sort();
56
- }
57
-
58
23
  interface CollectReachableOptions {
59
24
  htmlAnalysisByPath: Map<string, HtmlOutputAnalysis>;
60
25
  knownAssetTargets?: Set<string>;
@@ -75,7 +40,6 @@ export function collectReachableSiteAssets({
75
40
  throw new Error(`Pagefind reachability root not found: ${rootPath}`);
76
41
  }
77
42
 
78
- const knownHtmlAssetPaths = new Set(htmlAnalysisByPath.keys());
79
43
  const reachableHtmlPaths = new Set<string>();
80
44
  const reachableAssetTargets = new Set<string>();
81
45
  const pending: string[] = [rootPath];
@@ -89,19 +53,19 @@ export function collectReachableSiteAssets({
89
53
 
90
54
  const analysis = htmlAnalysisByPath.get(currentPath)!;
91
55
 
92
- for (const targetPath of collectOutgoingHtmlAssetPaths(
93
- analysis,
94
- knownHtmlAssetPaths,
95
- )) {
96
- if (!reachableHtmlPaths.has(targetPath)) {
97
- pending.push(targetPath);
56
+ for (const outgoingTarget of analysis.outgoingTargets) {
57
+ const target = normalizeOutputPath(outgoingTarget);
58
+ if (!target.endsWith('.html') && path.posix.extname(target)) {
59
+ if (knownAssetTargets.has(target)) {
60
+ reachableAssetTargets.add(target);
61
+ }
62
+ continue;
98
63
  }
99
- }
100
-
101
- for (const target of analysis.outgoingTargets) {
102
- const assetTarget = resolveTargetToAssetTarget(target);
103
- if (assetTarget && knownAssetTargets.has(assetTarget)) {
104
- reachableAssetTargets.add(assetTarget);
64
+ const targetPath = toCandidateHtmlAssetPaths(target).find(candidate =>
65
+ htmlAnalysisByPath.has(candidate),
66
+ );
67
+ if (targetPath && !reachableHtmlPaths.has(targetPath)) {
68
+ pending.push(targetPath);
105
69
  }
106
70
  }
107
71
  }
@@ -426,7 +426,7 @@ export function renderCopiedContentAsset({
426
426
  const rawSource = fs.readFileSync(filePath, 'utf-8');
427
427
  const templated = applySourceTemplate(rawSource, siteVariables, filePath);
428
428
 
429
- if (filePath.endsWith('.java')) {
429
+ if (path.extname(filePath).toLowerCase() === '.java') {
430
430
  const pageDirPath = toPosix(
431
431
  path.relative(contentDir, path.dirname(filePath)),
432
432
  );
@@ -568,7 +568,7 @@ function renderPlainTextContent(
568
568
  } {
569
569
  const applyBasePath = createApplyBasePath(siteVariables);
570
570
 
571
- const ext = path.extname(filePath);
571
+ const ext = path.extname(filePath).toLowerCase();
572
572
  const raw = fs.readFileSync(filePath, 'utf-8');
573
573
  const { pageVariables: rawPageVariables, content } =
574
574
  parseFrontMatterAndContent(raw, ext);
@@ -34,18 +34,13 @@ const MIN_ARRAY_CELL_WIDTH = 48;
34
34
 
35
35
  /** Information collected about a heap object across all trace steps. */
36
36
  interface ObjectInfo {
37
- id: string;
38
37
  type: string;
39
38
  firstStep: number;
40
39
  lastStep: number;
41
- /** Set of object IDs this object ever references (across all steps). */
42
- references: Set<string>;
43
40
  /** Step indices where this object is actually present. */
44
41
  activeSteps: Set<number>;
45
42
  /** Per-field reference targets (field name -> set of referenced object IDs). */
46
43
  fieldRefs: Map<string, Set<string>>;
47
- /** Max number of fields seen on this object (for field objects). */
48
- maxFields: number;
49
44
  /** Max number of elements seen on this object (for arrays). */
50
45
  maxElements: number;
51
46
  /** Scalar value (for heap objects represented by a single value). */
@@ -70,7 +65,7 @@ interface TreeNode {
70
65
 
71
66
  /**
72
67
  * Walk every step, collect every heap object's type, lifetime (first/last step),
73
- * all references it ever makes, max fields seen, whether it's an array or string.
68
+ * reference targets by field, display widths, and whether it's an array or string.
74
69
  */
75
70
  export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
76
71
  const objects = new Map<string, ObjectInfo>();
@@ -81,14 +76,11 @@ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
81
76
  let info = objects.get(id);
82
77
  if (!info) {
83
78
  info = {
84
- id,
85
79
  type: obj.type,
86
80
  firstStep: stepIndex,
87
81
  lastStep: stepIndex,
88
- references: new Set(),
89
82
  activeSteps: new Set(),
90
83
  fieldRefs: new Map(),
91
- maxFields: 0,
92
84
  maxElements: 0,
93
85
  value: null,
94
86
  hasValue: false,
@@ -115,18 +107,14 @@ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
115
107
 
116
108
  for (const elem of obj.elements) {
117
109
  if (elem.type === 'ref') {
118
- info.references.add(elem.id);
119
110
  elemRefs.add(elem.id);
120
111
  }
121
112
  }
122
113
  } else if ('fields' in obj) {
123
- const fieldEntries = Object.entries(obj.fields);
124
- info.maxFields = Math.max(info.maxFields, fieldEntries.length);
125
- for (const [fieldName, val] of fieldEntries) {
114
+ for (const [fieldName, val] of Object.entries(obj.fields)) {
126
115
  info.fieldNames.add(fieldName);
127
116
 
128
117
  if (val.type === 'ref') {
129
- info.references.add(val.id);
130
118
  if (!info.fieldRefs.has(fieldName)) {
131
119
  info.fieldRefs.set(fieldName, new Set());
132
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.19.0",
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
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ const PERSISTENT_COMPONENTS: Record<
25
25
  let startTime = -1;
26
26
 
27
27
  document.addEventListener('DOMContentLoaded', async () => {
28
+ const initialHref = window.location.href;
29
+ const initialHash = window.location.hash;
28
30
  startTime = window.performance.now();
29
31
 
30
32
  const failed: Record<string, string> = {};
@@ -54,8 +56,8 @@ document.addEventListener('DOMContentLoaded', async () => {
54
56
  // fragment scroll. Do it ourselves once per-page components have
55
57
  // finished mutating the DOM. On a fresh cold load the browser already
56
58
  // scrolled, so this is a harmless re-align.
57
- if (window.location.hash) {
58
- getHashTarget(window.document, window.location.hash)?.scrollIntoView();
59
+ if (initialHash && window.location.href === initialHref) {
60
+ getHashTarget(window.document, initialHash)?.scrollIntoView();
59
61
  }
60
62
 
61
63
  for (const [name, reason] of Object.entries(failed)) {
@@ -7,6 +7,11 @@ $movement-easing: cubic-bezier(0.2, 0, 0, 1);
7
7
  --page-transition-movement-easing: #{$movement-easing};
8
8
  }
9
9
 
10
+ html.navigation-loading,
11
+ html.navigation-loading * {
12
+ cursor: progress !important;
13
+ }
14
+
10
15
  @keyframes page-fade-out {
11
16
  to {
12
17
  opacity: 0;
@@ -100,12 +105,10 @@ $movement-easing: cubic-bezier(0.2, 0, 0, 1);
100
105
  mix-blend-mode: normal;
101
106
  }
102
107
 
103
- // The footer and appearance controls get their own stationary layer. Show only
104
- // the incoming snapshot so disabled defaults and stale hover states cannot blend.
105
- .page-bottom {
106
- view-transition-name: page-bottom;
107
- }
108
-
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.
109
112
  ::view-transition-group(page-bottom),
110
113
  ::view-transition-old(page-bottom),
111
114
  ::view-transition-new(page-bottom) {
@@ -120,6 +123,19 @@ $movement-easing: cubic-bezier(0.2, 0, 0, 1);
120
123
  mix-blend-mode: normal;
121
124
  }
122
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
+
123
139
  // Forward navigation: old content slides left, new content slides in from right
124
140
  .nav-forward::view-transition-old(root) {
125
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,16 +5,24 @@ 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';
11
12
 
13
+ const LOADING_CURSOR_DELAY = 400;
14
+
12
15
  let currentAbortController: AbortController | null = null;
13
16
  let historyIndex = 0;
14
17
  let currentPath = '';
18
+ let applyingFragment = false;
19
+ let fragmentTargetRequest = 0;
15
20
 
16
21
  const scrollByIndex = new Map<number, number>();
17
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>>();
18
26
 
19
27
  type Direction = 'forward' | 'back';
20
28
 
@@ -63,7 +71,19 @@ function updateHead(document: Document, newDoc: Document): void {
63
71
 
64
72
  for (const link of newDoc.querySelectorAll('link[rel="stylesheet"]')) {
65
73
  if (!existingHrefs.has(link.getAttribute('href'))) {
66
- 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);
67
87
  }
68
88
  }
69
89
  }
@@ -136,11 +156,29 @@ function setTitleTransitionNames(titleEl: Element, enabled: boolean): void {
136
156
  }
137
157
  }
138
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
+
139
173
  function cleanupViewTransitionNames(document: Document): void {
140
174
  const titleEl = document.querySelector('.title-and-info');
141
175
  if (titleEl) {
142
176
  setTitleTransitionNames(titleEl, false);
143
177
  }
178
+ const bottom = document.querySelector<HTMLElement>('.page-bottom');
179
+ if (bottom) {
180
+ bottom.style.viewTransitionName = '';
181
+ }
144
182
  }
145
183
 
146
184
  function getLocationKey(window: Window): string {
@@ -211,6 +249,60 @@ export function getSavedLocationScroll(
211
249
  return scrollByLocation.get(locationKey);
212
250
  }
213
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
+
214
306
  export async function navigateToUrl(
215
307
  window: Window,
216
308
  options: NavigationOptions,
@@ -235,15 +327,28 @@ export async function navigateToUrl(
235
327
 
236
328
  const header = document.querySelector('header');
237
329
  header?.classList.add('loading');
330
+ const loadingCursorTimeout = window.setTimeout(() => {
331
+ if (isActiveNavigation(controller)) {
332
+ document.documentElement.classList.add('navigation-loading');
333
+ }
334
+ }, LOADING_CURSOR_DELAY);
335
+ const stopLoading = (): void => {
336
+ window.clearTimeout(loadingCursorTimeout);
337
+ if (!isActiveNavigation(controller)) {
338
+ return;
339
+ }
340
+ header?.classList.remove('loading');
341
+ document.documentElement.classList.remove('navigation-loading');
342
+ };
238
343
 
239
344
  let response: Response;
240
345
  try {
241
346
  response = await globals.fetch(url, { signal: controller.signal });
242
347
  } catch (err: unknown) {
348
+ stopLoading();
243
349
  if (!isActiveNavigation(controller)) {
244
350
  return;
245
351
  }
246
- header?.classList.remove('loading');
247
352
  if (isAbortError(err)) {
248
353
  return;
249
354
  }
@@ -252,11 +357,12 @@ export async function navigateToUrl(
252
357
  }
253
358
 
254
359
  if (!isActiveNavigation(controller)) {
360
+ stopLoading();
255
361
  return;
256
362
  }
257
363
 
258
364
  if (!response.ok) {
259
- header?.classList.remove('loading');
365
+ stopLoading();
260
366
  globals.setLocationHref(window, url);
261
367
  return;
262
368
  }
@@ -265,10 +371,10 @@ export async function navigateToUrl(
265
371
  try {
266
372
  html = await response.text();
267
373
  } catch (err: unknown) {
374
+ stopLoading();
268
375
  if (!isActiveNavigation(controller)) {
269
376
  return;
270
377
  }
271
- header?.classList.remove('loading');
272
378
  if (isAbortError(err)) {
273
379
  return;
274
380
  }
@@ -277,10 +383,11 @@ export async function navigateToUrl(
277
383
  }
278
384
 
279
385
  if (!isActiveNavigation(controller)) {
386
+ stopLoading();
280
387
  return;
281
388
  }
282
389
 
283
- header?.classList.remove('loading');
390
+ stopLoading();
284
391
 
285
392
  const DOMParserCtor = (window as unknown as { DOMParser: typeof DOMParser })
286
393
  .DOMParser;
@@ -306,6 +413,11 @@ export async function navigateToUrl(
306
413
 
307
414
  teardownPerPageComponents();
308
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
+ }
309
421
  updateHead(document, newDoc);
310
422
  mountAppearancePickerForPage(window);
311
423
  currentPath = parsed.pathname + parsed.search;
@@ -352,6 +464,7 @@ export async function navigateToUrl(
352
464
  if (titleVisible) {
353
465
  setTitleTransitionNames(titleEl, true);
354
466
  }
467
+ setPageBottomTransitionName(window);
355
468
 
356
469
  document.documentElement.classList.add(
357
470
  direction === 'forward' ? 'nav-forward' : 'nav-back',
@@ -372,6 +485,7 @@ export async function navigateToUrl(
372
485
  if (newTitleVisible && newTitleEl) {
373
486
  setTitleTransitionNames(newTitleEl, true);
374
487
  }
488
+ setPageBottomTransitionName(window);
375
489
  });
376
490
 
377
491
  await transition.finished;
@@ -62,12 +62,19 @@ export default (window: Window) => {
62
62
  const bodies =
63
63
  window.document.querySelectorAll<HTMLElement>('.question-a-body');
64
64
  bodies.forEach(el => {
65
+ const content = el.querySelector<HTMLElement>('.question-a-content');
66
+ if (content) {
67
+ content.inert = !el.hasAttribute('data-revealed');
68
+ }
65
69
  el.setAttribute('role', 'button');
66
70
  el.setAttribute('tabindex', '0');
67
71
  el.setAttribute('aria-label', 'Click to reveal answer');
68
72
 
69
73
  const reveal = () => {
70
74
  el.setAttribute('data-revealed', '');
75
+ if (content) {
76
+ content.inert = false;
77
+ }
71
78
  el.removeAttribute('role');
72
79
  el.removeAttribute('tabindex');
73
80
  el.removeAttribute('aria-label');
@@ -94,25 +94,7 @@ async function doSearch(query: string, window: Window): Promise<SearchState> {
94
94
  return { totalResults: grouped.length, results: grouped };
95
95
  }
96
96
 
97
- function applyHighlight(
98
- resultsContainer: HTMLElement,
99
- focusedEl: HTMLElement | null,
100
- ) {
101
- const options = Array.from(
102
- resultsContainer.querySelectorAll('[role="option"]'),
103
- ) as HTMLElement[];
104
- options.forEach(opt => {
105
- const selected = focusedEl !== null && opt.contains(focusedEl);
106
- opt.setAttribute('aria-selected', selected ? 'true' : 'false');
107
- });
108
- }
109
-
110
- function render(
111
- input: HTMLInputElement,
112
- resultsContainer: HTMLElement,
113
- state: State,
114
- loading = false,
115
- ) {
97
+ function render(resultsContainer: HTMLElement, state: State, loading = false) {
116
98
  const doc = resultsContainer.ownerDocument;
117
99
 
118
100
  if (state.showResults) {
@@ -129,16 +111,10 @@ function render(
129
111
  }
130
112
 
131
113
  const ol = doc.createElement('ol');
132
- ol.id = `${input.name}-results`;
133
- ol.role = 'listbox';
134
- ol.tabIndex = -1;
135
114
  ol.setAttribute('aria-label', 'Search results');
136
115
 
137
- const totalVisible = state.results.length;
138
-
139
- state.results.forEach((result, i) => {
116
+ state.results.forEach(result => {
140
117
  const a = doc.createElement('a');
141
- a.id = `result-${i}`;
142
118
  a.className = 'result';
143
119
  a.href = result.url;
144
120
  const isGeneratedPage = Boolean(result.template);
@@ -148,7 +124,6 @@ function render(
148
124
  a.tabIndex = 0;
149
125
 
150
126
  const titleEl = doc.createElement('div');
151
- titleEl.id = `title-${i}`;
152
127
  titleEl.className = 'title';
153
128
  if (result.template === 'code') {
154
129
  titleEl.classList.add('code-page');
@@ -166,12 +141,6 @@ function render(
166
141
  a.appendChild(excerpt);
167
142
 
168
143
  const li = doc.createElement('li');
169
- li.id = `option-${i}`;
170
- li.setAttribute('role', 'option');
171
- li.setAttribute('aria-selected', 'false');
172
- li.setAttribute('aria-setsize', String(totalVisible));
173
- li.setAttribute('aria-posinset', String(i + 1));
174
- li.setAttribute('aria-labelledby', `title-${i}`);
175
144
  li.appendChild(a);
176
145
 
177
146
  const subsToShow = result.subResults;
@@ -265,9 +234,6 @@ function render(
265
234
  resultsContainer.setAttribute('aria-hidden', 'true');
266
235
  resultsContainer.setAttribute('inert', '');
267
236
  }
268
-
269
- input.setAttribute('aria-expanded', String(state.showResults));
270
- input.setAttribute('aria-controls', ol.id);
271
237
  }
272
238
 
273
239
  export default (window: Window) => {
@@ -335,7 +301,7 @@ export default (window: Window) => {
335
301
  pagefindLoadGeneration += 1;
336
302
  console.log(`failed to load Pagefind: ${err}`);
337
303
  if (state.showResults) {
338
- render(input!, resultsContainer, state, false);
304
+ render(resultsContainer, state, false);
339
305
  }
340
306
  }
341
307
  return false;
@@ -362,7 +328,7 @@ export default (window: Window) => {
362
328
 
363
329
  async function update(updateId: number) {
364
330
  if (state.showResults) {
365
- render(input!, resultsContainer, state, true);
331
+ render(resultsContainer, state, true);
366
332
  }
367
333
  const query = state.value;
368
334
  if (!(await loadPagefind()) || updateId !== latestUpdateId) {
@@ -380,7 +346,7 @@ export default (window: Window) => {
380
346
  if (!state.showResults) {
381
347
  return;
382
348
  }
383
- render(input!, resultsContainer, state, false);
349
+ render(resultsContainer, state, false);
384
350
  }
385
351
 
386
352
  function queueUpdate() {
@@ -402,7 +368,7 @@ export default (window: Window) => {
402
368
  .catch(err => {
403
369
  console.log(`failed to load Pagefind: ${err}`);
404
370
  if (state.showResults) {
405
- render(input!, resultsContainer, state, false);
371
+ render(resultsContainer, state, false);
406
372
  }
407
373
  });
408
374
  }
@@ -418,7 +384,7 @@ export default (window: Window) => {
418
384
  const stalePagefind = pagefind;
419
385
  invalidatePagefind();
420
386
  if (state.showResults) {
421
- render(input!, resultsContainer, state, true);
387
+ render(resultsContainer, state, true);
422
388
  }
423
389
  if (stalePagefind) {
424
390
  try {
@@ -434,7 +400,7 @@ export default (window: Window) => {
434
400
  reloadPagefindAfterPageUpdateRefresh().catch(err => {
435
401
  console.log(`failed to reload Pagefind: ${err}`);
436
402
  if (state.showResults) {
437
- render(input!, resultsContainer, state, false);
403
+ render(resultsContainer, state, false);
438
404
  }
439
405
  });
440
406
  }
@@ -446,7 +412,7 @@ export default (window: Window) => {
446
412
  return;
447
413
  }
448
414
  state.showResults = false;
449
- render(input!, resultsContainer, state);
415
+ render(resultsContainer, state);
450
416
  }
451
417
 
452
418
  function handleInput(e: Event) {
@@ -573,23 +539,11 @@ export default (window: Window) => {
573
539
  }
574
540
  } else if (e.key === 'Escape') {
575
541
  e.preventDefault();
576
- hide();
577
542
  input!.focus();
543
+ hide();
578
544
  }
579
545
  }
580
546
 
581
- function handleResultsFocusIn(e: FocusEvent) {
582
- applyHighlight(resultsContainer, e.target as HTMLElement);
583
- }
584
-
585
- function handleResultsFocusOut(e: FocusEvent) {
586
- const related = e.relatedTarget as HTMLElement | null;
587
- if (related && (resultsContainer.contains(related) || related === input)) {
588
- return;
589
- }
590
- applyHighlight(resultsContainer, null);
591
- }
592
-
593
547
  function handleWindowPointerDown(e: PointerEvent) {
594
548
  const target = e.target as Node;
595
549
  if (!input!.contains(target) && !resultsContainer.contains(target)) {
@@ -616,8 +570,6 @@ export default (window: Window) => {
616
570
  resultsContainer.addEventListener('pointerdown', handlePointerDown);
617
571
  resultsContainer.addEventListener('click', handleResultClick);
618
572
  resultsContainer.addEventListener('keydown', handleResultsKeyDown);
619
- resultsContainer.addEventListener('focusin', handleResultsFocusIn);
620
- resultsContainer.addEventListener('focusout', handleResultsFocusOut);
621
573
  window.addEventListener('pointerup', handleWindowPointerUp);
622
574
  window.addEventListener('pointerdown', handleWindowPointerDown);
623
575
  window.addEventListener('pointermove', handleWindowPointerMove);
@@ -637,8 +589,6 @@ export default (window: Window) => {
637
589
  window.removeEventListener('pointermove', handleWindowPointerMove);
638
590
  window.removeEventListener('pointerdown', handleWindowPointerDown);
639
591
  window.removeEventListener('pointerup', handleWindowPointerUp);
640
- resultsContainer.removeEventListener('focusout', handleResultsFocusOut);
641
- resultsContainer.removeEventListener('focusin', handleResultsFocusIn);
642
592
  resultsContainer.removeEventListener('keydown', handleResultsKeyDown);
643
593
  resultsContainer.removeEventListener('click', handleResultClick);
644
594
  resultsContainer.removeEventListener('pointerdown', handlePointerDown);
package/src/top/README.md CHANGED
@@ -1,4 +1,9 @@
1
1
  # The `top` component
2
2
 
3
- Show a floating "back to top" button once the page is scrolled more than 250px,
4
- and keep it visible until the page is back near the top.
3
+ Show a floating "back to top" button on long pages once the reader has scrolled
4
+ well down the page, and keep it visible until they are back above that point.
5
+
6
+ - The page must be at least 3 viewports tall; shorter pages never show it.
7
+ - It shows once the scroll position passes 1.5 viewports or 25% of the
8
+ scrollable distance, whichever is farther.
9
+ - Visibility is recalculated on scroll and on resize.
package/src/top/index.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { debounce, removeClass } from '../util';
2
2
  import { globals } from '../globals';
3
3
 
4
- /** Show the "Back to top" button once the user is past this scroll position */
5
- const SHOW_THRESHOLD_PX = 250;
4
+ /** Only show the "Back to top" button on pages at least this many viewports tall */
5
+ const MIN_PAGE_SCREENS = 3;
6
+
7
+ /** Show the button once the user is this many viewports down the page... */
8
+ const SHOW_AFTER_SCREENS = 1.5;
9
+
10
+ /** ...or this fraction of the scrollable distance, whichever is farther */
11
+ const SHOW_AFTER_FRACTION = 0.25;
6
12
 
7
13
  /** Debounce time (maximum amount of time to wait before updates) */
8
14
  const LATENCY_MS = 50;
@@ -60,8 +66,22 @@ export default (window: Window) => {
60
66
  isShowing = false;
61
67
  }
62
68
 
69
+ function shouldShow(): boolean {
70
+ const viewportHeight = window.innerHeight;
71
+ const maxScroll =
72
+ window.document.documentElement.scrollHeight - viewportHeight;
73
+ if (maxScroll < (MIN_PAGE_SCREENS - 1) * viewportHeight) {
74
+ return false;
75
+ }
76
+ const threshold = Math.max(
77
+ SHOW_AFTER_SCREENS * viewportHeight,
78
+ SHOW_AFTER_FRACTION * maxScroll,
79
+ );
80
+ return window.scrollY > threshold;
81
+ }
82
+
63
83
  function updateVisibility() {
64
- if (window.scrollY > SHOW_THRESHOLD_PX) {
84
+ if (shouldShow()) {
65
85
  show(link);
66
86
  } else {
67
87
  hide(link);
@@ -70,9 +90,11 @@ export default (window: Window) => {
70
90
 
71
91
  const debounced = debounce(window, updateVisibility, LATENCY_MS);
72
92
  window.addEventListener('scroll', debounced, { passive: true });
93
+ window.addEventListener('resize', debounced, { passive: true });
73
94
  updateVisibility();
74
95
 
75
96
  return () => {
76
97
  window.removeEventListener('scroll', debounced);
98
+ window.removeEventListener('resize', debounced);
77
99
  };
78
100
  };
@@ -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';
@@ -192,20 +192,17 @@
192
192
  <div class="header-controls">
193
193
  <div id="to-top-container" class="to-top-container"></div>
194
194
  <% if (site.features.search) { %>
195
- <div class="search-controls">
195
+ <div class="search-controls" role="search">
196
196
  <input type="search" class="search quick-search"
197
197
  autocomplete="off" name="quick-search"
198
198
  placeholder="Search"
199
- role="combobox"
200
- aria-haspopup="listbox"
201
- aria-autocomplete="list"
202
- aria-expanded="false"
199
+ aria-label="Search"
203
200
  aria-controls="quick-search-results"
204
201
  disabled />
205
202
  </div>
206
203
  <% } %>
207
204
  </div>
208
- <div class="results-container" role="region"
205
+ <div id="quick-search-results" class="results-container" role="region"
209
206
  aria-label="Search results"
210
207
  aria-live="polite" aria-hidden="true" inert>
211
208
  <div class="results" style="display:none"></div>