@celestia-island/hikari 0.55.323 → 0.55.324
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/HkAdminTablePage.test.tsx +57 -0
- package/src/components/HkAdminTablePage.tsx +46 -5
- package/src/components/HkFilterOperatorIcon.test.tsx +61 -0
- package/src/components/HkFilterOperatorIcon.tsx +8 -2
- package/src/components/HkFilterPanel.test.tsx +97 -0
- package/src/components/HkFilterPanel.tsx +226 -115
- package/src/components/HkInfiniteTail.test.tsx +5 -3
- package/src/components/HkInfiniteTail.tsx +3 -2
- package/src/components/HkLoadMore.test.tsx +15 -3
- package/src/components/HkLoadMore.tsx +28 -5
- package/src/components/HkScrollContainer.tsx +33 -18
- package/src/components/HkScrollProgress.scss +7 -4
- package/src/components/HkScrollProgress.test.tsx +26 -8
- package/src/components/HkScrollProgress.tsx +39 -13
- package/src/i18n/locales/ar/components.json +6 -2
- package/src/i18n/locales/de/components.json +6 -2
- package/src/i18n/locales/en/components.json +6 -2
- package/src/i18n/locales/es/components.json +6 -2
- package/src/i18n/locales/fr/components.json +6 -2
- package/src/i18n/locales/ja/components.json +6 -2
- package/src/i18n/locales/ko/components.json +6 -2
- package/src/i18n/locales/pt/components.json +6 -2
- package/src/i18n/locales/ru/components.json +6 -2
- package/src/i18n/locales/zh-Hans/components.json +6 -2
- package/src/i18n/locales/zh-Hant/components.json +6 -2
package/package.json
CHANGED
|
@@ -138,4 +138,61 @@ describe("HkAdminTablePage", () => {
|
|
|
138
138
|
expect(got[0]![0]).toBe(rows[0]);
|
|
139
139
|
expect(got[0]![1]).toBe(btn);
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
it("renders the count readout in the header, preferring the declared total", () => {
|
|
143
|
+
// User direction 2026-11-08: every standard list names its size —
|
|
144
|
+
// the scaffold owns the "N in total" wording so pages stop
|
|
145
|
+
// hand-rolling their own count spans.
|
|
146
|
+
const c = mount(
|
|
147
|
+
h(HkAdminTablePage, {
|
|
148
|
+
title: "T",
|
|
149
|
+
rows: Array.from({ length: 12 }, (_, i) => ({ name: `r${i}` })),
|
|
150
|
+
columns: COLS,
|
|
151
|
+
total: 500,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
const readout = c.querySelector('[data-testid="hk-admin-table-page-count"]');
|
|
155
|
+
expect(readout).not.toBeNull();
|
|
156
|
+
expect(readout!.textContent).toBe("500 in total");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("falls back to the delivered row count when no total is declared", () => {
|
|
160
|
+
const c = mount(
|
|
161
|
+
h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }, { name: "b" }], columns: COLS }),
|
|
162
|
+
);
|
|
163
|
+
expect(c.querySelector('[data-testid="hk-admin-table-page-count"]')!.textContent).toBe("2 in total");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("hides the readout on an empty list and honors countReadout:false", () => {
|
|
167
|
+
const empty = mount(h(HkAdminTablePage, { title: "T", rows: [], columns: COLS }));
|
|
168
|
+
expect(empty.querySelector('[data-testid="hk-admin-table-page-count"]')).toBeNull();
|
|
169
|
+
const optedOut = mount(
|
|
170
|
+
h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }], columns: COLS, countReadout: false }),
|
|
171
|
+
);
|
|
172
|
+
expect(optedOut.querySelector('[data-testid="hk-admin-table-page-count"]')).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("renders the readout beside page actions, rightmost", () => {
|
|
176
|
+
const slots = { actions: () => h("button", { class: "page-action" }, "Do") };
|
|
177
|
+
const c = mount(
|
|
178
|
+
h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }], columns: COLS }, slots),
|
|
179
|
+
);
|
|
180
|
+
const header = c.querySelector(".hk-page-header")!;
|
|
181
|
+
expect(header.querySelector("button.page-action")).not.toBeNull();
|
|
182
|
+
// The count span trails the page's own actions inside the header row.
|
|
183
|
+
const actions = header.querySelector(".hk-page-header-actions") ?? header;
|
|
184
|
+
expect(actions.textContent).toContain("1 in total");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("keeps the headerless actions band for a bare readout", () => {
|
|
188
|
+
// No title: the band is the readout's only host — one slim right
|
|
189
|
+
// aligned row, never a second stacked header (安全扫描 report,
|
|
190
|
+
// 2026-11-08).
|
|
191
|
+
const c = mount(
|
|
192
|
+
h(HkAdminTablePage, { rows: [{ name: "a" }], columns: COLS }),
|
|
193
|
+
);
|
|
194
|
+
const readout = c.querySelector('[data-testid="hk-admin-table-page-count"]');
|
|
195
|
+
expect(readout).not.toBeNull();
|
|
196
|
+
expect(readout!.textContent).toBe("1 in total");
|
|
197
|
+
});
|
|
141
198
|
});
|
|
@@ -69,8 +69,17 @@ export const HkAdminTablePage = defineComponent({
|
|
|
69
69
|
* denominator (the server count behind a paged list — chest's
|
|
70
70
|
* usePagedTableFetch total). Forwarded to HTable, which
|
|
71
71
|
* self-registers with the enclosing shell viewport's registrar.
|
|
72
|
-
* Omitted: the pill falls back to counting the delivered DOM rows.
|
|
72
|
+
* Omitted: the pill falls back to counting the delivered DOM rows.
|
|
73
|
+
* The SAME number feeds the header's count readout below. */
|
|
73
74
|
total: { type: Number, default: undefined },
|
|
75
|
+
/** Top-right "共 N 条" count readout in the page header (user
|
|
76
|
+
* direction 2026-11-08: EVERY standard list names its size — the
|
|
77
|
+
* scaffold owns the wording so pages stop hand-rolling their own
|
|
78
|
+
* "{n} items" spans). Prefers `total` (the server count behind a
|
|
79
|
+
* paged list) and falls back to the delivered row count; hidden at
|
|
80
|
+
* zero so a fresh or empty list shows no "共 0 条" flash. Pages
|
|
81
|
+
* that render their own count elsewhere set this false. */
|
|
82
|
+
countReadout: { type: Boolean, default: true },
|
|
74
83
|
},
|
|
75
84
|
emits: {
|
|
76
85
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
@@ -80,21 +89,53 @@ export const HkAdminTablePage = defineComponent({
|
|
|
80
89
|
return () => {
|
|
81
90
|
const { t } = useI18n();
|
|
82
91
|
const emptyTitle = props.emptyTitle || t("hikari::tablePage.emptyTitle", "No data");
|
|
92
|
+
// The header count readout: the authoritative total when declared,
|
|
93
|
+
// else the delivered rows; hidden at zero (an empty list's empty
|
|
94
|
+
// state already says so — "共 0 条" next to it reads as noise).
|
|
95
|
+
const readoutCount = props.total ?? props.rows.length;
|
|
96
|
+
const countReadout =
|
|
97
|
+
props.countReadout && readoutCount > 0 ? (
|
|
98
|
+
<span
|
|
99
|
+
data-testid="hk-admin-table-page-count"
|
|
100
|
+
style={{
|
|
101
|
+
fontSize: "var(--text-xs, 0.75rem)",
|
|
102
|
+
color: "rgb(var(--color-muted, var(--color-text-secondary)))",
|
|
103
|
+
fontVariantNumeric: "tabular-nums",
|
|
104
|
+
whiteSpace: "nowrap",
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{t("hikari::tablePage.totalCount", "{n} in total").replace("{n}", String(readoutCount))}
|
|
108
|
+
</span>
|
|
109
|
+
) : null;
|
|
83
110
|
// Extracted to an identifier: JSX children given as an inline object
|
|
84
111
|
// literal are not normalized to slots the way a bound one is (the
|
|
85
112
|
// chest retrySlots idiom this mirrors relies on the identifier form).
|
|
86
113
|
const emptySlots = slots["empty-action"]
|
|
87
114
|
? { action: () => slots["empty-action"]?.() }
|
|
88
115
|
: undefined;
|
|
116
|
+
// The readout rides the header's actions row RIGHTMOST — after the
|
|
117
|
+
// page's own actions — and is the band's sole content on a
|
|
118
|
+
// headerless scaffold, so a bare readout never claims a full-width
|
|
119
|
+
// row of its own (the 安全扫描 double-margin report, 2026-11-08).
|
|
120
|
+
const actionsSlot = slots.actions
|
|
121
|
+
? () => (
|
|
122
|
+
<>
|
|
123
|
+
{slots.actions?.()}
|
|
124
|
+
{countReadout}
|
|
125
|
+
</>
|
|
126
|
+
)
|
|
127
|
+
: countReadout
|
|
128
|
+
? () => countReadout
|
|
129
|
+
: undefined;
|
|
89
130
|
return (
|
|
90
131
|
<div>
|
|
91
132
|
{props.title ? (
|
|
92
133
|
<HkPageHeader title={props.title} subtitle={props.subtitle} icon={props.icon} dense={props.dense}>
|
|
93
|
-
{{ actions:
|
|
134
|
+
{{ actions: actionsSlot }}
|
|
94
135
|
</HkPageHeader>
|
|
95
|
-
) :
|
|
96
|
-
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "var(--space-16, 1rem)" }}>
|
|
97
|
-
{
|
|
136
|
+
) : actionsSlot ? (
|
|
137
|
+
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "var(--space-8, 0.5rem)", marginBottom: "var(--space-16, 1rem)" }}>
|
|
138
|
+
{actionsSlot()}
|
|
98
139
|
</div>
|
|
99
140
|
) : null}
|
|
100
141
|
{slots.filter ? <div class="hk-admin-table-page-filter">{slots.filter()}</div> : null}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkFilterOperatorIcon from "./HkFilterOperatorIcon";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(op: string) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => h(HkFilterOperatorIcon, { op: op as never }) });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** The custom glyphs are hikari-authored SVG in lucide's stroke grammar.
|
|
25
|
+
* Pin the exact path data: the ordered four's underlines ride y=19 — the
|
|
26
|
+
* SAME optical center as lucide's Equal / EqualNot (ink y5..19). The
|
|
27
|
+
* first cut drew them at y=20 and the ≥/≤ glyphs sat visibly low against
|
|
28
|
+
* the equals family inside the 28px operator toggles (user report
|
|
29
|
+
* 2026-11-08); this pin makes a revert to y=20 RED (mutation-verified:
|
|
30
|
+
* reverting both underlines failed this suite 2/2). */
|
|
31
|
+
describe("HkFilterOperatorIcon custom glyph geometry", () => {
|
|
32
|
+
it("draws lt/gt chevrons without an underline", () => {
|
|
33
|
+
const ds = (op: string) =>
|
|
34
|
+
[...mount(op).querySelectorAll("svg path")].map((p) => p.getAttribute("d"));
|
|
35
|
+
expect(ds("lt")).toEqual(["m19 6-11 6 11 6"]);
|
|
36
|
+
expect(ds("gt")).toEqual(["m5 6 11 6-11 6"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("draws the lte/gte underlines at y=19 (optically centered on y=12)", () => {
|
|
40
|
+
const ds = (op: string) =>
|
|
41
|
+
[...mount(op).querySelectorAll("svg path")].map((p) => p.getAttribute("d"));
|
|
42
|
+
expect(ds("lte")).toEqual(["m19 5-11 6 11 6", "M8 19h11"]);
|
|
43
|
+
expect(ds("gte")).toEqual(["m5 5 11 6-11 6", "M5 19h11"]);
|
|
44
|
+
// Belt against the exact regression: no underline path may reach
|
|
45
|
+
// y=20 — the ink band must stay y5..19 like the lucide equals pair.
|
|
46
|
+
for (const d of [...ds("lte"), ...ds("gte")]) {
|
|
47
|
+
expect(d).not.toMatch(/20/);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("composes napprox from the lucide wave pair + EqualNot slash", () => {
|
|
52
|
+
const ds = [...mount("napprox").querySelectorAll("svg path")].map((p) =>
|
|
53
|
+
p.getAttribute("d"),
|
|
54
|
+
);
|
|
55
|
+
expect(ds).toEqual([
|
|
56
|
+
"M5 15a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
|
|
57
|
+
"M5 9a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
|
|
58
|
+
"m5 19 14-14",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -24,9 +24,15 @@ const NAPPROX_PATHS = [
|
|
|
24
24
|
const CUSTOM: Partial<Record<HkFilterOperator, string[]>> = {
|
|
25
25
|
napprox: NAPPROX_PATHS,
|
|
26
26
|
lt: ["m19 6-11 6 11 6"],
|
|
27
|
-
|
|
27
|
+
// The comparison underline rides y=19, NOT y=20: the chevron spans
|
|
28
|
+
// y5..17, so a y19 line centers the ink band at y12 — the SAME optical
|
|
29
|
+
// center lucide's Equal / EqualNot (paths y5..19) draw. The old y20
|
|
30
|
+
// lines dragged the ink to y5..20 and the ≷ glyphs sat visibly low
|
|
31
|
+
// against the equals family inside the 28px operator toggles (user
|
|
32
|
+
// report 2026-11-08: ≥/≤ 没有与 = 垂直对齐).
|
|
33
|
+
lte: ["m19 5-11 6 11 6", "M8 19h11"],
|
|
28
34
|
gt: ["m5 6 11 6-11 6"],
|
|
29
|
-
gte: ["m5 5 11 6-11 6", "M5
|
|
35
|
+
gte: ["m5 5 11 6-11 6", "M5 19h11"],
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
const LUCIDE: Partial<Record<HkFilterOperator, typeof Equal>> = {
|
|
@@ -376,3 +376,100 @@ describe("hkFieldMatches — the canonical combination semantics (2026-10-15)",
|
|
|
376
376
|
expect(hkFieldMatches("text", [{ key: "x", op: "approx" as const, value: "WEB" }], "my-web-tool")).toBe(true);
|
|
377
377
|
});
|
|
378
378
|
});
|
|
379
|
+
|
|
380
|
+
describe("HkFilterPanel pinned fields (枚举, 2026-11-08)", () => {
|
|
381
|
+
const PINNED_FIELDS = [
|
|
382
|
+
{ key: "tier", label: "Tier", kind: "text" as const, pinned: true },
|
|
383
|
+
{ key: "agent", label: "Agent", kind: "select" as const, pinned: true, options: [{ value: "a1", label: "Agent One" }] },
|
|
384
|
+
{ key: "tool", label: "Tool", kind: "text" as const, placeholder: "web_automation" },
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
it("renders pinned fields as permanent rows ahead of any state", () => {
|
|
388
|
+
const c = mount(h(HkFilterPanel, { fields: PINNED_FIELDS, modelValue: [] }));
|
|
389
|
+
// Two pinned fields, zero state — the rows exist anyway, marked
|
|
390
|
+
// data-pinned, and the panel is NOT data-empty anymore.
|
|
391
|
+
const rows = c.querySelectorAll(".hk-filter-panel-row");
|
|
392
|
+
expect(rows).toHaveLength(2);
|
|
393
|
+
expect(rows[0]!.getAttribute("data-pinned")).toBe("true");
|
|
394
|
+
expect(rows[1]!.getAttribute("data-pinned")).toBe("true");
|
|
395
|
+
const labels = [...c.querySelectorAll(".hk-filter-panel-field")].map((el) => el.textContent);
|
|
396
|
+
expect(labels).toEqual(["Tier", "Agent"]);
|
|
397
|
+
// Permanent furniture: NO drop verb on pinned rows.
|
|
398
|
+
expect(c.querySelector('[data-pinned="true"] .hk-filter-panel-remove')).toBeNull();
|
|
399
|
+
expect(c.querySelector(".hk-filter-panel")!.getAttribute("data-empty")).toBeNull();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("a pinned edit appends its condition once, then patches in place", async () => {
|
|
403
|
+
let seen: HkFilterState | null = null;
|
|
404
|
+
const fields = [{ key: "tier", label: "Tier", kind: "text" as const, pinned: true }];
|
|
405
|
+
const c = mount(
|
|
406
|
+
h(HkFilterPanel, { fields, modelValue: [], "onUpdate:modelValue": (s: HkFilterState) => (seen = s) }),
|
|
407
|
+
);
|
|
408
|
+
const input = c.querySelector<HTMLInputElement>(".hk-filter-panel-row input")!;
|
|
409
|
+
input.value = "core";
|
|
410
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
411
|
+
await Promise.resolve();
|
|
412
|
+
// First edit on an untouched pinned row APPENDS the condition.
|
|
413
|
+
expect(seen).toEqual([{ key: "tier", op: "approx", value: "core" }]);
|
|
414
|
+
|
|
415
|
+
const c2 = mount(
|
|
416
|
+
h(HkFilterPanel, {
|
|
417
|
+
fields,
|
|
418
|
+
modelValue: [{ key: "tier", op: "approx", value: "core" }],
|
|
419
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
const input2 = c2.querySelector<HTMLInputElement>(".hk-filter-panel-row input")!;
|
|
423
|
+
input2.value = "edge";
|
|
424
|
+
input2.dispatchEvent(new Event("input", { bubbles: true }));
|
|
425
|
+
await Promise.resolve();
|
|
426
|
+
// The row is bound to the existing entry — edits patch it, never
|
|
427
|
+
// stack a second condition on the same pinned field.
|
|
428
|
+
expect(seen).toEqual([{ key: "tier", op: "approx", value: "edge" }]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("pinned fields never appear in the chooser; an all-pinned panel has no add verb", async () => {
|
|
432
|
+
let seen: HkFilterState | null = null;
|
|
433
|
+
const c = mount(
|
|
434
|
+
h(HkFilterPanel, { fields: PINNED_FIELDS, modelValue: [], "onUpdate:modelValue": (s: HkFilterState) => (seen = s) }),
|
|
435
|
+
);
|
|
436
|
+
c.querySelector(".hk-filter-panel-add")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
437
|
+
await Promise.resolve();
|
|
438
|
+
await Promise.resolve();
|
|
439
|
+
// Only the on-demand fields are offered.
|
|
440
|
+
const items = [...document.body.querySelectorAll(".hk-filter-add-item")].map((el) => el.textContent);
|
|
441
|
+
expect(items).toEqual(["Tool"]);
|
|
442
|
+
|
|
443
|
+
const only = mount(h(HkFilterPanel, { fields: PINNED_FIELDS.slice(0, 2), modelValue: [] }));
|
|
444
|
+
expect(only.querySelector(".hk-filter-panel-add")).toBeNull();
|
|
445
|
+
void seen;
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("surplus conditions on a pinned key render as ordinary removable rows", async () => {
|
|
449
|
+
let seen: HkFilterState | null = null;
|
|
450
|
+
const fields = [{ key: "tier", label: "Tier", kind: "text" as const, pinned: true }];
|
|
451
|
+
const c = mount(
|
|
452
|
+
h(HkFilterPanel, {
|
|
453
|
+
fields,
|
|
454
|
+
modelValue: [
|
|
455
|
+
{ key: "tier", op: "eq", value: "core" },
|
|
456
|
+
{ key: "tier", op: "approx", value: "edge" },
|
|
457
|
+
],
|
|
458
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
// One permanent bound row + ONE surplus row carrying a drop verb —
|
|
462
|
+
// nothing is silently dropped.
|
|
463
|
+
const rows = c.querySelectorAll(".hk-filter-panel-row");
|
|
464
|
+
expect(rows).toHaveLength(2);
|
|
465
|
+
expect(rows[0]!.getAttribute("data-pinned")).toBe("true");
|
|
466
|
+
expect(rows[1]!.getAttribute("data-pinned")).toBeNull();
|
|
467
|
+
expect(rows[1]!.querySelector(".hk-filter-panel-remove")).not.toBeNull();
|
|
468
|
+
(rows[1]!.querySelector(".hk-filter-panel-remove") as HTMLElement).dispatchEvent(
|
|
469
|
+
new MouseEvent("click", { bubbles: true }),
|
|
470
|
+
);
|
|
471
|
+
await Promise.resolve();
|
|
472
|
+
// The bound entry (the FIRST condition on the pinned key) survives.
|
|
473
|
+
expect(seen).toEqual([{ key: "tier", op: "eq", value: "core" }]);
|
|
474
|
+
});
|
|
475
|
+
});
|