@kgdiff/viewer 0.3.0 → 0.5.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 (120) hide show
  1. package/README.md +9 -8
  2. package/dist/context.d.ts +42 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +87 -0
  5. package/dist/context.js.map +1 -0
  6. package/dist/controller.d.ts +30 -173
  7. package/dist/controller.d.ts.map +1 -1
  8. package/dist/controller.js +94 -1099
  9. package/dist/controller.js.map +1 -1
  10. package/dist/element.d.ts +12 -6
  11. package/dist/element.d.ts.map +1 -1
  12. package/dist/element.js +105 -236
  13. package/dist/element.js.map +1 -1
  14. package/dist/hash-sync.d.ts +4 -2
  15. package/dist/hash-sync.d.ts.map +1 -1
  16. package/dist/hash-sync.js +21 -5
  17. package/dist/hash-sync.js.map +1 -1
  18. package/dist/i18n.d.ts +7 -7
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +14 -14
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts +24 -10
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/keyboard.d.ts +6 -4
  26. package/dist/keyboard.d.ts.map +1 -1
  27. package/dist/keyboard.js +6 -5
  28. package/dist/keyboard.js.map +1 -1
  29. package/dist/pipeline.d.ts.map +1 -1
  30. package/dist/pipeline.js +3 -2
  31. package/dist/pipeline.js.map +1 -1
  32. package/dist/prefs.d.ts +7 -1
  33. package/dist/prefs.d.ts.map +1 -1
  34. package/dist/prefs.js +15 -3
  35. package/dist/prefs.js.map +1 -1
  36. package/dist/root.d.ts +54 -0
  37. package/dist/root.d.ts.map +1 -0
  38. package/dist/root.js +265 -0
  39. package/dist/root.js.map +1 -0
  40. package/dist/schedule.d.ts +22 -0
  41. package/dist/schedule.d.ts.map +1 -0
  42. package/dist/schedule.js +41 -0
  43. package/dist/schedule.js.map +1 -0
  44. package/dist/session.d.ts +253 -0
  45. package/dist/session.d.ts.map +1 -0
  46. package/dist/session.js +1239 -0
  47. package/dist/session.js.map +1 -0
  48. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  49. package/dist/standalone/kgdiff-viewer.js +4 -4
  50. package/dist/styles.d.ts +13 -6
  51. package/dist/styles.d.ts.map +1 -1
  52. package/dist/styles.js +270 -295
  53. package/dist/styles.js.map +1 -1
  54. package/dist/theme.d.ts +25 -0
  55. package/dist/theme.d.ts.map +1 -0
  56. package/dist/theme.js +149 -0
  57. package/dist/theme.js.map +1 -0
  58. package/dist/ui/change-list.d.ts +18 -0
  59. package/dist/ui/change-list.d.ts.map +1 -0
  60. package/dist/ui/change-list.js +331 -0
  61. package/dist/ui/change-list.js.map +1 -0
  62. package/dist/ui/context.d.ts +21 -5
  63. package/dist/ui/context.d.ts.map +1 -1
  64. package/dist/ui/context.js.map +1 -1
  65. package/dist/ui/dock.d.ts +17 -0
  66. package/dist/ui/dock.d.ts.map +1 -0
  67. package/dist/ui/dock.js +151 -0
  68. package/dist/ui/dock.js.map +1 -0
  69. package/dist/ui/filters.d.ts +9 -0
  70. package/dist/ui/filters.d.ts.map +1 -0
  71. package/dist/ui/filters.js +68 -0
  72. package/dist/ui/filters.js.map +1 -0
  73. package/dist/ui/header.d.ts +7 -2
  74. package/dist/ui/header.d.ts.map +1 -1
  75. package/dist/ui/header.js +26 -135
  76. package/dist/ui/header.js.map +1 -1
  77. package/dist/ui/popovers.d.ts.map +1 -1
  78. package/dist/ui/popovers.js +3 -2
  79. package/dist/ui/popovers.js.map +1 -1
  80. package/dist/ui/search.d.ts +13 -0
  81. package/dist/ui/search.d.ts.map +1 -0
  82. package/dist/ui/search.js +191 -0
  83. package/dist/ui/search.js.map +1 -0
  84. package/dist/ui/sidebar.d.ts +8 -16
  85. package/dist/ui/sidebar.d.ts.map +1 -1
  86. package/dist/ui/sidebar.js +15 -538
  87. package/dist/ui/sidebar.js.map +1 -1
  88. package/dist/ui/stage.d.ts +4 -0
  89. package/dist/ui/stage.d.ts.map +1 -1
  90. package/dist/ui/stage.js +42 -95
  91. package/dist/ui/stage.js.map +1 -1
  92. package/dist/ui/swatch.d.ts +7 -0
  93. package/dist/ui/swatch.d.ts.map +1 -0
  94. package/dist/ui/swatch.js +26 -0
  95. package/dist/ui/swatch.js.map +1 -0
  96. package/package.json +6 -6
  97. package/src/context.ts +106 -0
  98. package/src/controller.ts +120 -1196
  99. package/src/element.ts +114 -254
  100. package/src/hash-sync.ts +20 -6
  101. package/src/i18n.ts +15 -14
  102. package/src/index.ts +24 -10
  103. package/src/keyboard.ts +12 -6
  104. package/src/pipeline.ts +4 -1
  105. package/src/prefs.ts +19 -4
  106. package/src/root.ts +308 -0
  107. package/src/schedule.ts +43 -0
  108. package/src/session.ts +1386 -0
  109. package/src/styles.ts +277 -299
  110. package/src/theme.ts +167 -0
  111. package/src/ui/change-list.ts +401 -0
  112. package/src/ui/context.ts +23 -5
  113. package/src/ui/dock.ts +198 -0
  114. package/src/ui/filters.ts +79 -0
  115. package/src/ui/header.ts +40 -170
  116. package/src/ui/popovers.ts +3 -2
  117. package/src/ui/search.ts +220 -0
  118. package/src/ui/sidebar.ts +28 -667
  119. package/src/ui/stage.ts +50 -115
  120. package/src/ui/swatch.ts +31 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Node search (RFC 0001 §3, `<kgdiff-search>` from phase 2): a combobox and the results list it
3
+ * owns. ID references (`aria-controls`, `aria-activedescendant`) do not cross shadow roots, so
4
+ * input and results stay in one component; the layout decides where the results show (the
5
+ * default layout puts them in the change list's place). The query and its hits live in the
6
+ * session, so the change list steps aside without either knowing the other. Registers itself
7
+ * as the root's `search` focus target (`/`).
8
+ */
9
+ import { attr, h, icon, replace } from '../dom.js';
10
+ import type { Topic } from '../session.js';
11
+ import type { UiContext } from './context.js';
12
+
13
+ export class Search {
14
+ /** The search box. */
15
+ readonly box: HTMLElement;
16
+ /** The results listbox. */
17
+ readonly results: HTMLElement;
18
+ readonly input: HTMLInputElement;
19
+ readonly #kbd: HTMLElement;
20
+ readonly #clear: HTMLButtonElement;
21
+ #activeHit = 0;
22
+ /** The query the results were rendered for (a new one starts at its first hit). */
23
+ #rendered = '';
24
+
25
+ readonly #ctx: UiContext;
26
+
27
+ constructor(ctx: UiContext) {
28
+ this.#ctx = ctx;
29
+ this.input = h('input', {
30
+ type: 'search',
31
+ autocomplete: 'off',
32
+ spellcheck: 'false',
33
+ role: 'combobox',
34
+ 'aria-autocomplete': 'list',
35
+ 'aria-expanded': 'false',
36
+ 'aria-controls': 'kg-results',
37
+ enterkeyhint: 'go',
38
+ });
39
+ this.#kbd = h('kbd', { 'aria-hidden': 'true', text: '/' });
40
+ this.#clear = h(
41
+ 'button',
42
+ { type: 'button', class: 'clear', hidden: true, onclick: () => this.#setQuery('', true) },
43
+ icon('close'),
44
+ );
45
+ this.input.addEventListener('input', () => this.#setQuery(this.input.value));
46
+ this.input.addEventListener('keydown', (e) => this.#onKey(e));
47
+ this.input.addEventListener('focus', () => this.#renderChrome());
48
+ this.input.addEventListener('blur', () => this.#renderChrome());
49
+ this.results = h('div', {
50
+ class: 'results list scroll',
51
+ id: 'kg-results',
52
+ role: 'listbox',
53
+ hidden: true,
54
+ });
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
+ });
60
+ }
61
+
62
+ update(topics: ReadonlySet<Topic>): void {
63
+ const all = topics.has('status') || topics.has('data') || topics.has('locale');
64
+ if (all) {
65
+ const tr = this.#ctx.tr();
66
+ this.input.placeholder = tr.t('sidebar.searchPlaceholder');
67
+ attr(this.input, 'aria-label', tr.t('sidebar.search'));
68
+ attr(this.#clear, 'aria-label', tr.t('sidebar.searchClear'));
69
+ this.input.disabled = this.#ctx.controller.status !== 'ready';
70
+ }
71
+ if (all || topics.has('search')) this.#render();
72
+ }
73
+
74
+ #setQuery(query: string, focus = false): void {
75
+ this.#ctx.controller.setQuery(query);
76
+ if (focus) this.input.focus();
77
+ }
78
+
79
+ #render(): void {
80
+ const query = this.#ctx.controller.query;
81
+ if (this.input.value !== query) this.input.value = query;
82
+ if (query !== this.#rendered) this.#activeHit = 0;
83
+ this.#rendered = query;
84
+ const searching = query.trim() !== '';
85
+ this.results.hidden = !searching;
86
+ attr(this.input, 'aria-expanded', String(searching));
87
+ this.#renderChrome();
88
+ if (searching) this.#renderResults();
89
+ else replace(this.results);
90
+ }
91
+
92
+ #renderChrome(): void {
93
+ const query = this.#ctx.controller.query;
94
+ const focused = this.#ctx.root.activeElement === this.input;
95
+ this.#kbd.hidden = query !== '' || focused;
96
+ this.#clear.hidden = query === '';
97
+ }
98
+
99
+ #renderResults(): void {
100
+ const tr = this.#ctx.tr();
101
+ const c = this.#ctx.controller;
102
+ const q = c.query.trim();
103
+ const hits = c.hits;
104
+ if (hits.length === 0) {
105
+ replace(
106
+ this.results,
107
+ h(
108
+ 'div',
109
+ { class: 'list-empty' },
110
+ icon('search'),
111
+ h('span', { text: tr.t('sidebar.noResults', { q }) }),
112
+ ),
113
+ );
114
+ attr(this.input, 'aria-activedescendant', null);
115
+ return;
116
+ }
117
+ const scene = c.scene;
118
+ const rows = hits.map((hit, i) => {
119
+ const sceneIndex = scene?.nodeIndex.get(hit.node.id);
120
+ const sn = sceneIndex !== undefined ? scene?.nodes[sceneIndex] : undefined;
121
+ const family =
122
+ sn?.family ??
123
+ (hit.node.status === 'added'
124
+ ? 'added'
125
+ : hit.node.status === 'removed'
126
+ ? 'removed'
127
+ : hit.node.status === 'modified'
128
+ ? 'content'
129
+ : 'neutral');
130
+ const sub = hit.field === 'label' ? hit.node.id : hit.matched;
131
+ return h(
132
+ 'button',
133
+ {
134
+ type: 'button',
135
+ class: 'result',
136
+ role: 'option',
137
+ id: `kg-hit-${i}`,
138
+ tabindex: '-1',
139
+ 'data-family': family,
140
+ 'aria-selected': String(i === this.#activeHit),
141
+ onclick: () => this.#choose(i),
142
+ onpointermove: () => this.#setActiveHit(i),
143
+ },
144
+ h('span', { class: 'sdot', 'aria-hidden': 'true' }),
145
+ h(
146
+ 'span',
147
+ { class: 'txt' },
148
+ h('span', { class: 'title' }, highlight(hit.node.label, hit.field === 'label' ? q : '')),
149
+ h('span', { class: 'sub' }, highlight(sub, hit.field === 'label' ? '' : q)),
150
+ ),
151
+ h('span', { class: 'status', text: tr.t(`status.${hit.node.status}`) }),
152
+ );
153
+ });
154
+ replace(
155
+ this.results,
156
+ h('div', {
157
+ class: 'results-head',
158
+ text: tr.plural('sidebar.results', hits.length),
159
+ }),
160
+ rows,
161
+ );
162
+ attr(this.input, 'aria-activedescendant', `kg-hit-${this.#activeHit}`);
163
+ }
164
+
165
+ #setActiveHit(i: number): void {
166
+ if (i === this.#activeHit || i < 0 || i >= this.#ctx.controller.hits.length) return;
167
+ this.#activeHit = i;
168
+ for (const el of this.results.querySelectorAll('.result')) {
169
+ attr(el, 'aria-selected', String(el.id === `kg-hit-${i}`));
170
+ }
171
+ attr(this.input, 'aria-activedescendant', `kg-hit-${i}`);
172
+ this.results.querySelector(`#kg-hit-${i}`)?.scrollIntoView?.({ block: 'nearest' });
173
+ }
174
+
175
+ /** Choose a hit: its node is revealed and selected, and focus moves to the change list. */
176
+ #choose(i: number): void {
177
+ const hit = this.#ctx.controller.hits[i];
178
+ if (!hit) return;
179
+ this.#setQuery('');
180
+ this.#ctx.controller.revealNode(hit.node.id);
181
+ this.#ctx.focus('list');
182
+ }
183
+
184
+ #onKey(e: KeyboardEvent): void {
185
+ switch (e.key) {
186
+ case 'ArrowDown':
187
+ e.preventDefault();
188
+ this.#setActiveHit(Math.min(this.#ctx.controller.hits.length - 1, this.#activeHit + 1));
189
+ break;
190
+ case 'ArrowUp':
191
+ e.preventDefault();
192
+ this.#setActiveHit(Math.max(0, this.#activeHit - 1));
193
+ break;
194
+ case 'Enter':
195
+ e.preventDefault();
196
+ this.#choose(this.#activeHit);
197
+ break;
198
+ case 'Escape':
199
+ e.preventDefault();
200
+ e.stopPropagation();
201
+ if (this.#ctx.controller.query) this.#setQuery('');
202
+ else this.#ctx.focus('list');
203
+ break;
204
+ default:
205
+ return;
206
+ }
207
+ }
208
+ }
209
+
210
+ /** The label with the first occurrence of `q` (case-insensitive) marked. */
211
+ function highlight(text: string, q: string): (string | HTMLElement)[] {
212
+ if (!q) return [text];
213
+ const at = text.toLowerCase().indexOf(q.toLowerCase());
214
+ if (at < 0) return [text];
215
+ return [
216
+ text.slice(0, at),
217
+ h('mark', { text: text.slice(at, at + q.length) }),
218
+ text.slice(at + q.length),
219
+ ];
220
+ }