@abreen/tada 1.4.1 → 1.5.1

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 +48 -6
  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} +88 -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 +102 -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 +382 -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 +21 -4
  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 +554 -0
  98. package/src/trace/index.ts +258 -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
@@ -104,6 +104,7 @@ function render(
104
104
  input: HTMLInputElement,
105
105
  resultsContainer: HTMLElement,
106
106
  state: State,
107
+ loading = false,
107
108
  ) {
108
109
  if (state.showResults) {
109
110
  resultsContainer.removeAttribute('inert');
@@ -201,10 +202,8 @@ function render(
201
202
  const hint = document.createElement('span');
202
203
  hint.className = 'search-hint';
203
204
  const kbd1 = document.createElement('kbd');
204
- kbd1.textContent = 'Ctrl';
205
- const kbd2 = document.createElement('kbd');
206
- kbd2.textContent = 'Space';
207
- hint.append(kbd1, '+', kbd2, '\u00a0to search');
205
+ kbd1.textContent = '/';
206
+ hint.append('Press\u00a0', kbd1, '\u00a0to search');
208
207
  infoSpan.appendChild(hint);
209
208
 
210
209
  resultsDiv.insertBefore(infoSpan, resultsDiv.firstChild);
@@ -218,15 +217,21 @@ function render(
218
217
  countSpan.className = 'results-count';
219
218
  infoSpan.insertBefore(countSpan, infoSpan.firstChild);
220
219
  }
221
- const n = state.totalResults;
222
- if (n === 0) {
223
- countSpan.textContent = 'No results';
224
- } else if (n === 1) {
225
- countSpan.textContent = 'One result';
226
- } else if (n <= MAX_RESULTS) {
227
- countSpan.textContent = `${n} results`;
220
+ if (loading) {
221
+ countSpan.textContent = 'Loading\u2026';
222
+ infoSpan.classList.add('loading');
228
223
  } else {
229
- countSpan.textContent = `Showing first ${MAX_RESULTS} results`;
224
+ infoSpan.classList.remove('loading');
225
+ const n = state.totalResults;
226
+ if (n === 0) {
227
+ countSpan.textContent = 'No results';
228
+ } else if (n === 1) {
229
+ countSpan.textContent = 'One result';
230
+ } else if (n <= MAX_RESULTS) {
231
+ countSpan.textContent = `${n} results`;
232
+ } else {
233
+ countSpan.textContent = `Showing first ${MAX_RESULTS} results`;
234
+ }
230
235
  }
231
236
 
232
237
  if (state.showResults) {
@@ -328,11 +333,20 @@ export default (window: Window) => {
328
333
  };
329
334
 
330
335
  async function update() {
336
+ if (state.showResults) {
337
+ render(input!, resultsContainer, state, true);
338
+ }
331
339
  await doSearch(state);
332
- render(input!, resultsContainer, state);
340
+ if (!state.showResults) {
341
+ return;
342
+ }
343
+ render(input!, resultsContainer, state, false);
333
344
  }
334
345
 
335
346
  function hide() {
347
+ if (!state.showResults) {
348
+ return;
349
+ }
336
350
  state.showResults = false;
337
351
  render(input!, resultsContainer, state);
338
352
  }
@@ -343,6 +357,12 @@ export default (window: Window) => {
343
357
  return;
344
358
  }
345
359
  state.value = value;
360
+ if (value.length === 0) {
361
+ hide();
362
+ state.results = [];
363
+ state.totalResults = 0;
364
+ return;
365
+ }
346
366
  state.showResults = true;
347
367
  update().catch(() => {});
348
368
  }
@@ -481,7 +501,11 @@ export default (window: Window) => {
481
501
 
482
502
  function handleWindowKeyDown(e: KeyboardEvent) {
483
503
  resultsContainer.classList.add('is-typing');
484
- if (e.key === ' ' && e.ctrlKey && !e.altKey && !e.metaKey) {
504
+ if (e.key === '/' && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) {
505
+ const tag = (e.target as HTMLElement)?.tagName;
506
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
507
+ return;
508
+ }
485
509
  e.preventDefault();
486
510
  input!.focus();
487
511
  }
@@ -14,13 +14,12 @@ header {
14
14
 
15
15
  .results-container.is-showing .results {
16
16
  opacity: 1;
17
- transform: translateY(0);
18
17
  pointer-events: all;
19
18
  }
20
19
 
21
20
  @media (hover: hover) {
22
- .results-container.is-typing a.result:hover:not(:focus-visible),
23
- .results-container.is-typing a.sub-result:hover:not(:focus-visible) {
21
+ .results-container.is-typing a.result:hover:not(:focus),
22
+ .results-container.is-typing a.sub-result:hover:not(:focus) {
24
23
  background: transparent;
25
24
  }
26
25
  }
@@ -41,13 +40,6 @@ header {
41
40
  min-height: 80px;
42
41
  @include user-select(none);
43
42
 
44
- @media (prefers-reduced-motion: no-preference) {
45
- transform: translateY(-8px);
46
- transition:
47
- opacity 0.25s ease,
48
- transform 0.25s ease;
49
- }
50
-
51
43
  .results-info {
52
44
  display: flex;
53
45
  align-items: center;
@@ -55,6 +47,25 @@ header {
55
47
  color: var(--fg2-color);
56
48
  @include user-select(none);
57
49
  padding: 8px 8px 8px 8px;
50
+ position: relative;
51
+
52
+ &.loading::after {
53
+ content: '';
54
+ position: absolute;
55
+ bottom: 0;
56
+ left: 0;
57
+ right: 0;
58
+ height: 2px;
59
+ background-image: linear-gradient(
60
+ 90deg,
61
+ transparent 25%,
62
+ var(--theme-color) 50%,
63
+ transparent 75%
64
+ );
65
+ background-size: 200% 100%;
66
+ background-repeat: no-repeat;
67
+ animation: shimmer 3s ease-in-out infinite;
68
+ }
58
69
 
59
70
  .search-hint {
60
71
  font-size: var(--font-size-small);
@@ -89,9 +100,8 @@ header {
89
100
  }
90
101
  }
91
102
 
92
- a.result:focus-visible {
103
+ a.result:focus {
93
104
  background: var(--bg2-color);
94
- outline: 2px solid var(--theme-color);
95
105
  outline-offset: -2px;
96
106
  }
97
107
  }
@@ -162,9 +172,8 @@ header {
162
172
  background: var(--bg2-color);
163
173
  }
164
174
 
165
- &:focus-visible {
175
+ &:focus {
166
176
  background: var(--bg2-color);
167
- outline: 2px solid var(--theme-color);
168
177
  outline-offset: -2px;
169
178
  }
170
179
 
package/src/style.scss CHANGED
@@ -167,27 +167,36 @@ input[type='submit'] {
167
167
  text-decoration: none;
168
168
  }
169
169
 
170
- button:active,
171
- a.button:active,
172
- input[type='submit']:active {
170
+ button:disabled,
171
+ input[type='submit']:disabled {
172
+ opacity: 0.4;
173
+ cursor: not-allowed;
174
+ }
175
+
176
+ button:active:not(:disabled),
177
+ a.button:not(.disabled):active,
178
+ input[type='submit']:active:not(:disabled) {
173
179
  background: var(--link-active-color) !important;
174
180
  color: var(--bg-color) !important;
175
181
  }
176
182
 
177
183
  @media (hover: hover) {
178
- button:hover,
179
- a.button:hover,
180
- input[type='submit']:hover {
184
+ button:hover:not(:disabled),
185
+ a.button:not(.disabled):hover,
186
+ input[type='submit']:hover:not(:disabled) {
181
187
  background: var(--bg2-color);
182
188
  cursor: pointer;
183
189
  }
184
190
  }
185
191
 
186
- button:focus-visible,
187
- a.button:focus-visible,
188
- input[type='submit']:focus-visible,
189
- input[type='text']:focus-visible,
190
- input[type='search']:focus-visible {
192
+ button:focus,
193
+ a.button:focus,
194
+ input[type='submit']:focus,
195
+ input[type='text']:focus,
196
+ input[type='search']:focus,
197
+ select:focus {
198
+ outline: 2px solid var(--theme-color);
199
+ outline-offset: 1px;
191
200
  border-color: var(--fg-color);
192
201
  background-color: var(--bg2-color);
193
202
  }
@@ -206,6 +215,10 @@ button.icon-button {
206
215
  vertical-align: top;
207
216
  }
208
217
 
218
+ button.icon-button:focus {
219
+ background-color: var(--bg2-color);
220
+ }
221
+
209
222
  ol.styled-list,
210
223
  ul.styled-list {
211
224
  color: var(--theme-color-text);
@@ -458,7 +471,7 @@ select {
458
471
  font-size: inherit;
459
472
  }
460
473
 
461
- // Shiki dual-theme support (defaultColor: false — CSS custom properties only,
474
+ // Shiki dual-theme support (defaultColor: false, CSS custom properties only,
462
475
  // no inline color/background-color, so no !important needed)
463
476
  .shiki span {
464
477
  color: var(--shiki-light);
@@ -1,5 +1,45 @@
1
+ // Set globals before importing the module, since it captures
2
+ // __SITE_TIMEZONES__ at load time.
3
+ const TIMEZONES: TimeZone[] = [
4
+ { value: 'America/New_York', label: 'US Eastern', abbreviation: 'ET' },
5
+ { value: 'America/Chicago', label: 'US Central', abbreviation: 'CT' },
6
+ { value: 'America/Los_Angeles', label: 'US Pacific', abbreviation: 'PT' },
7
+ ];
8
+ (globalThis as Record<string, unknown>).__SITE_TIMEZONES__ = TIMEZONES;
9
+ (globalThis as Record<string, unknown>).__SITE_DEFAULT_TIMEZONE__ =
10
+ 'America/New_York';
11
+
1
12
  import { describe, expect, test } from 'bun:test';
2
- import { detectPeriodStyle, to12Hour, normalizeHM } from './index';
13
+ import { JSDOM } from 'jsdom';
14
+ import mount, { detectPeriodStyle, to12Hour, normalizeHM } from './index';
15
+
16
+ function dom(bodyHtml: string) {
17
+ return new JSDOM(`<body>${bodyHtml}</body>`, { url: 'http://localhost/' });
18
+ }
19
+
20
+ function createPage(times: string[], selectedTz?: string) {
21
+ const options = TIMEZONES.map(
22
+ tz => `<option value="${tz.value}">${tz.label}</option>`,
23
+ ).join('');
24
+ const timeEls = times
25
+ .map(t => `<time datetime="${t}">${to12Hour(...parseHM(t))}</time>`)
26
+ .join('\n');
27
+ const html = `<select class="time-zone">${options}</select>${timeEls}`;
28
+ const jsdom = dom(html);
29
+ if (selectedTz) {
30
+ try {
31
+ jsdom.window.localStorage.setItem('timezoneSelection', selectedTz);
32
+ } catch {
33
+ // ignored
34
+ }
35
+ }
36
+ return jsdom.window;
37
+ }
38
+
39
+ function parseHM(hhmm: string): [number, number] {
40
+ const [h, m] = hhmm.split(':').map(Number);
41
+ return [h, m];
42
+ }
3
43
 
4
44
  describe('detectPeriodStyle', () => {
5
45
  test('detects uppercase "PM"', () => {
@@ -75,6 +115,10 @@ describe('to12Hour', () => {
75
115
  test('formats 23:59', () => {
76
116
  expect(to12Hour(23, 59)).toBe('11:59 p.m.');
77
117
  });
118
+
119
+ test('omits period when style is null', () => {
120
+ expect(to12Hour(14, 0, null)).toBe('2:00');
121
+ });
78
122
  });
79
123
 
80
124
  describe('normalizeHM', () => {
@@ -98,3 +142,259 @@ describe('normalizeHM', () => {
98
142
  expect(normalizeHM(750)).toEqual([12, 30]);
99
143
  });
100
144
  });
145
+
146
+ describe('timezone mount', () => {
147
+ test('unhides and enables the select element', () => {
148
+ const win = createPage(['09:00']);
149
+ mount(win);
150
+
151
+ const sel = win.document.querySelector(
152
+ 'select.time-zone',
153
+ ) as HTMLSelectElement;
154
+ expect(sel.hidden).toBe(false);
155
+ expect(sel.disabled).toBe(false);
156
+ });
157
+
158
+ test('sets select value to default timezone', () => {
159
+ const win = createPage(['09:00']);
160
+ mount(win);
161
+
162
+ const sel = win.document.querySelector(
163
+ 'select.time-zone',
164
+ ) as HTMLSelectElement;
165
+ expect(sel.value).toBe('America/New_York');
166
+ });
167
+
168
+ test('wraps select in a timezone-wrapper div', () => {
169
+ const win = createPage(['09:00']);
170
+ mount(win);
171
+
172
+ const wrapper = win.document.querySelector('.timezone-wrapper');
173
+ expect(wrapper).not.toBeNull();
174
+ expect(wrapper!.querySelector('select.time-zone')).not.toBeNull();
175
+ });
176
+
177
+ test('adds a reset button inside the wrapper', () => {
178
+ const win = createPage(['09:00']);
179
+ mount(win);
180
+
181
+ const btn = win.document.querySelector('.timezone-wrapper button');
182
+ expect(btn).not.toBeNull();
183
+ expect(btn!.className).toBe('icon-button');
184
+ });
185
+
186
+ test('reset button is initially invisible for default timezone', () => {
187
+ const win = createPage(['09:00']);
188
+ mount(win);
189
+
190
+ const btn = win.document.querySelector(
191
+ '.timezone-wrapper button',
192
+ ) as HTMLButtonElement;
193
+ expect(btn.style.opacity).toBe('0');
194
+ expect(btn.style.pointerEvents).toBe('none');
195
+ });
196
+
197
+ test('restores timezone from localStorage', () => {
198
+ const win = createPage(['09:00'], 'America/Chicago');
199
+ mount(win);
200
+
201
+ const sel = win.document.querySelector(
202
+ 'select.time-zone',
203
+ ) as HTMLSelectElement;
204
+ expect(sel.value).toBe('America/Chicago');
205
+ });
206
+
207
+ test('reset button is visible when non-default timezone is stored', () => {
208
+ const win = createPage(['09:00'], 'America/Chicago');
209
+ mount(win);
210
+
211
+ const btn = win.document.querySelector(
212
+ '.timezone-wrapper button',
213
+ ) as HTMLButtonElement;
214
+ expect(btn.style.opacity).toBe('1');
215
+ });
216
+
217
+ test('changing select updates time elements', () => {
218
+ const win = createPage(['12:00']);
219
+ mount(win);
220
+
221
+ const sel = win.document.querySelector(
222
+ 'select.time-zone',
223
+ ) as HTMLSelectElement;
224
+ const time = win.document.querySelector('time') as HTMLTimeElement;
225
+
226
+ const before = time.innerHTML;
227
+
228
+ sel.value = 'America/Los_Angeles';
229
+ sel.dispatchEvent(new win.Event('change'));
230
+
231
+ // The time text should have changed
232
+ expect(time.innerHTML).not.toBe(before);
233
+ });
234
+
235
+ test('changing to non-default timezone adds is-modified class', () => {
236
+ const win = createPage(['12:00']);
237
+ mount(win);
238
+
239
+ const sel = win.document.querySelector(
240
+ 'select.time-zone',
241
+ ) as HTMLSelectElement;
242
+ const time = win.document.querySelector('time') as HTMLTimeElement;
243
+
244
+ sel.value = 'America/Los_Angeles';
245
+ sel.dispatchEvent(new win.Event('change'));
246
+
247
+ expect(time.classList.contains('is-modified')).toBe(true);
248
+ });
249
+
250
+ test('changing back to default removes is-modified class', () => {
251
+ const win = createPage(['12:00']);
252
+ mount(win);
253
+
254
+ const sel = win.document.querySelector(
255
+ 'select.time-zone',
256
+ ) as HTMLSelectElement;
257
+ const time = win.document.querySelector('time') as HTMLTimeElement;
258
+
259
+ sel.value = 'America/Los_Angeles';
260
+ sel.dispatchEvent(new win.Event('change'));
261
+ expect(time.classList.contains('is-modified')).toBe(true);
262
+
263
+ sel.value = 'America/New_York';
264
+ sel.dispatchEvent(new win.Event('change'));
265
+ expect(time.classList.contains('is-modified')).toBe(false);
266
+ });
267
+
268
+ test('reset button restores default timezone', () => {
269
+ const win = createPage(['12:00'], 'America/Chicago');
270
+ mount(win);
271
+
272
+ const sel = win.document.querySelector(
273
+ 'select.time-zone',
274
+ ) as HTMLSelectElement;
275
+ const btn = win.document.querySelector(
276
+ '.timezone-wrapper button',
277
+ ) as HTMLButtonElement;
278
+
279
+ expect(sel.value).toBe('America/Chicago');
280
+
281
+ btn.click();
282
+
283
+ expect(sel.value).toBe('America/New_York');
284
+ });
285
+
286
+ test('reset button removes localStorage entry', () => {
287
+ const win = createPage(['12:00'], 'America/Chicago');
288
+ mount(win);
289
+
290
+ const btn = win.document.querySelector(
291
+ '.timezone-wrapper button',
292
+ ) as HTMLButtonElement;
293
+ btn.click();
294
+
295
+ expect(win.localStorage.getItem('timezoneSelection')).toBeNull();
296
+ });
297
+
298
+ test('changing timezone saves to localStorage', () => {
299
+ const win = createPage(['12:00']);
300
+ mount(win);
301
+
302
+ const sel = win.document.querySelector(
303
+ 'select.time-zone',
304
+ ) as HTMLSelectElement;
305
+ sel.value = 'America/Chicago';
306
+ sel.dispatchEvent(new win.Event('change'));
307
+
308
+ expect(win.localStorage.getItem('timezoneSelection')).toBe(
309
+ 'America/Chicago',
310
+ );
311
+ });
312
+
313
+ test('selecting default timezone removes localStorage entry', () => {
314
+ const win = createPage(['12:00'], 'America/Chicago');
315
+ mount(win);
316
+
317
+ const sel = win.document.querySelector(
318
+ 'select.time-zone',
319
+ ) as HTMLSelectElement;
320
+ sel.value = 'America/New_York';
321
+ sel.dispatchEvent(new win.Event('change'));
322
+
323
+ expect(win.localStorage.getItem('timezoneSelection')).toBeNull();
324
+ });
325
+
326
+ test('time element gets title with original time on change', () => {
327
+ const win = createPage(['14:00']);
328
+ mount(win);
329
+
330
+ const sel = win.document.querySelector(
331
+ 'select.time-zone',
332
+ ) as HTMLSelectElement;
333
+ const time = win.document.querySelector('time') as HTMLTimeElement;
334
+
335
+ sel.value = 'America/Chicago';
336
+ sel.dispatchEvent(new win.Event('change'));
337
+
338
+ expect(time.title).toContain('ET');
339
+ });
340
+
341
+ test('time element title is cleared when returning to default', () => {
342
+ const win = createPage(['14:00']);
343
+ mount(win);
344
+
345
+ const sel = win.document.querySelector(
346
+ 'select.time-zone',
347
+ ) as HTMLSelectElement;
348
+ const time = win.document.querySelector('time') as HTMLTimeElement;
349
+
350
+ sel.value = 'America/Chicago';
351
+ sel.dispatchEvent(new win.Event('change'));
352
+ expect(time.title).not.toBe('');
353
+
354
+ sel.value = 'America/New_York';
355
+ sel.dispatchEvent(new win.Event('change'));
356
+ expect(time.title).toBe('');
357
+ });
358
+
359
+ test('ignores stored timezone not in the timezone list', () => {
360
+ const win = createPage(['09:00']);
361
+ try {
362
+ win.localStorage.setItem('timezoneSelection', 'Mars/Olympus_Mons');
363
+ } catch {
364
+ // ignored
365
+ }
366
+ mount(win);
367
+
368
+ const sel = win.document.querySelector(
369
+ 'select.time-zone',
370
+ ) as HTMLSelectElement;
371
+ expect(sel.value).toBe('America/New_York');
372
+ });
373
+
374
+ test('returns a cleanup function', () => {
375
+ const win = createPage(['09:00']);
376
+ const cleanup = mount(win);
377
+ expect(typeof cleanup).toBe('function');
378
+ });
379
+
380
+ test('multiple select elements stay in sync', () => {
381
+ const options = TIMEZONES.map(
382
+ tz => `<option value="${tz.value}">${tz.label}</option>`,
383
+ ).join('');
384
+ const html =
385
+ `<select class="time-zone">${options}</select>` +
386
+ `<select class="time-zone">${options}</select>` +
387
+ '<time datetime="12:00">12:00 p.m.</time>';
388
+ const win = dom(html).window;
389
+ mount(win);
390
+
391
+ const selects = win.document.querySelectorAll(
392
+ 'select.time-zone',
393
+ ) as NodeListOf<HTMLSelectElement>;
394
+
395
+ selects[0].value = 'America/Chicago';
396
+ selects[0].dispatchEvent(new win.Event('change'));
397
+
398
+ expect(selects[1].value).toBe('America/Chicago');
399
+ });
400
+ });