@kgdiff/viewer 0.0.0-stage → 0.2.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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -2
  3. package/dist/compute-loader.d.ts +13 -0
  4. package/dist/compute-loader.d.ts.map +1 -0
  5. package/dist/compute-loader.js +46 -0
  6. package/dist/compute-loader.js.map +1 -0
  7. package/dist/compute.d.ts +8 -0
  8. package/dist/compute.d.ts.map +1 -0
  9. package/dist/compute.js +8 -0
  10. package/dist/compute.js.map +1 -0
  11. package/dist/controller.d.ts +199 -0
  12. package/dist/controller.d.ts.map +1 -0
  13. package/dist/controller.js +1254 -0
  14. package/dist/controller.js.map +1 -0
  15. package/dist/dom.d.ts +32 -0
  16. package/dist/dom.d.ts.map +1 -0
  17. package/dist/dom.js +105 -0
  18. package/dist/dom.js.map +1 -0
  19. package/dist/element.d.ts +43 -0
  20. package/dist/element.d.ts.map +1 -0
  21. package/dist/element.js +656 -0
  22. package/dist/element.js.map +1 -0
  23. package/dist/framing.d.ts +45 -0
  24. package/dist/framing.d.ts.map +1 -0
  25. package/dist/framing.js +107 -0
  26. package/dist/framing.js.map +1 -0
  27. package/dist/hash-sync.d.ts +18 -0
  28. package/dist/hash-sync.d.ts.map +1 -0
  29. package/dist/hash-sync.js +45 -0
  30. package/dist/hash-sync.js.map +1 -0
  31. package/dist/i18n.d.ts +222 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +434 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/icons.d.ts +37 -0
  36. package/dist/icons.d.ts.map +1 -0
  37. package/dist/icons.js +36 -0
  38. package/dist/icons.js.map +1 -0
  39. package/dist/index.d.ts +151 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +41 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/keyboard.d.ts +64 -0
  44. package/dist/keyboard.d.ts.map +1 -0
  45. package/dist/keyboard.js +95 -0
  46. package/dist/keyboard.js.map +1 -0
  47. package/dist/list-model.d.ts +35 -0
  48. package/dist/list-model.d.ts.map +1 -0
  49. package/dist/list-model.js +70 -0
  50. package/dist/list-model.js.map +1 -0
  51. package/dist/payload.d.ts +10 -0
  52. package/dist/payload.d.ts.map +1 -0
  53. package/dist/payload.js +138 -0
  54. package/dist/payload.js.map +1 -0
  55. package/dist/pipeline.d.ts +31 -0
  56. package/dist/pipeline.d.ts.map +1 -0
  57. package/dist/pipeline.js +122 -0
  58. package/dist/pipeline.js.map +1 -0
  59. package/dist/prefs.d.ts +18 -0
  60. package/dist/prefs.d.ts.map +1 -0
  61. package/dist/prefs.js +38 -0
  62. package/dist/prefs.js.map +1 -0
  63. package/dist/registry.d.ts +3 -0
  64. package/dist/registry.d.ts.map +1 -0
  65. package/dist/registry.js +3 -0
  66. package/dist/registry.js.map +1 -0
  67. package/dist/renderer-port.d.ts +14 -0
  68. package/dist/renderer-port.d.ts.map +1 -0
  69. package/dist/renderer-port.js +16 -0
  70. package/dist/renderer-port.js.map +1 -0
  71. package/dist/replay.d.ts +55 -0
  72. package/dist/replay.d.ts.map +1 -0
  73. package/dist/replay.js +147 -0
  74. package/dist/replay.js.map +1 -0
  75. package/dist/review-store.d.ts +30 -0
  76. package/dist/review-store.d.ts.map +1 -0
  77. package/dist/review-store.js +120 -0
  78. package/dist/review-store.js.map +1 -0
  79. package/dist/search.d.ts +25 -0
  80. package/dist/search.d.ts.map +1 -0
  81. package/dist/search.js +90 -0
  82. package/dist/search.js.map +1 -0
  83. package/dist/standalone/kgdiff-viewer-compute.js +1 -0
  84. package/dist/standalone/kgdiff-viewer.js +5 -0
  85. package/dist/standalone-compute.d.ts +2 -0
  86. package/dist/standalone-compute.d.ts.map +1 -0
  87. package/dist/standalone-compute.js +8 -0
  88. package/dist/standalone-compute.js.map +1 -0
  89. package/dist/standalone.d.ts +2 -0
  90. package/dist/standalone.d.ts.map +1 -0
  91. package/dist/standalone.js +18 -0
  92. package/dist/standalone.js.map +1 -0
  93. package/dist/styles.d.ts +11 -0
  94. package/dist/styles.d.ts.map +1 -0
  95. package/dist/styles.js +622 -0
  96. package/dist/styles.js.map +1 -0
  97. package/dist/text-diff.d.ts +44 -0
  98. package/dist/text-diff.d.ts.map +1 -0
  99. package/dist/text-diff.js +261 -0
  100. package/dist/text-diff.js.map +1 -0
  101. package/dist/ui/context.d.ts +32 -0
  102. package/dist/ui/context.d.ts.map +1 -0
  103. package/dist/ui/context.js +49 -0
  104. package/dist/ui/context.js.map +1 -0
  105. package/dist/ui/detail.d.ts +9 -0
  106. package/dist/ui/detail.d.ts.map +1 -0
  107. package/dist/ui/detail.js +609 -0
  108. package/dist/ui/detail.js.map +1 -0
  109. package/dist/ui/header.d.ts +14 -0
  110. package/dist/ui/header.d.ts.map +1 -0
  111. package/dist/ui/header.js +195 -0
  112. package/dist/ui/header.js.map +1 -0
  113. package/dist/ui/popovers.d.ts +20 -0
  114. package/dist/ui/popovers.d.ts.map +1 -0
  115. package/dist/ui/popovers.js +180 -0
  116. package/dist/ui/popovers.js.map +1 -0
  117. package/dist/ui/sidebar.d.ts +25 -0
  118. package/dist/ui/sidebar.d.ts.map +1 -0
  119. package/dist/ui/sidebar.js +609 -0
  120. package/dist/ui/sidebar.js.map +1 -0
  121. package/dist/ui/stage.d.ts +18 -0
  122. package/dist/ui/stage.d.ts.map +1 -0
  123. package/dist/ui/stage.js +664 -0
  124. package/dist/ui/stage.js.map +1 -0
  125. package/package.json +58 -3
  126. package/src/compute-loader.ts +58 -0
  127. package/src/compute.ts +7 -0
  128. package/src/controller.ts +1393 -0
  129. package/src/dom.ts +131 -0
  130. package/src/element.ts +697 -0
  131. package/src/framing.ts +150 -0
  132. package/src/hash-sync.ts +54 -0
  133. package/src/i18n.ts +467 -0
  134. package/src/icons.ts +48 -0
  135. package/src/index.ts +171 -0
  136. package/src/keyboard.ts +128 -0
  137. package/src/list-model.ts +86 -0
  138. package/src/payload.ts +165 -0
  139. package/src/pipeline.ts +179 -0
  140. package/src/prefs.ts +55 -0
  141. package/src/registry.ts +2 -0
  142. package/src/renderer-port.ts +31 -0
  143. package/src/replay.ts +163 -0
  144. package/src/review-store.ts +125 -0
  145. package/src/search.ts +113 -0
  146. package/src/standalone-compute.ts +8 -0
  147. package/src/standalone.ts +18 -0
  148. package/src/styles.ts +633 -0
  149. package/src/text-diff.ts +263 -0
  150. package/src/ui/context.ts +70 -0
  151. package/src/ui/detail.ts +929 -0
  152. package/src/ui/header.ts +278 -0
  153. package/src/ui/popovers.ts +251 -0
  154. package/src/ui/sidebar.ts +752 -0
  155. package/src/ui/stage.ts +853 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Header: brand, compared versions, mode segmented control, diffstat pills (filter shortcuts),
3
+ * appearance menu, fit, help and sidebar toggle.
4
+ */
5
+ import type { ChangeFamily } from '@kgdiff/scene';
6
+ import type { Topic } from '../controller.js';
7
+ import { attr, h, icon, iconButton, relabel, replace, setText } from '../dom.js';
8
+ import type { IconName } from '../icons.js';
9
+ import type { ViewerMode } from '../index.js';
10
+ import { familyCounts } from '../list-model.js';
11
+ import type { UiContext } from './context.js';
12
+
13
+ const MODES: readonly { mode: ViewerMode; icon: IconName; key: string }[] = [
14
+ { mode: 'overlay', icon: 'overlay', key: '1' },
15
+ { mode: 'swipe', icon: 'swipe', key: '2' },
16
+ { mode: 'replay', icon: 'replay', key: '3' },
17
+ { mode: 'changes', icon: 'changes', key: '4' },
18
+ ];
19
+
20
+ interface StatDef {
21
+ readonly id: 'added' | 'removed' | 'modified' | 'restructured';
22
+ readonly family: ChangeFamily;
23
+ readonly families: readonly ChangeFamily[];
24
+ readonly glyph: string;
25
+ }
26
+
27
+ const STATS: readonly StatDef[] = [
28
+ { id: 'added', family: 'added', families: ['added'], glyph: '+' },
29
+ { id: 'removed', family: 'removed', families: ['removed'], glyph: '−' },
30
+ { id: 'modified', family: 'content', families: ['content'], glyph: '~' },
31
+ { id: 'restructured', family: 'structure', families: ['structure', 'identity'], glyph: '⇄' },
32
+ ];
33
+
34
+ /** The kgdiff mark: a soft accent diamond holding a node. */
35
+ function brandMark(): SVGSVGElement {
36
+ const tpl = document.createElement('template');
37
+ tpl.innerHTML =
38
+ '<svg class="brand-mark" viewBox="0 0 20 20" aria-hidden="true" focusable="false">' +
39
+ '<rect x="4.1" y="4.1" width="11.8" height="11.8" rx="3.2" transform="rotate(45 10 10)" ' +
40
+ 'fill="var(--kg-accent-soft)" stroke="var(--kg-accent)" stroke-width="1.5"/>' +
41
+ '<circle cx="10" cy="10" r="2.4" fill="var(--kg-accent)"/></svg>';
42
+ return tpl.content.firstElementChild as SVGSVGElement;
43
+ }
44
+
45
+ export class Header {
46
+ readonly el: HTMLElement;
47
+ readonly #versions: HTMLElement;
48
+ readonly #seg: HTMLElement;
49
+ readonly #thumb: HTMLElement;
50
+ readonly #segButtons = new Map<ViewerMode, HTMLButtonElement>();
51
+ readonly #stats: HTMLElement;
52
+ readonly #statButtons = new Map<StatDef['id'], HTMLButtonElement>();
53
+ readonly menuButton: HTMLButtonElement;
54
+ readonly #fitButton: HTMLButtonElement;
55
+ readonly #helpButton: HTMLButtonElement;
56
+ readonly #sidebarButton: HTMLButtonElement;
57
+ readonly #brand: HTMLElement;
58
+
59
+ readonly #ctx: UiContext;
60
+
61
+ constructor(ctx: UiContext) {
62
+ this.#ctx = ctx;
63
+ const c = ctx.controller;
64
+ this.#brand = h(
65
+ 'div',
66
+ { class: 'brand' },
67
+ brandMark(),
68
+ h('span', { class: 'brand-name', text: 'kgdiff' }),
69
+ );
70
+ this.#versions = h('div', { class: 'versions' });
71
+ this.#thumb = h('span', { class: 'thumb', 'aria-hidden': 'true' });
72
+ this.#seg = h('div', { class: 'segmented', role: 'radiogroup' }, this.#thumb);
73
+ for (const m of MODES) {
74
+ const b = h(
75
+ 'button',
76
+ {
77
+ type: 'button',
78
+ class: 'seg',
79
+ role: 'radio',
80
+ 'data-mode': m.mode,
81
+ onclick: () => ctx.setMode(m.mode),
82
+ },
83
+ icon(m.icon),
84
+ h('span', { class: 'lbl' }),
85
+ );
86
+ this.#segButtons.set(m.mode, b);
87
+ this.#seg.appendChild(b);
88
+ }
89
+ this.#seg.addEventListener('keydown', (e) => this.#onSegKey(e));
90
+ this.#stats = h('div', { class: 'stats', role: 'group' });
91
+ for (const s of STATS) {
92
+ const b = h(
93
+ 'button',
94
+ {
95
+ type: 'button',
96
+ class: 'pill stat',
97
+ 'data-family': s.family,
98
+ 'aria-pressed': 'false',
99
+ onclick: () => c.setFamilies(s.families),
100
+ },
101
+ h('span', { class: 'glyph', 'aria-hidden': 'true', text: s.glyph }),
102
+ h('span', { class: 'n' }),
103
+ );
104
+ this.#statButtons.set(s.id, b);
105
+ this.#stats.appendChild(b);
106
+ }
107
+ this.menuButton = iconButton('contrast', '', () => ctx.toggleMenu(), {
108
+ 'aria-haspopup': 'menu',
109
+ 'aria-expanded': 'false',
110
+ });
111
+ this.#fitButton = iconButton('fit', '', () => void c.fit());
112
+ this.#helpButton = iconButton('help', '', () => ctx.openHelp());
113
+ this.#sidebarButton = iconButton('sidebar', '', () => ctx.toggleSidebar());
114
+ this.el = h(
115
+ 'header',
116
+ { class: 'header' },
117
+ this.#brand,
118
+ h('span', { class: 'header-sep', 'aria-hidden': 'true' }),
119
+ this.#versions,
120
+ h('div', { class: 'header-center' }, this.#seg),
121
+ h(
122
+ 'div',
123
+ { class: 'header-end' },
124
+ this.#stats,
125
+ this.menuButton,
126
+ this.#fitButton,
127
+ this.#helpButton,
128
+ this.#sidebarButton,
129
+ ),
130
+ );
131
+ }
132
+
133
+ update(topics: ReadonlySet<Topic>): void {
134
+ const tr = this.#ctx.tr();
135
+ if (topics.has('locale') || topics.has('data') || topics.has('status')) {
136
+ attr(this.#brand, 'title', `kgdiff — ${tr.t('brand.tagline')}`);
137
+ attr(this.#seg, 'aria-label', tr.t('mode.label'));
138
+ attr(this.#stats, 'aria-label', tr.t('stat.label'));
139
+ for (const m of MODES) {
140
+ const b = this.#segButtons.get(m.mode) as HTMLButtonElement;
141
+ const label = tr.t(`mode.${m.mode}`);
142
+ setText(b.querySelector('.lbl') as Element, label);
143
+ attr(b, 'title', `${label} (${m.key})`);
144
+ attr(b, 'aria-label', label);
145
+ }
146
+ relabel(this.menuButton, tr.t('action.settings'));
147
+ relabel(this.#fitButton, `${tr.t('action.fit')} (F)`);
148
+ relabel(this.#helpButton, `${tr.t('action.help')} (?)`);
149
+ relabel(this.#sidebarButton, `${tr.t('action.sidebar')} (\\)`);
150
+ this.#renderVersions();
151
+ this.#renderStats();
152
+ }
153
+ if (topics.has('mode') || topics.has('status') || topics.has('locale')) this.#renderMode();
154
+ if (topics.has('filters') || topics.has('data')) this.#renderFilterState();
155
+ }
156
+
157
+ /** Re-measure the segmented thumb (fonts / size changes). */
158
+ layout(): void {
159
+ this.#renderMode();
160
+ }
161
+
162
+ setSidebarState(open: boolean): void {
163
+ attr(this.#sidebarButton, 'aria-pressed', String(open));
164
+ }
165
+
166
+ setMenuState(open: boolean): void {
167
+ attr(this.menuButton, 'aria-expanded', String(open));
168
+ }
169
+
170
+ #renderVersions(): void {
171
+ const data = this.#ctx.controller.data;
172
+ if (!data) {
173
+ replace(this.#versions);
174
+ return;
175
+ }
176
+ const tr = this.#ctx.tr();
177
+ const { names, versions } = data;
178
+ const tag = (v: string | undefined) =>
179
+ v ? h('span', { class: 'tag', title: v, text: v }) : null;
180
+ const arrow = () => h('span', { class: 'arrow', 'aria-hidden': 'true', text: '→' });
181
+ attr(this.#versions, 'aria-label', tr.t('header.compare'));
182
+ attr(this.#versions, 'role', 'group');
183
+ if (names.before === names.after) {
184
+ replace(
185
+ this.#versions,
186
+ h('span', {
187
+ class: 'name',
188
+ title: data.title ?? names.after,
189
+ text: data.title ?? names.after,
190
+ }),
191
+ versions.before || versions.after
192
+ ? h(
193
+ 'span',
194
+ { class: 'side' },
195
+ tag(versions.before ?? '—'),
196
+ arrow(),
197
+ tag(versions.after ?? '—'),
198
+ )
199
+ : null,
200
+ );
201
+ return;
202
+ }
203
+ replace(
204
+ this.#versions,
205
+ h(
206
+ 'span',
207
+ { class: 'side' },
208
+ h('span', { class: 'name', title: names.before, text: names.before }),
209
+ tag(versions.before),
210
+ ),
211
+ arrow(),
212
+ h(
213
+ 'span',
214
+ { class: 'side' },
215
+ h('span', { class: 'name', title: names.after, text: names.after }),
216
+ tag(versions.after),
217
+ ),
218
+ );
219
+ }
220
+
221
+ #renderStats(): void {
222
+ const c = this.#ctx.controller;
223
+ const scene = c.scene;
224
+ const tr = this.#ctx.tr();
225
+ // Hunks per family: the same numbers as the sidebar's filter chips.
226
+ const f = scene ? familyCounts(scene) : null;
227
+ const counts = {
228
+ added: f?.added ?? 0,
229
+ removed: f?.removed ?? 0,
230
+ modified: f?.content ?? 0,
231
+ restructured: (f?.structure ?? 0) + (f?.identity ?? 0),
232
+ };
233
+ this.#stats.hidden = !scene || scene.hunks.length === 0;
234
+ for (const s of STATS) {
235
+ const b = this.#statButtons.get(s.id) as HTMLButtonElement;
236
+ const n = counts[s.id];
237
+ b.hidden = n === 0;
238
+ setText(b.querySelector('.n') as Element, tr.num(n));
239
+ const label = `${tr.t(`stat.${s.id}`, { n })} — ${tr.t('stat.filter')}`;
240
+ attr(b, 'aria-label', label);
241
+ attr(b, 'title', label);
242
+ }
243
+ }
244
+
245
+ #renderFilterState(): void {
246
+ const fams = this.#ctx.controller.families;
247
+ this.#stats.classList.toggle('filtering', fams.size > 0);
248
+ for (const s of STATS) {
249
+ const on = fams.size === s.families.length && s.families.every((f) => fams.has(f));
250
+ attr(this.#statButtons.get(s.id) as Element, 'aria-pressed', String(on));
251
+ }
252
+ }
253
+
254
+ #renderMode(): void {
255
+ const mode = this.#ctx.controller.mode;
256
+ for (const [m, b] of this.#segButtons) {
257
+ const on = m === mode;
258
+ attr(b, 'aria-checked', String(on));
259
+ b.tabIndex = on ? 0 : -1;
260
+ }
261
+ const active = this.#segButtons.get(mode);
262
+ if (active && active.offsetWidth > 0) {
263
+ this.#thumb.style.width = `${active.offsetWidth}px`;
264
+ this.#thumb.style.transform = `translateX(${active.offsetLeft}px)`;
265
+ }
266
+ }
267
+
268
+ #onSegKey(e: KeyboardEvent): void {
269
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
270
+ e.preventDefault();
271
+ e.stopPropagation();
272
+ const i = MODES.findIndex((m) => m.mode === this.#ctx.controller.mode);
273
+ const next = MODES[(i + (e.key === 'ArrowRight' ? 1 : MODES.length - 1)) % MODES.length];
274
+ if (!next) return;
275
+ this.#ctx.setMode(next.mode);
276
+ this.#segButtons.get(next.mode)?.focus();
277
+ }
278
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Popovers: the appearance menu (theme, palette, language) and the keyboard-shortcut dialog.
3
+ */
4
+ import type { Locale } from '@kgdiff/core';
5
+ import { getTheme, type PaletteName } from '@kgdiff/scene';
6
+ import { attr, h, icon, iconButton, replaceKeepingFocus } from '../dom.js';
7
+ import type { MessageKey } from '../i18n.js';
8
+ import type { IconName } from '../icons.js';
9
+ import type { ThemePreference } from '../index.js';
10
+ import { FAMILIES } from '../styles.js';
11
+ import type { UiContext } from './context.js';
12
+
13
+ interface RadioDef<T> {
14
+ readonly value: T;
15
+ readonly label: () => string;
16
+ readonly icon?: IconName;
17
+ readonly swatch?: readonly string[];
18
+ }
19
+
20
+ export class Menu {
21
+ readonly el: HTMLElement;
22
+ #returnFocus: HTMLElement | null = null;
23
+
24
+ readonly #ctx: UiContext;
25
+
26
+ constructor(ctx: UiContext) {
27
+ this.#ctx = ctx;
28
+ this.el = h('div', { class: 'menu card', role: 'menu', hidden: true, tabindex: '-1' });
29
+ this.el.addEventListener('keydown', (e) => this.#onKey(e));
30
+ }
31
+
32
+ get open(): boolean {
33
+ return !this.el.hidden;
34
+ }
35
+
36
+ show(anchor: HTMLElement | null): void {
37
+ this.#returnFocus = anchor;
38
+ this.render();
39
+ this.el.hidden = false;
40
+ (this.el.querySelector('[aria-checked="true"]') as HTMLElement | null)?.focus();
41
+ }
42
+
43
+ hide(restoreFocus = true): void {
44
+ if (this.el.hidden) return;
45
+ this.el.hidden = true;
46
+ if (restoreFocus) this.#returnFocus?.focus();
47
+ }
48
+
49
+ render(): void {
50
+ const tr = this.#ctx.tr();
51
+ const c = this.#ctx.controller;
52
+ const t = (key: MessageKey) => () => tr.t(key);
53
+ const swatches = (p: PaletteName): string[] => {
54
+ const family = getTheme(c.themeName, p).family;
55
+ return FAMILIES.map((f) => family[f].base);
56
+ };
57
+ const themes: RadioDef<ThemePreference>[] = [
58
+ { value: 'auto', label: t('theme.auto'), icon: 'monitor' },
59
+ { value: 'light', label: t('theme.light'), icon: 'sun' },
60
+ { value: 'dark', label: t('theme.dark'), icon: 'moon' },
61
+ ];
62
+ const palettes: RadioDef<PaletteName>[] = [
63
+ { value: 'default', label: t('palette.default'), swatch: swatches('default') },
64
+ { value: 'colorblind', label: t('palette.colorblind'), swatch: swatches('colorblind') },
65
+ ];
66
+ const locales: RadioDef<Locale>[] = [
67
+ { value: 'en', label: () => 'English' },
68
+ { value: 'zh-CN', label: () => '简体中文' },
69
+ ];
70
+ attr(this.el, 'aria-label', tr.t('action.settings'));
71
+ replaceKeepingFocus(
72
+ this.#ctx.root,
73
+ this.el,
74
+ this.el,
75
+ this.#group(tr.t('menu.theme'), themes, c.themePreference, (v) =>
76
+ this.#ctx.setThemePreference(v),
77
+ ),
78
+ this.#group(tr.t('menu.palette'), palettes, c.palette, (v) => this.#ctx.setPalette(v)),
79
+ this.#group(tr.t('menu.language'), locales, c.locale, (v) => this.#ctx.setLocale(v)),
80
+ );
81
+ }
82
+
83
+ #group<T extends string>(
84
+ title: string,
85
+ items: readonly RadioDef<T>[],
86
+ current: T,
87
+ onPick: (value: T) => void,
88
+ ): HTMLElement {
89
+ return h(
90
+ 'div',
91
+ { class: 'menu-group', role: 'group', 'aria-label': title },
92
+ h('div', { class: 'menu-label', 'aria-hidden': 'true', text: title }),
93
+ items.map((item) =>
94
+ h(
95
+ 'button',
96
+ {
97
+ type: 'button',
98
+ class: 'menu-item',
99
+ role: 'menuitemradio',
100
+ 'data-focus-key': item.value,
101
+ 'aria-checked': String(item.value === current),
102
+ tabindex: '-1',
103
+ onclick: () => {
104
+ onPick(item.value);
105
+ this.render();
106
+ (this.el.querySelector('[aria-checked="true"]') as HTMLElement | null)?.focus();
107
+ },
108
+ },
109
+ item.icon ? icon(item.icon) : null,
110
+ item.swatch
111
+ ? h(
112
+ 'span',
113
+ { class: 'sw', 'aria-hidden': 'true' },
114
+ item.swatch.map((c) => h('i', { style: `background:${c}` })),
115
+ )
116
+ : null,
117
+ h('span', { text: item.label() }),
118
+ icon('check', 'icon check'),
119
+ ),
120
+ ),
121
+ );
122
+ }
123
+
124
+ #onKey(e: KeyboardEvent): void {
125
+ const items = [...this.el.querySelectorAll<HTMLElement>('.menu-item')];
126
+ const i = items.indexOf(this.#ctx.root.activeElement as HTMLElement);
127
+ let next = -1;
128
+ if (e.key === 'ArrowDown') next = (i + 1) % items.length;
129
+ else if (e.key === 'ArrowUp') next = (i - 1 + items.length) % items.length;
130
+ else if (e.key === 'Home') next = 0;
131
+ else if (e.key === 'End') next = items.length - 1;
132
+ else if (e.key === 'Escape' || e.key === 'Tab') {
133
+ if (e.key === 'Escape') e.preventDefault();
134
+ e.stopPropagation();
135
+ this.hide(e.key === 'Escape');
136
+ return;
137
+ } else return;
138
+ e.preventDefault();
139
+ e.stopPropagation();
140
+ items[next]?.focus();
141
+ }
142
+ }
143
+
144
+ type Label = MessageKey | readonly MessageKey[];
145
+
146
+ /** Shortcut rows; labels reuse the controls' own names where they exist. */
147
+ const SHORTCUTS: readonly (readonly [readonly (readonly string[])[], Label])[] = [
148
+ [
149
+ [['1'], ['2'], ['3'], ['4']],
150
+ ['mode.overlay', 'mode.swipe', 'mode.replay', 'mode.changes'],
151
+ ],
152
+ [[['j'], ['↓']], 'detail.next'],
153
+ [[['k'], ['↑']], 'detail.prev'],
154
+ [[['Home'], ['End']], 'help.homeEnd'],
155
+ [[['Enter']], 'help.enter'],
156
+ [[['Esc']], 'help.esc'],
157
+ [[['Space']], 'help.space'],
158
+ [[['←'], ['→']], 'help.arrows'],
159
+ [[['f']], 'action.fit'],
160
+ [
161
+ [['+'], ['−']],
162
+ ['action.zoomIn', 'action.zoomOut'],
163
+ ],
164
+ [[['/']], 'sidebar.search'],
165
+ [[['x']], 'review.mark'],
166
+ [[['\\']], 'action.sidebar'],
167
+ [[['t']], 'help.theme'],
168
+ [[['e']], 'help.expand'],
169
+ [[['?']], 'action.help'],
170
+ ];
171
+ export class HelpDialog {
172
+ readonly el: HTMLElement;
173
+ #returnFocus: Element | null = null;
174
+ readonly #ctx: UiContext;
175
+
176
+ constructor(ctx: UiContext) {
177
+ this.#ctx = ctx;
178
+ this.el = h('div', { class: 'overlay-scrim', hidden: true });
179
+ this.el.addEventListener('click', (e) => {
180
+ if (e.target === this.el) this.hide();
181
+ });
182
+ this.el.addEventListener('keydown', (e) => {
183
+ if (e.key === 'Escape' || e.key === '?') {
184
+ e.preventDefault();
185
+ e.stopPropagation();
186
+ this.hide();
187
+ } else if (e.key === 'Tab') {
188
+ // Keep focus inside the dialog (it has one focusable control).
189
+ e.preventDefault();
190
+ (this.el.querySelector('.icon-btn') as HTMLElement | null)?.focus();
191
+ }
192
+ });
193
+ }
194
+
195
+ get open(): boolean {
196
+ return !this.el.hidden;
197
+ }
198
+
199
+ show(): void {
200
+ this.#returnFocus = this.#ctx.root.activeElement;
201
+ this.render();
202
+ this.el.hidden = false;
203
+ (this.el.querySelector('.icon-btn') as HTMLElement | null)?.focus();
204
+ }
205
+
206
+ hide(): void {
207
+ if (this.el.hidden) return;
208
+ this.el.hidden = true;
209
+ (this.#returnFocus as HTMLElement | null)?.focus?.();
210
+ }
211
+
212
+ render(): void {
213
+ const tr = this.#ctx.tr();
214
+ const keys = (combos: readonly (readonly string[])[]): HTMLElement =>
215
+ h(
216
+ 'span',
217
+ { class: 'ks' },
218
+ combos.map((combo, i) => [
219
+ i > 0 ? h('span', { 'aria-hidden': 'true', text: '/' }) : null,
220
+ combo.map((k) => h('kbd', { text: k })),
221
+ ]),
222
+ );
223
+ const rows = SHORTCUTS.map(([combos, label]) => {
224
+ const text = typeof label === 'string' ? tr.t(label) : label.map((k) => tr.t(k)).join(' / ');
225
+ return h('div', { class: 'key-row' }, h('span', { text }), keys(combos));
226
+ });
227
+ replaceKeepingFocus(
228
+ this.#ctx.root,
229
+ this.el,
230
+ null,
231
+ h(
232
+ 'div',
233
+ {
234
+ class: 'dialog card',
235
+ role: 'dialog',
236
+ 'aria-modal': 'true',
237
+ 'aria-labelledby': 'kg-help-title',
238
+ },
239
+ h(
240
+ 'div',
241
+ { class: 'dialog-head' },
242
+ h('h2', { id: 'kg-help-title', text: tr.t('help.title') }),
243
+ iconButton('close', tr.t('help.close'), () => this.hide(), {
244
+ 'data-focus-key': 'close',
245
+ }),
246
+ ),
247
+ h('div', { class: 'dialog-body' }, h('div', { class: 'keys' }, rows)),
248
+ ),
249
+ );
250
+ }
251
+ }