@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.
- package/package.json +1 -1
- package/src/components/HkBreadcrumb.tsx +2 -1
- package/src/components/HkDiffViewer.test.tsx +112 -0
- package/src/components/HkDiffViewer.tsx +122 -3
- package/src/components/HkFilterPanel.scss +65 -4
- package/src/components/HkFilterPanel.styles.test.ts +11 -0
- package/src/components/HkFilterPanel.test.tsx +74 -3
- package/src/components/HkFilterPanel.tsx +138 -12
- package/src/components/HkFilterTrigger.test.tsx +248 -0
- package/src/components/HkLogo.tsx +2 -1
- package/src/components/HkNavItem.tsx +3 -2
- package/src/components/HkPopover.shield.test.tsx +124 -0
- package/src/components/HkPopover.tsx +36 -1
- package/src/components/HkPopupSelect.tsx +10 -0
- package/src/components/HkScrollContainer.tsx +16 -2
- package/src/components/_menu-item.scss +1 -0
- package/src/composables/useOverlayScrollbar.scrollMap.test.ts +187 -0
- package/src/composables/useOverlayScrollbar.ts +162 -8
- package/src/i18n/locales/ar/components.json +1 -0
- package/src/i18n/locales/de/components.json +1 -0
- package/src/i18n/locales/en/components.json +1 -0
- package/src/i18n/locales/es/components.json +1 -0
- package/src/i18n/locales/fr/components.json +1 -0
- package/src/i18n/locales/ja/components.json +1 -0
- package/src/i18n/locales/ko/components.json +1 -0
- package/src/i18n/locales/pt/components.json +1 -0
- package/src/i18n/locales/ru/components.json +1 -0
- package/src/i18n/locales/zh-Hans/components.json +1 -0
- package/src/i18n/locales/zh-Hant/components.json +1 -0
- package/src/index.ts +2 -1
- package/src/utils/safeHref.test.ts +48 -0
- package/src/utils/safeHref.ts +34 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* panel
|
|
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("
|
|
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
|
-
//
|
|
74
|
-
//
|
|
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
|
|