@kgdiff/viewer 0.3.0 → 0.4.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.
- package/README.md +9 -8
- package/dist/controller.d.ts +23 -7
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +119 -14
- package/dist/controller.js.map +1 -1
- package/dist/element.d.ts +6 -3
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +68 -19
- package/dist/element.js.map +1 -1
- package/dist/hash-sync.d.ts +4 -2
- package/dist/hash-sync.d.ts.map +1 -1
- package/dist/hash-sync.js +21 -5
- package/dist/hash-sync.js.map +1 -1
- package/dist/i18n.d.ts +7 -7
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +14 -14
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +21 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keyboard.d.ts +6 -4
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +6 -5
- package/dist/keyboard.js.map +1 -1
- package/dist/prefs.d.ts +7 -1
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +15 -3
- package/dist/prefs.js.map +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +69 -63
- package/dist/styles.js.map +1 -1
- package/dist/ui/context.d.ts +2 -0
- package/dist/ui/context.d.ts.map +1 -1
- package/dist/ui/context.js.map +1 -1
- package/dist/ui/dock.d.ts +17 -0
- package/dist/ui/dock.d.ts.map +1 -0
- package/dist/ui/dock.js +151 -0
- package/dist/ui/dock.js.map +1 -0
- package/dist/ui/header.d.ts +7 -2
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +24 -133
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/popovers.d.ts.map +1 -1
- package/dist/ui/popovers.js +3 -2
- package/dist/ui/popovers.js.map +1 -1
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +2 -1
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.d.ts +4 -0
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +41 -94
- package/dist/ui/stage.js.map +1 -1
- package/dist/ui/swatch.d.ts +7 -0
- package/dist/ui/swatch.d.ts.map +1 -0
- package/dist/ui/swatch.js +26 -0
- package/dist/ui/swatch.js.map +1 -0
- package/package.json +6 -6
- package/src/controller.ts +131 -20
- package/src/element.ts +72 -21
- package/src/hash-sync.ts +20 -6
- package/src/i18n.ts +15 -14
- package/src/index.ts +21 -9
- package/src/keyboard.ts +12 -6
- package/src/prefs.ts +19 -4
- package/src/styles.ts +69 -63
- package/src/ui/context.ts +2 -0
- package/src/ui/dock.ts +198 -0
- package/src/ui/header.ts +38 -168
- package/src/ui/popovers.ts +3 -2
- package/src/ui/sidebar.ts +2 -1
- package/src/ui/stage.ts +49 -114
- package/src/ui/swatch.ts +31 -0
package/src/ui/dock.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view dock, floating at the bottom of the canvas. It holds two independent choices: how
|
|
3
|
+
* the versions are compared (overlay / swipe / replay, one at a time, keys `1`–`3`) and whether
|
|
4
|
+
* only the changes are shown (key `4`, in any mode). The latter brings its context hops and the
|
|
5
|
+
* nearest “+N” expansion along. Replay's caption and transport stack above it (`stage.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import type { Topic } from '../controller.js';
|
|
8
|
+
import { attr, h, icon, relabel, setText } from '../dom.js';
|
|
9
|
+
import type { IconName } from '../icons.js';
|
|
10
|
+
import type { ViewerMode } from '../index.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
|
+
];
|
|
18
|
+
|
|
19
|
+
export class Dock {
|
|
20
|
+
readonly el: HTMLElement;
|
|
21
|
+
readonly #seg: HTMLElement;
|
|
22
|
+
readonly #thumb: HTMLElement;
|
|
23
|
+
readonly #segButtons = new Map<ViewerMode, HTMLButtonElement>();
|
|
24
|
+
readonly #scopeButton: HTMLButtonElement;
|
|
25
|
+
readonly #context: HTMLElement;
|
|
26
|
+
readonly #opts: HTMLElement;
|
|
27
|
+
readonly #hopButtons: HTMLButtonElement[] = [];
|
|
28
|
+
readonly #expandButton: HTMLButtonElement;
|
|
29
|
+
|
|
30
|
+
readonly #ctx: UiContext;
|
|
31
|
+
|
|
32
|
+
constructor(ctx: UiContext) {
|
|
33
|
+
this.#ctx = ctx;
|
|
34
|
+
const c = ctx.controller;
|
|
35
|
+
this.#thumb = h('span', { class: 'thumb', 'aria-hidden': 'true' });
|
|
36
|
+
this.#seg = h('div', { class: 'segmented', role: 'radiogroup' }, this.#thumb);
|
|
37
|
+
for (const m of MODES) {
|
|
38
|
+
const b = h(
|
|
39
|
+
'button',
|
|
40
|
+
{
|
|
41
|
+
type: 'button',
|
|
42
|
+
class: 'seg',
|
|
43
|
+
role: 'radio',
|
|
44
|
+
'data-mode': m.mode,
|
|
45
|
+
onclick: () => ctx.setMode(m.mode),
|
|
46
|
+
},
|
|
47
|
+
icon(m.icon),
|
|
48
|
+
h('span', { class: 'lbl' }),
|
|
49
|
+
);
|
|
50
|
+
this.#segButtons.set(m.mode, b);
|
|
51
|
+
this.#seg.appendChild(b);
|
|
52
|
+
}
|
|
53
|
+
this.#seg.addEventListener('keydown', (e) => this.#onSegKey(e));
|
|
54
|
+
|
|
55
|
+
this.#scopeButton = h(
|
|
56
|
+
'button',
|
|
57
|
+
{
|
|
58
|
+
type: 'button',
|
|
59
|
+
class: 'scope-btn',
|
|
60
|
+
'aria-pressed': 'false',
|
|
61
|
+
'aria-keyshortcuts': '4',
|
|
62
|
+
onclick: () => ctx.toggleScope(),
|
|
63
|
+
},
|
|
64
|
+
icon('changes'),
|
|
65
|
+
h('span', { class: 'lbl' }),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Context hops (0 / 1 / 2) and the nearest collapsed group: only with the changes scope.
|
|
69
|
+
this.#opts = h('span', { class: 'opts', role: 'radiogroup' });
|
|
70
|
+
for (let n = 0; n <= 2; n++) {
|
|
71
|
+
const b = h('button', {
|
|
72
|
+
type: 'button',
|
|
73
|
+
class: 'opt',
|
|
74
|
+
role: 'radio',
|
|
75
|
+
text: String(n),
|
|
76
|
+
onclick: () => c.setHops(n),
|
|
77
|
+
});
|
|
78
|
+
this.#hopButtons.push(b);
|
|
79
|
+
this.#opts.appendChild(b);
|
|
80
|
+
}
|
|
81
|
+
this.#opts.addEventListener('keydown', (e) => {
|
|
82
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
const next = Math.max(0, Math.min(2, c.hops + (e.key === 'ArrowRight' ? 1 : -1)));
|
|
86
|
+
c.setHops(next);
|
|
87
|
+
this.#hopButtons[next]?.focus();
|
|
88
|
+
});
|
|
89
|
+
this.#expandButton = h(
|
|
90
|
+
'button',
|
|
91
|
+
{ type: 'button', class: 'expand', onclick: () => c.expandNearest() },
|
|
92
|
+
icon('plus'),
|
|
93
|
+
h('span', { class: 'n' }),
|
|
94
|
+
);
|
|
95
|
+
this.#context = h(
|
|
96
|
+
'div',
|
|
97
|
+
{ class: 'context', hidden: true },
|
|
98
|
+
h('span', { class: 'label' }),
|
|
99
|
+
this.#opts,
|
|
100
|
+
this.#expandButton,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
this.el = h(
|
|
104
|
+
'div',
|
|
105
|
+
{ class: 'dock-bar card', role: 'group' },
|
|
106
|
+
this.#seg,
|
|
107
|
+
h('span', { class: 'dock-sep', 'aria-hidden': 'true' }),
|
|
108
|
+
this.#scopeButton,
|
|
109
|
+
this.#context,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
update(topics: ReadonlySet<Topic>): void {
|
|
114
|
+
const all = topics.has('status') || topics.has('data') || topics.has('locale');
|
|
115
|
+
if (all) this.#renderLabels();
|
|
116
|
+
if (all || topics.has('mode')) this.#renderMode();
|
|
117
|
+
if (all || topics.has('mode') || topics.has('focus') || topics.has('selection')) {
|
|
118
|
+
this.#renderScope();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Re-measure the segmented thumb (fonts / size changes). */
|
|
123
|
+
layout(): void {
|
|
124
|
+
this.#renderMode();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#renderLabels(): void {
|
|
128
|
+
const tr = this.#ctx.tr();
|
|
129
|
+
attr(this.el, 'aria-label', tr.t('dock.label'));
|
|
130
|
+
attr(this.#seg, 'aria-label', tr.t('mode.label'));
|
|
131
|
+
for (const m of MODES) {
|
|
132
|
+
const b = this.#segButtons.get(m.mode) as HTMLButtonElement;
|
|
133
|
+
const label = tr.t(`mode.${m.mode}`);
|
|
134
|
+
setText(b.querySelector('.lbl') as Element, label);
|
|
135
|
+
attr(b, 'title', `${label} (${m.key})`);
|
|
136
|
+
attr(b, 'aria-label', label);
|
|
137
|
+
}
|
|
138
|
+
setText(this.#scopeButton.querySelector('.lbl') as Element, tr.t('mode.changes'));
|
|
139
|
+
attr(this.#scopeButton, 'aria-label', tr.t('mode.changes'));
|
|
140
|
+
setText(this.#context.querySelector('.label') as Element, tr.t('focus.context'));
|
|
141
|
+
attr(this.#opts, 'aria-label', tr.t('focus.hops'));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#renderMode(): void {
|
|
145
|
+
const mode = this.#ctx.controller.mode;
|
|
146
|
+
for (const [m, b] of this.#segButtons) {
|
|
147
|
+
const on = m === mode;
|
|
148
|
+
attr(b, 'aria-checked', String(on));
|
|
149
|
+
b.tabIndex = on ? 0 : -1;
|
|
150
|
+
}
|
|
151
|
+
const active = this.#segButtons.get(mode);
|
|
152
|
+
if (active && active.offsetWidth > 0) {
|
|
153
|
+
this.#thumb.style.width = `${active.offsetWidth}px`;
|
|
154
|
+
this.#thumb.style.transform = `translateX(${active.offsetLeft}px)`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#renderScope(): void {
|
|
159
|
+
const c = this.#ctx.controller;
|
|
160
|
+
const tr = this.#ctx.tr();
|
|
161
|
+
const on = c.scope === 'changes';
|
|
162
|
+
const locked = c.scopeLocked;
|
|
163
|
+
attr(this.#scopeButton, 'aria-pressed', String(on));
|
|
164
|
+
attr(this.#scopeButton, 'aria-disabled', locked ? 'true' : null);
|
|
165
|
+
attr(this.#scopeButton, 'title', locked ? tr.t('scope.locked') : `${tr.t('scope.hint')} (4)`);
|
|
166
|
+
this.#context.hidden = !on;
|
|
167
|
+
// The dock's width changed: keep the thumb under the active mode.
|
|
168
|
+
this.#renderMode();
|
|
169
|
+
if (!on) return;
|
|
170
|
+
this.#hopButtons.forEach((b, n) => {
|
|
171
|
+
attr(b, 'aria-checked', String(n === c.hops));
|
|
172
|
+
b.tabIndex = n === c.hops ? 0 : -1;
|
|
173
|
+
attr(b, 'aria-label', `${tr.t('focus.hops')}: ${n}`);
|
|
174
|
+
});
|
|
175
|
+
const group = c.focus?.collapsed[c.nearestGroup()];
|
|
176
|
+
this.#expandButton.hidden = !group;
|
|
177
|
+
if (group) {
|
|
178
|
+
const usable = c.canExpand(c.nearestGroup()) || group.nodes.length > 0;
|
|
179
|
+
this.#expandButton.disabled = !usable;
|
|
180
|
+
setText(this.#expandButton.querySelector('.n') as Element, tr.num(group.count));
|
|
181
|
+
relabel(
|
|
182
|
+
this.#expandButton,
|
|
183
|
+
`${usable ? tr.t('focus.expandNearest') : tr.t('focus.expandUnavailable')}: ${group.label} (E)`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#onSegKey(e: KeyboardEvent): void {
|
|
189
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
const i = MODES.findIndex((m) => m.mode === this.#ctx.controller.mode);
|
|
193
|
+
const next = MODES[(i + (e.key === 'ArrowRight' ? 1 : MODES.length - 1)) % MODES.length];
|
|
194
|
+
if (!next) return;
|
|
195
|
+
this.#ctx.setMode(next.mode);
|
|
196
|
+
this.#segButtons.get(next.mode)?.focus();
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/ui/header.ts
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* appearance menu,
|
|
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`).
|
|
4
5
|
*/
|
|
5
|
-
import type { ChangeFamily } from '@kgdiff/scene';
|
|
6
6
|
import type { Topic } from '../controller.js';
|
|
7
|
-
import { attr, h,
|
|
8
|
-
import type { IconName } from '../icons.js';
|
|
9
|
-
import type { ViewerMode } from '../index.js';
|
|
10
|
-
import { familyCounts } from '../list-model.js';
|
|
7
|
+
import { attr, h, iconButton, relabel, replace } from '../dom.js';
|
|
11
8
|
import type { UiContext } from './context.js';
|
|
12
9
|
|
|
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
10
|
/** The kgdiff mark: a soft accent diamond holding a node. */
|
|
35
11
|
function brandMark(): SVGSVGElement {
|
|
36
12
|
const tpl = document.createElement('template');
|
|
@@ -44,23 +20,18 @@ function brandMark(): SVGSVGElement {
|
|
|
44
20
|
|
|
45
21
|
export class Header {
|
|
46
22
|
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
23
|
readonly menuButton: HTMLButtonElement;
|
|
54
|
-
readonly #
|
|
24
|
+
readonly #brand: HTMLElement;
|
|
25
|
+
readonly #versions: HTMLElement;
|
|
26
|
+
readonly #actions: HTMLElement;
|
|
27
|
+
readonly #end: HTMLElement;
|
|
55
28
|
readonly #helpButton: HTMLButtonElement;
|
|
56
29
|
readonly #sidebarButton: HTMLButtonElement;
|
|
57
|
-
readonly #brand: HTMLElement;
|
|
58
30
|
|
|
59
31
|
readonly #ctx: UiContext;
|
|
60
32
|
|
|
61
33
|
constructor(ctx: UiContext) {
|
|
62
34
|
this.#ctx = ctx;
|
|
63
|
-
const c = ctx.controller;
|
|
64
35
|
this.#brand = h(
|
|
65
36
|
'div',
|
|
66
37
|
{ class: 'brand' },
|
|
@@ -68,95 +39,50 @@ export class Header {
|
|
|
68
39
|
h('span', { class: 'brand-name', text: 'kgdiff' }),
|
|
69
40
|
);
|
|
70
41
|
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
42
|
this.menuButton = iconButton('contrast', '', () => ctx.toggleMenu(), {
|
|
108
43
|
'aria-haspopup': 'menu',
|
|
109
44
|
'aria-expanded': 'false',
|
|
110
45
|
});
|
|
111
|
-
this.#fitButton = iconButton('fit', '', () => void c.fit());
|
|
112
46
|
this.#helpButton = iconButton('help', '', () => ctx.openHelp());
|
|
113
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);
|
|
114
58
|
this.el = h(
|
|
115
59
|
'header',
|
|
116
|
-
{ class: '
|
|
117
|
-
this.#brand,
|
|
118
|
-
h('span', { class: 'header-sep', 'aria-hidden': 'true' }),
|
|
119
|
-
this.#versions,
|
|
120
|
-
h('div', { class: 'header-center' }, this.#seg),
|
|
60
|
+
{ class: 'hud' },
|
|
121
61
|
h(
|
|
122
62
|
'div',
|
|
123
|
-
{ class: '
|
|
124
|
-
this.#
|
|
125
|
-
|
|
126
|
-
this.#
|
|
127
|
-
this.#helpButton,
|
|
128
|
-
this.#sidebarButton,
|
|
63
|
+
{ class: 'hud-title card' },
|
|
64
|
+
this.#brand,
|
|
65
|
+
h('span', { class: 'header-sep', 'aria-hidden': 'true' }),
|
|
66
|
+
this.#versions,
|
|
129
67
|
),
|
|
68
|
+
this.#end,
|
|
130
69
|
);
|
|
131
70
|
}
|
|
132
71
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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();
|
|
72
|
+
/** Place the appearance menu under the actions. */
|
|
73
|
+
attachMenu(menu: HTMLElement): void {
|
|
74
|
+
this.#end.appendChild(menu);
|
|
155
75
|
}
|
|
156
76
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
this.#
|
|
77
|
+
update(topics: ReadonlySet<Topic>): void {
|
|
78
|
+
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')} (\\)`);
|
|
85
|
+
this.#renderVersions();
|
|
160
86
|
}
|
|
161
87
|
|
|
162
88
|
setSidebarState(open: boolean): void {
|
|
@@ -171,8 +97,10 @@ export class Header {
|
|
|
171
97
|
const data = this.#ctx.controller.data;
|
|
172
98
|
if (!data) {
|
|
173
99
|
replace(this.#versions);
|
|
100
|
+
this.el.dataset.titled = 'false';
|
|
174
101
|
return;
|
|
175
102
|
}
|
|
103
|
+
this.el.dataset.titled = 'true';
|
|
176
104
|
const tr = this.#ctx.tr();
|
|
177
105
|
const { names, versions } = data;
|
|
178
106
|
const tag = (v: string | undefined) =>
|
|
@@ -217,62 +145,4 @@ export class Header {
|
|
|
217
145
|
),
|
|
218
146
|
);
|
|
219
147
|
}
|
|
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
148
|
}
|
package/src/ui/popovers.ts
CHANGED
|
@@ -146,9 +146,10 @@ type Label = MessageKey | readonly MessageKey[];
|
|
|
146
146
|
/** Shortcut rows; labels reuse the controls' own names where they exist. */
|
|
147
147
|
const SHORTCUTS: readonly (readonly [readonly (readonly string[])[], Label])[] = [
|
|
148
148
|
[
|
|
149
|
-
[['1'], ['2'], ['3']
|
|
150
|
-
['mode.overlay', 'mode.swipe', 'mode.replay'
|
|
149
|
+
[['1'], ['2'], ['3']],
|
|
150
|
+
['mode.overlay', 'mode.swipe', 'mode.replay'],
|
|
151
151
|
],
|
|
152
|
+
[[['4']], 'mode.changes'],
|
|
152
153
|
[[['j'], ['↓']], 'detail.next'],
|
|
153
154
|
[[['k'], ['↑']], 'detail.prev'],
|
|
154
155
|
[[['Home'], ['End']], 'help.homeEnd'],
|
package/src/ui/sidebar.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { SearchHit } from '../search.js';
|
|
|
11
11
|
import { FAMILIES } from '../styles.js';
|
|
12
12
|
import { sceneHunkGlyph, type UiContext } from './context.js';
|
|
13
13
|
import { Detail } from './detail.js';
|
|
14
|
+
import { swatch } from './swatch.js';
|
|
14
15
|
|
|
15
16
|
/** Row slot and group-header heights (px); rows are 52 px tall inside a 56 px slot. */
|
|
16
17
|
export const ROW_SLOT = 56;
|
|
@@ -96,7 +97,7 @@ export class Sidebar {
|
|
|
96
97
|
'aria-pressed': 'false',
|
|
97
98
|
onclick: () => c.toggleFamily(f),
|
|
98
99
|
},
|
|
99
|
-
|
|
100
|
+
swatch(f),
|
|
100
101
|
h('span', { class: 'lbl' }),
|
|
101
102
|
h('span', { class: 'n' }),
|
|
102
103
|
);
|