@kgdiff/viewer 0.5.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 (232) 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/controller.d.ts +22 -74
  8. package/dist/controller.d.ts.map +1 -1
  9. package/dist/controller.js +35 -376
  10. package/dist/controller.js.map +1 -1
  11. package/dist/dom.d.ts +10 -0
  12. package/dist/dom.d.ts.map +1 -1
  13. package/dist/dom.js +29 -0
  14. package/dist/dom.js.map +1 -1
  15. package/dist/element.d.ts +52 -46
  16. package/dist/element.d.ts.map +1 -1
  17. package/dist/element.js +362 -403
  18. package/dist/element.js.map +1 -1
  19. package/dist/elements/actions.d.ts +27 -0
  20. package/dist/elements/actions.d.ts.map +1 -0
  21. package/dist/elements/actions.js +81 -0
  22. package/dist/elements/actions.js.map +1 -0
  23. package/dist/elements/bound.d.ts +63 -0
  24. package/dist/elements/bound.d.ts.map +1 -0
  25. package/dist/elements/bound.js +213 -0
  26. package/dist/elements/bound.js.map +1 -0
  27. package/dist/elements/change-detail.d.ts +25 -0
  28. package/dist/elements/change-detail.d.ts.map +1 -0
  29. package/dist/elements/change-detail.js +19 -0
  30. package/dist/elements/change-detail.js.map +1 -0
  31. package/dist/elements/change-list.d.ts +27 -0
  32. package/dist/elements/change-list.d.ts.map +1 -0
  33. package/dist/elements/change-list.js +29 -0
  34. package/dist/elements/change-list.js.map +1 -0
  35. package/dist/elements/filters.d.ts +25 -0
  36. package/dist/elements/filters.d.ts.map +1 -0
  37. package/dist/elements/filters.js +19 -0
  38. package/dist/elements/filters.js.map +1 -0
  39. package/dist/elements/legend.d.ts +40 -0
  40. package/dist/elements/legend.d.ts.map +1 -0
  41. package/dist/elements/legend.js +87 -0
  42. package/dist/elements/legend.js.map +1 -0
  43. package/dist/elements/minimap.d.ts +38 -0
  44. package/dist/elements/minimap.d.ts.map +1 -0
  45. package/dist/elements/minimap.js +59 -0
  46. package/dist/elements/minimap.js.map +1 -0
  47. package/dist/elements/root.d.ts +40 -0
  48. package/dist/elements/root.d.ts.map +1 -0
  49. package/dist/elements/root.js +67 -0
  50. package/dist/elements/root.js.map +1 -0
  51. package/dist/elements/search.d.ts +29 -0
  52. package/dist/elements/search.d.ts.map +1 -0
  53. package/dist/elements/search.js +23 -0
  54. package/dist/elements/search.js.map +1 -0
  55. package/dist/elements/stage-context.d.ts +31 -0
  56. package/dist/elements/stage-context.d.ts.map +1 -0
  57. package/dist/elements/stage-context.js +4 -0
  58. package/dist/elements/stage-context.js.map +1 -0
  59. package/dist/elements/stage.d.ts +65 -0
  60. package/dist/elements/stage.d.ts.map +1 -0
  61. package/dist/elements/stage.js +259 -0
  62. package/dist/elements/stage.js.map +1 -0
  63. package/dist/elements/title.d.ts +25 -0
  64. package/dist/elements/title.d.ts.map +1 -0
  65. package/dist/elements/title.js +19 -0
  66. package/dist/elements/title.js.map +1 -0
  67. package/dist/elements/transport.d.ts +29 -0
  68. package/dist/elements/transport.d.ts.map +1 -0
  69. package/dist/elements/transport.js +24 -0
  70. package/dist/elements/transport.js.map +1 -0
  71. package/dist/elements/view-bound.d.ts +39 -0
  72. package/dist/elements/view-bound.d.ts.map +1 -0
  73. package/dist/elements/view-bound.js +88 -0
  74. package/dist/elements/view-bound.js.map +1 -0
  75. package/dist/elements/view-dock.d.ts +28 -0
  76. package/dist/elements/view-dock.d.ts.map +1 -0
  77. package/dist/elements/view-dock.js +28 -0
  78. package/dist/elements/view-dock.js.map +1 -0
  79. package/dist/elements/zoom.d.ts +28 -0
  80. package/dist/elements/zoom.d.ts.map +1 -0
  81. package/dist/elements/zoom.js +22 -0
  82. package/dist/elements/zoom.js.map +1 -0
  83. package/dist/i18n.d.ts +10 -0
  84. package/dist/i18n.d.ts.map +1 -1
  85. package/dist/i18n.js +20 -0
  86. package/dist/i18n.js.map +1 -1
  87. package/dist/icons.d.ts +1 -0
  88. package/dist/icons.d.ts.map +1 -1
  89. package/dist/icons.js +1 -0
  90. package/dist/icons.js.map +1 -1
  91. package/dist/index.d.ts +328 -34
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +13 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/keyboard.d.ts +39 -2
  96. package/dist/keyboard.d.ts.map +1 -1
  97. package/dist/keyboard.js +145 -62
  98. package/dist/keyboard.js.map +1 -1
  99. package/dist/layout-store.d.ts +26 -0
  100. package/dist/layout-store.d.ts.map +1 -0
  101. package/dist/layout-store.js +46 -0
  102. package/dist/layout-store.js.map +1 -0
  103. package/dist/panel-levels.d.ts +18 -0
  104. package/dist/panel-levels.d.ts.map +1 -0
  105. package/dist/panel-levels.js +41 -0
  106. package/dist/panel-levels.js.map +1 -0
  107. package/dist/panels.d.ts +28 -0
  108. package/dist/panels.d.ts.map +1 -0
  109. package/dist/panels.js +73 -0
  110. package/dist/panels.js.map +1 -0
  111. package/dist/prefs.d.ts +5 -2
  112. package/dist/prefs.d.ts.map +1 -1
  113. package/dist/prefs.js +12 -3
  114. package/dist/prefs.js.map +1 -1
  115. package/dist/react.d.ts +460 -0
  116. package/dist/react.d.ts.map +1 -0
  117. package/dist/react.js +2 -0
  118. package/dist/react.js.map +1 -0
  119. package/dist/root-element.d.ts +122 -0
  120. package/dist/root-element.d.ts.map +1 -0
  121. package/dist/root-element.js +541 -0
  122. package/dist/root-element.js.map +1 -0
  123. package/dist/root.d.ts +30 -30
  124. package/dist/root.d.ts.map +1 -1
  125. package/dist/root.js +65 -42
  126. package/dist/root.js.map +1 -1
  127. package/dist/session.d.ts +19 -3
  128. package/dist/session.d.ts.map +1 -1
  129. package/dist/session.js +15 -17
  130. package/dist/session.js.map +1 -1
  131. package/dist/standalone/kgdiff-viewer.js +4 -4
  132. package/dist/styles.d.ts +4 -0
  133. package/dist/styles.d.ts.map +1 -1
  134. package/dist/styles.js +224 -84
  135. package/dist/styles.js.map +1 -1
  136. package/dist/theme.d.ts +7 -0
  137. package/dist/theme.d.ts.map +1 -1
  138. package/dist/theme.js +10 -7
  139. package/dist/theme.js.map +1 -1
  140. package/dist/topics.d.ts +8 -0
  141. package/dist/topics.d.ts.map +1 -0
  142. package/dist/topics.js +18 -0
  143. package/dist/topics.js.map +1 -0
  144. package/dist/ui/change-list.d.ts.map +1 -1
  145. package/dist/ui/change-list.js +2 -2
  146. package/dist/ui/change-list.js.map +1 -1
  147. package/dist/ui/context.d.ts +20 -7
  148. package/dist/ui/context.d.ts.map +1 -1
  149. package/dist/ui/context.js.map +1 -1
  150. package/dist/ui/header.d.ts +41 -3
  151. package/dist/ui/header.d.ts.map +1 -1
  152. package/dist/ui/header.js +99 -34
  153. package/dist/ui/header.js.map +1 -1
  154. package/dist/ui/legend.d.ts +15 -0
  155. package/dist/ui/legend.d.ts.map +1 -0
  156. package/dist/ui/legend.js +68 -0
  157. package/dist/ui/legend.js.map +1 -0
  158. package/dist/ui/popovers.d.ts +3 -1
  159. package/dist/ui/popovers.d.ts.map +1 -1
  160. package/dist/ui/popovers.js +62 -23
  161. package/dist/ui/popovers.js.map +1 -1
  162. package/dist/ui/search.d.ts.map +1 -1
  163. package/dist/ui/search.js +3 -2
  164. package/dist/ui/search.js.map +1 -1
  165. package/dist/ui/sidebar.d.ts +26 -3
  166. package/dist/ui/sidebar.d.ts.map +1 -1
  167. package/dist/ui/sidebar.js +344 -12
  168. package/dist/ui/sidebar.js.map +1 -1
  169. package/dist/ui/stage.d.ts +56 -1
  170. package/dist/ui/stage.d.ts.map +1 -1
  171. package/dist/ui/stage.js +244 -256
  172. package/dist/ui/stage.js.map +1 -1
  173. package/dist/ui/transport.d.ts +12 -0
  174. package/dist/ui/transport.d.ts.map +1 -0
  175. package/dist/ui/transport.js +198 -0
  176. package/dist/ui/transport.js.map +1 -0
  177. package/dist/ui/zoom.d.ts +15 -0
  178. package/dist/ui/zoom.d.ts.map +1 -0
  179. package/dist/ui/zoom.js +30 -0
  180. package/dist/ui/zoom.js.map +1 -0
  181. package/dist/view.d.ts +94 -0
  182. package/dist/view.d.ts.map +1 -0
  183. package/dist/view.js +441 -0
  184. package/dist/view.js.map +1 -0
  185. package/package.json +95 -12
  186. package/src/bind.ts +80 -0
  187. package/src/controller.ts +51 -442
  188. package/src/dom.ts +30 -0
  189. package/src/element.ts +364 -406
  190. package/src/elements/actions.ts +94 -0
  191. package/src/elements/bound.ts +247 -0
  192. package/src/elements/change-detail.ts +34 -0
  193. package/src/elements/change-list.ts +51 -0
  194. package/src/elements/filters.ts +34 -0
  195. package/src/elements/legend.ts +103 -0
  196. package/src/elements/minimap.ts +75 -0
  197. package/src/elements/root.ts +86 -0
  198. package/src/elements/search.ts +38 -0
  199. package/src/elements/stage-context.ts +34 -0
  200. package/src/elements/stage.ts +285 -0
  201. package/src/elements/title.ts +34 -0
  202. package/src/elements/transport.ts +39 -0
  203. package/src/elements/view-bound.ts +118 -0
  204. package/src/elements/view-dock.ts +43 -0
  205. package/src/elements/zoom.ts +38 -0
  206. package/src/i18n.ts +20 -0
  207. package/src/icons.ts +1 -0
  208. package/src/index.ts +347 -34
  209. package/src/keyboard.ts +188 -61
  210. package/src/layout-store.ts +71 -0
  211. package/src/panel-levels.ts +53 -0
  212. package/src/panels.ts +81 -0
  213. package/src/prefs.ts +12 -4
  214. package/src/react.ts +459 -0
  215. package/src/root-element.ts +624 -0
  216. package/src/root.ts +92 -65
  217. package/src/session.ts +22 -33
  218. package/src/styles.ts +245 -84
  219. package/src/theme.ts +10 -7
  220. package/src/topics.ts +37 -0
  221. package/src/ui/change-list.ts +6 -2
  222. package/src/ui/context.ts +20 -7
  223. package/src/ui/header.ts +127 -56
  224. package/src/ui/legend.ts +112 -0
  225. package/src/ui/popovers.ts +68 -25
  226. package/src/ui/search.ts +10 -5
  227. package/src/ui/sidebar.ts +386 -23
  228. package/src/ui/stage.ts +267 -331
  229. package/src/ui/transport.ts +240 -0
  230. package/src/ui/zoom.ts +45 -0
  231. package/src/view.ts +516 -0
  232. package/vscode.html-custom-data.json +726 -0
package/src/ui/sidebar.ts CHANGED
@@ -2,31 +2,85 @@
2
2
  * The default layout's sidebar (RFC 0001 §4): the change list with the search and the family
3
3
  * filters in its head, the search results in the list's place, and the detail panel below.
4
4
  * Below 760 px it is a resizable bottom sheet. The regions in it are bound to the root on their
5
- * own; the sidebar only places them, opens and closes, and resizes as a sheet.
5
+ * 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.
6
10
  */
7
- import { attr, h } from '../dom.js';
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';
8
15
  import type { Topic } from '../session.js';
9
16
  import type { ChangeList } from './change-list.js';
10
- import type { UiActions } from './context.js';
17
+ import type { UiContext } from './context.js';
11
18
  import type { Detail } from './detail.js';
12
19
  import type { Filters } from './filters.js';
13
20
  import type { Search } from './search.js';
14
21
 
15
22
  export { HEADER_SLOT, ROW_SLOT } from './change-list.js';
16
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;
46
+ }
47
+
17
48
  export class Sidebar {
18
49
  readonly el: HTMLElement;
50
+ readonly #inner: HTMLElement;
19
51
  readonly #handle: HTMLElement;
20
- readonly #tr: UiActions['tr'];
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;
65
+ readonly #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 };
21
72
 
22
73
  constructor(
23
- tr: UiActions['tr'],
74
+ ctx: UiContext,
75
+ host: SidebarHost,
24
76
  list: ChangeList,
25
77
  search: Search,
26
78
  filters: Filters,
27
79
  detail: Detail,
28
80
  ) {
29
- this.#tr = tr;
81
+ this.#ctx = ctx;
82
+ this.#host = host;
83
+ this.#detail = detail;
30
84
  this.#handle = h('div', {
31
85
  class: 'sheet-handle',
32
86
  role: 'separator',
@@ -34,33 +88,332 @@ export class Sidebar {
34
88
  tabindex: '0',
35
89
  });
36
90
  this.#bindSheet();
37
- this.el = h(
38
- 'aside',
39
- { class: 'sidebar', 'data-open': 'true' },
40
- h(
41
- 'div',
42
- { class: 'sidebar-inner' },
43
- this.#handle,
44
- h('div', { class: 'sb-head' }, list.head, search.box, filters.el),
45
- h('div', { class: 'list-wrap' }, list.sticky, list.list, search.results, list.phases),
46
- detail.el,
47
- ),
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',
99
+ });
100
+ this.#searchButton = iconButton('search', '', () => host.setPanel('search', 'open'), {
101
+ class: 'icon-btn sm',
102
+ hidden: true,
103
+ });
104
+ this.#filtersButton = iconButton('filter', '', () => host.setPanel('filters', 'open'), {
105
+ class: 'icon-btn sm filters-btn',
106
+ hidden: true,
107
+ });
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',
129
+ role: 'separator',
130
+ 'aria-orientation': 'horizontal',
131
+ 'aria-valuemin': '0',
132
+ 'aria-valuemax': '100',
133
+ tabindex: '0',
134
+ });
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'),
48
146
  );
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,
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();
168
+ }
169
+
170
+ get width(): number {
171
+ return this.#width;
172
+ }
173
+
174
+ get detailShare(): number | null {
175
+ return this.#share;
49
176
  }
50
177
 
51
- setOpen(open: boolean): void {
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;
52
204
  attr(this.el, 'data-open', String(open));
53
205
  this.el.inert = !open;
206
+ this.#relabel();
207
+ }
208
+
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'));
217
+ }
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));
228
+ }
229
+ this.#syncSeparators();
54
230
  }
55
231
 
56
232
  update(topics: ReadonlySet<Topic>): void {
57
- if (topics.has('status') || topics.has('data') || topics.has('locale')) {
58
- const tr = this.#tr();
59
- attr(this.el, 'aria-label', tr.t('sidebar.label'));
60
- attr(this.#handle, 'aria-label', tr.t('sidebar.sheet'));
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() !== '');
236
+ }
237
+ if (topics.has('filters') || topics.has('status')) {
238
+ attr(this.#filtersButton, 'data-active', String(this.#ctx.controller.families.size > 0));
61
239
  }
62
240
  }
63
241
 
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');
255
+ }
256
+
257
+ #relabel(): void {
258
+ const tr = this.#ctx.tr();
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'),
273
+ );
274
+ }
275
+
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);
282
+ }
283
+
284
+ // ─── Width ───────────────────────────────────────────────────────────────
285
+
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;
292
+ }
293
+
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}%` }));
303
+ }
304
+
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();
346
+ e.stopPropagation();
347
+ set(next, true);
348
+ });
349
+ }
350
+
351
+ // ─── Split between the list and the detail panel ────────────────────────
352
+
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;
357
+ }
358
+
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));
366
+ }
367
+
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);
414
+ });
415
+ }
416
+
64
417
  // ─── Bottom sheet ────────────────────────────────────────────────────────
65
418
 
66
419
  /** Separator semantics: the sheet's height as a percentage of the available height. */
@@ -69,7 +422,11 @@ export class Sidebar {
69
422
  attr(this.#handle, 'aria-valuemin', String(pct(120)));
70
423
  attr(this.#handle, 'aria-valuemax', String(pct(total - 96)));
71
424
  attr(this.#handle, 'aria-valuenow', String(pct(px)));
72
- attr(this.#handle, 'aria-valuetext', this.#tr().t('sidebar.sheetValue', { p: `${pct(px)}%` }));
425
+ attr(
426
+ this.#handle,
427
+ 'aria-valuetext',
428
+ this.#ctx.tr().t('sidebar.sheetValue', { p: `${pct(px)}%` }),
429
+ );
73
430
  }
74
431
 
75
432
  /** Refresh the separator values (layout changes). */
@@ -78,6 +435,12 @@ export class Sidebar {
78
435
  if (total > 0) this.#sheetValue(this.el.getBoundingClientRect().height, total);
79
436
  }
80
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
+
81
444
  #bindSheet(): void {
82
445
  let start: { y: number; h: number; id: number } | null = null;
83
446
  const host = (): HTMLElement | null => this.el.parentElement;