@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.
Files changed (103) hide show
  1. package/README.md +4 -2
  2. package/bin/tada.ts +81 -57
  3. package/build/apply-base-path-plugin.ts +22 -6
  4. package/build/code.test.ts +108 -1
  5. package/build/colors.test.ts +66 -0
  6. package/build/columns-plugin.ts +116 -0
  7. package/build/content-watch.ts +30 -21
  8. package/build/copy.ts +1 -1
  9. package/build/external-links-plugin.ts +1 -1
  10. package/build/generate-content-assets.ts +46 -4
  11. package/build/generate-favicon.ts +1 -1
  12. package/build/generate-fonts.ts +1 -1
  13. package/build/generate-katex-assets.ts +1 -1
  14. package/build/log.test.ts +7 -1
  15. package/build/log.ts +4 -1
  16. package/build/markdown-plugins.test.ts +65 -3
  17. package/build/pagefind.ts +1 -1
  18. package/build/pipeline.ts +4 -20
  19. package/build/serve.test.ts +59 -0
  20. package/build/serve.ts +29 -63
  21. package/build/templates.ts +2 -2
  22. package/build/toc-plugin.test.ts +40 -25
  23. package/build/toc-plugin.ts +9 -2
  24. package/build/{types.ts → types.d.ts} +87 -1
  25. package/build/util.ts +1 -1
  26. package/build/utils/code.ts +76 -3
  27. package/build/utils/content-files.test.ts +56 -0
  28. package/build/utils/content-files.ts +15 -11
  29. package/build/utils/file-types.test.ts +18 -0
  30. package/build/utils/file-types.ts +4 -0
  31. package/build/utils/include.test.ts +117 -0
  32. package/build/utils/include.ts +53 -0
  33. package/build/utils/jdi-runner/TraceRunner.java +416 -0
  34. package/build/utils/literate-java.ts +2 -2
  35. package/build/utils/markdown.ts +32 -14
  36. package/build/utils/paths.ts +1 -1
  37. package/build/utils/render.ts +97 -11
  38. package/build/utils/shiki-highlighter.ts +1 -1
  39. package/build/utils/trace-layout.test.ts +570 -0
  40. package/build/utils/trace-layout.ts +848 -0
  41. package/build/utils/trace-svg.test.ts +425 -0
  42. package/build/utils/trace-svg.ts +905 -0
  43. package/build/utils/trace.test.ts +169 -0
  44. package/build/utils/trace.ts +334 -0
  45. package/build/validate-internal-links-plugin.test.ts +30 -2
  46. package/build/validate-internal-links-plugin.ts +3 -3
  47. package/build/watch-reload-client.ts +4 -20
  48. package/build/watch.ts +436 -448
  49. package/init/content/index.md +10 -5
  50. package/init/content/labs/00/index.md +1 -1
  51. package/init/content/labs/01/SearchTreeDemo.java +31 -0
  52. package/init/content/labs/01/TraceDemo.java +22 -0
  53. package/init/content/labs/01/index.md +27 -0
  54. package/init/content/labs/index.md +2 -0
  55. package/init/content/lectures/01/Rectangle.java +2 -0
  56. package/init/content/lectures/01/index.md +2 -1
  57. package/init/content/lectures/01/rectangle.py +45 -0
  58. package/init/content/lectures/02/_pr1.md +5 -0
  59. package/init/content/lectures/02/index.md +11 -0
  60. package/init/content/lectures/02/subdir/_foobar.html +3 -0
  61. package/init/content/lectures/02/subdir/_pr2.md +6 -0
  62. package/init/content/lectures/index.md +3 -0
  63. package/init/content/markdown.md +30 -0
  64. package/package.json +20 -3
  65. package/src/_base.scss +13 -2
  66. package/src/_layout.scss +6 -5
  67. package/src/anchor/index.test.ts +64 -0
  68. package/src/code/index.test.ts +71 -0
  69. package/src/code/style.scss +23 -0
  70. package/src/code.scss +5 -3
  71. package/src/header/_base.scss +48 -9
  72. package/src/header/index.test.ts +89 -0
  73. package/src/header/index.ts +1 -1
  74. package/src/header/style.scss +1 -5
  75. package/src/index.ts +18 -20
  76. package/src/literate/style.scss +15 -7
  77. package/src/navigate/eligible.test.ts +101 -0
  78. package/src/navigate/eligible.ts +62 -0
  79. package/src/navigate/index.test.ts +82 -0
  80. package/src/navigate/index.ts +365 -0
  81. package/src/navigate/lifecycle.test.ts +26 -0
  82. package/src/navigate/lifecycle.ts +65 -0
  83. package/src/navigate/style.scss +61 -0
  84. package/src/print/index.test.ts +71 -0
  85. package/src/question/index.test.ts +82 -0
  86. package/src/question/style.scss +12 -0
  87. package/src/search/index.ts +38 -14
  88. package/src/search/style.scss +23 -14
  89. package/src/style.scss +25 -12
  90. package/src/timezone/index.test.ts +301 -1
  91. package/src/timezone/index.ts +68 -56
  92. package/src/toc/index.test.ts +237 -0
  93. package/src/toc/index.ts +29 -69
  94. package/src/toc/style.scss +17 -0
  95. package/src/top/index.test.ts +46 -0
  96. package/src/top/index.ts +2 -2
  97. package/src/trace/index.test.ts +528 -0
  98. package/src/trace/index.ts +250 -0
  99. package/src/trace/style.scss +210 -0
  100. package/src/trace/types.d.ts +47 -0
  101. package/src/util.test.ts +135 -0
  102. package/templates/_top.html +23 -22
  103. 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
+ });
@@ -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
  }