@abreen/tada 1.4.1 → 1.5.0
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 +4 -2
- package/bin/tada.ts +81 -57
- package/build/apply-base-path-plugin.ts +22 -6
- package/build/code.test.ts +108 -1
- package/build/colors.test.ts +66 -0
- package/build/columns-plugin.ts +116 -0
- package/build/content-watch.ts +30 -21
- package/build/copy.ts +1 -1
- package/build/external-links-plugin.ts +1 -1
- package/build/generate-content-assets.ts +46 -4
- package/build/generate-favicon.ts +1 -1
- package/build/generate-fonts.ts +1 -1
- package/build/generate-katex-assets.ts +1 -1
- package/build/log.test.ts +7 -1
- package/build/log.ts +4 -1
- package/build/markdown-plugins.test.ts +65 -3
- package/build/pagefind.ts +1 -1
- package/build/pipeline.ts +4 -20
- package/build/serve.test.ts +59 -0
- package/build/serve.ts +29 -63
- package/build/templates.ts +2 -2
- package/build/toc-plugin.test.ts +40 -25
- package/build/toc-plugin.ts +9 -2
- package/build/{types.ts → types.d.ts} +87 -1
- package/build/util.ts +1 -1
- package/build/utils/code.ts +76 -3
- package/build/utils/content-files.test.ts +56 -0
- package/build/utils/content-files.ts +15 -11
- package/build/utils/file-types.test.ts +18 -0
- package/build/utils/file-types.ts +4 -0
- package/build/utils/include.test.ts +117 -0
- package/build/utils/include.ts +53 -0
- package/build/utils/jdi-runner/TraceRunner.java +416 -0
- package/build/utils/literate-java.ts +2 -2
- package/build/utils/markdown.ts +32 -14
- package/build/utils/paths.ts +1 -1
- package/build/utils/render.ts +97 -11
- package/build/utils/shiki-highlighter.ts +1 -1
- package/build/utils/trace-layout.test.ts +570 -0
- package/build/utils/trace-layout.ts +848 -0
- package/build/utils/trace-svg.test.ts +425 -0
- package/build/utils/trace-svg.ts +905 -0
- package/build/utils/trace.test.ts +169 -0
- package/build/utils/trace.ts +334 -0
- package/build/validate-internal-links-plugin.test.ts +30 -2
- package/build/validate-internal-links-plugin.ts +3 -3
- package/build/watch-reload-client.ts +4 -20
- package/build/watch.ts +436 -448
- package/init/content/index.md +10 -5
- package/init/content/labs/00/index.md +1 -1
- package/init/content/labs/01/SearchTreeDemo.java +31 -0
- package/init/content/labs/01/TraceDemo.java +22 -0
- package/init/content/labs/01/index.md +27 -0
- package/init/content/labs/index.md +2 -0
- package/init/content/lectures/01/Rectangle.java +2 -0
- package/init/content/lectures/01/index.md +2 -1
- package/init/content/lectures/01/rectangle.py +45 -0
- package/init/content/lectures/02/_pr1.md +5 -0
- package/init/content/lectures/02/index.md +11 -0
- package/init/content/lectures/02/subdir/_foobar.html +3 -0
- package/init/content/lectures/02/subdir/_pr2.md +6 -0
- package/init/content/lectures/index.md +3 -0
- package/init/content/markdown.md +30 -0
- package/package.json +20 -3
- package/src/_base.scss +13 -2
- package/src/_layout.scss +6 -5
- package/src/anchor/index.test.ts +64 -0
- package/src/code/index.test.ts +71 -0
- package/src/code/style.scss +23 -0
- package/src/code.scss +5 -3
- package/src/header/_base.scss +48 -9
- package/src/header/index.test.ts +89 -0
- package/src/header/index.ts +1 -1
- package/src/header/style.scss +1 -5
- package/src/index.ts +18 -20
- package/src/literate/style.scss +15 -7
- package/src/navigate/eligible.test.ts +101 -0
- package/src/navigate/eligible.ts +62 -0
- package/src/navigate/index.test.ts +82 -0
- package/src/navigate/index.ts +365 -0
- package/src/navigate/lifecycle.test.ts +26 -0
- package/src/navigate/lifecycle.ts +65 -0
- package/src/navigate/style.scss +61 -0
- package/src/print/index.test.ts +71 -0
- package/src/question/index.test.ts +82 -0
- package/src/question/style.scss +12 -0
- package/src/search/index.ts +38 -14
- package/src/search/style.scss +23 -14
- package/src/style.scss +25 -12
- package/src/timezone/index.test.ts +301 -1
- package/src/timezone/index.ts +68 -56
- package/src/toc/index.test.ts +237 -0
- package/src/toc/index.ts +29 -69
- package/src/toc/style.scss +17 -0
- package/src/top/index.test.ts +46 -0
- package/src/top/index.ts +2 -2
- package/src/trace/index.test.ts +528 -0
- package/src/trace/index.ts +250 -0
- package/src/trace/style.scss +210 -0
- package/src/trace/types.d.ts +47 -0
- package/src/util.test.ts +135 -0
- package/templates/_top.html +23 -22
- package/types/jsdom.d.ts +9 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { isEligibleLink } from './eligible';
|
|
2
|
+
import { mountPerPageComponents, teardownPerPageComponents } from './lifecycle';
|
|
3
|
+
|
|
4
|
+
let currentAbortController: AbortController | null = null;
|
|
5
|
+
let historyIndex = 0;
|
|
6
|
+
let currentPath = '';
|
|
7
|
+
|
|
8
|
+
type Direction = 'forward' | 'back';
|
|
9
|
+
|
|
10
|
+
function findAnchor(event: MouseEvent): HTMLAnchorElement | null {
|
|
11
|
+
const target = event.target as HTMLElement | null;
|
|
12
|
+
return target?.closest('a[href]') ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function shouldIgnoreClick(
|
|
16
|
+
event: MouseEvent,
|
|
17
|
+
anchor: HTMLAnchorElement,
|
|
18
|
+
): boolean {
|
|
19
|
+
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (anchor.target) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const href = anchor.href;
|
|
28
|
+
if (!href) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function updateHead(newDoc: Document): void {
|
|
36
|
+
document.title = newDoc.title;
|
|
37
|
+
|
|
38
|
+
const metaTags = ['description', 'author'];
|
|
39
|
+
for (const name of metaTags) {
|
|
40
|
+
const newMeta = newDoc.querySelector(`meta[name="${name}"]`);
|
|
41
|
+
const oldMeta = document.querySelector(`meta[name="${name}"]`);
|
|
42
|
+
if (newMeta && oldMeta) {
|
|
43
|
+
oldMeta.setAttribute('content', newMeta.getAttribute('content') ?? '');
|
|
44
|
+
} else if (newMeta && !oldMeta) {
|
|
45
|
+
document.head.appendChild(newMeta.cloneNode(true));
|
|
46
|
+
} else if (!newMeta && oldMeta) {
|
|
47
|
+
oldMeta.remove();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ogTags = ['og:title', 'og:author'];
|
|
52
|
+
for (const prop of ogTags) {
|
|
53
|
+
const newMeta = newDoc.querySelector(`meta[property="${prop}"]`);
|
|
54
|
+
const oldMeta = document.querySelector(`meta[property="${prop}"]`);
|
|
55
|
+
if (newMeta && oldMeta) {
|
|
56
|
+
oldMeta.setAttribute('content', newMeta.getAttribute('content') ?? '');
|
|
57
|
+
} else if (newMeta && !oldMeta) {
|
|
58
|
+
document.head.appendChild(newMeta.cloneNode(true));
|
|
59
|
+
} else if (!newMeta && oldMeta) {
|
|
60
|
+
oldMeta.remove();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Adopt stylesheet links from the new page that aren't already loaded
|
|
65
|
+
const existingHrefs = new Set(
|
|
66
|
+
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(link =>
|
|
67
|
+
link.getAttribute('href'),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
for (const link of newDoc.querySelectorAll('link[rel="stylesheet"]')) {
|
|
71
|
+
if (!existingHrefs.has(link.getAttribute('href'))) {
|
|
72
|
+
document.head.appendChild(link.cloneNode(true));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function swapContent(newDoc: Document): void {
|
|
78
|
+
const newContainer = newDoc.querySelector('.container');
|
|
79
|
+
const oldContainer = document.querySelector('.container');
|
|
80
|
+
if (newContainer && oldContainer) {
|
|
81
|
+
oldContainer.replaceChildren(...newContainer.childNodes);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Update body class (carries template name and toc-is-active)
|
|
85
|
+
document.body.className = newDoc.body.className;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clearSearch(): void {
|
|
89
|
+
const input = document.querySelector(
|
|
90
|
+
'input.search.quick-search',
|
|
91
|
+
) as HTMLInputElement | null;
|
|
92
|
+
if (input && input.value) {
|
|
93
|
+
input.value = '';
|
|
94
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function performNavigation(
|
|
99
|
+
url: string,
|
|
100
|
+
scrollTarget: number | string | null,
|
|
101
|
+
direction: Direction,
|
|
102
|
+
isPush: boolean,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
if (currentAbortController) {
|
|
105
|
+
currentAbortController.abort();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
currentAbortController = controller;
|
|
110
|
+
|
|
111
|
+
const header = document.querySelector('header');
|
|
112
|
+
header?.classList.add('loading');
|
|
113
|
+
|
|
114
|
+
let response: Response;
|
|
115
|
+
try {
|
|
116
|
+
response = await fetch(url, { signal: controller.signal });
|
|
117
|
+
} catch (err: unknown) {
|
|
118
|
+
header?.classList.remove('loading');
|
|
119
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Fetch failed, fall back to full navigation
|
|
123
|
+
window.location.href = url;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
header?.classList.remove('loading');
|
|
129
|
+
window.location.href = url;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const html = await response.text();
|
|
134
|
+
header?.classList.remove('loading');
|
|
135
|
+
const parser = new DOMParser();
|
|
136
|
+
const newDoc = parser.parseFromString(html, 'text/html');
|
|
137
|
+
|
|
138
|
+
if (!newDoc.querySelector('meta[name="generator"][content="Tada"]')) {
|
|
139
|
+
window.location.href = url;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const doSwap = () => {
|
|
144
|
+
teardownPerPageComponents();
|
|
145
|
+
swapContent(newDoc);
|
|
146
|
+
updateHead(newDoc);
|
|
147
|
+
if (isPush) {
|
|
148
|
+
historyIndex++;
|
|
149
|
+
history.pushState({ navIndex: historyIndex }, '', url);
|
|
150
|
+
}
|
|
151
|
+
const parsed = new URL(url);
|
|
152
|
+
currentPath = parsed.pathname + parsed.search;
|
|
153
|
+
|
|
154
|
+
if (typeof scrollTarget === 'string') {
|
|
155
|
+
// Hash target
|
|
156
|
+
const el = document.getElementById(scrollTarget);
|
|
157
|
+
if (el) {
|
|
158
|
+
el.scrollIntoView();
|
|
159
|
+
} else {
|
|
160
|
+
window.scrollTo({ top: 0 });
|
|
161
|
+
}
|
|
162
|
+
} else if (typeof scrollTarget === 'number') {
|
|
163
|
+
// Restore scroll position (popstate)
|
|
164
|
+
window.scrollTo({ top: scrollTarget });
|
|
165
|
+
} else {
|
|
166
|
+
window.scrollTo({ top: 0 });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const doc = document as Document & {
|
|
171
|
+
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (typeof doc.startViewTransition === 'function') {
|
|
175
|
+
// Only give the title its own transition group if it's visible
|
|
176
|
+
const titleEl = document.querySelector('.title-and-info');
|
|
177
|
+
const headerHeight =
|
|
178
|
+
document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
|
179
|
+
const titleVisible =
|
|
180
|
+
titleEl != null && titleEl.getBoundingClientRect().bottom >= headerHeight;
|
|
181
|
+
|
|
182
|
+
if (titleVisible) {
|
|
183
|
+
const h1 = titleEl.querySelector('h1') as HTMLElement | null;
|
|
184
|
+
const info = titleEl.querySelector('.info') as HTMLElement | null;
|
|
185
|
+
const breadcrumb = titleEl.querySelector(
|
|
186
|
+
'a.breadcrumb',
|
|
187
|
+
) as HTMLElement | null;
|
|
188
|
+
if (h1) {
|
|
189
|
+
h1.style.viewTransitionName = 'page-title';
|
|
190
|
+
}
|
|
191
|
+
if (info) {
|
|
192
|
+
info.style.viewTransitionName = 'page-info';
|
|
193
|
+
}
|
|
194
|
+
if (breadcrumb) {
|
|
195
|
+
breadcrumb.style.viewTransitionName = 'page-breadcrumb';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
document.documentElement.classList.add(
|
|
200
|
+
direction === 'forward' ? 'nav-forward' : 'nav-back',
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const transition = doc.startViewTransition(() => {
|
|
204
|
+
doSwap();
|
|
205
|
+
// Apply transition names to the new title if the old one was visible
|
|
206
|
+
if (titleVisible) {
|
|
207
|
+
const newTitleEl = document.querySelector('.title-and-info');
|
|
208
|
+
if (newTitleEl) {
|
|
209
|
+
const h1 = newTitleEl.querySelector('h1') as HTMLElement | null;
|
|
210
|
+
const info = newTitleEl.querySelector('.info') as HTMLElement | null;
|
|
211
|
+
const breadcrumb = newTitleEl.querySelector(
|
|
212
|
+
'a.breadcrumb',
|
|
213
|
+
) as HTMLElement | null;
|
|
214
|
+
if (h1) {
|
|
215
|
+
h1.style.viewTransitionName = 'page-title';
|
|
216
|
+
}
|
|
217
|
+
if (info) {
|
|
218
|
+
info.style.viewTransitionName = 'page-info';
|
|
219
|
+
}
|
|
220
|
+
if (breadcrumb) {
|
|
221
|
+
breadcrumb.style.viewTransitionName = 'page-breadcrumb';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
await transition.finished;
|
|
227
|
+
document.documentElement.classList.remove('nav-forward', 'nav-back');
|
|
228
|
+
// Clean up inline styles
|
|
229
|
+
const newH1 = document.querySelector(
|
|
230
|
+
'.title-and-info h1',
|
|
231
|
+
) as HTMLElement | null;
|
|
232
|
+
const newInfo = document.querySelector(
|
|
233
|
+
'.title-and-info .info',
|
|
234
|
+
) as HTMLElement | null;
|
|
235
|
+
if (newH1) {
|
|
236
|
+
newH1.style.viewTransitionName = '';
|
|
237
|
+
}
|
|
238
|
+
if (newInfo) {
|
|
239
|
+
newInfo.style.viewTransitionName = '';
|
|
240
|
+
}
|
|
241
|
+
const newBreadcrumb = document.querySelector(
|
|
242
|
+
'.title-and-info a.breadcrumb',
|
|
243
|
+
) as HTMLElement | null;
|
|
244
|
+
if (newBreadcrumb) {
|
|
245
|
+
newBreadcrumb.style.viewTransitionName = '';
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
doSwap();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await mountPerPageComponents(window);
|
|
252
|
+
currentAbortController = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export default function mountNavigate(window: Window): () => void {
|
|
256
|
+
window.history.scrollRestoration = 'manual';
|
|
257
|
+
currentPath = window.location.pathname + window.location.search;
|
|
258
|
+
|
|
259
|
+
function handleClick(event: MouseEvent) {
|
|
260
|
+
const anchor = findAnchor(event);
|
|
261
|
+
if (!anchor) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (shouldIgnoreClick(event, anchor)) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (
|
|
270
|
+
!isEligibleLink(anchor.href, window.location.origin, __SITE_BASE_PATH__)
|
|
271
|
+
) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Same-page hash links: smooth scroll instead of letting the browser jump
|
|
276
|
+
const url = new URL(anchor.href);
|
|
277
|
+
if (
|
|
278
|
+
url.pathname === window.location.pathname &&
|
|
279
|
+
url.search === window.location.search
|
|
280
|
+
) {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
clearSearch();
|
|
283
|
+
const details = window.document.querySelector(
|
|
284
|
+
'header details',
|
|
285
|
+
) as HTMLDetailsElement | null;
|
|
286
|
+
if (details?.open) {
|
|
287
|
+
details.open = false;
|
|
288
|
+
}
|
|
289
|
+
if (url.hash) {
|
|
290
|
+
window.history.pushState(null, '', url.hash);
|
|
291
|
+
const el = window.document.getElementById(url.hash.slice(1));
|
|
292
|
+
if (el) {
|
|
293
|
+
el.scrollIntoView({ behavior: 'smooth' });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
|
|
301
|
+
// Clear search and close header immediately, before the fetch
|
|
302
|
+
clearSearch();
|
|
303
|
+
|
|
304
|
+
const details = window.document.querySelector(
|
|
305
|
+
'header details',
|
|
306
|
+
) as HTMLDetailsElement | null;
|
|
307
|
+
if (details?.open) {
|
|
308
|
+
details.open = false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Save current scroll position and history index
|
|
312
|
+
window.history.replaceState(
|
|
313
|
+
{
|
|
314
|
+
...window.history.state,
|
|
315
|
+
scrollY: window.scrollY,
|
|
316
|
+
navIndex: historyIndex,
|
|
317
|
+
},
|
|
318
|
+
'',
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const hash = url.hash ? url.hash.slice(1) : null;
|
|
322
|
+
|
|
323
|
+
performNavigation(anchor.href, hash, 'forward', true);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function handlePopState(event: PopStateEvent) {
|
|
327
|
+
const newPath = window.location.pathname + window.location.search;
|
|
328
|
+
if (newPath === currentPath) {
|
|
329
|
+
// Same page, different hash: scroll to the target
|
|
330
|
+
const hash = window.location.hash.slice(1);
|
|
331
|
+
if (hash) {
|
|
332
|
+
const el = window.document.getElementById(hash);
|
|
333
|
+
if (el) {
|
|
334
|
+
el.scrollIntoView({ behavior: 'smooth' });
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
currentPath = newPath;
|
|
342
|
+
|
|
343
|
+
const scrollY = event.state?.scrollY ?? null;
|
|
344
|
+
const targetIndex = event.state?.navIndex ?? 0;
|
|
345
|
+
const direction: Direction =
|
|
346
|
+
targetIndex >= historyIndex ? 'forward' : 'back';
|
|
347
|
+
historyIndex = targetIndex;
|
|
348
|
+
performNavigation(
|
|
349
|
+
window.location.href,
|
|
350
|
+
typeof scrollY === 'number' ? scrollY : null,
|
|
351
|
+
direction,
|
|
352
|
+
false,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Use capture phase so the handler fires before stopPropagation
|
|
357
|
+
// in the header component can block the event from reaching us
|
|
358
|
+
window.document.addEventListener('click', handleClick, true);
|
|
359
|
+
window.addEventListener('popstate', handlePopState);
|
|
360
|
+
|
|
361
|
+
return () => {
|
|
362
|
+
window.document.removeEventListener('click', handleClick, true);
|
|
363
|
+
window.removeEventListener('popstate', handlePopState);
|
|
364
|
+
};
|
|
365
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
|
|
4
|
+
describe('component lifecycle', () => {
|
|
5
|
+
test('mountPerPageComponents returns a teardown function', async () => {
|
|
6
|
+
const dom = new JSDOM(
|
|
7
|
+
`<body><header><details><summary>Menu</summary><nav></nav></details></header><div class="container"></div></body>`,
|
|
8
|
+
{ url: 'http://localhost/' },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const { mountPerPageComponents } = await import('./lifecycle');
|
|
12
|
+
const teardown = await mountPerPageComponents(dom.window);
|
|
13
|
+
expect(typeof teardown).toBe('function');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('teardown calls all per-page cleanup functions', async () => {
|
|
17
|
+
const dom = new JSDOM(
|
|
18
|
+
`<body><header><details><summary>Menu</summary><nav></nav></details></header><div class="container"></div></body>`,
|
|
19
|
+
{ url: 'http://localhost/' },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const { mountPerPageComponents } = await import('./lifecycle');
|
|
23
|
+
const teardown = await mountPerPageComponents(dom.window);
|
|
24
|
+
teardown();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import mountTableOfContents from '../toc';
|
|
2
|
+
import mountAnchor from '../anchor';
|
|
3
|
+
import mountQuestion from '../question';
|
|
4
|
+
import mountTimeZone from '../timezone';
|
|
5
|
+
import mountCode from '../code';
|
|
6
|
+
import mountTrace from '../trace';
|
|
7
|
+
import mountPrint from '../print';
|
|
8
|
+
import { scheduleTask } from '../util';
|
|
9
|
+
|
|
10
|
+
const PER_PAGE_COMPONENTS: Record<
|
|
11
|
+
string,
|
|
12
|
+
(w: Window) => void | (() => void) | Promise<void>
|
|
13
|
+
> = {
|
|
14
|
+
toc: mountTableOfContents,
|
|
15
|
+
anchor: mountAnchor,
|
|
16
|
+
question: mountQuestion,
|
|
17
|
+
timeZone: mountTimeZone,
|
|
18
|
+
code: mountCode,
|
|
19
|
+
trace: mountTrace,
|
|
20
|
+
print: mountPrint,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let cleanups: (() => void)[] = [];
|
|
24
|
+
|
|
25
|
+
export async function mountPerPageComponents(
|
|
26
|
+
window: Window,
|
|
27
|
+
): Promise<() => void> {
|
|
28
|
+
cleanups = [];
|
|
29
|
+
|
|
30
|
+
const entries = Object.entries(PER_PAGE_COMPONENTS);
|
|
31
|
+
|
|
32
|
+
const mountPromises = entries.map(([name, mount]) => {
|
|
33
|
+
return new Promise<void>((resolve, reject) => {
|
|
34
|
+
scheduleTask(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const cleanup = await mount(window);
|
|
37
|
+
if (typeof cleanup === 'function') {
|
|
38
|
+
cleanups.push(cleanup);
|
|
39
|
+
}
|
|
40
|
+
resolve();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (__IS_DEV__) {
|
|
43
|
+
console.error(`Failed to mount ${name} component:`, String(err));
|
|
44
|
+
}
|
|
45
|
+
reject();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await Promise.allSettled(mountPromises);
|
|
52
|
+
|
|
53
|
+
return teardownPerPageComponents;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function teardownPerPageComponents(): void {
|
|
57
|
+
for (const cleanup of cleanups) {
|
|
58
|
+
try {
|
|
59
|
+
cleanup();
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore teardown errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
cleanups = [];
|
|
65
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
$shift: 40px;
|
|
2
|
+
$duration: 200ms;
|
|
3
|
+
|
|
4
|
+
@keyframes slide-out-left {
|
|
5
|
+
to {
|
|
6
|
+
opacity: 0;
|
|
7
|
+
transform: translateX(-$shift);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@keyframes slide-in-right {
|
|
12
|
+
from {
|
|
13
|
+
opacity: 0;
|
|
14
|
+
transform: translateX($shift);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@keyframes slide-out-right {
|
|
19
|
+
to {
|
|
20
|
+
opacity: 0;
|
|
21
|
+
transform: translateX($shift);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@keyframes slide-in-left {
|
|
26
|
+
from {
|
|
27
|
+
opacity: 0;
|
|
28
|
+
transform: translateX(-$shift);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Page heading and info: crossfade in place instead of sliding with the page
|
|
33
|
+
// view-transition-name is set via inline style by the navigator
|
|
34
|
+
// only when .title-and-info is visible in the viewport
|
|
35
|
+
|
|
36
|
+
::view-transition-old(page-title),
|
|
37
|
+
::view-transition-new(page-title),
|
|
38
|
+
::view-transition-old(page-info),
|
|
39
|
+
::view-transition-new(page-info),
|
|
40
|
+
::view-transition-old(page-breadcrumb),
|
|
41
|
+
::view-transition-new(page-breadcrumb) {
|
|
42
|
+
animation-duration: $duration;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Forward navigation: old content slides left, new content slides in from right
|
|
46
|
+
.nav-forward::view-transition-old(root) {
|
|
47
|
+
animation: slide-out-left $duration ease-in;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.nav-forward::view-transition-new(root) {
|
|
51
|
+
animation: slide-in-right $duration ease-out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Back navigation: old content slides right, new content slides in from left
|
|
55
|
+
.nav-back::view-transition-old(root) {
|
|
56
|
+
animation: slide-out-right $duration ease-in;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.nav-back::view-transition-new(root) {
|
|
60
|
+
animation: slide-in-left $duration ease-out;
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import mount from './index';
|
|
4
|
+
|
|
5
|
+
function create(html: string) {
|
|
6
|
+
const dom = new JSDOM(`<body>${html}</body>`);
|
|
7
|
+
return dom.window;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('print', () => {
|
|
11
|
+
test('opens closed details on beforeprint', () => {
|
|
12
|
+
const win = create('<details><summary>S</summary>Content</details>');
|
|
13
|
+
mount(win);
|
|
14
|
+
|
|
15
|
+
const details = win.document.querySelector('details')!;
|
|
16
|
+
expect(details.open).toBe(false);
|
|
17
|
+
|
|
18
|
+
win.dispatchEvent(new win.Event('beforeprint'));
|
|
19
|
+
expect(details.open).toBe(true);
|
|
20
|
+
expect(details.getAttribute('data-was-closed')).toBe('true');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('restores closed details on afterprint', () => {
|
|
24
|
+
const win = create('<details><summary>S</summary>Content</details>');
|
|
25
|
+
mount(win);
|
|
26
|
+
|
|
27
|
+
win.dispatchEvent(new win.Event('beforeprint'));
|
|
28
|
+
win.dispatchEvent(new win.Event('afterprint'));
|
|
29
|
+
|
|
30
|
+
const details = win.document.querySelector('details')!;
|
|
31
|
+
expect(details.open).toBe(false);
|
|
32
|
+
expect(details.hasAttribute('data-was-closed')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('leaves already-open details open after print', () => {
|
|
36
|
+
const win = create('<details open><summary>S</summary>Content</details>');
|
|
37
|
+
mount(win);
|
|
38
|
+
|
|
39
|
+
win.dispatchEvent(new win.Event('beforeprint'));
|
|
40
|
+
win.dispatchEvent(new win.Event('afterprint'));
|
|
41
|
+
|
|
42
|
+
const details = win.document.querySelector('details')!;
|
|
43
|
+
expect(details.open).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('skips details inside header', () => {
|
|
47
|
+
const win = create(
|
|
48
|
+
'<header><details><summary>Nav</summary>Menu</details></header>' +
|
|
49
|
+
'<details><summary>S</summary>Content</details>',
|
|
50
|
+
);
|
|
51
|
+
mount(win);
|
|
52
|
+
|
|
53
|
+
win.dispatchEvent(new win.Event('beforeprint'));
|
|
54
|
+
|
|
55
|
+
const headerDetails = win.document.querySelector('header details')!;
|
|
56
|
+
const bodyDetails = win.document.querySelectorAll('details')[1];
|
|
57
|
+
expect(headerDetails.hasAttribute('data-was-closed')).toBe(false);
|
|
58
|
+
expect(bodyDetails.open).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('cleanup removes event listeners', () => {
|
|
62
|
+
const win = create('<details><summary>S</summary>Content</details>');
|
|
63
|
+
const cleanup = mount(win);
|
|
64
|
+
|
|
65
|
+
cleanup!();
|
|
66
|
+
|
|
67
|
+
win.dispatchEvent(new win.Event('beforeprint'));
|
|
68
|
+
const details = win.document.querySelector('details')!;
|
|
69
|
+
expect(details.open).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import mount from './index';
|
|
4
|
+
|
|
5
|
+
function create(html: string) {
|
|
6
|
+
const dom = new JSDOM(`<body>${html}</body>`);
|
|
7
|
+
return dom.window;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('question', () => {
|
|
11
|
+
test('sets ARIA attributes on answer body', () => {
|
|
12
|
+
const win = create('<div class="question-a-body">Answer</div>');
|
|
13
|
+
mount(win);
|
|
14
|
+
|
|
15
|
+
const el = win.document.querySelector('.question-a-body')!;
|
|
16
|
+
expect(el.getAttribute('role')).toBe('button');
|
|
17
|
+
expect(el.getAttribute('tabindex')).toBe('0');
|
|
18
|
+
expect(el.getAttribute('aria-label')).toBe('Click to reveal answer');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('reveals answer on click', () => {
|
|
22
|
+
const win = create('<div class="question-a-body">Answer</div>');
|
|
23
|
+
mount(win);
|
|
24
|
+
|
|
25
|
+
const el = win.document.querySelector('.question-a-body')!;
|
|
26
|
+
el.dispatchEvent(new win.MouseEvent('click', { bubbles: true }));
|
|
27
|
+
|
|
28
|
+
expect(el.hasAttribute('data-revealed')).toBe(true);
|
|
29
|
+
expect(el.hasAttribute('role')).toBe(false);
|
|
30
|
+
expect(el.hasAttribute('tabindex')).toBe(false);
|
|
31
|
+
expect(el.hasAttribute('aria-label')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('reveals answer on Enter key', () => {
|
|
35
|
+
const win = create('<div class="question-a-body">Answer</div>');
|
|
36
|
+
mount(win);
|
|
37
|
+
|
|
38
|
+
const el = win.document.querySelector('.question-a-body')!;
|
|
39
|
+
el.dispatchEvent(
|
|
40
|
+
new win.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(el.hasAttribute('data-revealed')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('reveals answer on Space key', () => {
|
|
47
|
+
const win = create('<div class="question-a-body">Answer</div>');
|
|
48
|
+
mount(win);
|
|
49
|
+
|
|
50
|
+
const el = win.document.querySelector('.question-a-body')!;
|
|
51
|
+
el.dispatchEvent(
|
|
52
|
+
new win.KeyboardEvent('keydown', { key: ' ', bubbles: true }),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(el.hasAttribute('data-revealed')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('does not reveal on other keys', () => {
|
|
59
|
+
const win = create('<div class="question-a-body">Answer</div>');
|
|
60
|
+
mount(win);
|
|
61
|
+
|
|
62
|
+
const el = win.document.querySelector('.question-a-body')!;
|
|
63
|
+
el.dispatchEvent(
|
|
64
|
+
new win.KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(el.hasAttribute('data-revealed')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('handles multiple question bodies', () => {
|
|
71
|
+
const win = create(
|
|
72
|
+
'<div class="question-a-body">A1</div><div class="question-a-body">A2</div>',
|
|
73
|
+
);
|
|
74
|
+
mount(win);
|
|
75
|
+
|
|
76
|
+
const els = win.document.querySelectorAll('.question-a-body');
|
|
77
|
+
expect(els.length).toBe(2);
|
|
78
|
+
els.forEach(el => {
|
|
79
|
+
expect(el.getAttribute('role')).toBe('button');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/question/style.scss
CHANGED
|
@@ -35,6 +35,18 @@
|
|
|
35
35
|
@include user-select(none);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
.question-a-body:focus-visible {
|
|
39
|
+
outline: 2px solid var(--theme-color);
|
|
40
|
+
outline-offset: -2px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.question-a-body[data-revealed] {
|
|
44
|
+
&:focus,
|
|
45
|
+
&:focus-visible {
|
|
46
|
+
outline: none;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
.question-a-body > *:first-child {
|
|
39
51
|
margin-top: 0;
|
|
40
52
|
}
|