@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
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The family filters (RFC 0001 §3, `<kgdiff-filters>` from phase 2): one chip per family the
3
+ * diff has, with its count and the legend's swatch, toggling the session's family filter, and
4
+ * "show all" while a filter is on.
5
+ */
6
+ import { attr, h, setText } from '../dom.js';
7
+ import { familyCounts } from '../list-model.js';
8
+ import type { Topic } from '../session.js';
9
+ import { FAMILIES } from '../styles.js';
10
+ import type { UiContext } from './context.js';
11
+ import { swatch } from './swatch.js';
12
+
13
+ export class Filters {
14
+ readonly el: HTMLElement;
15
+ readonly #chips = new Map<string, HTMLButtonElement>();
16
+ readonly #showAll: HTMLButtonElement;
17
+
18
+ readonly #ctx: UiContext;
19
+
20
+ constructor(ctx: UiContext) {
21
+ this.#ctx = ctx;
22
+ const c = ctx.controller;
23
+ this.el = h('div', { class: 'filters', role: 'group' });
24
+ for (const f of FAMILIES) {
25
+ const chip = h(
26
+ 'button',
27
+ {
28
+ type: 'button',
29
+ class: 'fchip',
30
+ 'data-family': f,
31
+ 'aria-pressed': 'false',
32
+ onclick: () => c.toggleFamily(f),
33
+ },
34
+ swatch(f),
35
+ h('span', { class: 'lbl' }),
36
+ h('span', { class: 'n' }),
37
+ );
38
+ this.#chips.set(f, chip);
39
+ this.el.appendChild(chip);
40
+ }
41
+ this.#showAll = h('button', {
42
+ type: 'button',
43
+ class: 'link-btn',
44
+ hidden: true,
45
+ onclick: () => c.setFamilies([]),
46
+ });
47
+ this.el.appendChild(this.#showAll);
48
+ }
49
+
50
+ update(topics: ReadonlySet<Topic>): void {
51
+ const all = topics.has('status') || topics.has('data') || topics.has('locale');
52
+ if (all) this.#renderChips();
53
+ if (all || topics.has('filters')) this.#renderState();
54
+ }
55
+
56
+ #renderChips(): void {
57
+ const tr = this.#ctx.tr();
58
+ const scene = this.#ctx.controller.scene;
59
+ const counts = scene ? familyCounts(scene) : null;
60
+ attr(this.el, 'aria-label', tr.t('sidebar.filters'));
61
+ setText(this.#showAll, tr.t('sidebar.clearFilters'));
62
+ this.el.hidden = !scene || scene.hunks.length === 0;
63
+ for (const [f, chip] of this.#chips) {
64
+ const n = counts ? counts[f as keyof typeof counts] : 0;
65
+ setText(chip.querySelector('.lbl') as Element, tr.t(`family.${f as keyof typeof counts}`));
66
+ setText(chip.querySelector('.n') as Element, tr.num(n));
67
+ chip.hidden = n === 0;
68
+ attr(chip, 'title', tr.t(`familyHint.${f as keyof typeof counts}`));
69
+ }
70
+ }
71
+
72
+ #renderState(): void {
73
+ const fams = this.#ctx.controller.families;
74
+ for (const [f, chip] of this.#chips) {
75
+ attr(chip, 'aria-pressed', String(fams.has(f as never)));
76
+ }
77
+ this.#showAll.hidden = fams.size === 0;
78
+ }
79
+ }
package/src/ui/header.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The canvas HUD: the title (brand and compared versions) floating top-left, the actions
3
- * (appearance menu, help, sidebar toggle) floating top-right. Both stay up while loading and on
4
- * errors. How the diff is viewed is chosen in the dock at the bottom (`dock.ts`).
2
+ * The canvas HUD: the title (`Title`, `<kgdiff-title>`: brand and compared versions) floating
3
+ * top-left, the actions (`Actions`, `<kgdiff-actions>`: appearance menu, help, sidebar toggle)
4
+ * floating top-right. Both stay up while loading and on errors. How the diff is viewed is
5
+ * chosen in the dock at the bottom (`dock.ts`).
5
6
  */
6
7
  import type { Topic } from '../controller.js';
7
- import { attr, h, iconButton, relabel, replace } from '../dom.js';
8
+ import { attr, h, iconButton, moveFocusOut, relabel, replace } from '../dom.js';
9
+ import type { PanelState } from '../index.js';
8
10
  import type { UiContext } from './context.js';
9
11
 
10
12
  /** The kgdiff mark: a soft accent diamond holding a node. */
@@ -13,21 +15,16 @@ function brandMark(): SVGSVGElement {
13
15
  tpl.innerHTML =
14
16
  '<svg class="brand-mark" viewBox="0 0 20 20" aria-hidden="true" focusable="false">' +
15
17
  '<rect x="4.1" y="4.1" width="11.8" height="11.8" rx="3.2" transform="rotate(45 10 10)" ' +
16
- 'fill="var(--kg-accent-soft)" stroke="var(--kg-accent)" stroke-width="1.5"/>' +
17
- '<circle cx="10" cy="10" r="2.4" fill="var(--kg-accent)"/></svg>';
18
+ 'fill="var(--kgdiff-accent-soft)" stroke="var(--kgdiff-accent)" stroke-width="1.5"/>' +
19
+ '<circle cx="10" cy="10" r="2.4" fill="var(--kgdiff-accent)"/></svg>';
18
20
  return tpl.content.firstElementChild as SVGSVGElement;
19
21
  }
20
22
 
21
- export class Header {
23
+ /** The title card: the brand, then the compared names and versions. */
24
+ export class Title {
22
25
  readonly el: HTMLElement;
23
- readonly menuButton: HTMLButtonElement;
24
26
  readonly #brand: HTMLElement;
25
27
  readonly #versions: HTMLElement;
26
- readonly #actions: HTMLElement;
27
- readonly #end: HTMLElement;
28
- readonly #helpButton: HTMLButtonElement;
29
- readonly #sidebarButton: HTMLButtonElement;
30
-
31
28
  readonly #ctx: UiContext;
32
29
 
33
30
  constructor(ctx: UiContext) {
@@ -39,60 +36,21 @@ export class Header {
39
36
  h('span', { class: 'brand-name', text: 'kgdiff' }),
40
37
  );
41
38
  this.#versions = h('div', { class: 'versions' });
42
- this.menuButton = iconButton('contrast', '', () => ctx.toggleMenu(), {
43
- 'aria-haspopup': 'menu',
44
- 'aria-expanded': 'false',
45
- });
46
- this.#helpButton = iconButton('help', '', () => ctx.openHelp());
47
- this.#sidebarButton = iconButton('sidebar', '', () => ctx.toggleSidebar());
48
- this.#actions = h(
49
- 'div',
50
- { class: 'hud-actions card', role: 'group' },
51
- this.menuButton,
52
- this.#helpButton,
53
- this.#sidebarButton,
54
- );
55
- // The menu is a sibling of the actions card, not inside it: a backdrop filter would clip
56
- // the menu's own blur to the card.
57
- this.#end = h('div', { class: 'hud-end' }, this.#actions);
58
39
  this.el = h(
59
- 'header',
60
- { class: 'hud' },
61
- h(
62
- 'div',
63
- { class: 'hud-title card' },
64
- this.#brand,
65
- h('span', { class: 'header-sep', 'aria-hidden': 'true' }),
66
- this.#versions,
67
- ),
68
- this.#end,
40
+ 'div',
41
+ { class: 'hud-title card' },
42
+ this.#brand,
43
+ h('span', { class: 'header-sep', 'aria-hidden': 'true' }),
44
+ this.#versions,
69
45
  );
70
46
  }
71
47
 
72
- /** Place the appearance menu under the actions. */
73
- attachMenu(menu: HTMLElement): void {
74
- this.#end.appendChild(menu);
75
- }
76
-
77
48
  update(topics: ReadonlySet<Topic>): void {
78
49
  if (!(topics.has('locale') || topics.has('data') || topics.has('status'))) return;
79
- const tr = this.#ctx.tr();
80
- attr(this.#brand, 'title', `kgdiff — ${tr.t('brand.tagline')}`);
81
- attr(this.#actions, 'aria-label', tr.t('header.actions'));
82
- relabel(this.menuButton, tr.t('action.settings'));
83
- relabel(this.#helpButton, `${tr.t('action.help')} (?)`);
84
- relabel(this.#sidebarButton, `${tr.t('action.sidebar')} (\\)`);
50
+ attr(this.#brand, 'title', `kgdiff — ${this.#ctx.tr().t('brand.tagline')}`);
85
51
  this.#renderVersions();
86
52
  }
87
53
 
88
- setSidebarState(open: boolean): void {
89
- attr(this.#sidebarButton, 'aria-pressed', String(open));
90
- }
91
-
92
- setMenuState(open: boolean): void {
93
- attr(this.menuButton, 'aria-expanded', String(open));
94
- }
95
-
96
54
  #renderVersions(): void {
97
55
  const data = this.#ctx.controller.data;
98
56
  if (!data) {
@@ -146,3 +104,116 @@ export class Header {
146
104
  );
147
105
  }
148
106
  }
107
+
108
+ /**
109
+ * The actions: the appearance menu's button (the menu itself is placed next to the card with
110
+ * `attachMenu`: a backdrop filter would clip its blur to the card), help, and, in the default
111
+ * layout, the sidebar toggle.
112
+ */
113
+ export class Actions {
114
+ /** The card and, once attached, the menu next to it. */
115
+ readonly el: HTMLElement;
116
+ readonly menuButton: HTMLButtonElement;
117
+ readonly sidebarButton: HTMLButtonElement | null;
118
+ readonly #card: HTMLElement;
119
+ readonly #helpButton: HTMLButtonElement;
120
+ readonly #ctx: UiContext;
121
+
122
+ /** `sidebar`: with the sidebar toggle (the default layout's). */
123
+ constructor(ctx: UiContext, sidebar = true) {
124
+ this.#ctx = ctx;
125
+ this.menuButton = iconButton('contrast', '', () => ctx.toggleMenu(), {
126
+ 'aria-haspopup': 'menu',
127
+ 'aria-expanded': 'false',
128
+ });
129
+ this.#helpButton = iconButton('help', '', () => ctx.openHelp());
130
+ this.sidebarButton = sidebar ? iconButton('sidebar', '', () => ctx.toggleSidebar()) : null;
131
+ this.#card = h(
132
+ 'div',
133
+ { class: 'hud-actions card', role: 'group' },
134
+ this.menuButton,
135
+ this.#helpButton,
136
+ this.sidebarButton,
137
+ );
138
+ this.el = h('div', { class: 'hud-end' }, this.#card);
139
+ }
140
+
141
+ /** Place the appearance menu under the actions. */
142
+ attachMenu(menu: HTMLElement): void {
143
+ this.el.appendChild(menu);
144
+ }
145
+
146
+ update(topics: ReadonlySet<Topic>): void {
147
+ if (!(topics.has('locale') || topics.has('data') || topics.has('status'))) return;
148
+ const tr = this.#ctx.tr();
149
+ attr(this.#card, 'aria-label', tr.t('header.actions'));
150
+ relabel(this.menuButton, tr.t('action.settings'));
151
+ relabel(this.#helpButton, `${tr.t('action.help')} (?)`);
152
+ if (this.sidebarButton) relabel(this.sidebarButton, `${tr.t('action.sidebar')} (\\)`);
153
+ }
154
+
155
+ setMenuState(open: boolean): void {
156
+ attr(this.menuButton, 'aria-expanded', String(open));
157
+ }
158
+
159
+ /** Whether the sidebar is open (the toggle's pressed state). */
160
+ setSidebarState(open: boolean): void {
161
+ if (this.sidebarButton) attr(this.sidebarButton, 'aria-pressed', String(open));
162
+ }
163
+ }
164
+
165
+ /** The default layout's HUD: the title and the actions over the canvas. */
166
+ export class Header {
167
+ readonly el: HTMLElement;
168
+ readonly title: Title;
169
+ readonly actions: Actions;
170
+ readonly menuButton: HTMLButtonElement;
171
+ readonly sidebarButton: HTMLButtonElement;
172
+ readonly #ctx: UiContext;
173
+
174
+ constructor(ctx: UiContext) {
175
+ this.#ctx = ctx;
176
+ this.title = new Title(ctx);
177
+ this.actions = new Actions(ctx);
178
+ this.menuButton = this.actions.menuButton;
179
+ this.sidebarButton = this.actions.sidebarButton as HTMLButtonElement;
180
+ this.el = h('header', { class: 'hud' }, this.title.el, this.actions.el);
181
+ }
182
+
183
+ /** Place the appearance menu under the actions. */
184
+ attachMenu(menu: HTMLElement): void {
185
+ this.actions.attachMenu(menu);
186
+ }
187
+
188
+ update(topics: ReadonlySet<Topic>): void {
189
+ this.title.update(topics);
190
+ this.actions.update(topics);
191
+ }
192
+
193
+ setSidebarState(open: boolean): void {
194
+ this.actions.setSidebarState(open);
195
+ }
196
+
197
+ /**
198
+ * Follow the panel states of the title and the actions (not shown unless open); the sidebar
199
+ * button goes when the sidebar has nothing to show. Focus in the actions moves to `outside`.
200
+ */
201
+ setPanels(
202
+ title: PanelState,
203
+ actions: PanelState,
204
+ sidebar: boolean,
205
+ outside: () => HTMLElement | null,
206
+ ): void {
207
+ attr(this.title.el, 'data-panel', title);
208
+ const end = this.actions.el;
209
+ if (actions !== 'open') moveFocusOut(this.#ctx.root, end, outside);
210
+ attr(end, 'data-panel', actions);
211
+ end.inert = actions !== 'open';
212
+ if (!sidebar) moveFocusOut(this.#ctx.root, this.sidebarButton, outside);
213
+ this.sidebarButton.hidden = !sidebar;
214
+ }
215
+
216
+ setMenuState(open: boolean): void {
217
+ this.actions.setMenuState(open);
218
+ }
219
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The legend (RFC 0001 §3, `<kgdiff-legend>`): a card of every family's swatch and name, folded
3
+ * to its head until opened, or one line of dots for the families this diff has, then the
4
+ * unchanged context. Which one shows is the layout's (`.stage-wrap[data-legend]`).
5
+ */
6
+ import type { Family } from '@kgdiff/scene';
7
+ import { attr, h, icon, replace, setText } from '../dom.js';
8
+ import type { Translator } from '../i18n.js';
9
+ import { FAMILIES } from '../styles.js';
10
+ import type { Topic } from '../topics.js';
11
+ import type { UiContext } from './context.js';
12
+ import { swatch } from './swatch.js';
13
+
14
+ export class Legend {
15
+ /** The card (a toggle head and the rows). */
16
+ readonly card: HTMLElement;
17
+ /** The one-line legend. */
18
+ readonly mini: HTMLElement;
19
+ readonly #head: HTMLButtonElement;
20
+ readonly #body: HTMLElement;
21
+ #open = false;
22
+
23
+ readonly #ctx: UiContext;
24
+
25
+ /** `toggled`: the user opened or folded the card from its head. */
26
+ constructor(ctx: UiContext, toggled: (open: boolean) => void = () => undefined) {
27
+ this.#ctx = ctx;
28
+ this.#head = h(
29
+ 'button',
30
+ {
31
+ type: 'button',
32
+ class: 'legend-head',
33
+ 'aria-expanded': 'false',
34
+ onclick: () => {
35
+ this.setOpen(!this.#open);
36
+ toggled(this.#open);
37
+ },
38
+ },
39
+ h('span', { class: 'lbl' }),
40
+ icon('chevron-down'),
41
+ );
42
+ this.#body = h('div', { class: 'legend-body', id: 'kg-legend' });
43
+ attr(this.#head, 'aria-controls', 'kg-legend');
44
+ this.card = h(
45
+ 'div',
46
+ { class: 'legend card float bottom-left', 'data-open': 'false' },
47
+ this.#head,
48
+ this.#body,
49
+ );
50
+ this.mini = h('div', { class: 'mini-legend float bottom-left', role: 'list' });
51
+ }
52
+
53
+ get open(): boolean {
54
+ return this.#open;
55
+ }
56
+
57
+ setOpen(open: boolean): void {
58
+ this.#open = open;
59
+ attr(this.card, 'data-open', String(open));
60
+ attr(this.#head, 'aria-expanded', String(open));
61
+ }
62
+
63
+ update(topics: ReadonlySet<Topic>): void {
64
+ if (!(topics.has('status') || topics.has('data') || topics.has('locale'))) return;
65
+ const tr = this.#ctx.tr();
66
+ this.#renderCard(tr);
67
+ this.#renderMini(tr);
68
+ }
69
+
70
+ #renderCard(tr: Translator): void {
71
+ setText(this.#head.querySelector('.lbl') as Element, tr.t('legend.title'));
72
+ attr(this.#head, 'aria-label', tr.t('legend.toggle'));
73
+ const fams: Family[] = [...FAMILIES, 'neutral'];
74
+ replace(
75
+ this.#body,
76
+ fams.map((f) =>
77
+ h(
78
+ 'div',
79
+ { class: 'legend-row', 'data-family': f, title: tr.t(`familyHint.${f}`) },
80
+ swatch(f),
81
+ h('span', { class: 'name', text: tr.t(`family.${f}`) }),
82
+ ),
83
+ ),
84
+ );
85
+ }
86
+
87
+ /** One line: the families this diff has, then the unchanged context. */
88
+ #renderMini(tr: Translator): void {
89
+ const scene = this.#ctx.controller.scene;
90
+ const present = new Set<Family>(scene?.hunks.map((x) => x.family));
91
+ if (scene?.nodes.some((n) => n.status === 'unchanged')) present.add('neutral');
92
+ attr(this.mini, 'aria-label', tr.t('legend.title'));
93
+ replace(
94
+ this.mini,
95
+ [...FAMILIES, 'neutral' as const]
96
+ .filter((f) => present.has(f))
97
+ .map((f) =>
98
+ h(
99
+ 'span',
100
+ {
101
+ class: 'mini-item',
102
+ role: 'listitem',
103
+ 'data-family': f,
104
+ title: tr.t(`familyHint.${f}`),
105
+ },
106
+ h('span', { class: 'dot', 'aria-hidden': 'true' }),
107
+ tr.t(`family.${f}`),
108
+ ),
109
+ ),
110
+ );
111
+ }
112
+ }
@@ -7,6 +7,7 @@ import { attr, h, icon, iconButton, replaceKeepingFocus } from '../dom.js';
7
7
  import type { MessageKey } from '../i18n.js';
8
8
  import type { IconName } from '../icons.js';
9
9
  import type { ThemePreference } from '../index.js';
10
+ import { type Keymap, type ShortcutName, shortcutKeys } from '../keyboard.js';
10
11
  import { FAMILIES } from '../styles.js';
11
12
  import type { UiContext } from './context.js';
12
13
 
@@ -49,6 +50,7 @@ export class Menu {
49
50
  render(): void {
50
51
  const tr = this.#ctx.tr();
51
52
  const c = this.#ctx.controller;
53
+ attr(this.el, 'aria-label', tr.t('action.settings'));
52
54
  const t = (key: MessageKey) => () => tr.t(key);
53
55
  const swatches = (p: PaletteName): string[] => {
54
56
  const family = getTheme(c.themeName, p).family;
@@ -143,39 +145,79 @@ export class Menu {
143
145
 
144
146
  type Label = MessageKey | readonly MessageKey[];
145
147
 
146
- /** Shortcut rows; labels reuse the controls' own names where they exist. */
147
- const SHORTCUTS: readonly (readonly [readonly (readonly string[])[], Label])[] = [
148
+ /**
149
+ * Shortcut rows, by the shortcuts they list; labels reuse the controls' own names where they
150
+ * exist. A row of several labels names one shortcut each.
151
+ */
152
+ const SHORTCUTS: readonly (readonly [readonly ShortcutName[], Label])[] = [
148
153
  [
149
- [['1'], ['2'], ['3']],
154
+ ['overlay', 'swipe', 'replay'],
150
155
  ['mode.overlay', 'mode.swipe', 'mode.replay'],
151
156
  ],
152
- [[['4']], 'mode.changes'],
153
- [[['j'], ['↓']], 'detail.next'],
154
- [[['k'], ['↑']], 'detail.prev'],
155
- [[['Home'], ['End']], 'help.homeEnd'],
156
- [[['Enter']], 'help.enter'],
157
- [[['Esc']], 'help.esc'],
158
- [[['Space']], 'help.space'],
159
- [[['←'], ['→']], 'help.arrows'],
160
- [[['f']], 'action.fit'],
157
+ [['scope'], 'mode.changes'],
158
+ [['next'], 'detail.next'],
159
+ [['prev'], 'detail.prev'],
160
+ [['first', 'last'], 'help.homeEnd'],
161
+ [['fly'], 'help.enter'],
162
+ [['escape'], 'help.esc'],
163
+ [['play-pause'], 'help.space'],
164
+ [['left', 'right'], 'help.arrows'],
165
+ [['fit'], 'action.fit'],
161
166
  [
162
- [['+'], ['−']],
167
+ ['zoom-in', 'zoom-out'],
163
168
  ['action.zoomIn', 'action.zoomOut'],
164
169
  ],
165
- [[['/']], 'sidebar.search'],
166
- [[['x']], 'review.mark'],
167
- [[['\\']], 'action.sidebar'],
168
- [[['t']], 'help.theme'],
169
- [[['e']], 'help.expand'],
170
- [[['?']], 'action.help'],
170
+ [['search'], 'sidebar.search'],
171
+ [['review'], 'review.mark'],
172
+ [['sidebar'], 'action.sidebar'],
173
+ [['theme'], 'help.theme'],
174
+ [['expand'], 'help.expand'],
175
+ [['help'], 'action.help'],
171
176
  ];
177
+
178
+ /** How a key shows in the dialog. */
179
+ const KEY_LABELS: Readonly<Record<string, string>> = {
180
+ ArrowDown: '↓',
181
+ ArrowUp: '↑',
182
+ ArrowLeft: '←',
183
+ ArrowRight: '→',
184
+ ' ': 'Space',
185
+ Escape: 'Esc',
186
+ '-': '−',
187
+ };
188
+
189
+ /** The dialog's rows for the keys bound now: a shortcut with no key is left out. */
190
+ function shortcutRows(
191
+ keymap: Keymap | null,
192
+ omit: ReadonlySet<ShortcutName>,
193
+ ): { keys: string[]; label: Label }[] {
194
+ const bound = shortcutKeys(keymap);
195
+ const keysOf = (name: ShortcutName): string[] =>
196
+ omit.has(name) ? [] : (bound.get(name) ?? []).map((k) => KEY_LABELS[k] ?? k);
197
+ const rows: { keys: string[]; label: Label }[] = [];
198
+ for (const [names, label] of SHORTCUTS) {
199
+ const keys = names.map(keysOf);
200
+ if (typeof label !== 'string' && keys.some((k) => k.length !== 1)) {
201
+ // Not one key each: a row per shortcut.
202
+ names.forEach((_, i) => {
203
+ const k = keys[i] ?? [];
204
+ if (k.length > 0) rows.push({ keys: k, label: label[i] as MessageKey });
205
+ });
206
+ } else if (keys.flat().length > 0) rows.push({ keys: keys.flat(), label });
207
+ }
208
+ return rows;
209
+ }
210
+
172
211
  export class HelpDialog {
173
212
  readonly el: HTMLElement;
174
213
  #returnFocus: Element | null = null;
175
214
  readonly #ctx: UiContext;
215
+ readonly #omit: ReadonlySet<ShortcutName>;
176
216
 
177
- constructor(ctx: UiContext) {
217
+ /** `omit`: shortcuts that do nothing in this layout (`sidebar` without a sidebar). */
218
+ constructor(ctx: UiContext, omit: readonly ShortcutName[] = []) {
178
219
  this.#ctx = ctx;
220
+ this.#omit = new Set(omit);
179
221
  this.el = h('div', { class: 'overlay-scrim', hidden: true });
180
222
  this.el.addEventListener('click', (e) => {
181
223
  if (e.target === this.el) this.hide();
@@ -212,18 +254,19 @@ export class HelpDialog {
212
254
 
213
255
  render(): void {
214
256
  const tr = this.#ctx.tr();
215
- const keys = (combos: readonly (readonly string[])[]): HTMLElement =>
257
+ const keys = (list: readonly string[]): HTMLElement =>
216
258
  h(
217
259
  'span',
218
260
  { class: 'ks' },
219
- combos.map((combo, i) => [
261
+ list.map((k, i) => [
220
262
  i > 0 ? h('span', { 'aria-hidden': 'true', text: '/' }) : null,
221
- combo.map((k) => h('kbd', { text: k })),
263
+ h('kbd', { text: k }),
222
264
  ]),
223
265
  );
224
- const rows = SHORTCUTS.map(([combos, label]) => {
266
+ // The keys bound now, after the root's `keymap`.
267
+ const rows = shortcutRows(this.#ctx.keymap(), this.#omit).map(({ keys: list, label }) => {
225
268
  const text = typeof label === 'string' ? tr.t(label) : label.map((k) => tr.t(k)).join(' / ');
226
- return h('div', { class: 'key-row' }, h('span', { text }), keys(combos));
269
+ return h('div', { class: 'key-row' }, h('span', { text }), keys(list));
227
270
  });
228
271
  replaceKeepingFocus(
229
272
  this.#ctx.root,