@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
@@ -1,547 +1,361 @@
1
- import { attr, h, icon, replace, setText } from '../dom.js';
2
- import { familyCounts } from '../list-model.js';
3
- import { FAMILIES } from '../styles.js';
4
- import { sceneHunkGlyph } from './context.js';
5
- import { Detail } from './detail.js';
6
- import { swatch } from './swatch.js';
7
- /** Row slot and group-header heights (px); rows are 52 px tall inside a 56 px slot. */
8
- export const ROW_SLOT = 56;
9
- export const HEADER_SLOT = 32;
10
- const OVERSCAN = 320;
11
- const SEARCH_LIMIT = 50;
12
- function itemHeight(item) {
13
- return item.kind === 'header' ? HEADER_SLOT : ROW_SLOT;
14
- }
1
+ /**
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.
10
+ */
11
+ import { attr, h, icon, iconButton, moveFocusOut, relabel } from '../dom.js';
12
+ import { SIDEBAR_REGIONS } from '../panel-levels.js';
13
+ import { frameOrTimeout } from '../schedule.js';
14
+ export { HEADER_SLOT, ROW_SLOT } from './change-list.js';
15
+ /** The sidebar's default width and the range it can be dragged in, px. */
16
+ export const SIDEBAR_WIDTH = 360;
17
+ export const SIDEBAR_MIN = 280;
18
+ const SIDEBAR_MAX = 720;
19
+ /** The canvas keeps at least this much width next to the sidebar, px. */
20
+ const STAGE_MIN = 320;
21
+ /** Keyboard steps of the width (px) and of the split (share of the height). */
22
+ const WIDTH_STEP = 16;
23
+ const WIDTH_STEP_LARGE = 64;
24
+ const SPLIT_STEP = 0.05;
25
+ /** The change list keeps this much height, the detail panel this much, px. */
26
+ const LIST_MIN = 96;
27
+ const DETAIL_MIN = 64;
15
28
  export class Sidebar {
16
29
  el;
17
- detail;
18
- #title;
19
- #count;
20
- #progress;
21
- #progressText;
22
- #progressBar;
23
- searchInput;
24
- #searchKbd;
25
- #searchClear;
26
- #filters;
27
- #chips = new Map();
28
- #showAll;
29
- list;
30
- #listInner;
31
- /** Visually hidden group names the options are described by. */
32
- #phases = h('div', { class: 'sr-only' });
33
- #sticky;
34
- #results;
30
+ #inner;
35
31
  #handle;
36
- #offsets = [];
37
- #rendered = new Map();
38
- #scrollQueued = false;
39
- #query = '';
40
- #hits = [];
41
- #activeHit = 0;
32
+ #resize;
33
+ #split;
34
+ #head;
35
+ #listHead;
36
+ #listWrap;
37
+ #listToggle;
38
+ #searchWrap;
39
+ #searchButton;
40
+ #filtersWrap;
41
+ #filtersButton;
42
+ #detailWrap;
43
+ #detailBar;
44
+ #detail;
42
45
  #ctx;
43
- constructor(ctx) {
46
+ #host;
47
+ #states = null;
48
+ #width = SIDEBAR_WIDTH;
49
+ #share = null;
50
+ /** The sizes last reported as done (or applied as remembered): a gesture that ends there is not reported. */
51
+ #reported = { width: SIDEBAR_WIDTH, share: null };
52
+ constructor(ctx, host, list, search, filters, detail) {
44
53
  this.#ctx = ctx;
45
- const c = ctx.controller;
46
- this.#title = h('h2', { id: 'kg-changes-title' });
47
- this.#count = h('span', { class: 'count-badge' });
48
- this.#progressText = h('span');
49
- this.#progressBar = h('div');
50
- this.#progress = h('div', { class: 'progress' }, this.#progressText, h('span', { class: 'bar', 'aria-hidden': 'true' }, this.#progressBar));
51
- this.searchInput = h('input', {
52
- type: 'search',
53
- autocomplete: 'off',
54
- spellcheck: 'false',
55
- role: 'combobox',
56
- 'aria-autocomplete': 'list',
57
- 'aria-expanded': 'false',
58
- 'aria-controls': 'kg-results',
59
- enterkeyhint: 'go',
54
+ this.#host = host;
55
+ this.#detail = detail;
56
+ this.#handle = h('div', {
57
+ class: 'sheet-handle',
58
+ role: 'separator',
59
+ 'aria-orientation': 'horizontal',
60
+ tabindex: '0',
60
61
  });
61
- this.#searchKbd = h('kbd', { 'aria-hidden': 'true', text: '/' });
62
- this.#searchClear = h('button', { type: 'button', class: 'clear', hidden: true, onclick: () => this.#setQuery('', true) }, icon('close'));
63
- this.searchInput.addEventListener('input', () => this.#setQuery(this.searchInput.value));
64
- this.searchInput.addEventListener('keydown', (e) => this.#onSearchKey(e));
65
- this.searchInput.addEventListener('focus', () => this.#updateSearchChrome());
66
- this.searchInput.addEventListener('blur', () => this.#updateSearchChrome());
67
- this.#filters = h('div', { class: 'filters', role: 'group' });
68
- for (const f of FAMILIES) {
69
- const chip = h('button', {
70
- type: 'button',
71
- class: 'fchip',
72
- 'data-family': f,
73
- 'aria-pressed': 'false',
74
- onclick: () => c.toggleFamily(f),
75
- }, swatch(f), h('span', { class: 'lbl' }), h('span', { class: 'n' }));
76
- this.#chips.set(f, chip);
77
- this.#filters.appendChild(chip);
78
- }
79
- this.#showAll = h('button', {
80
- type: 'button',
81
- class: 'link-btn',
82
- hidden: true,
83
- onclick: () => c.setFamilies([]),
62
+ this.#bindSheet();
63
+ const toggle = (region) => () => {
64
+ const open = this.#states?.[region] === 'open';
65
+ host.setPanel(region, open ? 'collapsed' : 'open');
66
+ };
67
+ this.#listToggle = iconButton('chevron-up', '', toggle('list'), {
68
+ class: 'icon-btn sm list-toggle',
69
+ 'aria-expanded': 'true',
70
+ 'aria-controls': 'kg-list-wrap',
84
71
  });
85
- this.#filters.appendChild(this.#showAll);
86
- this.#listInner = h('div', { class: 'list-inner', role: 'presentation' });
87
- this.list = h('div', {
88
- class: 'list scroll',
89
- role: 'listbox',
90
- tabindex: '0',
91
- 'aria-labelledby': 'kg-changes-title',
92
- 'aria-keyshortcuts': 'J K X Enter Home End',
93
- }, this.#listInner);
94
- this.list.addEventListener('scroll', () => this.#queueWindow(), { passive: true });
95
- this.list.addEventListener('click', (e) => this.#onListClick(e));
96
- this.list.addEventListener('dblclick', (e) => {
97
- const row = e.target.closest('.row');
98
- if (row)
99
- c.flyToSelection();
72
+ this.#searchButton = iconButton('search', '', () => host.setPanel('search', 'open'), {
73
+ class: 'icon-btn sm',
74
+ hidden: true,
100
75
  });
101
- this.#sticky = h('div', { class: 'sticky', 'aria-hidden': 'true', hidden: true });
102
- this.#results = h('div', {
103
- class: 'results list scroll',
104
- id: 'kg-results',
105
- role: 'listbox',
76
+ this.#filtersButton = iconButton('filter', '', () => host.setPanel('filters', 'open'), {
77
+ class: 'icon-btn sm filters-btn',
106
78
  hidden: true,
107
79
  });
108
- this.#handle = h('div', {
109
- class: 'sheet-handle',
80
+ this.#listHead = list.head;
81
+ this.#head = h('div', { class: 'sb-row' }, list.head, this.#searchButton, this.#filtersButton, this.#listToggle);
82
+ this.#searchWrap = h('div', { class: 'search-wrap' }, search.box);
83
+ this.#filtersWrap = h('div', { class: 'filters-wrap', id: 'kg-filters' }, filters.el);
84
+ this.#listWrap = h('div', { class: 'list-wrap', id: 'kg-list-wrap' }, list.sticky, list.list, search.results, list.phases);
85
+ this.#split = h('div', {
86
+ class: 'sb-split',
110
87
  role: 'separator',
111
88
  'aria-orientation': 'horizontal',
89
+ 'aria-valuemin': '0',
90
+ 'aria-valuemax': '100',
112
91
  tabindex: '0',
113
92
  });
114
- this.#bindSheet();
115
- this.detail = new Detail(ctx);
116
- const head = h('div', { class: 'sb-head' }, h('div', { class: 'sb-title' }, this.#title, this.#count, this.#progress), h('div', { class: 'search' }, icon('search'), this.searchInput, this.#searchKbd, this.#searchClear), this.#filters);
117
- this.el = h('aside', { class: 'sidebar', 'data-open': 'true' }, h('div', { class: 'sidebar-inner' }, this.#handle, head, h('div', { class: 'list-wrap' }, this.#sticky, this.list, this.#results, this.#phases), this.detail.el));
93
+ this.#detailBar = h('button', {
94
+ type: 'button',
95
+ class: 'detail-bar',
96
+ 'aria-expanded': 'true',
97
+ 'aria-controls': 'kg-detail',
98
+ onclick: toggle('detail'),
99
+ }, h('span', { class: 'lbl' }), icon('chevron-down'));
100
+ attr(detail.el, 'id', 'kg-detail');
101
+ this.#detailWrap = h('div', { class: 'detail-wrap' }, this.#detailBar, detail.el);
102
+ this.#inner = h('div', { class: 'sidebar-inner' }, this.#handle, h('div', { class: 'sb-head' }, this.#head, this.#searchWrap, this.#filtersWrap), this.#listWrap, this.#split, this.#detailWrap);
103
+ this.#resize = h('div', {
104
+ class: 'sb-resize',
105
+ role: 'separator',
106
+ 'aria-orientation': 'vertical',
107
+ 'aria-valuemin': String(SIDEBAR_MIN),
108
+ tabindex: '0',
109
+ });
110
+ this.el = h('aside', { class: 'sidebar', 'data-open': 'true' }, this.#resize, this.#inner);
111
+ this.#bindResize();
112
+ this.#bindSplit();
113
+ }
114
+ get width() {
115
+ return this.#width;
118
116
  }
119
- setOpen(open) {
117
+ get detailShare() {
118
+ return this.#share;
119
+ }
120
+ /** Follow the panel states of the four regions (the sidebar folds when none is open). */
121
+ setPanels(states) {
122
+ this.#states = states;
123
+ const shown = SIDEBAR_REGIONS.filter((r) => states[r] !== 'hidden');
124
+ const open = shown.some((r) => states[r] === 'open');
125
+ const root = this.#ctx.root;
126
+ if (!open)
127
+ moveFocusOut(root, this.el, () => this.#host.focusOutside());
128
+ const fold = (el, state, to) => {
129
+ if (state !== 'open')
130
+ moveFocusOut(root, el, to);
131
+ el.hidden = state !== 'open';
132
+ el.inert = state !== 'open';
133
+ };
134
+ fold(this.#searchWrap, states.search, () => this.#searchButton);
135
+ fold(this.#filtersWrap, states.filters, () => this.#filtersButton);
136
+ this.#searchButton.hidden = states.search !== 'collapsed';
137
+ this.#filtersButton.hidden = states.filters !== 'collapsed';
138
+ this.#listHead.hidden = states.list === 'hidden';
139
+ this.#listToggle.hidden = states.list === 'hidden';
140
+ attr(this.#listToggle, 'aria-expanded', String(states.list === 'open'));
141
+ attr(this.#detailWrap, 'data-state', states.detail);
142
+ this.#detailWrap.hidden = states.detail === 'hidden';
143
+ if (states.detail !== 'open')
144
+ moveFocusOut(root, this.#detail.el, () => this.#detailBar);
145
+ this.#detail.el.inert = states.detail !== 'open';
146
+ attr(this.#detailBar, 'aria-expanded', String(states.detail === 'open'));
147
+ this.#placeList(this.#ctx.controller.query.trim() !== '');
148
+ this.el.hidden = shown.length === 0;
120
149
  attr(this.el, 'data-open', String(open));
121
150
  this.el.inert = !open;
151
+ this.#relabel();
122
152
  }
123
- update(topics) {
124
- const tr = this.#ctx.tr();
125
- const all = topics.has('status') || topics.has('data') || topics.has('locale');
126
- if (all) {
127
- attr(this.el, 'aria-label', tr.t('sidebar.label'));
128
- setText(this.#title, tr.t('sidebar.changes'));
129
- this.searchInput.placeholder = tr.t('sidebar.searchPlaceholder');
130
- attr(this.searchInput, 'aria-label', tr.t('sidebar.search'));
131
- attr(this.#searchClear, 'aria-label', tr.t('sidebar.searchClear'));
132
- attr(this.#filters, 'aria-label', tr.t('sidebar.filters'));
133
- attr(this.#handle, 'aria-label', tr.t('sidebar.sheet'));
134
- setText(this.#showAll, tr.t('sidebar.clearFilters'));
135
- this.searchInput.disabled = this.#ctx.controller.status !== 'ready';
136
- this.#renderChips();
137
- if (this.#query)
138
- this.#runSearch();
139
- }
140
- if (all || topics.has('filters')) {
141
- this.#renderChipState();
142
- this.#layoutList();
143
- }
144
- if (all || topics.has('review')) {
145
- this.#renderProgress();
146
- this.#refreshRows();
153
+ /**
154
+ * Apply sizes (after a drag, or `instant`: remembered ones, which must not animate in on
155
+ * load); the width is clamped when the layout is known.
156
+ */
157
+ setSizes(width, detailShare, instant = false) {
158
+ if (instant) {
159
+ this.el.classList.add('dragging');
160
+ frameOrTimeout(() => this.el.classList.remove('dragging'));
147
161
  }
148
- if (topics.has('selection')) {
149
- this.#refreshRows();
150
- this.#revealSelected();
162
+ this.#width = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, Math.round(width)));
163
+ this.#share = detailShare === null ? null : Math.max(0, Math.min(1, detailShare));
164
+ if (instant)
165
+ this.#reported = { width: this.#width, share: this.#share };
166
+ this.el.style.setProperty('--sidebar-w', `${this.#width}px`);
167
+ if (this.#share === null) {
168
+ this.el.removeAttribute('data-split');
169
+ this.el.style.removeProperty('--detail-share');
151
170
  }
152
- this.detail.update(topics);
153
- }
154
- /** Move keyboard focus into the list (after choosing a search result). */
155
- focusList() {
156
- this.list.focus({ preventScroll: true });
157
- }
158
- focusSearch() {
159
- this.searchInput.focus();
160
- this.searchInput.select();
161
- }
162
- /** Esc inside the sidebar: clear the search first. Returns whether it handled the key. */
163
- escape() {
164
- if (this.#query) {
165
- this.#setQuery('', true);
166
- return true;
171
+ else {
172
+ attr(this.el, 'data-split', 'true');
173
+ this.el.style.setProperty('--detail-share', String(this.#share));
167
174
  }
168
- return false;
175
+ this.#syncSeparators();
169
176
  }
170
- // ─── Header bits ─────────────────────────────────────────────────────────
171
- #renderChips() {
172
- const tr = this.#ctx.tr();
173
- const scene = this.#ctx.controller.scene;
174
- const counts = scene ? familyCounts(scene) : null;
175
- setText(this.#count, tr.num(scene?.hunks.length ?? 0));
176
- this.#count.hidden = !scene;
177
- this.#filters.hidden = !scene || scene.hunks.length === 0;
178
- for (const [f, chip] of this.#chips) {
179
- const n = counts ? counts[f] : 0;
180
- setText(chip.querySelector('.lbl'), tr.t(`family.${f}`));
181
- setText(chip.querySelector('.n'), tr.num(n));
182
- chip.hidden = n === 0;
183
- attr(chip, 'title', tr.t(`familyHint.${f}`));
177
+ update(topics) {
178
+ if (topics.has('status') || topics.has('data') || topics.has('locale'))
179
+ this.#relabel();
180
+ if (topics.has('search') || topics.has('status')) {
181
+ this.#placeList(this.#ctx.controller.query.trim() !== '');
184
182
  }
185
- }
186
- #renderChipState() {
187
- const fams = this.#ctx.controller.families;
188
- for (const [f, chip] of this.#chips) {
189
- attr(chip, 'aria-pressed', String(fams.has(f)));
183
+ if (topics.has('filters') || topics.has('status')) {
184
+ attr(this.#filtersButton, 'data-active', String(this.#ctx.controller.families.size > 0));
190
185
  }
191
- this.#showAll.hidden = fams.size === 0;
192
- }
193
- #renderProgress() {
194
- const tr = this.#ctx.tr();
195
- const state = this.#ctx.controller.reviewState();
196
- const done = state.reviewed.length;
197
- const total = state.total;
198
- this.#progress.hidden = total === 0;
199
- const complete = total > 0 && done === total;
200
- attr(this.#progress, 'data-complete', String(complete));
201
- setText(this.#progressText, complete ? tr.t('review.complete') : tr.t('review.progress', { done, total }));
202
- this.#progressBar.style.transform = `scaleX(${total > 0 ? done / total : 0})`;
203
186
  }
204
- // ─── Virtual list ────────────────────────────────────────────────────────
205
- #layoutList() {
206
- const c = this.#ctx.controller;
207
- const tr = this.#ctx.tr();
208
- replace(this.#phases, c.list.items.map((it) => it.kind === 'header'
209
- ? h('span', {
210
- id: `kg-phase-${it.phase}`,
211
- text: `${tr.t(`phase.${it.phase}`)} (${tr.num(it.count)})`,
212
- })
213
- : null));
214
- for (const el of this.#rendered.values())
215
- el.remove();
216
- this.#rendered.clear();
217
- const items = c.list.items;
218
- this.#offsets = new Array(items.length);
219
- let y = 0;
220
- for (let i = 0; i < items.length; i++) {
221
- this.#offsets[i] = y;
222
- y += itemHeight(items[i]);
223
- }
224
- this.#listInner.style.height = `${y + 8}px`;
225
- this.#renderEmpty();
226
- this.renderWindow();
187
+ /**
188
+ * The list's place shows the rows while the list is open and the search results while a
189
+ * search is active, whatever the list's state (the results take its place).
190
+ */
191
+ #placeList(searching) {
192
+ const state = this.#states?.list ?? 'open';
193
+ const shown = state === 'open' || searching;
194
+ if (!shown)
195
+ moveFocusOut(this.#ctx.root, this.#listWrap, () => this.#listToggle);
196
+ this.#listWrap.hidden = !shown;
197
+ this.#listWrap.inert = !shown;
198
+ const detailOpen = (this.#states?.detail ?? 'open') === 'open';
199
+ this.#split.hidden = !(shown && detailOpen);
200
+ attr(this.el, 'data-list', shown ? 'open' : 'folded');
227
201
  }
228
- #renderEmpty() {
229
- const c = this.#ctx.controller;
202
+ #relabel() {
230
203
  const tr = this.#ctx.tr();
231
- const existing = this.#listInner.querySelector('.list-empty, .skeleton');
232
- existing?.remove();
233
- if (c.status === 'loading' || c.status === 'idle') {
234
- const sk = h('div', { class: 'skeleton', 'aria-hidden': 'true' });
235
- if (c.status === 'loading') {
236
- for (let i = 0; i < 6; i++) {
237
- sk.appendChild(h('div', { class: 'skeleton-row shimmer' }, h('span', { class: 'g' }), h('span', { class: 'b' })));
238
- }
239
- }
240
- this.#listInner.appendChild(sk);
241
- return;
242
- }
243
- if (c.status !== 'ready' || c.list.items.length > 0)
244
- return;
245
- const filtered = c.families.size > 0 && c.hunkCount > 0;
246
- this.#listInner.appendChild(h('div', { class: 'list-empty' }, icon(filtered ? 'search' : 'check-circle'), h('span', { text: tr.t(filtered ? 'sidebar.filteredEmpty' : 'sidebar.noChanges') }), filtered
247
- ? h('button', {
248
- type: 'button',
249
- class: 'btn',
250
- text: tr.t('sidebar.clearFilters'),
251
- onclick: () => c.setFamilies([]),
252
- })
253
- : null));
204
+ attr(this.el, 'aria-label', tr.t('sidebar.label'));
205
+ attr(this.#handle, 'aria-label', tr.t('sidebar.sheet'));
206
+ attr(this.#resize, 'aria-label', tr.t('sidebar.resize'));
207
+ attr(this.#split, 'aria-label', tr.t('sidebar.split'));
208
+ const listOpen = this.#states?.list !== 'collapsed';
209
+ relabel(this.#listToggle, tr.t(listOpen ? 'sidebar.hideList' : 'sidebar.showList'));
210
+ relabel(this.#searchButton, `${tr.t('sidebar.search')} (/)`);
211
+ relabel(this.#filtersButton, tr.t('sidebar.filters'));
212
+ const bar = this.#detailBar.querySelector('.lbl');
213
+ if (bar)
214
+ bar.textContent = tr.t('detail.label');
215
+ attr(this.#detailBar, 'title', tr.t(this.#states?.detail === 'collapsed' ? 'detail.show' : 'detail.hide'));
254
216
  }
255
- #queueWindow() {
256
- if (this.#scrollQueued)
217
+ /** A drag or key press ended: report the sizes, unless they are where they were. */
218
+ #done() {
219
+ const { width, share } = this.#reported;
220
+ if (width === this.#width && share === this.#share)
257
221
  return;
258
- this.#scrollQueued = true;
259
- requestAnimationFrame(() => {
260
- this.#scrollQueued = false;
261
- this.renderWindow();
262
- });
222
+ this.#reported = { width: this.#width, share: this.#share };
223
+ this.#host.resized(this.#width, this.#share);
263
224
  }
264
- /** First item index whose bottom is below `y` (binary search over offsets). */
265
- #indexAt(y) {
266
- let lo = 0;
267
- let hi = this.#offsets.length;
268
- while (lo < hi) {
269
- const mid = (lo + hi) >> 1;
270
- if (this.#offsets[mid] + ROW_SLOT <= y)
271
- lo = mid + 1;
272
- else
273
- hi = mid;
274
- }
275
- return lo;
276
- }
277
- renderWindow() {
278
- const items = this.#ctx.controller.list.items;
279
- const top = this.list.scrollTop;
280
- const height = this.list.clientHeight || 640;
281
- const from = this.#indexAt(Math.max(0, top - OVERSCAN));
282
- const limit = top + height + OVERSCAN;
283
- const needed = new Set();
284
- for (let i = from; i < items.length && this.#offsets[i] < limit; i++)
285
- needed.add(i);
286
- for (const [i, el] of this.#rendered) {
287
- if (!needed.has(i)) {
288
- el.remove();
289
- this.#rendered.delete(i);
290
- }
291
- }
292
- for (const i of needed) {
293
- if (this.#rendered.has(i))
294
- continue;
295
- const el = this.#renderItem(items[i], this.#offsets[i]);
296
- this.#rendered.set(i, el);
297
- this.#listInner.appendChild(el);
298
- }
299
- this.#renderSticky(top);
300
- }
301
- #renderItem(item, y) {
302
- const tr = this.#ctx.tr();
303
- const c = this.#ctx.controller;
304
- if (item.kind === 'header') {
305
- return h('div', { class: 'group', role: 'presentation', style: `top: ${y}px` }, h('span', { text: tr.t(`phase.${item.phase}`) }), h('span', { class: 'n', text: tr.num(item.count) }));
306
- }
307
- const hunk = c.scene?.hunks[item.hunk];
308
- const reviewed = c.isReviewed(hunk.id);
309
- // Pointer shortcut only: options hold no interactive children. Keyboard users press `x` or
310
- // use the detail panel's "Reviewed" button.
311
- const rv = h('span', {
312
- class: 'rv',
313
- 'aria-hidden': 'true',
314
- 'data-pressed': String(reviewed),
315
- title: tr.t(reviewed ? 'review.unmark' : 'review.mark'),
316
- 'data-hunk': hunk.id,
317
- }, h('span', { class: 'ring' }, icon('check')));
318
- const rank = c.list.rank.get(item.hunk) ?? 0;
319
- const row = h('div', {
320
- class: 'row',
321
- role: 'option',
322
- id: `kg-opt-${item.hunk}`,
323
- 'data-index': String(item.hunk),
324
- 'data-family': hunk.family,
325
- 'data-reviewed': String(reviewed),
326
- 'aria-selected': String(c.selectedHunk === item.hunk),
327
- 'aria-setsize': String(c.list.order.length),
328
- 'aria-posinset': String(rank + 1),
329
- 'aria-describedby': `kg-phase-${hunk.phase}`,
330
- style: `top: ${y + 2}px`,
331
- }, h('span', { class: 'glyph-box', 'aria-hidden': 'true', text: sceneHunkGlyph(hunk) }), h('span', { class: 'txt' }, h('span', { class: 'title', title: hunk.title, text: hunk.title }), h('span', { class: 'sub', text: hunk.subtitle })), rv);
332
- this.#labelRow(row, hunk, reviewed);
333
- return row;
334
- }
335
- #labelRow(row, hunk, reviewed) {
336
- const tr = this.#ctx.tr();
337
- const parts = [hunk.title];
338
- if (hunk.subtitle)
339
- parts.push(hunk.subtitle);
340
- if (reviewed)
341
- parts.push(tr.t('review.toggle'));
342
- attr(row, 'aria-label', parts.join('. '));
343
- }
344
- /** Update selection / reviewed state of the rendered rows in place. */
345
- #refreshRows() {
346
- const c = this.#ctx.controller;
347
- const tr = this.#ctx.tr();
348
- for (const el of this.#rendered.values()) {
349
- if (!el.classList.contains('row'))
350
- continue;
351
- const index = Number(el.dataset.index);
352
- const hunk = c.scene?.hunks[index];
353
- if (!hunk)
354
- continue;
355
- const reviewed = c.isReviewed(hunk.id);
356
- attr(el, 'aria-selected', String(index === c.selectedHunk));
357
- attr(el, 'data-reviewed', String(reviewed));
358
- const rv = el.querySelector('.rv');
359
- attr(rv, 'data-pressed', String(reviewed));
360
- attr(rv, 'title', tr.t(reviewed ? 'review.unmark' : 'review.mark'));
361
- this.#labelRow(el, hunk, reviewed);
362
- }
363
- const sel = c.selectedHunk;
364
- attr(this.list, 'aria-activedescendant', sel >= 0 && c.list.rank.has(sel) ? `kg-opt-${sel}` : null);
365
- }
366
- #revealSelected() {
367
- const c = this.#ctx.controller;
368
- const item = c.list.itemOf.get(c.selectedHunk);
369
- if (item === undefined)
370
- return;
371
- const y = this.#offsets[item];
372
- const top = this.list.scrollTop;
373
- // Not laid out yet (hidden sheet): position it for when it shows.
374
- const height = this.list.clientHeight || 640;
375
- // The first row of a group scrolls its group header into view too.
376
- const above = c.list.items[item - 1]?.kind === 'header'
377
- ? this.#offsets[item - 1]
378
- : y - HEADER_SLOT;
379
- if (y < top + HEADER_SLOT)
380
- this.list.scrollTop = Math.max(0, above);
381
- else if (y + ROW_SLOT > top + height)
382
- this.list.scrollTop = y + ROW_SLOT - height + 8;
383
- this.renderWindow();
384
- this.#refreshRows();
225
+ // ─── Width ───────────────────────────────────────────────────────────────
226
+ /** The widest the sidebar may be next to the canvas, px. */
227
+ #maxWidth() {
228
+ const total = this.el.parentElement?.clientWidth ?? 0;
229
+ return total > 0
230
+ ? Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, total - STAGE_MIN))
231
+ : SIDEBAR_MAX;
385
232
  }
386
- #renderSticky(top) {
387
- const items = this.#ctx.controller.list.items;
388
- let current = -1;
389
- let next = -1;
390
- for (let i = 0; i < items.length; i++) {
391
- if (items[i]?.kind !== 'header')
392
- continue;
393
- if (this.#offsets[i] <= top)
394
- current = i;
395
- else {
396
- next = i;
397
- break;
398
- }
399
- }
400
- if (current < 0 || top <= 0) {
401
- this.#sticky.hidden = true;
402
- return;
403
- }
404
- const item = items[current];
405
- const tr = this.#ctx.tr();
406
- this.#sticky.hidden = false;
407
- const label = `${tr.t(`phase.${item.phase}`)}|${item.count}`;
408
- if (this.#sticky.dataset.key !== label) {
409
- this.#sticky.dataset.key = label;
410
- replace(this.#sticky, h('div', { class: 'group' }, h('span', { text: tr.t(`phase.${item.phase}`) }), h('span', { class: 'n', text: tr.num(item.count) })));
411
- }
412
- const push = next >= 0 ? Math.min(0, this.#offsets[next] - top - HEADER_SLOT) : 0;
413
- this.#sticky.style.transform = `translateY(${push}px)`;
233
+ #syncSeparators() {
234
+ const max = this.#maxWidth();
235
+ attr(this.#resize, 'aria-valuemax', String(max));
236
+ const now = Math.min(this.#width, max);
237
+ attr(this.#resize, 'aria-valuenow', String(now));
238
+ attr(this.#resize, 'aria-valuetext', `${now} px`);
239
+ const list = Math.round(100 * (1 - (this.#share ?? this.#measuredShare())));
240
+ attr(this.#split, 'aria-valuenow', String(list));
241
+ attr(this.#split, 'aria-valuetext', this.#ctx.tr().t('sidebar.splitValue', { p: `${list}%` }));
414
242
  }
415
- #onListClick(e) {
416
- const c = this.#ctx.controller;
417
- const target = e.target;
418
- const rv = target.closest('.rv');
419
- if (rv?.dataset.hunk) {
243
+ #bindResize() {
244
+ let start = null;
245
+ const set = (px, done) => {
246
+ const width = Math.max(SIDEBAR_MIN, Math.min(this.#maxWidth(), px));
247
+ this.setSizes(width, this.#share);
248
+ if (done)
249
+ this.#done();
250
+ };
251
+ this.#resize.addEventListener('pointerdown', (e) => {
252
+ e.preventDefault();
253
+ start = { x: e.clientX, w: this.el.getBoundingClientRect().width, id: e.pointerId };
254
+ this.#resize.setPointerCapture?.(e.pointerId);
255
+ this.el.classList.add('dragging');
256
+ });
257
+ this.#resize.addEventListener('pointermove', (e) => {
258
+ // The sidebar is on the right: dragging its edge left widens it.
259
+ if (start && e.pointerId === start.id)
260
+ set(start.w + (start.x - e.clientX), false);
261
+ });
262
+ const end = (e) => {
263
+ if (!start || e.pointerId !== start.id)
264
+ return;
265
+ start = null;
266
+ this.el.classList.remove('dragging');
267
+ this.#done();
268
+ };
269
+ this.#resize.addEventListener('pointerup', end);
270
+ this.#resize.addEventListener('pointercancel', end);
271
+ this.#resize.addEventListener('dblclick', () => set(SIDEBAR_WIDTH, true));
272
+ this.#resize.addEventListener('keydown', (e) => {
273
+ const step = e.shiftKey ? WIDTH_STEP_LARGE : WIDTH_STEP;
274
+ // From the width shown (a narrow layout holds the sidebar below the stored width).
275
+ const shown = Math.min(this.#width, this.#maxWidth());
276
+ const to = {
277
+ ArrowLeft: shown + step,
278
+ ArrowRight: shown - step,
279
+ PageUp: shown + WIDTH_STEP_LARGE,
280
+ PageDown: shown - WIDTH_STEP_LARGE,
281
+ Home: SIDEBAR_MIN,
282
+ End: this.#maxWidth(),
283
+ };
284
+ const next = to[e.key];
285
+ if (next === undefined)
286
+ return;
287
+ e.preventDefault();
420
288
  e.stopPropagation();
421
- c.toggleReviewed(rv.dataset.hunk);
422
- return;
423
- }
424
- const row = target.closest('.row');
425
- if (!row)
426
- return;
427
- const index = Number(row.dataset.index);
428
- if (Number.isInteger(index))
429
- c.selectHunk(index);
430
- }
431
- // ─── Search ──────────────────────────────────────────────────────────────
432
- #setQuery(query, focus = false) {
433
- this.#query = query;
434
- if (this.searchInput.value !== query)
435
- this.searchInput.value = query;
436
- this.#activeHit = 0;
437
- this.#runSearch();
438
- if (focus)
439
- this.searchInput.focus();
440
- }
441
- #runSearch() {
442
- const q = this.#query.trim();
443
- this.#hits = q ? this.#ctx.controller.search(q, SEARCH_LIMIT) : [];
444
- const searching = q !== '';
445
- this.#results.hidden = !searching;
446
- this.list.hidden = searching;
447
- this.#sticky.hidden = searching || this.#sticky.hidden;
448
- attr(this.searchInput, 'aria-expanded', String(searching));
449
- this.#updateSearchChrome();
450
- if (!searching) {
451
- replace(this.#results);
452
- this.renderWindow();
453
- return;
454
- }
455
- this.#renderResults();
456
- }
457
- #updateSearchChrome() {
458
- const focused = this.#ctx.root.activeElement === this.searchInput;
459
- this.#searchKbd.hidden = this.#query !== '' || focused;
460
- this.#searchClear.hidden = this.#query === '';
461
- }
462
- #renderResults() {
463
- const tr = this.#ctx.tr();
464
- const c = this.#ctx.controller;
465
- const q = this.#query.trim();
466
- if (this.#hits.length === 0) {
467
- replace(this.#results, h('div', { class: 'list-empty' }, icon('search'), h('span', { text: tr.t('sidebar.noResults', { q }) })));
468
- attr(this.searchInput, 'aria-activedescendant', null);
469
- return;
470
- }
471
- const scene = c.scene;
472
- const rows = this.#hits.map((hit, i) => {
473
- const sceneIndex = scene?.nodeIndex.get(hit.node.id);
474
- const sn = sceneIndex !== undefined ? scene?.nodes[sceneIndex] : undefined;
475
- const family = sn?.family ??
476
- (hit.node.status === 'added'
477
- ? 'added'
478
- : hit.node.status === 'removed'
479
- ? 'removed'
480
- : hit.node.status === 'modified'
481
- ? 'content'
482
- : 'neutral');
483
- const sub = hit.field === 'label' ? hit.node.id : hit.field === 'alias' ? hit.matched : hit.matched;
484
- return h('button', {
485
- type: 'button',
486
- class: 'result',
487
- role: 'option',
488
- id: `kg-hit-${i}`,
489
- tabindex: '-1',
490
- 'data-family': family,
491
- 'aria-selected': String(i === this.#activeHit),
492
- onclick: () => this.#choose(i),
493
- onpointermove: () => this.#setActiveHit(i),
494
- }, h('span', { class: 'sdot', 'aria-hidden': 'true' }), h('span', { class: 'txt' }, h('span', { class: 'title' }, highlight(hit.node.label, hit.field === 'label' ? q : '')), h('span', { class: 'sub' }, highlight(sub, hit.field === 'label' ? '' : q))), h('span', { class: 'status', text: tr.t(`status.${hit.node.status}`) }));
289
+ set(next, true);
495
290
  });
496
- replace(this.#results, h('div', {
497
- class: 'results-head',
498
- text: tr.plural('sidebar.results', this.#hits.length),
499
- }), rows);
500
- attr(this.searchInput, 'aria-activedescendant', `kg-hit-${this.#activeHit}`);
501
291
  }
502
- #setActiveHit(i) {
503
- if (i === this.#activeHit || i < 0 || i >= this.#hits.length)
504
- return;
505
- this.#activeHit = i;
506
- for (const el of this.#results.querySelectorAll('.result')) {
507
- attr(el, 'aria-selected', String(el.id === `kg-hit-${i}`));
508
- }
509
- attr(this.searchInput, 'aria-activedescendant', `kg-hit-${i}`);
510
- this.#results.querySelector(`#kg-hit-${i}`)?.scrollIntoView?.({ block: 'nearest' });
292
+ // ─── Split between the list and the detail panel ────────────────────────
293
+ /** The detail panel's share of the sidebar's height as laid out now (0 before layout). */
294
+ #measuredShare() {
295
+ const total = this.#inner.clientHeight;
296
+ return total > 0 ? this.#detailWrap.getBoundingClientRect().height / total : 0;
511
297
  }
512
- #choose(i) {
513
- const hit = this.#hits[i];
514
- if (!hit)
515
- return;
516
- this.#setQuery('');
517
- this.#ctx.controller.revealNode(hit.node.id);
518
- this.focusList();
298
+ /** Clamp a share so that the list and the detail panel keep their minimum heights. */
299
+ #clampShare(share) {
300
+ const total = this.#inner.clientHeight;
301
+ if (total <= 0)
302
+ return Math.max(0.1, Math.min(0.9, share));
303
+ const above = this.#head.parentElement?.getBoundingClientRect().height ?? 0;
304
+ const max = Math.max(DETAIL_MIN, total - above - LIST_MIN) / total;
305
+ return Math.max(DETAIL_MIN / total, Math.min(max, share));
519
306
  }
520
- #onSearchKey(e) {
521
- switch (e.key) {
522
- case 'ArrowDown':
523
- e.preventDefault();
524
- this.#setActiveHit(Math.min(this.#hits.length - 1, this.#activeHit + 1));
525
- break;
526
- case 'ArrowUp':
527
- e.preventDefault();
528
- this.#setActiveHit(Math.max(0, this.#activeHit - 1));
529
- break;
530
- case 'Enter':
531
- e.preventDefault();
532
- this.#choose(this.#activeHit);
533
- break;
534
- case 'Escape':
535
- e.preventDefault();
536
- e.stopPropagation();
537
- if (this.#query)
538
- this.#setQuery('');
539
- else
540
- this.focusList();
541
- break;
542
- default:
307
+ #bindSplit() {
308
+ let start = null;
309
+ const set = (share, done) => {
310
+ this.setSizes(this.#width, this.#clampShare(share));
311
+ if (done)
312
+ this.#done();
313
+ };
314
+ this.#split.addEventListener('pointerdown', (e) => {
315
+ e.preventDefault();
316
+ start = { y: e.clientY, share: this.#share ?? this.#measuredShare(), id: e.pointerId };
317
+ this.#split.setPointerCapture?.(e.pointerId);
318
+ this.el.classList.add('dragging');
319
+ });
320
+ this.#split.addEventListener('pointermove', (e) => {
321
+ if (!start || e.pointerId !== start.id)
543
322
  return;
544
- }
323
+ const total = this.#inner.clientHeight;
324
+ if (total > 0)
325
+ set(start.share + (start.y - e.clientY) / total, false);
326
+ });
327
+ const end = (e) => {
328
+ if (!start || e.pointerId !== start.id)
329
+ return;
330
+ start = null;
331
+ this.el.classList.remove('dragging');
332
+ this.#done();
333
+ };
334
+ this.#split.addEventListener('pointerup', end);
335
+ this.#split.addEventListener('pointercancel', end);
336
+ // Back to the content's height.
337
+ this.#split.addEventListener('dblclick', () => {
338
+ this.setSizes(this.#width, null);
339
+ this.#done();
340
+ });
341
+ this.#split.addEventListener('keydown', (e) => {
342
+ const current = this.#share ?? this.#measuredShare();
343
+ // Up moves the split up: the detail panel grows.
344
+ const to = {
345
+ ArrowUp: current + SPLIT_STEP,
346
+ ArrowDown: current - SPLIT_STEP,
347
+ PageUp: current + 2 * SPLIT_STEP,
348
+ PageDown: current - 2 * SPLIT_STEP,
349
+ Home: 1,
350
+ End: 0,
351
+ };
352
+ const next = to[e.key];
353
+ if (next === undefined)
354
+ return;
355
+ e.preventDefault();
356
+ e.stopPropagation();
357
+ set(next, true);
358
+ });
545
359
  }
546
360
  // ─── Bottom sheet ────────────────────────────────────────────────────────
547
361
  /** Separator semantics: the sheet's height as a percentage of the available height. */
@@ -558,6 +372,13 @@ export class Sidebar {
558
372
  if (total > 0)
559
373
  this.#sheetValue(this.el.getBoundingClientRect().height, total);
560
374
  }
375
+ /** The layout changed size: keep the width within what fits, refresh the separators. */
376
+ layout(compact) {
377
+ if (compact)
378
+ this.syncSheet();
379
+ else
380
+ this.#syncSeparators();
381
+ }
561
382
  #bindSheet() {
562
383
  let start = null;
563
384
  const host = () => this.el.parentElement;
@@ -594,17 +415,4 @@ export class Sidebar {
594
415
  });
595
416
  }
596
417
  }
597
- /** The label with the first occurrence of `q` (case-insensitive) marked. */
598
- function highlight(text, q) {
599
- if (!q)
600
- return [text];
601
- const at = text.toLowerCase().indexOf(q.toLowerCase());
602
- if (at < 0)
603
- return [text];
604
- return [
605
- text.slice(0, at),
606
- h('mark', { text: text.slice(at, at + q.length) }),
607
- text.slice(at + q.length),
608
- ];
609
- }
610
418
  //# sourceMappingURL=sidebar.js.map