@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/theme.ts CHANGED
@@ -11,9 +11,12 @@
11
11
  import { contrastRatio, type Theme } from '@kgdiff/scene';
12
12
  import { FAMILIES, sheetFor } from './styles.js';
13
13
 
14
- /** Token (after `--kgdiff-`) → where its value lives in a `Theme`. */
15
- const TOKENS: readonly (readonly [token: string, path: string])[] = [
16
- ['bg', 'canvas.background'],
14
+ /**
15
+ * The public tokens (after `--kgdiff-`), each with where its value lives in a `Theme`: the
16
+ * manifest's CSS custom properties are generated from this list.
17
+ */
18
+ export const TOKENS: readonly (readonly [token: string, path: string])[] = [
19
+ ['background', 'canvas.background'],
17
20
  ['grid', 'canvas.grid'],
18
21
  ['panel', 'surface.panel'],
19
22
  ['raised', 'surface.raised'],
@@ -23,8 +26,8 @@ const TOKENS: readonly (readonly [token: string, path: string])[] = [
23
26
  ['control-border', 'surface.controlBorder'],
24
27
  ['shadow', 'surface.shadow'],
25
28
  ['text', 'text.primary'],
26
- ['text-2', 'text.secondary'],
27
- ['text-3', 'text.muted'],
29
+ ['text-secondary', 'text.secondary'],
30
+ ['text-muted', 'text.muted'],
28
31
  ['accent', 'accent.base'],
29
32
  ['accent-strong', 'accent.strong'],
30
33
  ['accent-soft', 'accent.soft'],
@@ -34,7 +37,7 @@ const TOKENS: readonly (readonly [token: string, path: string])[] = [
34
37
  ['neutral-edge', 'neutral.edge'],
35
38
  ['neutral-label', 'neutral.label'],
36
39
  ['font', 'font.family'],
37
- ['mono', 'font.mono'],
40
+ ['font-mono', 'font.mono'],
38
41
  ...FAMILIES.flatMap((f) => [
39
42
  [f, `family.${f}.base`] as const,
40
43
  [`${f}-strong`, `family.${f}.strong`] as const,
@@ -44,7 +47,7 @@ const TOKENS: readonly (readonly [token: string, path: string])[] = [
44
47
  ];
45
48
 
46
49
  /** Tokens that are not colors (taken as they are). */
47
- const VERBATIM: ReadonlySet<string> = new Set(['shadow', 'font', 'mono']);
50
+ export const VERBATIM: ReadonlySet<string> = new Set(['shadow', 'font', 'font-mono']);
48
51
 
49
52
  type Tree = { readonly [key: string]: unknown };
50
53
 
package/src/topics.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What the session notifies its listeners about (`DiffSession.subscribe`): one topic per part
3
+ * of its state. A module of its own, so elements can name them without importing the session.
4
+ */
5
+ export type Topic =
6
+ | 'status'
7
+ | 'data'
8
+ | 'mode'
9
+ | 'selection'
10
+ | 'filters'
11
+ | 'search'
12
+ | 'review'
13
+ | 'replay'
14
+ | 'hover'
15
+ | 'theme'
16
+ | 'locale'
17
+ | 'focus'
18
+ | 'swipe'
19
+ | 'overview';
20
+
21
+ /** Every topic: a full render. */
22
+ export const ALL_TOPICS: ReadonlySet<Topic> = new Set<Topic>([
23
+ 'status',
24
+ 'data',
25
+ 'mode',
26
+ 'selection',
27
+ 'filters',
28
+ 'search',
29
+ 'review',
30
+ 'replay',
31
+ 'hover',
32
+ 'theme',
33
+ 'locale',
34
+ 'focus',
35
+ 'swipe',
36
+ 'overview',
37
+ ]);
@@ -6,7 +6,7 @@
6
6
  * target.
7
7
  */
8
8
  import type { SceneHunk } from '@kgdiff/scene';
9
- import { attr, h, icon, replace, setText } from '../dom.js';
9
+ import { attr, h, icon, isOpen, replace, setText } from '../dom.js';
10
10
  import type { ListItem } from '../list-model.js';
11
11
  import { frameOrTimeout } from '../schedule.js';
12
12
  import type { Topic } from '../session.js';
@@ -73,7 +73,11 @@ export class ChangeList {
73
73
  if (row) c.flyToSelection();
74
74
  });
75
75
  this.sticky = h('div', { class: 'sticky', 'aria-hidden': 'true', hidden: true });
76
- ctx.target('list', () => this.list.focus({ preventScroll: true }));
76
+ ctx.target(
77
+ 'list',
78
+ () => this.list.focus({ preventScroll: true }),
79
+ () => isOpen(this.list),
80
+ );
77
81
  }
78
82
 
79
83
  update(topics: ReadonlySet<Topic>): void {
package/src/ui/context.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  */
5
5
  import type { AttrValue, HunkKind, Locale } from '@kgdiff/core';
6
6
  import type { ChangeFamily, PaletteName, SceneHunk } from '@kgdiff/scene';
7
- import type { ViewerController } from '../controller.js';
8
7
  import type { Translator } from '../i18n.js';
9
- import type { ThemePreference, ViewerMode } from '../index.js';
8
+ import type { PanelRegion, PanelState, ThemePreference, ViewerMode } from '../index.js';
9
+ import type { Keymap } from '../keyboard.js';
10
+ import type { DiffSession } from '../session.js';
10
11
 
11
12
  /**
12
13
  * What the layout does for the regions (preferences it reflects as attributes, the panels and
@@ -22,24 +23,36 @@ export interface UiActions {
22
23
  setLocale(locale: Locale): void;
23
24
  toggleSidebar(open?: boolean): void;
24
25
  sidebarOpen(): boolean;
26
+ /**
27
+ * Whether the layout has a sidebar to toggle now (`<kgdiff-viewer>`, unless its four regions
28
+ * are hidden); absent where there is none (a composed root).
29
+ */
30
+ hasSidebar?(): boolean;
25
31
  toggleMenu(open?: boolean): void;
26
32
  openHelp(): void;
27
33
  /** Show and focus the search (`/`). */
28
34
  focusSearch(): void;
35
+ /** The user opened or folded a region from inside it (the legend's head). */
36
+ setPanel?(region: PanelRegion, state: PanelState): void;
29
37
  }
30
38
 
31
39
  /** Elements of a region that others move focus to, through the root. */
32
- export type FocusTarget = 'search' | 'list';
40
+ export type FocusTarget = 'search' | 'list' | 'help';
33
41
 
34
42
  /** What a region gets from the root it is bound to: the session, the actions, focus moves. */
35
43
  export interface UiContext extends UiActions {
36
- readonly controller: ViewerController;
44
+ readonly controller: DiffSession;
37
45
  /** Root of the region's shadow tree (for focus management). */
38
46
  readonly root: ShadowRoot;
39
- /** Register `focus` as the root's `name` target; returns the function that unregisters it. */
40
- target(name: FocusTarget, focus: () => void): () => void;
41
- /** Focus the root's `name` target registered last; false when there is none. */
47
+ /**
48
+ * Register `focus` as the root's `name` target, available while `available` says so (default:
49
+ * always); returns the function that unregisters it.
50
+ */
51
+ target(name: FocusTarget, focus: () => void, available?: () => boolean): () => void;
52
+ /** Focus the root's available `name` target registered last; false when there is none. */
42
53
  focus(name: FocusTarget): boolean;
54
+ /** The root's `keymap` now (the keys bound over the default map), or null. */
55
+ keymap(): Keymap | null;
43
56
  }
44
57
 
45
58
  /** One-character glyph of a hunk (docs/interaction.md §Change list): `+ − ~ ⇄ ⊕ ⑂`. */
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. */
@@ -18,16 +20,11 @@ function brandMark(): SVGSVGElement {
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,
package/src/ui/search.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * session, so the change list steps aside without either knowing the other. Registers itself
7
7
  * as the root's `search` focus target (`/`).
8
8
  */
9
- import { attr, h, icon, replace } from '../dom.js';
9
+ import { attr, h, icon, isOpen, replace } from '../dom.js';
10
10
  import type { Topic } from '../session.js';
11
11
  import type { UiContext } from './context.js';
12
12
 
@@ -53,10 +53,14 @@ export class Search {
53
53
  hidden: true,
54
54
  });
55
55
  this.box = h('div', { class: 'search' }, icon('search'), this.input, this.#kbd, this.#clear);
56
- ctx.target('search', () => {
57
- this.input.focus();
58
- this.input.select();
59
- });
56
+ ctx.target(
57
+ 'search',
58
+ () => {
59
+ this.input.focus();
60
+ this.input.select();
61
+ },
62
+ () => isOpen(this.input),
63
+ );
60
64
  }
61
65
 
62
66
  update(topics: ReadonlySet<Topic>): void {
@@ -66,6 +70,7 @@ export class Search {
66
70
  this.input.placeholder = tr.t('sidebar.searchPlaceholder');
67
71
  attr(this.input, 'aria-label', tr.t('sidebar.search'));
68
72
  attr(this.#clear, 'aria-label', tr.t('sidebar.searchClear'));
73
+ attr(this.results, 'aria-label', tr.t('sidebar.searchResults'));
69
74
  this.input.disabled = this.#ctx.controller.status !== 'ready';
70
75
  }
71
76
  if (all || topics.has('search')) this.#render();