@celestia-island/hikari 0.55.322 → 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/HkAvatar.scss +28 -0
- package/src/components/HkAvatarFallback.centering.styles.test.ts +147 -0
- 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/HkMenuIdentityItem.scss +10 -0
- 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/src/styles/admin-tokens.scss +16 -0
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}
|
|
@@ -50,6 +50,34 @@
|
|
|
50
50
|
font-weight: 600;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/* Optical vertical centering for the single-glyph fallback (2026-11-08
|
|
54
|
+
* user report: 头像首字母偏下). Flex centers the LINE box, but a capital
|
|
55
|
+
* glyph sits on the baseline — below the line box's geometric center on
|
|
56
|
+
* every UI font whose ascent exceeds its descent (Noto Sans CJK ≈0.065em,
|
|
57
|
+
* Windows Segoe UI ≈0.064em: ≈1px at md's 16px, visible in a circle).
|
|
58
|
+
* `text-box: trim-both cap alphabetic` trims the line box to
|
|
59
|
+
* [cap-top, baseline], so the centered box hugs the glyph ink — the
|
|
60
|
+
* avatar-initial case the property was designed for. Trim only reaches
|
|
61
|
+
* BLOCK-container line boxes (Chromium ignores it on a flex container's
|
|
62
|
+
* anonymous text block — verified headless), so under the guard the span
|
|
63
|
+
* swaps flex for block + align-content/text-align centering: same box,
|
|
64
|
+
* the trimmed line is centered on the block axis instead. Engines without
|
|
65
|
+
* text-box (pre-133 Chromium / pre-18.2 Safari / pre-154 Firefox) fail
|
|
66
|
+
* @supports and keep the flex anatomy unchanged — zero regression.
|
|
67
|
+
* CJK initials stay centered: the ideograph ink center lands on the
|
|
68
|
+
* [cap, baseline] band center (德 verified side by side, headless
|
|
69
|
+
* Chrome). Support note: the guard tests the `text-box` SHORTHAND,
|
|
70
|
+
* which Chromium parses from 133 (the longhands land in 128) — engines
|
|
71
|
+
* older than that fail @supports and keep the flex anatomy. */
|
|
72
|
+
@supports (text-box: trim-both cap alphabetic) {
|
|
73
|
+
.hk-avatar-fallback {
|
|
74
|
+
display: block;
|
|
75
|
+
align-content: center;
|
|
76
|
+
text-align: center;
|
|
77
|
+
text-box: trim-both cap alphabetic;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
[data-theme="dark"] .hk-avatar {
|
|
54
82
|
background-color: var(--hi-surface);
|
|
55
83
|
color: var(--hi-color-text-secondary, rgba(102,102,102,0.6));
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiled-stylesheet guard for the avatar letter-fallback optical
|
|
10
|
+
* centering (2026-11-08 user report: 头像首字母偏下).
|
|
11
|
+
*
|
|
12
|
+
* Flex centering centers the LINE box, but a capital glyph sits on the
|
|
13
|
+
* baseline — below the line box's geometric center on every UI font
|
|
14
|
+
* whose ascent exceeds its descent (Noto Sans CJK ≈0.065em, Windows
|
|
15
|
+
* Segoe UI ≈0.064em). The fix trims the line box to [cap-top, baseline]
|
|
16
|
+
* with `text-box: trim-both cap alphabetic` under an @supports guard
|
|
17
|
+
* (trim only reaches BLOCK-container line boxes — Chromium ignores it on
|
|
18
|
+
* a flex container's anonymous text block, verified headless — so the
|
|
19
|
+
* height:100% fallbacks swap flex for block + align-content centering
|
|
20
|
+
* under the guard; the auto-height menu fallback only adds the trim).
|
|
21
|
+
*
|
|
22
|
+
* Vitest stubs CSS, so the runtime suite can never catch a lost rule;
|
|
23
|
+
* this guard pins the COMPILED sheets. All three sheets are mirrored
|
|
24
|
+
* verbatim into the Rust crate (vue-mirror drift tests), which catches
|
|
25
|
+
* one-sided edits — this file catches the block disappearing from BOTH
|
|
26
|
+
* copies at once. Supported engines: Chromium 133+, Safari 18.2+,
|
|
27
|
+
* Firefox 154+ (the guard tests the text-box shorthand; the longhands
|
|
28
|
+
* exist in Chromium from 128) — anything older fails @supports and keeps
|
|
29
|
+
* the flex anatomy (the pre-fix behavior), which is why the base rules
|
|
30
|
+
* below are also pinned to keep their flex centering.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
34
|
+
|
|
35
|
+
const SUPPORTS_PREAMBLE = "@supports (text-box: trim-both cap alphabetic)";
|
|
36
|
+
|
|
37
|
+
const css = {
|
|
38
|
+
avatar: compile(resolve(componentDir, "HkAvatar.scss"), { style: "expanded" }).css,
|
|
39
|
+
menuIdentity: compile(resolve(componentDir, "HkMenuIdentityItem.scss"), { style: "expanded" }).css,
|
|
40
|
+
adminTokens: compile(resolve(componentDir, "../styles/admin-tokens.scss"), { style: "expanded" }).css,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The body of the FIRST @supports block with exactly `preamble`, brace-matched. */
|
|
44
|
+
function supportsBody(sheetCss: string, preamble: string, label: string): string {
|
|
45
|
+
const start = sheetCss.indexOf(preamble);
|
|
46
|
+
expect(start, `${label}: ${preamble} block present in the compiled sheet`).toBeGreaterThanOrEqual(0);
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let open = -1;
|
|
49
|
+
for (let at = start + preamble.length; at < sheetCss.length; at += 1) {
|
|
50
|
+
const ch = sheetCss[at];
|
|
51
|
+
if (ch === "{") {
|
|
52
|
+
if (open < 0) open = at;
|
|
53
|
+
depth += 1;
|
|
54
|
+
} else if (ch === "}") {
|
|
55
|
+
depth -= 1;
|
|
56
|
+
if (depth === 0) return sheetCss.slice(open + 1, at);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`${label}: unterminated @supports block`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The prelude of the FIRST rule inside the first `@supports` block with
|
|
63
|
+
* exactly `preamble` — the selector the guard actually targets. */
|
|
64
|
+
function guardedSelector(sheetCss: string, preamble: string, label: string): string {
|
|
65
|
+
const body = supportsBody(sheetCss, preamble, label);
|
|
66
|
+
const open = body.indexOf("{");
|
|
67
|
+
return body.slice(0, open).trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The body of the top-level rule whose prelude is exactly `selector`. */
|
|
71
|
+
function ruleFor(sheetCss: string, selector: string, label: string): string {
|
|
72
|
+
const needle = `${selector} {`;
|
|
73
|
+
const start = sheetCss.indexOf(needle);
|
|
74
|
+
expect(start, `${label}: ${selector} rule present in the compiled sheet`).toBeGreaterThanOrEqual(0);
|
|
75
|
+
const close = sheetCss.indexOf("}", start);
|
|
76
|
+
return sheetCss.slice(start, close + 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("avatar letter-fallback optical centering (text-box guard)", () => {
|
|
80
|
+
it("positive control: the extractor finds the known base anatomy", () => {
|
|
81
|
+
// If this stops matching, ruleFor/supportsBody are broken — fix the
|
|
82
|
+
// extractor, not the assertions below.
|
|
83
|
+
expect(ruleFor(css.avatar, ".hk-avatar", "avatar")).toContain("align-items: center");
|
|
84
|
+
expect(ruleFor(css.adminTokens, ".s-user-avatar", "admin-tokens")).toContain("display: flex");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("hk-avatar-fallback: guard block swaps flex for block + centered trimmed line", () => {
|
|
88
|
+
const body = supportsBody(css.avatar, SUPPORTS_PREAMBLE, "avatar");
|
|
89
|
+
expect(guardedSelector(css.avatar, SUPPORTS_PREAMBLE, "avatar")).toBe(".hk-avatar-fallback");
|
|
90
|
+
expect(body).toContain("display: block");
|
|
91
|
+
expect(body).toContain("align-content: center");
|
|
92
|
+
expect(body).toContain("text-align: center");
|
|
93
|
+
expect(body).toContain("text-box: trim-both cap alphabetic");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("hk-avatar-fallback: base rule keeps the flex anatomy (no-trim engines)", () => {
|
|
97
|
+
const rule = ruleFor(css.avatar, ".hk-avatar-fallback", "avatar");
|
|
98
|
+
expect(rule).toContain("display: flex");
|
|
99
|
+
expect(rule).toContain("align-items: center");
|
|
100
|
+
expect(rule).toContain("justify-content: center");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("s-user-avatar: guard block swaps flex for block + centered trimmed line", () => {
|
|
104
|
+
const body = supportsBody(css.adminTokens, SUPPORTS_PREAMBLE, "admin-tokens");
|
|
105
|
+
expect(guardedSelector(css.adminTokens, SUPPORTS_PREAMBLE, "admin-tokens")).toBe(".s-user-avatar");
|
|
106
|
+
expect(body).toContain("display: block");
|
|
107
|
+
expect(body).toContain("align-content: center");
|
|
108
|
+
expect(body).toContain("text-align: center");
|
|
109
|
+
expect(body).toContain("text-box: trim-both cap alphabetic");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("s-user-avatar: base rule keeps the flex anatomy (no-trim engines)", () => {
|
|
113
|
+
const rule = ruleFor(css.adminTokens, ".s-user-avatar", "admin-tokens");
|
|
114
|
+
expect(rule).toContain("display: flex");
|
|
115
|
+
expect(rule).toContain("align-items: center");
|
|
116
|
+
expect(rule).toContain("justify-content: center");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("menu identity fallback: trim only — auto-height flex item needs no anatomy swap", () => {
|
|
120
|
+
const body = supportsBody(css.menuIdentity, SUPPORTS_PREAMBLE, "menu-identity");
|
|
121
|
+
expect(guardedSelector(css.menuIdentity, SUPPORTS_PREAMBLE, "menu-identity")).toBe(
|
|
122
|
+
".hk-menu-identity-item__fallback",
|
|
123
|
+
);
|
|
124
|
+
expect(body).toContain("text-box: trim-both cap alphabetic");
|
|
125
|
+
expect(body).not.toContain("display:");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("avatar letter-fallback sheets stay mirrored", () => {
|
|
130
|
+
// The Rust crate compiles vendored copies of these sheets; the drift
|
|
131
|
+
// tests over there fail on one-sided edits. This re-reads the SOURCE
|
|
132
|
+
// files to pin the mirror files' presence next to the pinned behavior,
|
|
133
|
+
// so deleting the guard from BOTH copies cannot slip through silently
|
|
134
|
+
// (drift tests compare copies — they stay green when both sides lose
|
|
135
|
+
// the block together).
|
|
136
|
+
it("vendored mirror copies still carry the guard (double-sided delete tripwire)", () => {
|
|
137
|
+
const crateRoot = resolve(componentDir, "../../../components/src/styles/vue-mirror");
|
|
138
|
+
for (const rel of [
|
|
139
|
+
"components/HkAvatar.scss",
|
|
140
|
+
"components/HkMenuIdentityItem.scss",
|
|
141
|
+
"styles/admin-tokens.scss",
|
|
142
|
+
]) {
|
|
143
|
+
const mirror = readFileSync(resolve(crateRoot, rel), "utf8");
|
|
144
|
+
expect(mirror, `${rel} mirror carries the guard`).toContain("text-box: trim-both cap alphabetic");
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -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
|
+
});
|