@celestia-island/hikari 0.55.160 → 0.55.162

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkBreadcrumb.tsx +2 -1
  3. package/src/components/HkDiffViewer.test.tsx +112 -0
  4. package/src/components/HkDiffViewer.tsx +122 -3
  5. package/src/components/HkFilterPanel.scss +65 -4
  6. package/src/components/HkFilterPanel.styles.test.ts +11 -0
  7. package/src/components/HkFilterPanel.test.tsx +74 -3
  8. package/src/components/HkFilterPanel.tsx +138 -12
  9. package/src/components/HkFilterTrigger.test.tsx +248 -0
  10. package/src/components/HkLogo.tsx +2 -1
  11. package/src/components/HkNavItem.tsx +3 -2
  12. package/src/components/HkPopover.shield.test.tsx +124 -0
  13. package/src/components/HkPopover.tsx +36 -1
  14. package/src/components/HkPopupSelect.tsx +10 -0
  15. package/src/components/HkScrollContainer.tsx +16 -2
  16. package/src/components/_menu-item.scss +1 -0
  17. package/src/composables/useOverlayScrollbar.scrollMap.test.ts +187 -0
  18. package/src/composables/useOverlayScrollbar.ts +162 -8
  19. package/src/i18n/locales/ar/components.json +1 -0
  20. package/src/i18n/locales/de/components.json +1 -0
  21. package/src/i18n/locales/en/components.json +1 -0
  22. package/src/i18n/locales/es/components.json +1 -0
  23. package/src/i18n/locales/fr/components.json +1 -0
  24. package/src/i18n/locales/ja/components.json +1 -0
  25. package/src/i18n/locales/ko/components.json +1 -0
  26. package/src/i18n/locales/pt/components.json +1 -0
  27. package/src/i18n/locales/ru/components.json +1 -0
  28. package/src/i18n/locales/zh-Hans/components.json +1 -0
  29. package/src/i18n/locales/zh-Hant/components.json +1 -0
  30. package/src/index.ts +2 -1
  31. package/src/utils/safeHref.test.ts +48 -0
  32. package/src/utils/safeHref.ts +34 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.160",
3
+ "version": "0.55.162",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1,4 +1,5 @@
1
1
  import { computed, defineComponent, type PropType } from "vue";
2
+ import { safeHref } from "../utils/safeHref";
2
3
  import { useI18n } from "../i18n/context";
3
4
  import "./HkBreadcrumb.scss";
4
5
 
@@ -19,7 +20,7 @@ export default defineComponent({
19
20
  {props.items.map((item, index) => (
20
21
  <li key={index} class="hk-breadcrumb-item">
21
22
  {item.to ? (
22
- <a href={item.to} class="hk-breadcrumb-link">{item.label}</a>
23
+ <a href={safeHref(item.to)} class="hk-breadcrumb-link">{item.label}</a>
23
24
  ) : (
24
25
  <span class="hk-breadcrumb-link">{item.label}</span>
25
26
  )}
@@ -238,3 +238,115 @@ describe("HkDiffViewer", () => {
238
238
  expect(root.querySelector(".hk-diff-viewer__hunk")).toBeNull();
239
239
  });
240
240
  });
241
+
242
+ // ── The fold-aware scroll map (2026-10-12 user direction) ──────────────
243
+ // The scrollbar must represent the WHOLE document while the viewer folds
244
+ // unchanged regions away: the map total is fold-invariant, deleted rows
245
+ // carry no document extent (they are not in the current file), and the
246
+ // hunk heading is chrome. Mounting and folding drives the emission.
247
+ describe("HkDiffViewer scroll map", () => {
248
+ const MAPPED_DIFF = [
249
+ // Counts MUST match the listed lines (3 old-side, 4 new-side; then
250
+ // 3 and 3) or the map total drifts off totalLines.
251
+ "@@ -1,3 +1,4 @@",
252
+ " ctx1",
253
+ " ctx2",
254
+ " ctx3",
255
+ "+add",
256
+ "@@ -50,3 +51,3 @@",
257
+ " ctxA",
258
+ "-old",
259
+ "+new",
260
+ " ctxB",
261
+ ].join("\n");
262
+
263
+ function mountViewer(extra: Record<string, unknown> = {}) {
264
+ const emitted: Array<Array<{ dom: number; map: number }>> = [];
265
+ const root = mount({
266
+ diff: MAPPED_DIFF,
267
+ totalLines: 100,
268
+ onScrollMap: (segs: Array<{ dom: number; map: number }>) => emitted.push(segs),
269
+ ...extra,
270
+ });
271
+ return { root, emitted };
272
+ }
273
+
274
+ it("emits a map whose total is the document, fold-invariant", async () => {
275
+ const { root, emitted } = mountViewer();
276
+ await nextTick();
277
+ expect(emitted.length).toBeGreaterThanOrEqual(1);
278
+ const total = (segs: Array<{ map: number }>) => segs.reduce((a, s) => a + s.map, 0);
279
+ expect(total(emitted[emitted.length - 1])).toBe(100);
280
+ // Collapse a hunk: the document total must not change (the scrollbar
281
+ // is a map of the WHOLE file whatever is folded).
282
+ const head = root.querySelector<HTMLButtonElement>(".hk-diff-viewer__hunk-head")!;
283
+ head.click();
284
+ await nextTick();
285
+ expect(total(emitted[emitted.length - 1])).toBe(100);
286
+ head.click(); // back
287
+ await nextTick();
288
+ expect(total(emitted[emitted.length - 1])).toBe(100);
289
+ });
290
+
291
+ it("keeps an empty-batched gap's rung at zero dom while the document keeps its lines", async () => {
292
+ // A host answering [] marks the gap done with NO rows fetched: the
293
+ // rung must not claim dom extent for lines that never rendered (R2's
294
+ // N2 caught +1957px of phantom dom) while the document side keeps
295
+ // the gap's full extent.
296
+ const { root, emitted } = mountViewer({
297
+ loadContext: async () => [],
298
+ });
299
+ await nextTick();
300
+ // MAPPED_DIFF has no leading gap (hunk 1 starts at line 1); the first
301
+ // divider is the 46-line middle gap (lines 5..50, anchor=start).
302
+ const divider = root.querySelector<HTMLButtonElement>(".hk-diff-viewer__gap")!;
303
+ divider.click();
304
+ await tick();
305
+ await nextTick();
306
+ const segs = emitted[emitted.length - 1];
307
+ // The gap is DONE via the empty batch: its document extent survives
308
+ // as a zero-dom rung — no phantom rows for lines that never rendered.
309
+ expect(segs).toContainEqual({ dom: 0, map: 46 });
310
+ expect(segs).not.toContainEqual({ dom: 46, map: 46 });
311
+ // …and the document total is still the whole file.
312
+ expect(segs.reduce((a, x) => a + x.map, 0)).toBe(100);
313
+ });
314
+
315
+ it("orders an expanded anchor=start gap's revealed rung BEFORE its divider rung", async () => {
316
+ // R2's N3: swapping the rung order moved the remaining-divider's
317
+ // document start by ~727px. The render puts revealed rows above the
318
+ // remaining divider for anchor=start, and the map must mirror it.
319
+ const lines: string[] = [];
320
+ for (let i = 0; i < 40; i += 1) lines.push(` fetched-${i}`);
321
+ const { root, emitted } = mountViewer({
322
+ loadContext: async () => lines,
323
+ });
324
+ await nextTick();
325
+ // The trailing gap (anchor=start, after the last hunk).
326
+ const dividers = [...root.querySelectorAll<HTMLButtonElement>(".hk-diff-viewer__gap")];
327
+ const trailing = dividers[dividers.length - 1];
328
+ trailing.click();
329
+ await tick();
330
+ await nextTick();
331
+ const segs = emitted[emitted.length - 1];
332
+ // Find the trailing gap's rungs: revealed (dom>0, map=40) directly
333
+ // before the remaining divider (map = 100 - docBefore - 40).
334
+ const idx = segs.findIndex((x, i) => x.map === 40 && segs[i + 1] && segs[i + 1].map > 0 && segs[i + 1].dom >= 0 && x.dom > 0 && i > segs.length - 6);
335
+ expect(idx).toBeGreaterThan(-1);
336
+ expect(segs[idx].dom).toBeGreaterThan(0);
337
+ expect(segs[idx + 1].map).toBeGreaterThan(0);
338
+ });
339
+
340
+ it("counts deleted rows as rendered-but-not-in-the-document", async () => {
341
+ const { emitted } = mountViewer();
342
+ await nextTick();
343
+ const segs = emitted[emitted.length - 1];
344
+ const total = segs.reduce((a, s) => a + s.map, 0);
345
+ const dom = segs.reduce((a, s) => a + s.dom, 0);
346
+ // 4 context/add rows in hunk 1 + 6 in hunk 2 (one of them a DELETED
347
+ // row: rendered, map 0) → dom exceeds the hunks' map contribution.
348
+ expect(dom).toBeGreaterThan(total - 100 + 0); // sanity: dom side exists
349
+ const hunkRows = segs.filter((s) => s.dom === 1 && s.map <= 1);
350
+ expect(hunkRows.length).toBeGreaterThan(4);
351
+ });
352
+ });
@@ -1,9 +1,10 @@
1
- import { computed, defineComponent, ref, watch } from "vue";
1
+ import { computed, defineComponent, onMounted, ref, watch } from "vue";
2
2
  import { AlertTriangle, ChevronDown, ChevronRight, UnfoldVertical } from "lucide-vue-next";
3
3
  import HkSpinner from "./HkSpinner";
4
4
  import { useI18n } from "../i18n/context";
5
5
  import { highlight } from "../composables/useHighlight";
6
6
  import { computeGaps, parseUnifiedDiff, type DiffLine, type UnchangedGap } from "./diffParse";
7
+ import type { OverlayScrollMapSegment } from "../composables/useOverlayScrollbar";
7
8
  import "./HkDiffViewer.scss";
8
9
 
9
10
  /**
@@ -57,15 +58,133 @@ export default defineComponent({
57
58
  */
58
59
  loadContext: { type: Function, default: null },
59
60
  },
60
- setup(props) {
61
+ emits: {
62
+ /**
63
+ * The fold-aware scroll map (see `OverlayScrollMapSegment`): document
64
+ * units are NEW-SIDE file lines (deleted rows carry none — they do
65
+ * not exist in the current file), dom units are MEASURED PIXELS of
66
+ * the rows that render (row-count fallback where nothing lays out; a
67
+ * hunk heading is chrome and carries no document extent). The total
68
+ * is FOLD-INVARIANT — expanding a region moves its extent from the
69
+ * divider's rung onto real rows without changing the document — so
70
+ * the scrollbar stays a map of the WHOLE file whatever is folded.
71
+ * Bind it to a HkScrollContainer's `mapSegments`.
72
+ */
73
+ "scroll-map": null as OverlayScrollMapSegment[] | null,
74
+ },
75
+ setup(props, { emit }) {
76
+ const host = ref<HTMLElement | null>(null);
61
77
  const { t } = useI18n();
62
78
 
63
79
  const parsed = computed(() => parseUnifiedDiff(props.diff));
64
80
  const gaps = computed(() => computeGaps(parsed.value, props.totalLines ?? null));
65
81
  const collapsedHunks = ref<Set<number>>(new Set());
82
+
83
+ /**
84
+ * Measured pixel heights of the three row kinds, so the map's dom
85
+ * side can be honest in px instead of row counts — a hunk heading is
86
+ * ~1.7 lines tall and a divider ~1.35, and count-units drift the
87
+ * rung boundaries one to two rows off the real geometry (R1's pin
88
+ * plateau sat 17px past the divider). Zero = not measured yet
89
+ * (no-layout environments), and the map falls back to counts.
90
+ */
91
+ const rowKinds = ref({ line: 0, head: 0, gap: 0 });
92
+
93
+ function measureRowKinds() {
94
+ const root = host.value;
95
+ if (!root) return;
96
+ const line = root.querySelector<HTMLElement>(".hk-diff-viewer__line");
97
+ const head = root.querySelector<HTMLElement>(".hk-diff-viewer__hunk-head");
98
+ const gap = root.querySelector<HTMLElement>(".hk-diff-viewer__gap");
99
+ const next = {
100
+ line: line?.offsetHeight ?? 0,
101
+ head: head?.offsetHeight ?? 0,
102
+ gap: gap?.offsetHeight ?? 0,
103
+ };
104
+ // Write only on a real change: `rowKinds` feeds the scrollMap
105
+ // computed, and this runs from a watcher on that computed — an
106
+ // unconditional assign (a fresh object with equal values) would
107
+ // re-trigger it and recurse forever.
108
+ const prev = rowKinds.value;
109
+ if (
110
+ next.line !== prev.line || next.head !== prev.head || next.gap !== prev.gap
111
+ ) {
112
+ rowKinds.value = next;
113
+ }
114
+ }
115
+
116
+ /** The fold-aware scroll map, in RENDER order (mirrors the tree the
117
+ * render function interleaves below, rung for rung). */
118
+ const scrollMap = computed<OverlayScrollMapSegment[]>(() => {
119
+ const segs: OverlayScrollMapSegment[] = [];
120
+ const mapOfGap = (gap: UnchangedGap) => {
121
+ const st = gapStates.value.get(gap.start);
122
+ const revealed = st?.revealed ?? 0;
123
+ const remaining = Math.max(gap.count - revealed, 0);
124
+ // Dom side counts the rows that actually RENDER (the same window
125
+ // walk the render does): a host answering an empty batch marks
126
+ // the gap done with zero rows shown, and the map must not claim
127
+ // dom extent for them — the DOCUMENT side keeps the lines.
128
+ const first =
129
+ gap.anchor === "end" ? gap.start + gap.count - revealed : gap.start;
130
+ let rendered = 0;
131
+ for (let no = first; no < first + revealed; no += 1) {
132
+ if (st?.lines.has(no)) rendered += 1;
133
+ }
134
+ const px = rowKinds.value.line > 0 ? rowKinds.value.line : 1;
135
+ const dividerPx =
136
+ rowKinds.value.gap > 0 ? rowKinds.value.gap : rowKinds.value.line > 0 ? rowKinds.value.line : 1;
137
+ const revealedRung = { dom: rendered * px, map: revealed };
138
+ const dividerRung = { dom: remaining > 0 ? dividerPx : 0, map: remaining };
139
+ // Mirror renderGap's order: anchor=end puts the divider ABOVE the
140
+ // revealed rows, anchor=start below them.
141
+ if (gap.anchor === "end") segs.push(dividerRung, revealedRung);
142
+ else segs.push(revealedRung, dividerRung);
143
+ };
144
+ const mapOfHunk = (idx: number) => {
145
+ const hunk = parsed.value.hunks[idx];
146
+ const linePx = rowKinds.value.line > 0 ? rowKinds.value.line : 1;
147
+ const headPx = rowKinds.value.head > 0 ? rowKinds.value.head : 1;
148
+ // The heading is chrome: it occupies a row and no document line.
149
+ segs.push({ dom: headPx, map: 0 });
150
+ const live = hunk.lines.filter((l) => l.kind !== "del").length;
151
+ if (collapsedHunks.value.has(idx)) {
152
+ // Body hidden: the whole new-side extent rides the heading rung.
153
+ segs.push({ dom: 0, map: live });
154
+ } else {
155
+ for (const line of hunk.lines) {
156
+ // Deleted rows render but exist only in the old file.
157
+ segs.push({ dom: linePx, map: line.kind === "del" ? 0 : 1 });
158
+ }
159
+ }
160
+ };
161
+ const gapsByHunk = new Map<number, UnchangedGap>();
162
+ for (const gap of gaps.value) gapsByHunk.set(gap.beforeHunk, gap);
163
+ for (let i = 0; i < parsed.value.hunks.length; i += 1) {
164
+ const gap = gapsByHunk.get(i);
165
+ if (gap) mapOfGap(gap);
166
+ mapOfHunk(i);
167
+ }
168
+ const trailing = gapsByHunk.get(parsed.value.hunks.length);
169
+ if (trailing) mapOfGap(trailing);
170
+ return segs;
171
+ });
172
+
66
173
  /** Gap states keyed by the gap's new-side start line (stable identity). */
67
174
  const gapStates = ref<Map<number, GapState>>(new Map());
68
175
 
176
+ // Emit on every fold change (and once up front) — the consumer feeds
177
+ // it straight into its HkScrollContainer's `mapSegments`. Declared
178
+ // AFTER gapStates: the immediate evaluation reads it, and a watch
179
+ // above the declaration would hit the temporal dead zone.
180
+ watch(scrollMap, (segs) => emit("scroll-map", segs), { immediate: true, flush: "post" });
181
+ // Re-measure after the DOM catches up (mount + every fold change:
182
+ // a first-ever divider/heading may have appeared).
183
+ onMounted(() => measureRowKinds());
184
+ watch([() => props.diff, () => props.totalLines, scrollMap], () => {
185
+ measureRowKinds();
186
+ }, { flush: "post" });
187
+
69
188
  // A fresh diff invalidates every cached context line and fold state.
70
189
  watch(
71
190
  () => [props.diff, props.totalLines],
@@ -267,7 +386,7 @@ export default defineComponent({
267
386
  const trailing = gapsByHunk.get(hunks.length);
268
387
  if (trailing) blocks.push(renderGap(trailing));
269
388
  return (
270
- <div class="hk-diff-viewer">
389
+ <div class="hk-diff-viewer" ref={host}>
271
390
  <div class="hk-diff-viewer__body">{blocks}</div>
272
391
  </div>
273
392
  );
@@ -9,10 +9,12 @@
9
9
  * Row anatomy: [field label | operator symbol group | value control +
10
10
  * remove]. Rows are the page's ACTIVE conditions; the trailing add row
11
11
  * (button parked in the operator column, user direction 2026-10-12)
12
- * materializes the next latent field. Edits commit LIVE (the desktop
13
- * panel has no confirm button); the mobile sheet's single confirm merely
14
- * closes — the standard HkPopover sheetOnMobile split renders the same
15
- * panel in both shapes.
12
+ * opens the FIELD CHOOSER — a popover of the latent fields (user
13
+ * direction 2026-10-14: add means pick WHO, never append by declaration
14
+ * order) and the picked field materializes as a row. Edits commit LIVE
15
+ * (the desktop panel has no confirm button); the mobile sheet's single
16
+ * confirm merely closes — the standard HkPopover sheetOnMobile split
17
+ * renders the same panel in both shapes.
16
18
  *
17
19
  * ONE GRID, ONE AXIS (user direction 2026-10-12): the panel itself is
18
20
  * the grid and each row contributes `display: contents` cells, so the
@@ -21,6 +23,7 @@
21
23
  * puts every cell of every row on the same vertical axis — label,
22
24
  * symbol group, control, and remove button all share it.
23
25
  */
26
+ @use "./menu-item" as mi;
24
27
 
25
28
  .hk-filter-panel {
26
29
  display: grid;
@@ -179,6 +182,64 @@
179
182
  cursor: not-allowed;
180
183
  }
181
184
 
185
+ /* The trigger's open face while the chooser is up (the popup-select
186
+ * trigger's [data-state="open"] grammar — the user must see the button
187
+ * and its menu as one surface). */
188
+ .hk-filter-panel-add[data-state="open"] {
189
+ background: rgb(var(--color-primary) / 12%);
190
+ color: rgb(var(--color-primary));
191
+ }
192
+
193
+ /* ── The add-condition field chooser ───────────────────────────────
194
+ * A popover of the LATENT fields (user direction 2026-10-14): the
195
+ * add button opens it, the picked field materializes as a row. Same
196
+ * popout geometry as the panel's popup selects (min-width floor, one
197
+ * 240px content cap, then viewport scroll); item rows speak the
198
+ * unified menu-item spec, so the list reads as one family with select
199
+ * options and menu rows around it. The mobile sheet form flows from
200
+ * the shared `.hk-is-sheet` token context — touch geometry comes
201
+ * free, no per-component media query. */
202
+ .hk-filter-add-content {
203
+ min-width: 180px;
204
+ max-height: 240px;
205
+ display: flex;
206
+ flex-direction: column;
207
+ border-radius: var(--radius-sm);
208
+ overflow: hidden;
209
+ }
210
+
211
+ .hk-filter-add-viewport .hk-scroll-container-viewport {
212
+ display: flex;
213
+ flex-direction: column;
214
+ }
215
+
216
+ .hk-filter-add-list {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: var(--space-2);
220
+ padding: var(--space-2);
221
+ }
222
+
223
+ .hk-filter-add-item {
224
+ @include mi.item;
225
+ width: 100%;
226
+ justify-content: flex-start;
227
+ text-align: start;
228
+ }
229
+
230
+ /* Keyboard highlight face — identical values to the popup-select item's
231
+ * [data-highlighted]; hover comes from mi.item's own guarded :hover. */
232
+ .hk-filter-add-item[data-highlighted] {
233
+ background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
234
+ border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
235
+ color: rgb(var(--color-text));
236
+ }
237
+
238
+ .hk-filter-add-item:disabled {
239
+ opacity: 0.45;
240
+ cursor: not-allowed;
241
+ }
242
+
182
243
  /* Phones: the operator column widens a touch (user direction
183
244
  * 2026-10-12 — the old 4.25rem cap cramped the symbol group) and the
184
245
  * row lets the value control take the remaining width. The group itself
@@ -79,4 +79,15 @@ describe("HkFilterPanel stylesheet contract", () => {
79
79
  expect(media).toMatch(/max-width:\s*5\.25rem/);
80
80
  expect(media).toMatch(/minmax\(4\.75rem,\s*auto\)/);
81
81
  });
82
+
83
+ it("registers the chooser rows in the sheet-context token swap (44px touch rows)", () => {
84
+ // The add-condition chooser docks as a bottom sheet on mobile; its
85
+ // items must ride the unified menu-item SHEET geometry. The swap
86
+ // lives in _menu-item.scss's adopter list (pulled in via the
87
+ // @use chain) — an unregistered class silently keeps the 34px
88
+ // desktop rows (R1 2026-10-14 finding: exactly that happened).
89
+ expect(css).toMatch(
90
+ /\.hk-popover-panel\.hk-is-sheet[^{]*\.hk-filter-add-item[^{]*\{[^}]*--hk-menu-item-min-h:\s*44px/,
91
+ );
92
+ });
82
93
  });
@@ -19,6 +19,11 @@ afterEach(() => {
19
19
  app.unmount();
20
20
  container.remove();
21
21
  }
22
+ // The field chooser (and any popout it opened) teleports to <body>
23
+ // (HkPopover) — sweep the leftovers so the next test starts clean.
24
+ document.body
25
+ .querySelectorAll(".hk-filter-add-item, .hk-popover-panel, .hk-select-popout-host")
26
+ .forEach((el) => el.remove());
22
27
  });
23
28
 
24
29
  const FIELDS = [
@@ -59,7 +64,34 @@ describe("HkFilterPanel", () => {
59
64
  expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(FIELDS.length);
60
65
  });
61
66
 
62
- it("adds the next latent field with its kind's default operator and an empty value", () => {
67
+ it("opens a field chooser on the add verb instead of appending anything", async () => {
68
+ let seen: HkFilterState | null = null;
69
+ const c = mount(
70
+ h(HkFilterPanel, {
71
+ fields: FIELDS,
72
+ modelValue: { tool: { op: "eq", value: "web" } },
73
+ "onUpdate:modelValue": (s: HkFilterState) => (seen = s),
74
+ }),
75
+ );
76
+ const add = c.querySelector<HTMLButtonElement>(".hk-filter-panel-add")!;
77
+ expect(add.getAttribute("aria-haspopup")).toBe("menu");
78
+ expect(add.getAttribute("aria-expanded")).toBe("false");
79
+ add.dispatchEvent(new MouseEvent("click", { bubbles: true }));
80
+ await Promise.resolve();
81
+ await Promise.resolve();
82
+ // The click itself appends NOTHING (user direction 2026-10-14: add
83
+ // means pick WHO — never the next field in declaration order)…
84
+ expect(seen).toBeNull();
85
+ // …and the chooser lists ONLY the latent fields, in declaration
86
+ // order, labeled by the field defs.
87
+ const items = [...document.body.querySelectorAll(".hk-filter-add-item")].map(
88
+ (el) => el.textContent,
89
+ );
90
+ expect(items).toEqual(["Calls", "Agent", "Since"]);
91
+ expect(add.getAttribute("aria-expanded")).toBe("true");
92
+ });
93
+
94
+ it("materializes the picked field with its kind's default operator and closes the chooser", async () => {
63
95
  let seen: HkFilterState | null = null;
64
96
  const c = mount(
65
97
  h(HkFilterPanel, {
@@ -69,9 +101,48 @@ describe("HkFilterPanel", () => {
69
101
  }),
70
102
  );
71
103
  c.querySelector(".hk-filter-panel-add")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
104
+ await Promise.resolve();
105
+ await Promise.resolve();
106
+ const item = [...document.body.querySelectorAll(".hk-filter-add-item")].find(
107
+ (el) => el.textContent === "Agent",
108
+ )!;
109
+ expect(item, "chooser lists the latent Agent field").toBeTruthy();
110
+ item.dispatchEvent(new MouseEvent("click", { bubbles: true }));
111
+ await Promise.resolve();
72
112
  expect(seen).not.toBeNull();
73
- // "count" is the next unused field in declaration order; its kind's
74
- // first operator (lt) and an empty value.
113
+ // The picked field joins with its kind's first operator (select →
114
+ // eq) and an empty value; the existing condition is untouched.
115
+ expect(seen!["agent"]).toEqual({ op: "eq", value: "" });
116
+ expect(seen!["tool"]).toEqual({ op: "eq", value: "web" });
117
+ // The chooser leaves with the pick (after the leave settle).
118
+ await new Promise((r) => setTimeout(r, 60));
119
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeNull();
120
+ });
121
+
122
+ it("drives the chooser from the add button — arrows highlight, Enter picks", async () => {
123
+ let seen: HkFilterState | null = null;
124
+ const c = mount(
125
+ h(HkFilterPanel, {
126
+ fields: FIELDS,
127
+ modelValue: {},
128
+ "onUpdate:modelValue": (s: HkFilterState) => (seen = s),
129
+ }),
130
+ );
131
+ const add = c.querySelector<HTMLButtonElement>(".hk-filter-panel-add")!;
132
+ // ArrowDown opens the chooser with the first item highlighted.
133
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
134
+ await Promise.resolve();
135
+ await Promise.resolve();
136
+ const items = [...document.body.querySelectorAll(".hk-filter-add-item")];
137
+ expect(items).toHaveLength(4);
138
+ expect(items[0]!.getAttribute("data-highlighted")).toBe("true");
139
+ // ArrowDown again moves the highlight to the second item.
140
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
141
+ await Promise.resolve();
142
+ expect(items[1]!.getAttribute("data-highlighted")).toBe("true");
143
+ // Enter confirms the highlighted field — "Calls" (number kind → lt).
144
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
145
+ await Promise.resolve();
75
146
  expect(seen!["count"]).toEqual({ op: "lt", value: "" });
76
147
  });
77
148