@kgdiff/viewer 0.4.0 → 0.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 (84) hide show
  1. package/dist/context.d.ts +42 -0
  2. package/dist/context.d.ts.map +1 -0
  3. package/dist/context.js +87 -0
  4. package/dist/context.js.map +1 -0
  5. package/dist/controller.d.ts +26 -185
  6. package/dist/controller.d.ts.map +1 -1
  7. package/dist/controller.js +45 -1155
  8. package/dist/controller.js.map +1 -1
  9. package/dist/element.d.ts +6 -3
  10. package/dist/element.d.ts.map +1 -1
  11. package/dist/element.js +49 -229
  12. package/dist/element.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/pipeline.d.ts.map +1 -1
  17. package/dist/pipeline.js +3 -2
  18. package/dist/pipeline.js.map +1 -1
  19. package/dist/root.d.ts +54 -0
  20. package/dist/root.d.ts.map +1 -0
  21. package/dist/root.js +265 -0
  22. package/dist/root.js.map +1 -0
  23. package/dist/schedule.d.ts +22 -0
  24. package/dist/schedule.d.ts.map +1 -0
  25. package/dist/schedule.js +41 -0
  26. package/dist/schedule.js.map +1 -0
  27. package/dist/session.d.ts +253 -0
  28. package/dist/session.d.ts.map +1 -0
  29. package/dist/session.js +1239 -0
  30. package/dist/session.js.map +1 -0
  31. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  32. package/dist/standalone/kgdiff-viewer.js +4 -4
  33. package/dist/styles.d.ts +13 -6
  34. package/dist/styles.d.ts.map +1 -1
  35. package/dist/styles.js +217 -248
  36. package/dist/styles.js.map +1 -1
  37. package/dist/theme.d.ts +25 -0
  38. package/dist/theme.d.ts.map +1 -0
  39. package/dist/theme.js +149 -0
  40. package/dist/theme.js.map +1 -0
  41. package/dist/ui/change-list.d.ts +18 -0
  42. package/dist/ui/change-list.d.ts.map +1 -0
  43. package/dist/ui/change-list.js +331 -0
  44. package/dist/ui/change-list.js.map +1 -0
  45. package/dist/ui/context.d.ts +19 -5
  46. package/dist/ui/context.d.ts.map +1 -1
  47. package/dist/ui/context.js.map +1 -1
  48. package/dist/ui/filters.d.ts +9 -0
  49. package/dist/ui/filters.d.ts.map +1 -0
  50. package/dist/ui/filters.js +68 -0
  51. package/dist/ui/filters.js.map +1 -0
  52. package/dist/ui/header.js +2 -2
  53. package/dist/ui/header.js.map +1 -1
  54. package/dist/ui/search.d.ts +13 -0
  55. package/dist/ui/search.d.ts.map +1 -0
  56. package/dist/ui/search.js +191 -0
  57. package/dist/ui/search.js.map +1 -0
  58. package/dist/ui/sidebar.d.ts +8 -16
  59. package/dist/ui/sidebar.d.ts.map +1 -1
  60. package/dist/ui/sidebar.js +15 -539
  61. package/dist/ui/sidebar.js.map +1 -1
  62. package/dist/ui/stage.js +1 -1
  63. package/dist/ui/stage.js.map +1 -1
  64. package/dist/ui/swatch.js +1 -1
  65. package/dist/ui/swatch.js.map +1 -1
  66. package/package.json +6 -6
  67. package/src/context.ts +106 -0
  68. package/src/controller.ts +70 -1257
  69. package/src/element.ts +52 -243
  70. package/src/index.ts +3 -1
  71. package/src/pipeline.ts +4 -1
  72. package/src/root.ts +308 -0
  73. package/src/schedule.ts +43 -0
  74. package/src/session.ts +1386 -0
  75. package/src/styles.ts +225 -253
  76. package/src/theme.ts +167 -0
  77. package/src/ui/change-list.ts +401 -0
  78. package/src/ui/context.ts +21 -5
  79. package/src/ui/filters.ts +79 -0
  80. package/src/ui/header.ts +2 -2
  81. package/src/ui/search.ts +220 -0
  82. package/src/ui/sidebar.ts +28 -668
  83. package/src/ui/stage.ts +1 -1
  84. package/src/ui/swatch.ts +1 -1
package/src/ui/sidebar.ts CHANGED
@@ -1,142 +1,32 @@
1
1
  /**
2
- * Sidebar: "Changes" header with review progress, node search, family filter chips, the
3
- * phase-grouped change list (virtualized, sticky group headers, `listbox` with
4
- * `aria-activedescendant`) and the detail panel. Below 760 px it is a resizable bottom sheet.
2
+ * The default layout's sidebar (RFC 0001 §4): the change list with the search and the family
3
+ * filters in its head, the search results in the list's place, and the detail panel below.
4
+ * Below 760 px it is a resizable bottom sheet. The regions in it are bound to the root on their
5
+ * own; the sidebar only places them, opens and closes, and resizes as a sheet.
5
6
  */
6
- import type { SceneHunk } from '@kgdiff/scene';
7
- import type { Topic } from '../controller.js';
8
- import { attr, h, icon, replace, setText } from '../dom.js';
9
- import { familyCounts, type ListItem } from '../list-model.js';
10
- import type { SearchHit } from '../search.js';
11
- import { FAMILIES } from '../styles.js';
12
- import { sceneHunkGlyph, type UiContext } from './context.js';
13
- import { Detail } from './detail.js';
14
- import { swatch } from './swatch.js';
7
+ import { attr, h } from '../dom.js';
8
+ import type { Topic } from '../session.js';
9
+ import type { ChangeList } from './change-list.js';
10
+ import type { UiActions } from './context.js';
11
+ import type { Detail } from './detail.js';
12
+ import type { Filters } from './filters.js';
13
+ import type { Search } from './search.js';
15
14
 
16
- /** Row slot and group-header heights (px); rows are 52 px tall inside a 56 px slot. */
17
- export const ROW_SLOT = 56;
18
- export const HEADER_SLOT = 32;
19
- const OVERSCAN = 320;
20
- const SEARCH_LIMIT = 50;
21
-
22
- function itemHeight(item: ListItem): number {
23
- return item.kind === 'header' ? HEADER_SLOT : ROW_SLOT;
24
- }
15
+ export { HEADER_SLOT, ROW_SLOT } from './change-list.js';
25
16
 
26
17
  export class Sidebar {
27
18
  readonly el: HTMLElement;
28
- readonly detail: Detail;
29
- readonly #title: HTMLElement;
30
- readonly #count: HTMLElement;
31
- readonly #progress: HTMLElement;
32
- readonly #progressText: HTMLElement;
33
- readonly #progressBar: HTMLElement;
34
- readonly searchInput: HTMLInputElement;
35
- readonly #searchKbd: HTMLElement;
36
- readonly #searchClear: HTMLButtonElement;
37
- readonly #filters: HTMLElement;
38
- readonly #chips = new Map<string, HTMLButtonElement>();
39
- readonly #showAll: HTMLButtonElement;
40
- readonly list: HTMLElement;
41
- readonly #listInner: HTMLElement;
42
- /** Visually hidden group names the options are described by. */
43
- readonly #phases: HTMLElement = h('div', { class: 'sr-only' });
44
- readonly #sticky: HTMLElement;
45
- readonly #results: HTMLElement;
46
19
  readonly #handle: HTMLElement;
47
- #offsets: number[] = [];
48
- #rendered = new Map<number, HTMLElement>();
49
- #scrollQueued = false;
50
- #query = '';
51
- #hits: SearchHit[] = [];
52
- #activeHit = 0;
53
-
54
- readonly #ctx: UiContext;
55
-
56
- constructor(ctx: UiContext) {
57
- this.#ctx = ctx;
58
- const c = ctx.controller;
59
- this.#title = h('h2', { id: 'kg-changes-title' });
60
- this.#count = h('span', { class: 'count-badge' });
61
- this.#progressText = h('span');
62
- this.#progressBar = h('div');
63
- this.#progress = h(
64
- 'div',
65
- { class: 'progress' },
66
- this.#progressText,
67
- h('span', { class: 'bar', 'aria-hidden': 'true' }, this.#progressBar),
68
- );
69
- this.searchInput = h('input', {
70
- type: 'search',
71
- autocomplete: 'off',
72
- spellcheck: 'false',
73
- role: 'combobox',
74
- 'aria-autocomplete': 'list',
75
- 'aria-expanded': 'false',
76
- 'aria-controls': 'kg-results',
77
- enterkeyhint: 'go',
78
- });
79
- this.#searchKbd = h('kbd', { 'aria-hidden': 'true', text: '/' });
80
- this.#searchClear = h(
81
- 'button',
82
- { type: 'button', class: 'clear', hidden: true, onclick: () => this.#setQuery('', true) },
83
- icon('close'),
84
- );
85
- this.searchInput.addEventListener('input', () => this.#setQuery(this.searchInput.value));
86
- this.searchInput.addEventListener('keydown', (e) => this.#onSearchKey(e));
87
- this.searchInput.addEventListener('focus', () => this.#updateSearchChrome());
88
- this.searchInput.addEventListener('blur', () => this.#updateSearchChrome());
89
- this.#filters = h('div', { class: 'filters', role: 'group' });
90
- for (const f of FAMILIES) {
91
- const chip = h(
92
- 'button',
93
- {
94
- type: 'button',
95
- class: 'fchip',
96
- 'data-family': f,
97
- 'aria-pressed': 'false',
98
- onclick: () => c.toggleFamily(f),
99
- },
100
- swatch(f),
101
- h('span', { class: 'lbl' }),
102
- h('span', { class: 'n' }),
103
- );
104
- this.#chips.set(f, chip);
105
- this.#filters.appendChild(chip);
106
- }
107
- this.#showAll = h('button', {
108
- type: 'button',
109
- class: 'link-btn',
110
- hidden: true,
111
- onclick: () => c.setFamilies([]),
112
- });
113
- this.#filters.appendChild(this.#showAll);
114
-
115
- this.#listInner = h('div', { class: 'list-inner', role: 'presentation' });
116
- this.list = h(
117
- 'div',
118
- {
119
- class: 'list scroll',
120
- role: 'listbox',
121
- tabindex: '0',
122
- 'aria-labelledby': 'kg-changes-title',
123
- 'aria-keyshortcuts': 'J K X Enter Home End',
124
- },
125
- this.#listInner,
126
- );
127
- this.list.addEventListener('scroll', () => this.#queueWindow(), { passive: true });
128
- this.list.addEventListener('click', (e) => this.#onListClick(e));
129
- this.list.addEventListener('dblclick', (e) => {
130
- const row = (e.target as Element).closest('.row');
131
- if (row) c.flyToSelection();
132
- });
133
- this.#sticky = h('div', { class: 'sticky', 'aria-hidden': 'true', hidden: true });
134
- this.#results = h('div', {
135
- class: 'results list scroll',
136
- id: 'kg-results',
137
- role: 'listbox',
138
- hidden: true,
139
- });
20
+ readonly #tr: UiActions['tr'];
21
+
22
+ constructor(
23
+ tr: UiActions['tr'],
24
+ list: ChangeList,
25
+ search: Search,
26
+ filters: Filters,
27
+ detail: Detail,
28
+ ) {
29
+ this.#tr = tr;
140
30
  this.#handle = h('div', {
141
31
  class: 'sheet-handle',
142
32
  role: 'separator',
@@ -144,21 +34,6 @@ export class Sidebar {
144
34
  tabindex: '0',
145
35
  });
146
36
  this.#bindSheet();
147
- this.detail = new Detail(ctx);
148
- const head = h(
149
- 'div',
150
- { class: 'sb-head' },
151
- h('div', { class: 'sb-title' }, this.#title, this.#count, this.#progress),
152
- h(
153
- 'div',
154
- { class: 'search' },
155
- icon('search'),
156
- this.searchInput,
157
- this.#searchKbd,
158
- this.#searchClear,
159
- ),
160
- this.#filters,
161
- );
162
37
  this.el = h(
163
38
  'aside',
164
39
  { class: 'sidebar', 'data-open': 'true' },
@@ -166,9 +41,9 @@ export class Sidebar {
166
41
  'div',
167
42
  { class: 'sidebar-inner' },
168
43
  this.#handle,
169
- head,
170
- h('div', { class: 'list-wrap' }, this.#sticky, this.list, this.#results, this.#phases),
171
- this.detail.el,
44
+ h('div', { class: 'sb-head' }, list.head, search.box, filters.el),
45
+ h('div', { class: 'list-wrap' }, list.sticky, list.list, search.results, list.phases),
46
+ detail.el,
172
47
  ),
173
48
  );
174
49
  }
@@ -179,509 +54,10 @@ export class Sidebar {
179
54
  }
180
55
 
181
56
  update(topics: ReadonlySet<Topic>): void {
182
- const tr = this.#ctx.tr();
183
- const all = topics.has('status') || topics.has('data') || topics.has('locale');
184
- if (all) {
57
+ if (topics.has('status') || topics.has('data') || topics.has('locale')) {
58
+ const tr = this.#tr();
185
59
  attr(this.el, 'aria-label', tr.t('sidebar.label'));
186
- setText(this.#title, tr.t('sidebar.changes'));
187
- this.searchInput.placeholder = tr.t('sidebar.searchPlaceholder');
188
- attr(this.searchInput, 'aria-label', tr.t('sidebar.search'));
189
- attr(this.#searchClear, 'aria-label', tr.t('sidebar.searchClear'));
190
- attr(this.#filters, 'aria-label', tr.t('sidebar.filters'));
191
60
  attr(this.#handle, 'aria-label', tr.t('sidebar.sheet'));
192
- setText(this.#showAll, tr.t('sidebar.clearFilters'));
193
- this.searchInput.disabled = this.#ctx.controller.status !== 'ready';
194
- this.#renderChips();
195
- if (this.#query) this.#runSearch();
196
- }
197
- if (all || topics.has('filters')) {
198
- this.#renderChipState();
199
- this.#layoutList();
200
- }
201
- if (all || topics.has('review')) {
202
- this.#renderProgress();
203
- this.#refreshRows();
204
- }
205
- if (topics.has('selection')) {
206
- this.#refreshRows();
207
- this.#revealSelected();
208
- }
209
- this.detail.update(topics);
210
- }
211
-
212
- /** Move keyboard focus into the list (after choosing a search result). */
213
- focusList(): void {
214
- this.list.focus({ preventScroll: true });
215
- }
216
-
217
- focusSearch(): void {
218
- this.searchInput.focus();
219
- this.searchInput.select();
220
- }
221
-
222
- /** Esc inside the sidebar: clear the search first. Returns whether it handled the key. */
223
- escape(): boolean {
224
- if (this.#query) {
225
- this.#setQuery('', true);
226
- return true;
227
- }
228
- return false;
229
- }
230
-
231
- // ─── Header bits ─────────────────────────────────────────────────────────
232
-
233
- #renderChips(): void {
234
- const tr = this.#ctx.tr();
235
- const scene = this.#ctx.controller.scene;
236
- const counts = scene ? familyCounts(scene) : null;
237
- setText(this.#count, tr.num(scene?.hunks.length ?? 0));
238
- this.#count.hidden = !scene;
239
- this.#filters.hidden = !scene || scene.hunks.length === 0;
240
- for (const [f, chip] of this.#chips) {
241
- const n = counts ? counts[f as keyof typeof counts] : 0;
242
- setText(chip.querySelector('.lbl') as Element, tr.t(`family.${f as keyof typeof counts}`));
243
- setText(chip.querySelector('.n') as Element, tr.num(n));
244
- chip.hidden = n === 0;
245
- attr(chip, 'title', tr.t(`familyHint.${f as keyof typeof counts}`));
246
- }
247
- }
248
-
249
- #renderChipState(): void {
250
- const fams = this.#ctx.controller.families;
251
- for (const [f, chip] of this.#chips) {
252
- attr(chip, 'aria-pressed', String(fams.has(f as never)));
253
- }
254
- this.#showAll.hidden = fams.size === 0;
255
- }
256
-
257
- #renderProgress(): void {
258
- const tr = this.#ctx.tr();
259
- const state = this.#ctx.controller.reviewState();
260
- const done = state.reviewed.length;
261
- const total = state.total;
262
- this.#progress.hidden = total === 0;
263
- const complete = total > 0 && done === total;
264
- attr(this.#progress, 'data-complete', String(complete));
265
- setText(
266
- this.#progressText,
267
- complete ? tr.t('review.complete') : tr.t('review.progress', { done, total }),
268
- );
269
- this.#progressBar.style.transform = `scaleX(${total > 0 ? done / total : 0})`;
270
- }
271
-
272
- // ─── Virtual list ────────────────────────────────────────────────────────
273
-
274
- #layoutList(): void {
275
- const c = this.#ctx.controller;
276
- const tr = this.#ctx.tr();
277
- replace(
278
- this.#phases,
279
- c.list.items.map((it) =>
280
- it.kind === 'header'
281
- ? h('span', {
282
- id: `kg-phase-${it.phase}`,
283
- text: `${tr.t(`phase.${it.phase}`)} (${tr.num(it.count)})`,
284
- })
285
- : null,
286
- ),
287
- );
288
- for (const el of this.#rendered.values()) el.remove();
289
- this.#rendered.clear();
290
- const items = c.list.items;
291
- this.#offsets = new Array(items.length);
292
- let y = 0;
293
- for (let i = 0; i < items.length; i++) {
294
- this.#offsets[i] = y;
295
- y += itemHeight(items[i] as ListItem);
296
- }
297
- this.#listInner.style.height = `${y + 8}px`;
298
- this.#renderEmpty();
299
- this.renderWindow();
300
- }
301
-
302
- #renderEmpty(): void {
303
- const c = this.#ctx.controller;
304
- const tr = this.#ctx.tr();
305
- const existing = this.#listInner.querySelector('.list-empty, .skeleton');
306
- existing?.remove();
307
- if (c.status === 'loading' || c.status === 'idle') {
308
- const sk = h('div', { class: 'skeleton', 'aria-hidden': 'true' });
309
- if (c.status === 'loading') {
310
- for (let i = 0; i < 6; i++) {
311
- sk.appendChild(
312
- h(
313
- 'div',
314
- { class: 'skeleton-row shimmer' },
315
- h('span', { class: 'g' }),
316
- h('span', { class: 'b' }),
317
- ),
318
- );
319
- }
320
- }
321
- this.#listInner.appendChild(sk);
322
- return;
323
- }
324
- if (c.status !== 'ready' || c.list.items.length > 0) return;
325
- const filtered = c.families.size > 0 && c.hunkCount > 0;
326
- this.#listInner.appendChild(
327
- h(
328
- 'div',
329
- { class: 'list-empty' },
330
- icon(filtered ? 'search' : 'check-circle'),
331
- h('span', { text: tr.t(filtered ? 'sidebar.filteredEmpty' : 'sidebar.noChanges') }),
332
- filtered
333
- ? h('button', {
334
- type: 'button',
335
- class: 'btn',
336
- text: tr.t('sidebar.clearFilters'),
337
- onclick: () => c.setFamilies([]),
338
- })
339
- : null,
340
- ),
341
- );
342
- }
343
-
344
- #queueWindow(): void {
345
- if (this.#scrollQueued) return;
346
- this.#scrollQueued = true;
347
- requestAnimationFrame(() => {
348
- this.#scrollQueued = false;
349
- this.renderWindow();
350
- });
351
- }
352
-
353
- /** First item index whose bottom is below `y` (binary search over offsets). */
354
- #indexAt(y: number): number {
355
- let lo = 0;
356
- let hi = this.#offsets.length;
357
- while (lo < hi) {
358
- const mid = (lo + hi) >> 1;
359
- if ((this.#offsets[mid] as number) + ROW_SLOT <= y) lo = mid + 1;
360
- else hi = mid;
361
- }
362
- return lo;
363
- }
364
-
365
- renderWindow(): void {
366
- const items = this.#ctx.controller.list.items;
367
- const top = this.list.scrollTop;
368
- const height = this.list.clientHeight || 640;
369
- const from = this.#indexAt(Math.max(0, top - OVERSCAN));
370
- const limit = top + height + OVERSCAN;
371
- const needed = new Set<number>();
372
- for (let i = from; i < items.length && (this.#offsets[i] as number) < limit; i++) needed.add(i);
373
- for (const [i, el] of this.#rendered) {
374
- if (!needed.has(i)) {
375
- el.remove();
376
- this.#rendered.delete(i);
377
- }
378
- }
379
- for (const i of needed) {
380
- if (this.#rendered.has(i)) continue;
381
- const el = this.#renderItem(items[i] as ListItem, this.#offsets[i] as number);
382
- this.#rendered.set(i, el);
383
- this.#listInner.appendChild(el);
384
- }
385
- this.#renderSticky(top);
386
- }
387
-
388
- #renderItem(item: ListItem, y: number): HTMLElement {
389
- const tr = this.#ctx.tr();
390
- const c = this.#ctx.controller;
391
- if (item.kind === 'header') {
392
- return h(
393
- 'div',
394
- { class: 'group', role: 'presentation', style: `top: ${y}px` },
395
- h('span', { text: tr.t(`phase.${item.phase}`) }),
396
- h('span', { class: 'n', text: tr.num(item.count) }),
397
- );
398
- }
399
- const hunk = c.scene?.hunks[item.hunk] as SceneHunk;
400
- const reviewed = c.isReviewed(hunk.id);
401
- // Pointer shortcut only: options hold no interactive children. Keyboard users press `x` or
402
- // use the detail panel's "Reviewed" button.
403
- const rv = h(
404
- 'span',
405
- {
406
- class: 'rv',
407
- 'aria-hidden': 'true',
408
- 'data-pressed': String(reviewed),
409
- title: tr.t(reviewed ? 'review.unmark' : 'review.mark'),
410
- 'data-hunk': hunk.id,
411
- },
412
- h('span', { class: 'ring' }, icon('check')),
413
- );
414
- const rank = c.list.rank.get(item.hunk) ?? 0;
415
- const row = h(
416
- 'div',
417
- {
418
- class: 'row',
419
- role: 'option',
420
- id: `kg-opt-${item.hunk}`,
421
- 'data-index': String(item.hunk),
422
- 'data-family': hunk.family,
423
- 'data-reviewed': String(reviewed),
424
- 'aria-selected': String(c.selectedHunk === item.hunk),
425
- 'aria-setsize': String(c.list.order.length),
426
- 'aria-posinset': String(rank + 1),
427
- 'aria-describedby': `kg-phase-${hunk.phase}`,
428
- style: `top: ${y + 2}px`,
429
- },
430
- h('span', { class: 'glyph-box', 'aria-hidden': 'true', text: sceneHunkGlyph(hunk) }),
431
- h(
432
- 'span',
433
- { class: 'txt' },
434
- h('span', { class: 'title', title: hunk.title, text: hunk.title }),
435
- h('span', { class: 'sub', text: hunk.subtitle }),
436
- ),
437
- rv,
438
- );
439
- this.#labelRow(row, hunk, reviewed);
440
- return row;
441
- }
442
-
443
- #labelRow(row: HTMLElement, hunk: SceneHunk, reviewed: boolean): void {
444
- const tr = this.#ctx.tr();
445
- const parts = [hunk.title];
446
- if (hunk.subtitle) parts.push(hunk.subtitle);
447
- if (reviewed) parts.push(tr.t('review.toggle'));
448
- attr(row, 'aria-label', parts.join('. '));
449
- }
450
-
451
- /** Update selection / reviewed state of the rendered rows in place. */
452
- #refreshRows(): void {
453
- const c = this.#ctx.controller;
454
- const tr = this.#ctx.tr();
455
- for (const el of this.#rendered.values()) {
456
- if (!el.classList.contains('row')) continue;
457
- const index = Number(el.dataset.index);
458
- const hunk = c.scene?.hunks[index];
459
- if (!hunk) continue;
460
- const reviewed = c.isReviewed(hunk.id);
461
- attr(el, 'aria-selected', String(index === c.selectedHunk));
462
- attr(el, 'data-reviewed', String(reviewed));
463
- const rv = el.querySelector('.rv') as HTMLElement;
464
- attr(rv, 'data-pressed', String(reviewed));
465
- attr(rv, 'title', tr.t(reviewed ? 'review.unmark' : 'review.mark'));
466
- this.#labelRow(el, hunk, reviewed);
467
- }
468
- const sel = c.selectedHunk;
469
- attr(
470
- this.list,
471
- 'aria-activedescendant',
472
- sel >= 0 && c.list.rank.has(sel) ? `kg-opt-${sel}` : null,
473
- );
474
- }
475
-
476
- #revealSelected(): void {
477
- const c = this.#ctx.controller;
478
- const item = c.list.itemOf.get(c.selectedHunk);
479
- if (item === undefined) return;
480
- const y = this.#offsets[item] as number;
481
- const top = this.list.scrollTop;
482
- // Not laid out yet (hidden sheet): position it for when it shows.
483
- const height = this.list.clientHeight || 640;
484
- // The first row of a group scrolls its group header into view too.
485
- const above =
486
- c.list.items[item - 1]?.kind === 'header'
487
- ? (this.#offsets[item - 1] as number)
488
- : y - HEADER_SLOT;
489
- if (y < top + HEADER_SLOT) this.list.scrollTop = Math.max(0, above);
490
- else if (y + ROW_SLOT > top + height) this.list.scrollTop = y + ROW_SLOT - height + 8;
491
- this.renderWindow();
492
- this.#refreshRows();
493
- }
494
-
495
- #renderSticky(top: number): void {
496
- const items = this.#ctx.controller.list.items;
497
- let current = -1;
498
- let next = -1;
499
- for (let i = 0; i < items.length; i++) {
500
- if (items[i]?.kind !== 'header') continue;
501
- if ((this.#offsets[i] as number) <= top) current = i;
502
- else {
503
- next = i;
504
- break;
505
- }
506
- }
507
- if (current < 0 || top <= 0) {
508
- this.#sticky.hidden = true;
509
- return;
510
- }
511
- const item = items[current] as Extract<ListItem, { kind: 'header' }>;
512
- const tr = this.#ctx.tr();
513
- this.#sticky.hidden = false;
514
- const label = `${tr.t(`phase.${item.phase}`)}|${item.count}`;
515
- if (this.#sticky.dataset.key !== label) {
516
- this.#sticky.dataset.key = label;
517
- replace(
518
- this.#sticky,
519
- h(
520
- 'div',
521
- { class: 'group' },
522
- h('span', { text: tr.t(`phase.${item.phase}`) }),
523
- h('span', { class: 'n', text: tr.num(item.count) }),
524
- ),
525
- );
526
- }
527
- const push = next >= 0 ? Math.min(0, (this.#offsets[next] as number) - top - HEADER_SLOT) : 0;
528
- this.#sticky.style.transform = `translateY(${push}px)`;
529
- }
530
-
531
- #onListClick(e: MouseEvent): void {
532
- const c = this.#ctx.controller;
533
- const target = e.target as Element;
534
- const rv = target.closest('.rv') as HTMLElement | null;
535
- if (rv?.dataset.hunk) {
536
- e.stopPropagation();
537
- c.toggleReviewed(rv.dataset.hunk);
538
- return;
539
- }
540
- const row = target.closest('.row') as HTMLElement | null;
541
- if (!row) return;
542
- const index = Number(row.dataset.index);
543
- if (Number.isInteger(index)) c.selectHunk(index);
544
- }
545
-
546
- // ─── Search ──────────────────────────────────────────────────────────────
547
-
548
- #setQuery(query: string, focus = false): void {
549
- this.#query = query;
550
- if (this.searchInput.value !== query) this.searchInput.value = query;
551
- this.#activeHit = 0;
552
- this.#runSearch();
553
- if (focus) this.searchInput.focus();
554
- }
555
-
556
- #runSearch(): void {
557
- const q = this.#query.trim();
558
- this.#hits = q ? this.#ctx.controller.search(q, SEARCH_LIMIT) : [];
559
- const searching = q !== '';
560
- this.#results.hidden = !searching;
561
- this.list.hidden = searching;
562
- this.#sticky.hidden = searching || this.#sticky.hidden;
563
- attr(this.searchInput, 'aria-expanded', String(searching));
564
- this.#updateSearchChrome();
565
- if (!searching) {
566
- replace(this.#results);
567
- this.renderWindow();
568
- return;
569
- }
570
- this.#renderResults();
571
- }
572
-
573
- #updateSearchChrome(): void {
574
- const focused = this.#ctx.root.activeElement === this.searchInput;
575
- this.#searchKbd.hidden = this.#query !== '' || focused;
576
- this.#searchClear.hidden = this.#query === '';
577
- }
578
-
579
- #renderResults(): void {
580
- const tr = this.#ctx.tr();
581
- const c = this.#ctx.controller;
582
- const q = this.#query.trim();
583
- if (this.#hits.length === 0) {
584
- replace(
585
- this.#results,
586
- h(
587
- 'div',
588
- { class: 'list-empty' },
589
- icon('search'),
590
- h('span', { text: tr.t('sidebar.noResults', { q }) }),
591
- ),
592
- );
593
- attr(this.searchInput, 'aria-activedescendant', null);
594
- return;
595
- }
596
- const scene = c.scene;
597
- const rows = this.#hits.map((hit, i) => {
598
- const sceneIndex = scene?.nodeIndex.get(hit.node.id);
599
- const sn = sceneIndex !== undefined ? scene?.nodes[sceneIndex] : undefined;
600
- const family =
601
- sn?.family ??
602
- (hit.node.status === 'added'
603
- ? 'added'
604
- : hit.node.status === 'removed'
605
- ? 'removed'
606
- : hit.node.status === 'modified'
607
- ? 'content'
608
- : 'neutral');
609
- const sub =
610
- hit.field === 'label' ? hit.node.id : hit.field === 'alias' ? hit.matched : hit.matched;
611
- return h(
612
- 'button',
613
- {
614
- type: 'button',
615
- class: 'result',
616
- role: 'option',
617
- id: `kg-hit-${i}`,
618
- tabindex: '-1',
619
- 'data-family': family,
620
- 'aria-selected': String(i === this.#activeHit),
621
- onclick: () => this.#choose(i),
622
- onpointermove: () => this.#setActiveHit(i),
623
- },
624
- h('span', { class: 'sdot', 'aria-hidden': 'true' }),
625
- h(
626
- 'span',
627
- { class: 'txt' },
628
- h('span', { class: 'title' }, highlight(hit.node.label, hit.field === 'label' ? q : '')),
629
- h('span', { class: 'sub' }, highlight(sub, hit.field === 'label' ? '' : q)),
630
- ),
631
- h('span', { class: 'status', text: tr.t(`status.${hit.node.status}`) }),
632
- );
633
- });
634
- replace(
635
- this.#results,
636
- h('div', {
637
- class: 'results-head',
638
- text: tr.plural('sidebar.results', this.#hits.length),
639
- }),
640
- rows,
641
- );
642
- attr(this.searchInput, 'aria-activedescendant', `kg-hit-${this.#activeHit}`);
643
- }
644
-
645
- #setActiveHit(i: number): void {
646
- if (i === this.#activeHit || i < 0 || i >= this.#hits.length) return;
647
- this.#activeHit = i;
648
- for (const el of this.#results.querySelectorAll('.result')) {
649
- attr(el, 'aria-selected', String(el.id === `kg-hit-${i}`));
650
- }
651
- attr(this.searchInput, 'aria-activedescendant', `kg-hit-${i}`);
652
- this.#results.querySelector(`#kg-hit-${i}`)?.scrollIntoView?.({ block: 'nearest' });
653
- }
654
-
655
- #choose(i: number): void {
656
- const hit = this.#hits[i];
657
- if (!hit) return;
658
- this.#setQuery('');
659
- this.#ctx.controller.revealNode(hit.node.id);
660
- this.focusList();
661
- }
662
-
663
- #onSearchKey(e: KeyboardEvent): void {
664
- switch (e.key) {
665
- case 'ArrowDown':
666
- e.preventDefault();
667
- this.#setActiveHit(Math.min(this.#hits.length - 1, this.#activeHit + 1));
668
- break;
669
- case 'ArrowUp':
670
- e.preventDefault();
671
- this.#setActiveHit(Math.max(0, this.#activeHit - 1));
672
- break;
673
- case 'Enter':
674
- e.preventDefault();
675
- this.#choose(this.#activeHit);
676
- break;
677
- case 'Escape':
678
- e.preventDefault();
679
- e.stopPropagation();
680
- if (this.#query) this.#setQuery('');
681
- else this.focusList();
682
- break;
683
- default:
684
- return;
685
61
  }
686
62
  }
687
63
 
@@ -693,11 +69,7 @@ export class Sidebar {
693
69
  attr(this.#handle, 'aria-valuemin', String(pct(120)));
694
70
  attr(this.#handle, 'aria-valuemax', String(pct(total - 96)));
695
71
  attr(this.#handle, 'aria-valuenow', String(pct(px)));
696
- attr(
697
- this.#handle,
698
- 'aria-valuetext',
699
- this.#ctx.tr().t('sidebar.sheetValue', { p: `${pct(px)}%` }),
700
- );
72
+ attr(this.#handle, 'aria-valuetext', this.#tr().t('sidebar.sheetValue', { p: `${pct(px)}%` }));
701
73
  }
702
74
 
703
75
  /** Refresh the separator values (layout changes). */
@@ -739,15 +111,3 @@ export class Sidebar {
739
111
  });
740
112
  }
741
113
  }
742
-
743
- /** The label with the first occurrence of `q` (case-insensitive) marked. */
744
- function highlight(text: string, q: string): (string | HTMLElement)[] {
745
- if (!q) return [text];
746
- const at = text.toLowerCase().indexOf(q.toLowerCase());
747
- if (at < 0) return [text];
748
- return [
749
- text.slice(0, at),
750
- h('mark', { text: text.slice(at, at + q.length) }),
751
- text.slice(at + q.length),
752
- ];
753
- }