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