@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.323",
3
+ "version": "0.55.324",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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: () => slots.actions?.() }}
134
+ {{ actions: actionsSlot }}
94
135
  </HkPageHeader>
95
- ) : slots.actions ? (
96
- <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "var(--space-16, 1rem)" }}>
97
- {slots.actions()}
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
- lte: ["m19 5-11 6 11 6", "M8 20h11"],
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 20h11"],
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
+ });