@kgdiff/viewer 0.4.0 → 0.6.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 (257) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/context.d.ts +42 -0
  8. package/dist/context.d.ts.map +1 -0
  9. package/dist/context.js +87 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/controller.d.ts +26 -237
  12. package/dist/controller.d.ts.map +1 -1
  13. package/dist/controller.js +33 -1484
  14. package/dist/controller.js.map +1 -1
  15. package/dist/dom.d.ts +10 -0
  16. package/dist/dom.d.ts.map +1 -1
  17. package/dist/dom.js +29 -0
  18. package/dist/dom.js.map +1 -1
  19. package/dist/element.d.ts +51 -42
  20. package/dist/element.d.ts.map +1 -1
  21. package/dist/element.js +381 -602
  22. package/dist/element.js.map +1 -1
  23. package/dist/elements/actions.d.ts +27 -0
  24. package/dist/elements/actions.d.ts.map +1 -0
  25. package/dist/elements/actions.js +81 -0
  26. package/dist/elements/actions.js.map +1 -0
  27. package/dist/elements/bound.d.ts +63 -0
  28. package/dist/elements/bound.d.ts.map +1 -0
  29. package/dist/elements/bound.js +213 -0
  30. package/dist/elements/bound.js.map +1 -0
  31. package/dist/elements/change-detail.d.ts +25 -0
  32. package/dist/elements/change-detail.d.ts.map +1 -0
  33. package/dist/elements/change-detail.js +19 -0
  34. package/dist/elements/change-detail.js.map +1 -0
  35. package/dist/elements/change-list.d.ts +27 -0
  36. package/dist/elements/change-list.d.ts.map +1 -0
  37. package/dist/elements/change-list.js +29 -0
  38. package/dist/elements/change-list.js.map +1 -0
  39. package/dist/elements/filters.d.ts +25 -0
  40. package/dist/elements/filters.d.ts.map +1 -0
  41. package/dist/elements/filters.js +19 -0
  42. package/dist/elements/filters.js.map +1 -0
  43. package/dist/elements/legend.d.ts +40 -0
  44. package/dist/elements/legend.d.ts.map +1 -0
  45. package/dist/elements/legend.js +87 -0
  46. package/dist/elements/legend.js.map +1 -0
  47. package/dist/elements/minimap.d.ts +38 -0
  48. package/dist/elements/minimap.d.ts.map +1 -0
  49. package/dist/elements/minimap.js +59 -0
  50. package/dist/elements/minimap.js.map +1 -0
  51. package/dist/elements/root.d.ts +40 -0
  52. package/dist/elements/root.d.ts.map +1 -0
  53. package/dist/elements/root.js +67 -0
  54. package/dist/elements/root.js.map +1 -0
  55. package/dist/elements/search.d.ts +29 -0
  56. package/dist/elements/search.d.ts.map +1 -0
  57. package/dist/elements/search.js +23 -0
  58. package/dist/elements/search.js.map +1 -0
  59. package/dist/elements/stage-context.d.ts +31 -0
  60. package/dist/elements/stage-context.d.ts.map +1 -0
  61. package/dist/elements/stage-context.js +4 -0
  62. package/dist/elements/stage-context.js.map +1 -0
  63. package/dist/elements/stage.d.ts +65 -0
  64. package/dist/elements/stage.d.ts.map +1 -0
  65. package/dist/elements/stage.js +259 -0
  66. package/dist/elements/stage.js.map +1 -0
  67. package/dist/elements/title.d.ts +25 -0
  68. package/dist/elements/title.d.ts.map +1 -0
  69. package/dist/elements/title.js +19 -0
  70. package/dist/elements/title.js.map +1 -0
  71. package/dist/elements/transport.d.ts +29 -0
  72. package/dist/elements/transport.d.ts.map +1 -0
  73. package/dist/elements/transport.js +24 -0
  74. package/dist/elements/transport.js.map +1 -0
  75. package/dist/elements/view-bound.d.ts +39 -0
  76. package/dist/elements/view-bound.d.ts.map +1 -0
  77. package/dist/elements/view-bound.js +88 -0
  78. package/dist/elements/view-bound.js.map +1 -0
  79. package/dist/elements/view-dock.d.ts +28 -0
  80. package/dist/elements/view-dock.d.ts.map +1 -0
  81. package/dist/elements/view-dock.js +28 -0
  82. package/dist/elements/view-dock.js.map +1 -0
  83. package/dist/elements/zoom.d.ts +28 -0
  84. package/dist/elements/zoom.d.ts.map +1 -0
  85. package/dist/elements/zoom.js +22 -0
  86. package/dist/elements/zoom.js.map +1 -0
  87. package/dist/i18n.d.ts +10 -0
  88. package/dist/i18n.d.ts.map +1 -1
  89. package/dist/i18n.js +20 -0
  90. package/dist/i18n.js.map +1 -1
  91. package/dist/icons.d.ts +1 -0
  92. package/dist/icons.d.ts.map +1 -1
  93. package/dist/icons.js +1 -0
  94. package/dist/icons.js.map +1 -1
  95. package/dist/index.d.ts +331 -36
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +13 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/keyboard.d.ts +39 -2
  100. package/dist/keyboard.d.ts.map +1 -1
  101. package/dist/keyboard.js +145 -62
  102. package/dist/keyboard.js.map +1 -1
  103. package/dist/layout-store.d.ts +26 -0
  104. package/dist/layout-store.d.ts.map +1 -0
  105. package/dist/layout-store.js +46 -0
  106. package/dist/layout-store.js.map +1 -0
  107. package/dist/panel-levels.d.ts +18 -0
  108. package/dist/panel-levels.d.ts.map +1 -0
  109. package/dist/panel-levels.js +41 -0
  110. package/dist/panel-levels.js.map +1 -0
  111. package/dist/panels.d.ts +28 -0
  112. package/dist/panels.d.ts.map +1 -0
  113. package/dist/panels.js +73 -0
  114. package/dist/panels.js.map +1 -0
  115. package/dist/pipeline.d.ts.map +1 -1
  116. package/dist/pipeline.js +3 -2
  117. package/dist/pipeline.js.map +1 -1
  118. package/dist/prefs.d.ts +5 -2
  119. package/dist/prefs.d.ts.map +1 -1
  120. package/dist/prefs.js +12 -3
  121. package/dist/prefs.js.map +1 -1
  122. package/dist/react.d.ts +460 -0
  123. package/dist/react.d.ts.map +1 -0
  124. package/dist/react.js +2 -0
  125. package/dist/react.js.map +1 -0
  126. package/dist/root-element.d.ts +122 -0
  127. package/dist/root-element.d.ts.map +1 -0
  128. package/dist/root-element.js +541 -0
  129. package/dist/root-element.js.map +1 -0
  130. package/dist/root.d.ts +54 -0
  131. package/dist/root.d.ts.map +1 -0
  132. package/dist/root.js +288 -0
  133. package/dist/root.js.map +1 -0
  134. package/dist/schedule.d.ts +22 -0
  135. package/dist/schedule.d.ts.map +1 -0
  136. package/dist/schedule.js +41 -0
  137. package/dist/schedule.js.map +1 -0
  138. package/dist/session.d.ts +269 -0
  139. package/dist/session.d.ts.map +1 -0
  140. package/dist/session.js +1237 -0
  141. package/dist/session.js.map +1 -0
  142. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  143. package/dist/standalone/kgdiff-viewer.js +4 -4
  144. package/dist/styles.d.ts +17 -6
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +367 -258
  147. package/dist/styles.js.map +1 -1
  148. package/dist/theme.d.ts +32 -0
  149. package/dist/theme.d.ts.map +1 -0
  150. package/dist/theme.js +152 -0
  151. package/dist/theme.js.map +1 -0
  152. package/dist/topics.d.ts +8 -0
  153. package/dist/topics.d.ts.map +1 -0
  154. package/dist/topics.js +18 -0
  155. package/dist/topics.js.map +1 -0
  156. package/dist/ui/change-list.d.ts +18 -0
  157. package/dist/ui/change-list.d.ts.map +1 -0
  158. package/dist/ui/change-list.js +331 -0
  159. package/dist/ui/change-list.js.map +1 -0
  160. package/dist/ui/context.d.ts +34 -7
  161. package/dist/ui/context.d.ts.map +1 -1
  162. package/dist/ui/context.js.map +1 -1
  163. package/dist/ui/filters.d.ts +9 -0
  164. package/dist/ui/filters.d.ts.map +1 -0
  165. package/dist/ui/filters.js +68 -0
  166. package/dist/ui/filters.js.map +1 -0
  167. package/dist/ui/header.d.ts +41 -3
  168. package/dist/ui/header.d.ts.map +1 -1
  169. package/dist/ui/header.js +101 -36
  170. package/dist/ui/header.js.map +1 -1
  171. package/dist/ui/legend.d.ts +15 -0
  172. package/dist/ui/legend.d.ts.map +1 -0
  173. package/dist/ui/legend.js +68 -0
  174. package/dist/ui/legend.js.map +1 -0
  175. package/dist/ui/popovers.d.ts +3 -1
  176. package/dist/ui/popovers.d.ts.map +1 -1
  177. package/dist/ui/popovers.js +62 -23
  178. package/dist/ui/popovers.js.map +1 -1
  179. package/dist/ui/search.d.ts +13 -0
  180. package/dist/ui/search.d.ts.map +1 -0
  181. package/dist/ui/search.js +192 -0
  182. package/dist/ui/search.js.map +1 -0
  183. package/dist/ui/sidebar.d.ts +32 -17
  184. package/dist/ui/sidebar.d.ts.map +1 -1
  185. package/dist/ui/sidebar.js +327 -519
  186. package/dist/ui/sidebar.js.map +1 -1
  187. package/dist/ui/stage.d.ts +56 -1
  188. package/dist/ui/stage.d.ts.map +1 -1
  189. package/dist/ui/stage.js +244 -256
  190. package/dist/ui/stage.js.map +1 -1
  191. package/dist/ui/swatch.js +1 -1
  192. package/dist/ui/swatch.js.map +1 -1
  193. package/dist/ui/transport.d.ts +12 -0
  194. package/dist/ui/transport.d.ts.map +1 -0
  195. package/dist/ui/transport.js +198 -0
  196. package/dist/ui/transport.js.map +1 -0
  197. package/dist/ui/zoom.d.ts +15 -0
  198. package/dist/ui/zoom.d.ts.map +1 -0
  199. package/dist/ui/zoom.js +30 -0
  200. package/dist/ui/zoom.js.map +1 -0
  201. package/dist/view.d.ts +94 -0
  202. package/dist/view.d.ts.map +1 -0
  203. package/dist/view.js +441 -0
  204. package/dist/view.js.map +1 -0
  205. package/package.json +95 -12
  206. package/src/bind.ts +80 -0
  207. package/src/context.ts +106 -0
  208. package/src/controller.ts +52 -1630
  209. package/src/dom.ts +30 -0
  210. package/src/element.ts +386 -619
  211. package/src/elements/actions.ts +94 -0
  212. package/src/elements/bound.ts +247 -0
  213. package/src/elements/change-detail.ts +34 -0
  214. package/src/elements/change-list.ts +51 -0
  215. package/src/elements/filters.ts +34 -0
  216. package/src/elements/legend.ts +103 -0
  217. package/src/elements/minimap.ts +75 -0
  218. package/src/elements/root.ts +86 -0
  219. package/src/elements/search.ts +38 -0
  220. package/src/elements/stage-context.ts +34 -0
  221. package/src/elements/stage.ts +285 -0
  222. package/src/elements/title.ts +34 -0
  223. package/src/elements/transport.ts +39 -0
  224. package/src/elements/view-bound.ts +118 -0
  225. package/src/elements/view-dock.ts +43 -0
  226. package/src/elements/zoom.ts +38 -0
  227. package/src/i18n.ts +20 -0
  228. package/src/icons.ts +1 -0
  229. package/src/index.ts +350 -35
  230. package/src/keyboard.ts +188 -61
  231. package/src/layout-store.ts +71 -0
  232. package/src/panel-levels.ts +53 -0
  233. package/src/panels.ts +81 -0
  234. package/src/pipeline.ts +4 -1
  235. package/src/prefs.ts +12 -4
  236. package/src/react.ts +459 -0
  237. package/src/root-element.ts +624 -0
  238. package/src/root.ts +335 -0
  239. package/src/schedule.ts +43 -0
  240. package/src/session.ts +1375 -0
  241. package/src/styles.ts +396 -263
  242. package/src/theme.ts +170 -0
  243. package/src/topics.ts +37 -0
  244. package/src/ui/change-list.ts +405 -0
  245. package/src/ui/context.ts +36 -7
  246. package/src/ui/filters.ts +79 -0
  247. package/src/ui/header.ts +129 -58
  248. package/src/ui/legend.ts +112 -0
  249. package/src/ui/popovers.ts +68 -25
  250. package/src/ui/search.ts +225 -0
  251. package/src/ui/sidebar.ts +362 -639
  252. package/src/ui/stage.ts +267 -331
  253. package/src/ui/swatch.ts +1 -1
  254. package/src/ui/transport.ts +240 -0
  255. package/src/ui/zoom.ts +45 -0
  256. package/src/view.ts +516 -0
  257. package/vscode.html-custom-data.json +726 -0
package/src/ui/sidebar.ts CHANGED
@@ -1,688 +1,417 @@
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 places them and follows their panel states: a collapsed region folds to the
6
+ * control that opens it again (the list to its title, the detail panel to its bar, the search
7
+ * and the filters to a button), and the sidebar folds away when all four are collapsed. It has
8
+ * a draggable width, and a draggable split between the list and the detail panel; both
9
+ * separators also move with the arrow keys.
5
10
  */
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';
15
-
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;
11
+ import { attr, h, icon, iconButton, moveFocusOut, relabel } from '../dom.js';
12
+ import type { PanelRegion, PanelState, PanelStates } from '../index.js';
13
+ import { SIDEBAR_REGIONS } from '../panel-levels.js';
14
+ import { frameOrTimeout } from '../schedule.js';
15
+ import type { Topic } from '../session.js';
16
+ import type { ChangeList } from './change-list.js';
17
+ import type { UiContext } from './context.js';
18
+ import type { Detail } from './detail.js';
19
+ import type { Filters } from './filters.js';
20
+ import type { Search } from './search.js';
21
+
22
+ export { HEADER_SLOT, ROW_SLOT } from './change-list.js';
23
+
24
+ /** The sidebar's default width and the range it can be dragged in, px. */
25
+ export const SIDEBAR_WIDTH = 360;
26
+ export const SIDEBAR_MIN = 280;
27
+ const SIDEBAR_MAX = 720;
28
+ /** The canvas keeps at least this much width next to the sidebar, px. */
29
+ const STAGE_MIN = 320;
30
+ /** Keyboard steps of the width (px) and of the split (share of the height). */
31
+ const WIDTH_STEP = 16;
32
+ const WIDTH_STEP_LARGE = 64;
33
+ const SPLIT_STEP = 0.05;
34
+ /** The change list keeps this much height, the detail panel this much, px. */
35
+ const LIST_MIN = 96;
36
+ const DETAIL_MIN = 64;
37
+
38
+ /** What the sidebar asks of the layout that owns it. */
39
+ export interface SidebarHost {
40
+ /** The user opened or folded one of the sidebar's regions. */
41
+ setPanel(region: PanelRegion, state: PanelState): void;
42
+ /** The user resized the sidebar or moved the split (reported once the drag or key press ended). */
43
+ resized(width: number, detailShare: number | null): void;
44
+ /** Where focus goes when the whole sidebar folds while it has focus. */
45
+ focusOutside(): HTMLElement | null;
24
46
  }
25
47
 
26
48
  export class Sidebar {
27
49
  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;
50
+ readonly #inner: HTMLElement;
46
51
  readonly #handle: HTMLElement;
47
- #offsets: number[] = [];
48
- #rendered = new Map<number, HTMLElement>();
49
- #scrollQueued = false;
50
- #query = '';
51
- #hits: SearchHit[] = [];
52
- #activeHit = 0;
53
-
52
+ readonly #resize: HTMLElement;
53
+ readonly #split: HTMLElement;
54
+ readonly #head: HTMLElement;
55
+ readonly #listHead: HTMLElement;
56
+ readonly #listWrap: HTMLElement;
57
+ readonly #listToggle: HTMLButtonElement;
58
+ readonly #searchWrap: HTMLElement;
59
+ readonly #searchButton: HTMLButtonElement;
60
+ readonly #filtersWrap: HTMLElement;
61
+ readonly #filtersButton: HTMLButtonElement;
62
+ readonly #detailWrap: HTMLElement;
63
+ readonly #detailBar: HTMLButtonElement;
64
+ readonly #detail: Detail;
54
65
  readonly #ctx: UiContext;
55
-
56
- constructor(ctx: UiContext) {
66
+ readonly #host: SidebarHost;
67
+ #states: PanelStates | null = null;
68
+ #width = SIDEBAR_WIDTH;
69
+ #share: number | null = null;
70
+ /** The sizes last reported as done (or applied as remembered): a gesture that ends there is not reported. */
71
+ #reported: { width: number; share: number | null } = { width: SIDEBAR_WIDTH, share: null };
72
+
73
+ constructor(
74
+ ctx: UiContext,
75
+ host: SidebarHost,
76
+ list: ChangeList,
77
+ search: Search,
78
+ filters: Filters,
79
+ detail: Detail,
80
+ ) {
57
81
  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',
82
+ this.#host = host;
83
+ this.#detail = detail;
84
+ this.#handle = h('div', {
85
+ class: 'sheet-handle',
86
+ role: 'separator',
87
+ 'aria-orientation': 'horizontal',
88
+ tabindex: '0',
78
89
  });
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([]),
90
+ this.#bindSheet();
91
+ const toggle = (region: PanelRegion) => () => {
92
+ const open = this.#states?.[region] === 'open';
93
+ host.setPanel(region, open ? 'collapsed' : 'open');
94
+ };
95
+ this.#listToggle = iconButton('chevron-up', '', toggle('list'), {
96
+ class: 'icon-btn sm list-toggle',
97
+ 'aria-expanded': 'true',
98
+ 'aria-controls': 'kg-list-wrap',
112
99
  });
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();
100
+ this.#searchButton = iconButton('search', '', () => host.setPanel('search', 'open'), {
101
+ class: 'icon-btn sm',
102
+ hidden: true,
132
103
  });
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',
104
+ this.#filtersButton = iconButton('filter', '', () => host.setPanel('filters', 'open'), {
105
+ class: 'icon-btn sm filters-btn',
138
106
  hidden: true,
139
107
  });
140
- this.#handle = h('div', {
141
- class: 'sheet-handle',
108
+ this.#listHead = list.head;
109
+ this.#head = h(
110
+ 'div',
111
+ { class: 'sb-row' },
112
+ list.head,
113
+ this.#searchButton,
114
+ this.#filtersButton,
115
+ this.#listToggle,
116
+ );
117
+ this.#searchWrap = h('div', { class: 'search-wrap' }, search.box);
118
+ this.#filtersWrap = h('div', { class: 'filters-wrap', id: 'kg-filters' }, filters.el);
119
+ this.#listWrap = h(
120
+ 'div',
121
+ { class: 'list-wrap', id: 'kg-list-wrap' },
122
+ list.sticky,
123
+ list.list,
124
+ search.results,
125
+ list.phases,
126
+ );
127
+ this.#split = h('div', {
128
+ class: 'sb-split',
142
129
  role: 'separator',
143
130
  'aria-orientation': 'horizontal',
131
+ 'aria-valuemin': '0',
132
+ 'aria-valuemax': '100',
144
133
  tabindex: '0',
145
134
  });
146
- 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,
135
+ this.#detailBar = h(
136
+ 'button',
137
+ {
138
+ type: 'button',
139
+ class: 'detail-bar',
140
+ 'aria-expanded': 'true',
141
+ 'aria-controls': 'kg-detail',
142
+ onclick: toggle('detail'),
143
+ },
144
+ h('span', { class: 'lbl' }),
145
+ icon('chevron-down'),
161
146
  );
162
- this.el = h(
163
- 'aside',
164
- { class: 'sidebar', 'data-open': 'true' },
165
- h(
166
- 'div',
167
- { class: 'sidebar-inner' },
168
- this.#handle,
169
- head,
170
- h('div', { class: 'list-wrap' }, this.#sticky, this.list, this.#results, this.#phases),
171
- this.detail.el,
172
- ),
147
+ attr(detail.el, 'id', 'kg-detail');
148
+ this.#detailWrap = h('div', { class: 'detail-wrap' }, this.#detailBar, detail.el);
149
+ this.#inner = h(
150
+ 'div',
151
+ { class: 'sidebar-inner' },
152
+ this.#handle,
153
+ h('div', { class: 'sb-head' }, this.#head, this.#searchWrap, this.#filtersWrap),
154
+ this.#listWrap,
155
+ this.#split,
156
+ this.#detailWrap,
173
157
  );
158
+ this.#resize = h('div', {
159
+ class: 'sb-resize',
160
+ role: 'separator',
161
+ 'aria-orientation': 'vertical',
162
+ 'aria-valuemin': String(SIDEBAR_MIN),
163
+ tabindex: '0',
164
+ });
165
+ this.el = h('aside', { class: 'sidebar', 'data-open': 'true' }, this.#resize, this.#inner);
166
+ this.#bindResize();
167
+ this.#bindSplit();
174
168
  }
175
169
 
176
- setOpen(open: boolean): void {
177
- attr(this.el, 'data-open', String(open));
178
- this.el.inert = !open;
179
- }
180
-
181
- 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) {
185
- 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
- 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);
170
+ get width(): number {
171
+ return this.#width;
210
172
  }
211
173
 
212
- /** Move keyboard focus into the list (after choosing a search result). */
213
- focusList(): void {
214
- this.list.focus({ preventScroll: true });
174
+ get detailShare(): number | null {
175
+ return this.#share;
215
176
  }
216
177
 
217
- focusSearch(): void {
218
- this.searchInput.focus();
219
- this.searchInput.select();
178
+ /** Follow the panel states of the four regions (the sidebar folds when none is open). */
179
+ setPanels(states: PanelStates): void {
180
+ this.#states = states;
181
+ const shown = SIDEBAR_REGIONS.filter((r) => states[r] !== 'hidden');
182
+ const open = shown.some((r) => states[r] === 'open');
183
+ const root = this.#ctx.root;
184
+ if (!open) moveFocusOut(root, this.el, () => this.#host.focusOutside());
185
+ const fold = (el: HTMLElement, state: PanelState, to: () => HTMLElement | null): void => {
186
+ if (state !== 'open') moveFocusOut(root, el, to);
187
+ el.hidden = state !== 'open';
188
+ el.inert = state !== 'open';
189
+ };
190
+ fold(this.#searchWrap, states.search, () => this.#searchButton);
191
+ fold(this.#filtersWrap, states.filters, () => this.#filtersButton);
192
+ this.#searchButton.hidden = states.search !== 'collapsed';
193
+ this.#filtersButton.hidden = states.filters !== 'collapsed';
194
+ this.#listHead.hidden = states.list === 'hidden';
195
+ this.#listToggle.hidden = states.list === 'hidden';
196
+ attr(this.#listToggle, 'aria-expanded', String(states.list === 'open'));
197
+ attr(this.#detailWrap, 'data-state', states.detail);
198
+ this.#detailWrap.hidden = states.detail === 'hidden';
199
+ if (states.detail !== 'open') moveFocusOut(root, this.#detail.el, () => this.#detailBar);
200
+ this.#detail.el.inert = states.detail !== 'open';
201
+ attr(this.#detailBar, 'aria-expanded', String(states.detail === 'open'));
202
+ this.#placeList(this.#ctx.controller.query.trim() !== '');
203
+ this.el.hidden = shown.length === 0;
204
+ attr(this.el, 'data-open', String(open));
205
+ this.el.inert = !open;
206
+ this.#relabel();
220
207
  }
221
208
 
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;
209
+ /**
210
+ * Apply sizes (after a drag, or `instant`: remembered ones, which must not animate in on
211
+ * load); the width is clamped when the layout is known.
212
+ */
213
+ setSizes(width: number, detailShare: number | null, instant = false): void {
214
+ if (instant) {
215
+ this.el.classList.add('dragging');
216
+ frameOrTimeout(() => this.el.classList.remove('dragging'));
227
217
  }
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}`));
218
+ this.#width = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, Math.round(width)));
219
+ this.#share = detailShare === null ? null : Math.max(0, Math.min(1, detailShare));
220
+ if (instant) this.#reported = { width: this.#width, share: this.#share };
221
+ this.el.style.setProperty('--sidebar-w', `${this.#width}px`);
222
+ if (this.#share === null) {
223
+ this.el.removeAttribute('data-split');
224
+ this.el.style.removeProperty('--detail-share');
225
+ } else {
226
+ attr(this.el, 'data-split', 'true');
227
+ this.el.style.setProperty('--detail-share', String(this.#share));
246
228
  }
229
+ this.#syncSeparators();
247
230
  }
248
231
 
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)));
232
+ update(topics: ReadonlySet<Topic>): void {
233
+ if (topics.has('status') || topics.has('data') || topics.has('locale')) this.#relabel();
234
+ if (topics.has('search') || topics.has('status')) {
235
+ this.#placeList(this.#ctx.controller.query.trim() !== '');
253
236
  }
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);
237
+ if (topics.has('filters') || topics.has('status')) {
238
+ attr(this.#filtersButton, 'data-active', String(this.#ctx.controller.families.size > 0));
296
239
  }
297
- this.#listInner.style.height = `${y + 8}px`;
298
- this.#renderEmpty();
299
- this.renderWindow();
300
240
  }
301
241
 
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
- );
242
+ /**
243
+ * The list's place shows the rows while the list is open and the search results while a
244
+ * search is active, whatever the list's state (the results take its place).
245
+ */
246
+ #placeList(searching: boolean): void {
247
+ const state = this.#states?.list ?? 'open';
248
+ const shown = state === 'open' || searching;
249
+ if (!shown) moveFocusOut(this.#ctx.root, this.#listWrap, () => this.#listToggle);
250
+ this.#listWrap.hidden = !shown;
251
+ this.#listWrap.inert = !shown;
252
+ const detailOpen = (this.#states?.detail ?? 'open') === 'open';
253
+ this.#split.hidden = !(shown && detailOpen);
254
+ attr(this.el, 'data-list', shown ? 'open' : 'folded');
342
255
  }
343
256
 
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 {
257
+ #relabel(): void {
389
258
  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,
259
+ attr(this.el, 'aria-label', tr.t('sidebar.label'));
260
+ attr(this.#handle, 'aria-label', tr.t('sidebar.sheet'));
261
+ attr(this.#resize, 'aria-label', tr.t('sidebar.resize'));
262
+ attr(this.#split, 'aria-label', tr.t('sidebar.split'));
263
+ const listOpen = this.#states?.list !== 'collapsed';
264
+ relabel(this.#listToggle, tr.t(listOpen ? 'sidebar.hideList' : 'sidebar.showList'));
265
+ relabel(this.#searchButton, `${tr.t('sidebar.search')} (/)`);
266
+ relabel(this.#filtersButton, tr.t('sidebar.filters'));
267
+ const bar = this.#detailBar.querySelector('.lbl');
268
+ if (bar) bar.textContent = tr.t('detail.label');
269
+ attr(
270
+ this.#detailBar,
271
+ 'title',
272
+ tr.t(this.#states?.detail === 'collapsed' ? 'detail.show' : 'detail.hide'),
438
273
  );
439
- this.#labelRow(row, hunk, reviewed);
440
- return row;
441
274
  }
442
275
 
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('. '));
276
+ /** A drag or key press ended: report the sizes, unless they are where they were. */
277
+ #done(): void {
278
+ const { width, share } = this.#reported;
279
+ if (width === this.#width && share === this.#share) return;
280
+ this.#reported = { width: this.#width, share: this.#share };
281
+ this.#host.resized(this.#width, this.#share);
449
282
  }
450
283
 
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
- }
284
+ // ─── Width ───────────────────────────────────────────────────────────────
475
285
 
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();
286
+ /** The widest the sidebar may be next to the canvas, px. */
287
+ #maxWidth(): number {
288
+ const total = this.el.parentElement?.clientWidth ?? 0;
289
+ return total > 0
290
+ ? Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, total - STAGE_MIN))
291
+ : SIDEBAR_MAX;
493
292
  }
494
293
 
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)`;
294
+ #syncSeparators(): void {
295
+ const max = this.#maxWidth();
296
+ attr(this.#resize, 'aria-valuemax', String(max));
297
+ const now = Math.min(this.#width, max);
298
+ attr(this.#resize, 'aria-valuenow', String(now));
299
+ attr(this.#resize, 'aria-valuetext', `${now} px`);
300
+ const list = Math.round(100 * (1 - (this.#share ?? this.#measuredShare())));
301
+ attr(this.#split, 'aria-valuenow', String(list));
302
+ attr(this.#split, 'aria-valuetext', this.#ctx.tr().t('sidebar.splitValue', { p: `${list}%` }));
529
303
  }
530
304
 
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) {
305
+ #bindResize(): void {
306
+ let start: { x: number; w: number; id: number } | null = null;
307
+ const set = (px: number, done: boolean): void => {
308
+ const width = Math.max(SIDEBAR_MIN, Math.min(this.#maxWidth(), px));
309
+ this.setSizes(width, this.#share);
310
+ if (done) this.#done();
311
+ };
312
+ this.#resize.addEventListener('pointerdown', (e) => {
313
+ e.preventDefault();
314
+ start = { x: e.clientX, w: this.el.getBoundingClientRect().width, id: e.pointerId };
315
+ this.#resize.setPointerCapture?.(e.pointerId);
316
+ this.el.classList.add('dragging');
317
+ });
318
+ this.#resize.addEventListener('pointermove', (e) => {
319
+ // The sidebar is on the right: dragging its edge left widens it.
320
+ if (start && e.pointerId === start.id) set(start.w + (start.x - e.clientX), false);
321
+ });
322
+ const end = (e: PointerEvent): void => {
323
+ if (!start || e.pointerId !== start.id) return;
324
+ start = null;
325
+ this.el.classList.remove('dragging');
326
+ this.#done();
327
+ };
328
+ this.#resize.addEventListener('pointerup', end);
329
+ this.#resize.addEventListener('pointercancel', end);
330
+ this.#resize.addEventListener('dblclick', () => set(SIDEBAR_WIDTH, true));
331
+ this.#resize.addEventListener('keydown', (e) => {
332
+ const step = e.shiftKey ? WIDTH_STEP_LARGE : WIDTH_STEP;
333
+ // From the width shown (a narrow layout holds the sidebar below the stored width).
334
+ const shown = Math.min(this.#width, this.#maxWidth());
335
+ const to: Record<string, number> = {
336
+ ArrowLeft: shown + step,
337
+ ArrowRight: shown - step,
338
+ PageUp: shown + WIDTH_STEP_LARGE,
339
+ PageDown: shown - WIDTH_STEP_LARGE,
340
+ Home: SIDEBAR_MIN,
341
+ End: this.#maxWidth(),
342
+ };
343
+ const next = to[e.key];
344
+ if (next === undefined) return;
345
+ e.preventDefault();
536
346
  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);
347
+ set(next, true);
348
+ });
544
349
  }
545
350
 
546
- // ─── Search ──────────────────────────────────────────────────────────────
351
+ // ─── Split between the list and the detail panel ────────────────────────
547
352
 
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();
353
+ /** The detail panel's share of the sidebar's height as laid out now (0 before layout). */
354
+ #measuredShare(): number {
355
+ const total = this.#inner.clientHeight;
356
+ return total > 0 ? this.#detailWrap.getBoundingClientRect().height / total : 0;
554
357
  }
555
358
 
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();
359
+ /** Clamp a share so that the list and the detail panel keep their minimum heights. */
360
+ #clampShare(share: number): number {
361
+ const total = this.#inner.clientHeight;
362
+ if (total <= 0) return Math.max(0.1, Math.min(0.9, share));
363
+ const above = this.#head.parentElement?.getBoundingClientRect().height ?? 0;
364
+ const max = Math.max(DETAIL_MIN, total - above - LIST_MIN) / total;
365
+ return Math.max(DETAIL_MIN / total, Math.min(max, share));
571
366
  }
572
367
 
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
- );
368
+ #bindSplit(): void {
369
+ let start: { y: number; share: number; id: number } | null = null;
370
+ const set = (share: number, done: boolean): void => {
371
+ this.setSizes(this.#width, this.#clampShare(share));
372
+ if (done) this.#done();
373
+ };
374
+ this.#split.addEventListener('pointerdown', (e) => {
375
+ e.preventDefault();
376
+ start = { y: e.clientY, share: this.#share ?? this.#measuredShare(), id: e.pointerId };
377
+ this.#split.setPointerCapture?.(e.pointerId);
378
+ this.el.classList.add('dragging');
379
+ });
380
+ this.#split.addEventListener('pointermove', (e) => {
381
+ if (!start || e.pointerId !== start.id) return;
382
+ const total = this.#inner.clientHeight;
383
+ if (total > 0) set(start.share + (start.y - e.clientY) / total, false);
384
+ });
385
+ const end = (e: PointerEvent): void => {
386
+ if (!start || e.pointerId !== start.id) return;
387
+ start = null;
388
+ this.el.classList.remove('dragging');
389
+ this.#done();
390
+ };
391
+ this.#split.addEventListener('pointerup', end);
392
+ this.#split.addEventListener('pointercancel', end);
393
+ // Back to the content's height.
394
+ this.#split.addEventListener('dblclick', () => {
395
+ this.setSizes(this.#width, null);
396
+ this.#done();
397
+ });
398
+ this.#split.addEventListener('keydown', (e) => {
399
+ const current = this.#share ?? this.#measuredShare();
400
+ // Up moves the split up: the detail panel grows.
401
+ const to: Record<string, number> = {
402
+ ArrowUp: current + SPLIT_STEP,
403
+ ArrowDown: current - SPLIT_STEP,
404
+ PageUp: current + 2 * SPLIT_STEP,
405
+ PageDown: current - 2 * SPLIT_STEP,
406
+ Home: 1,
407
+ End: 0,
408
+ };
409
+ const next = to[e.key];
410
+ if (next === undefined) return;
411
+ e.preventDefault();
412
+ e.stopPropagation();
413
+ set(next, true);
633
414
  });
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
- }
686
415
  }
687
416
 
688
417
  // ─── Bottom sheet ────────────────────────────────────────────────────────
@@ -706,6 +435,12 @@ export class Sidebar {
706
435
  if (total > 0) this.#sheetValue(this.el.getBoundingClientRect().height, total);
707
436
  }
708
437
 
438
+ /** The layout changed size: keep the width within what fits, refresh the separators. */
439
+ layout(compact: boolean): void {
440
+ if (compact) this.syncSheet();
441
+ else this.#syncSeparators();
442
+ }
443
+
709
444
  #bindSheet(): void {
710
445
  let start: { y: number; h: number; id: number } | null = null;
711
446
  const host = (): HTMLElement | null => this.el.parentElement;
@@ -739,15 +474,3 @@ export class Sidebar {
739
474
  });
740
475
  }
741
476
  }
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
- }