@celestia-island/hikari 0.55.152 → 0.55.154
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/HkFilterPanel.scss +100 -20
- package/src/components/HkFilterPanel.styles.test.ts +82 -0
- package/src/components/HkFilterPanel.test.tsx +105 -17
- package/src/components/HkFilterPanel.tsx +102 -45
- package/src/components/HkFilterTrigger.test.tsx +4 -1
- package/src/components/HkInput.hideLabel.test.tsx +72 -0
- package/src/components/HkInput.tsx +18 -1
- package/src/components/HkPopupSelect.mobile.test.tsx +50 -0
- package/src/components/HkPopupSelect.scss +14 -0
- package/src/components/HkPopupSelect.test.tsx +84 -0
- package/src/components/HkPopupSelect.tsx +18 -3
- package/src/components/HkScrollContainer.test.ts +42 -0
- package/src/components/HkScrollContainer.tsx +15 -2
- package/src/components/overlayScrollbarChrome.contract.test.ts +41 -0
- package/src/composables/useOverlayScrollbar.test.ts +262 -3
- package/src/composables/useOverlayScrollbar.ts +185 -2
- package/src/i18n/locales/ar/components.json +2 -0
- package/src/i18n/locales/de/components.json +2 -0
- package/src/i18n/locales/en/components.json +2 -0
- package/src/i18n/locales/es/components.json +2 -0
- package/src/i18n/locales/fr/components.json +2 -0
- package/src/i18n/locales/ja/components.json +2 -0
- package/src/i18n/locales/ko/components.json +2 -0
- package/src/i18n/locales/pt/components.json +2 -0
- package/src/i18n/locales/ru/components.json +2 -0
- package/src/i18n/locales/zh-Hans/components.json +2 -0
- package/src/i18n/locales/zh-Hant/components.json +2 -0
- package/src/index.ts +3 -0
- package/src/styles/theme/scrollbar.scss +76 -0
package/package.json
CHANGED
|
@@ -6,15 +6,26 @@
|
|
|
6
6
|
* narrows to roughly TWO visible symbols and scrolls horizontally
|
|
7
7
|
* instead.
|
|
8
8
|
*
|
|
9
|
-
* Row anatomy: [field label | operator symbol group | value control
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Row anatomy: [field label | operator symbol group | value control +
|
|
10
|
+
* remove]. Rows are the page's ACTIVE conditions; the trailing add row
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* ONE GRID, ONE AXIS (user direction 2026-10-12): the panel itself is
|
|
18
|
+
* the grid and each row contributes `display: contents` cells, so the
|
|
19
|
+
* three columns track ONE set of widths across every row and the add
|
|
20
|
+
* row lines up with the operator column exactly. `align-items: center`
|
|
21
|
+
* puts every cell of every row on the same vertical axis — label,
|
|
22
|
+
* symbol group, control, and remove button all share it.
|
|
13
23
|
*/
|
|
14
24
|
|
|
15
25
|
.hk-filter-panel {
|
|
16
|
-
display:
|
|
17
|
-
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-columns: minmax(4.5rem, auto) minmax(4.5rem, auto) minmax(6rem, 1fr);
|
|
28
|
+
align-items: center;
|
|
18
29
|
gap: var(--space-8, 0.5rem);
|
|
19
30
|
/* Bounded width: wide enough for the operator group, narrow enough to
|
|
20
31
|
* read as a header dropdown (the popover host clamps to the viewport
|
|
@@ -25,14 +36,16 @@
|
|
|
25
36
|
* a field-rich page must not grow an unbounded dropdown. */
|
|
26
37
|
max-height: min(26rem, 60vh);
|
|
27
38
|
overflow-y: auto;
|
|
28
|
-
|
|
39
|
+
/* Minimum outer margins for every column (user direction
|
|
40
|
+
* 2026-10-12): the rows never touch the panel edge. */
|
|
41
|
+
padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
/* A row is not a box — it donates its three cells to the panel grid so
|
|
45
|
+
* column widths stay shared across all rows (and the add row). */
|
|
46
|
+
.hk-filter-panel-row,
|
|
47
|
+
.hk-filter-panel-addrow {
|
|
48
|
+
display: contents;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
.hk-filter-panel-field {
|
|
@@ -93,23 +106,90 @@
|
|
|
93
106
|
cursor: not-allowed;
|
|
94
107
|
}
|
|
95
108
|
|
|
109
|
+
/* Value cell: the control takes what is left (min-width guarded, never
|
|
110
|
+
* hard-coded — it shrinks with the panel, user direction 2026-10-12);
|
|
111
|
+
* the remove verb rides at the row's end on the same axis. */
|
|
96
112
|
.hk-filter-panel-value {
|
|
113
|
+
display: flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: var(--space-4, 0.25rem);
|
|
97
116
|
min-width: 0;
|
|
98
117
|
}
|
|
99
118
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
.hk-filter-panel-control {
|
|
120
|
+
flex: 1 1 auto;
|
|
121
|
+
min-width: 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.hk-filter-panel-remove {
|
|
125
|
+
flex: 0 0 auto;
|
|
126
|
+
width: 1.5rem;
|
|
127
|
+
height: 1.5rem;
|
|
128
|
+
display: inline-flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
border: none;
|
|
132
|
+
border-radius: var(--radius-sm, 6px);
|
|
133
|
+
background: transparent;
|
|
134
|
+
color: rgb(var(--color-muted, var(--color-text-secondary)));
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.hk-filter-panel-remove:hover {
|
|
140
|
+
background: rgb(var(--color-error) / 12%);
|
|
141
|
+
color: rgb(var(--color-error));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.hk-filter-panel-remove:disabled {
|
|
145
|
+
opacity: 0.5;
|
|
146
|
+
cursor: not-allowed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* The growth verb: parked in the OPERATOR COLUMN (grid-column 2), so it
|
|
150
|
+
* reads as part of the aligned axis — not a stray footer link. Ghost
|
|
151
|
+
* face; it appends the next latent field. */
|
|
152
|
+
.hk-filter-panel-add {
|
|
153
|
+
grid-column: 2 / -1;
|
|
154
|
+
justify-self: start;
|
|
155
|
+
display: inline-flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: var(--space-4, 0.25rem);
|
|
158
|
+
min-height: 2rem;
|
|
159
|
+
padding: 0 var(--space-8, 0.5rem);
|
|
160
|
+
border: none;
|
|
161
|
+
border-radius: var(--radius-sm, 6px);
|
|
162
|
+
background: transparent;
|
|
163
|
+
color: rgb(var(--color-muted, var(--color-text-secondary)));
|
|
164
|
+
font-family: inherit;
|
|
165
|
+
font-size: var(--text-xs, 0.75rem);
|
|
166
|
+
line-height: 1;
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.hk-filter-panel-add:hover {
|
|
173
|
+
background: rgb(var(--color-primary) / 12%);
|
|
174
|
+
color: rgb(var(--color-primary));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.hk-filter-panel-add:disabled {
|
|
178
|
+
opacity: 0.5;
|
|
179
|
+
cursor: not-allowed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Phones: the operator column widens a touch (user direction
|
|
183
|
+
* 2026-10-12 — the old 4.25rem cap cramped the symbol group) and the
|
|
184
|
+
* row lets the value control take the remaining width. The group itself
|
|
185
|
+
* scrolls — same interaction, smaller footprint. */
|
|
103
186
|
@media (max-width: 767px) {
|
|
104
187
|
.hk-filter-panel {
|
|
105
188
|
width: 100%;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.hk-filter-panel-row {
|
|
109
|
-
grid-template-columns: minmax(3.75rem, auto) minmax(3.9rem, auto) minmax(0, 1fr);
|
|
189
|
+
grid-template-columns: minmax(3.75rem, auto) minmax(4.75rem, auto) minmax(5rem, 1fr);
|
|
110
190
|
}
|
|
111
191
|
|
|
112
192
|
.hk-filter-panel-ops {
|
|
113
|
-
max-width:
|
|
193
|
+
max-width: 5.25rem;
|
|
114
194
|
}
|
|
115
195
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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 HkFilterPanel — the one-axis anatomy the
|
|
10
|
+
* 2026-10-12 user direction pinned:
|
|
11
|
+
*
|
|
12
|
+
* 1. the panel is a single grid with `align-items: center` (every cell
|
|
13
|
+
* of every row on ONE vertical axis);
|
|
14
|
+
* 2. rows/add-row donate `display: contents` cells so column widths
|
|
15
|
+
* stay shared (the add button lands in the operator column);
|
|
16
|
+
* 3. real horizontal padding (minimum outer margins);
|
|
17
|
+
* 4. the value column shrinks (min-width floors, nothing hard-coded);
|
|
18
|
+
* 5. the phone operator column keeps its widened cap.
|
|
19
|
+
*
|
|
20
|
+
* Vitest stubs CSS, so the runtime suite can never catch a lost rule —
|
|
21
|
+
* this compiles the sheet directly and asserts the contract.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
|
|
26
|
+
const css = compile(resolve(componentDir, "HkFilterPanel.scss"), {
|
|
27
|
+
style: "expanded",
|
|
28
|
+
}).css;
|
|
29
|
+
|
|
30
|
+
function ruleFor(selector: string): string {
|
|
31
|
+
const start = css.indexOf(selector);
|
|
32
|
+
expect(start, `${selector} present in the compiled sheet`).toBeGreaterThanOrEqual(0);
|
|
33
|
+
const open = css.indexOf("{", start);
|
|
34
|
+
const close = css.indexOf("}", open);
|
|
35
|
+
return css.slice(start, close + 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("HkFilterPanel stylesheet contract", () => {
|
|
39
|
+
it("compiles without warnings", () => {
|
|
40
|
+
expect(css).toContain(".hk-filter-panel");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("the panel is one grid, vertically centered on a single axis", () => {
|
|
44
|
+
const rule = ruleFor(".hk-filter-panel {");
|
|
45
|
+
expect(rule).toContain("display: grid");
|
|
46
|
+
expect(rule).toContain("align-items: center");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rows and the add row donate contents cells to the shared grid", () => {
|
|
50
|
+
// sass keeps the selector list wrapped: `.row,` newline `.addrow {`.
|
|
51
|
+
expect(css).toMatch(/\.hk-filter-panel-row,\s*\.hk-filter-panel-addrow\s*\{[^}]*display:\s*contents/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps minimum outer margins on the columns", () => {
|
|
55
|
+
const rule = ruleFor(".hk-filter-panel {");
|
|
56
|
+
// Inline padding (both edges) — not the old 0.25rem sliver.
|
|
57
|
+
expect(rule).toMatch(/padding:[^;]*var\(--space-12/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("the value column shrinks with the panel (min floors, nothing fixed)", () => {
|
|
61
|
+
const rule = ruleFor(".hk-filter-panel {");
|
|
62
|
+
expect(rule).toMatch(/minmax\(6rem,\s*1fr\)/);
|
|
63
|
+
const value = ruleFor(".hk-filter-panel-value {");
|
|
64
|
+
expect(value).toContain("min-width: 0");
|
|
65
|
+
const control = ruleFor(".hk-filter-panel-control {");
|
|
66
|
+
expect(control).toContain("min-width: 0");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("parks the add button in the operator column (grid-column 2 to the end)", () => {
|
|
70
|
+
const rule = ruleFor(".hk-filter-panel-add {");
|
|
71
|
+
// sass normalizes the track list ("2 / -1" → "2/-1").
|
|
72
|
+
expect(rule).toMatch(/grid-column:\s*2\s*\/\s*-1/);
|
|
73
|
+
expect(rule).toContain("justify-self: start");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("the phone operator column keeps its widened cap (≥ the old 4.25rem)", () => {
|
|
77
|
+
expect(css).toContain("@media (max-width: 767px)");
|
|
78
|
+
const media = css.slice(css.indexOf("@media (max-width: 767px)"));
|
|
79
|
+
expect(media).toMatch(/max-width:\s*5\.25rem/);
|
|
80
|
+
expect(media).toMatch(/minmax\(4\.75rem,\s*auto\)/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -28,17 +28,74 @@ const FIELDS = [
|
|
|
28
28
|
{ key: "since", label: "Since", kind: "date" as const },
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
+
/** State seeding all four fields — every declared row visible. */
|
|
32
|
+
const ALL_SEEDED: HkFilterState = {
|
|
33
|
+
tool: { op: "eq", value: "" },
|
|
34
|
+
count: { op: "lt", value: "" },
|
|
35
|
+
agent: { op: "eq", value: "" },
|
|
36
|
+
since: { op: "gte", value: "" },
|
|
37
|
+
};
|
|
38
|
+
|
|
31
39
|
describe("HkFilterPanel", () => {
|
|
32
|
-
it("renders
|
|
40
|
+
it("renders rows ONLY for fields present in the state — the rest stay latent", () => {
|
|
33
41
|
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: {} }));
|
|
34
|
-
|
|
35
|
-
expect(
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
// Empty state: no condition rows at all, just the add row.
|
|
43
|
+
expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(0);
|
|
44
|
+
expect(c.querySelector(".hk-filter-panel-add")).toBeTruthy();
|
|
45
|
+
// Seeded subset: rows in declaration order, only for seeded keys.
|
|
46
|
+
const c2 = mount(
|
|
47
|
+
h(HkFilterPanel, {
|
|
48
|
+
fields: FIELDS,
|
|
49
|
+
modelValue: { agent: { op: "eq", value: "" }, tool: { op: "eq", value: "" } },
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
const labels = [...c2.querySelectorAll(".hk-filter-panel-field")].map((el) => el.textContent);
|
|
53
|
+
expect(labels).toEqual(["Tool", "Agent"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("hides the add row once every declared field is in the state", () => {
|
|
57
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: ALL_SEEDED }));
|
|
58
|
+
expect(c.querySelector(".hk-filter-panel-add")).toBeNull();
|
|
59
|
+
expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(FIELDS.length);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("adds the next latent field with its kind's default operator and an empty value", () => {
|
|
63
|
+
let seen: HkFilterState | null = null;
|
|
64
|
+
const c = mount(
|
|
65
|
+
h(HkFilterPanel, {
|
|
66
|
+
fields: FIELDS,
|
|
67
|
+
modelValue: { tool: { op: "eq", value: "web" } },
|
|
68
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
c.querySelector(".hk-filter-panel-add")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
72
|
+
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.
|
|
75
|
+
expect(seen!["count"]).toEqual({ op: "lt", value: "" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("removes a condition entirely — the key leaves the state", () => {
|
|
79
|
+
let seen: HkFilterState | null = null;
|
|
80
|
+
const c = mount(
|
|
81
|
+
h(HkFilterPanel, {
|
|
82
|
+
fields: FIELDS,
|
|
83
|
+
modelValue: { tool: { op: "eq", value: "web" }, count: { op: "lt", value: "3" } },
|
|
84
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
const rows = [...c.querySelectorAll(".hk-filter-panel-row")];
|
|
88
|
+
expect(rows).toHaveLength(2);
|
|
89
|
+
rows[0]!.querySelector(".hk-filter-panel-remove")!.dispatchEvent(
|
|
90
|
+
new MouseEvent("click", { bubbles: true }),
|
|
91
|
+
);
|
|
92
|
+
expect(seen).not.toBeNull();
|
|
93
|
+
expect(seen!["tool"]).toBeUndefined();
|
|
94
|
+
expect(seen!["count"]).toEqual({ op: "lt", value: "3" });
|
|
38
95
|
});
|
|
39
96
|
|
|
40
97
|
it("gives each kind its default operator set (regular 3, numeric 5, select 2, date 2)", () => {
|
|
41
|
-
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue:
|
|
98
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: ALL_SEEDED }));
|
|
42
99
|
const rows = [...c.querySelectorAll(".hk-filter-panel-row")];
|
|
43
100
|
const opCounts = rows.map(
|
|
44
101
|
(row) => row.querySelectorAll(".hk-filter-panel-op").length,
|
|
@@ -78,14 +135,14 @@ describe("HkFilterPanel", () => {
|
|
|
78
135
|
const c = mount(
|
|
79
136
|
h(HkFilterPanel, {
|
|
80
137
|
fields: FIELDS,
|
|
81
|
-
modelValue: {},
|
|
138
|
+
modelValue: { count: { op: "lt", value: "" } },
|
|
82
139
|
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
83
140
|
}),
|
|
84
141
|
);
|
|
85
142
|
// The number row's control is the number-variant input (type=number —
|
|
86
143
|
// the browser refuses illegal keystrokes); the model side keeps the
|
|
87
144
|
// shape guard (sanitizeNumberInput) as the belt.
|
|
88
|
-
const input = c.querySelectorAll<HTMLInputElement>(".hk-filter-panel-row input")[
|
|
145
|
+
const input = c.querySelectorAll<HTMLInputElement>(".hk-filter-panel-row input")[0]!;
|
|
89
146
|
expect(input.type).toBe("number");
|
|
90
147
|
input.value = "42.5";
|
|
91
148
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
@@ -95,22 +152,23 @@ describe("HkFilterPanel", () => {
|
|
|
95
152
|
});
|
|
96
153
|
|
|
97
154
|
it("keeps select fields on a popup select and prepends the empty clear row", () => {
|
|
98
|
-
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: {} }));
|
|
99
|
-
const selectRow = c.querySelectorAll(".hk-filter-panel-row")[
|
|
155
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: { agent: { op: "eq", value: "" } } }));
|
|
156
|
+
const selectRow = c.querySelectorAll(".hk-filter-panel-row")[0]!;
|
|
100
157
|
expect(selectRow.querySelector(".hk-popup-select-trigger")).toBeTruthy();
|
|
101
158
|
});
|
|
102
159
|
|
|
103
160
|
it("prepends a localized empty All row to select fields without one (the clear affordance)", async () => {
|
|
104
161
|
// The select field's ONLY clear affordance is the empty value row —
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// {value:"", label:All} row;
|
|
108
|
-
// label. (R1 mutation f2 removed
|
|
109
|
-
// this test is that missing pin.)
|
|
162
|
+
// removal drops the whole condition, but an EMPTY value still means
|
|
163
|
+
// "filters nothing" on the wire. A page without its own "all" entry
|
|
164
|
+
// must therefore always find the prepended {value:"", label:All} row;
|
|
165
|
+
// a page that ships one keeps its own label. (R1 mutation f2 removed
|
|
166
|
+
// the prepend and NOTHING went red — this test is that missing pin.)
|
|
110
167
|
const withOwnEmpty = [
|
|
111
168
|
{ key: "level", label: "Level", kind: "select" as const, options: [{ value: "", label: "Everything" }, { value: "high", label: "High" }] },
|
|
112
169
|
];
|
|
113
|
-
const
|
|
170
|
+
const seeded = { level: { op: "eq" as const, value: "" } };
|
|
171
|
+
const c = mount(h(HkFilterPanel, { fields: withOwnEmpty, modelValue: seeded }));
|
|
114
172
|
// A page shipping its own empty row keeps it as-is (no duplicate):
|
|
115
173
|
// the panel prepends only when NO option carries the empty value.
|
|
116
174
|
const ownTrigger = c.querySelector(".hk-filter-panel-row .hk-popup-select-trigger");
|
|
@@ -126,7 +184,7 @@ describe("HkFilterPanel", () => {
|
|
|
126
184
|
const withoutAll = [
|
|
127
185
|
{ key: "level", label: "Level", kind: "select" as const, options: [{ value: "high", label: "High" }] },
|
|
128
186
|
];
|
|
129
|
-
const c2 = mount(h(HkFilterPanel, { fields: withoutAll, modelValue: {} }));
|
|
187
|
+
const c2 = mount(h(HkFilterPanel, { fields: withoutAll, modelValue: { level: { op: "eq", value: "" } } }));
|
|
130
188
|
const trigger2 = c2.querySelector(".hk-filter-panel-row .hk-popup-select-trigger")!;
|
|
131
189
|
trigger2.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
132
190
|
await Promise.resolve();
|
|
@@ -143,6 +201,36 @@ describe("HkFilterPanel", () => {
|
|
|
143
201
|
expect(c2.querySelector(".hk-filter-panel")!.getAttribute("data-active")).toBeNull();
|
|
144
202
|
});
|
|
145
203
|
|
|
204
|
+
it("suppresses the control captions (the row label is the one label) but keeps them semantic", () => {
|
|
205
|
+
const c = mount(
|
|
206
|
+
h(HkFilterPanel, {
|
|
207
|
+
fields: FIELDS,
|
|
208
|
+
modelValue: { agent: { op: "eq", value: "" }, tool: { op: "eq", value: "" } },
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
// No floating caption above either control.
|
|
212
|
+
expect(c.querySelector(".hk-popup-select-label")).toBeNull();
|
|
213
|
+
expect(c.querySelector(".hk-input-label")).toBeNull();
|
|
214
|
+
// …but the field element still carries the name for AT and the
|
|
215
|
+
// mobile select sheet's breadcrumb title.
|
|
216
|
+
const selectTrigger = c.querySelector(".hk-popup-select-trigger")!;
|
|
217
|
+
expect(selectTrigger.getAttribute("aria-label")).toBe("Agent");
|
|
218
|
+
const input = c.querySelector(".hk-input-element")!;
|
|
219
|
+
expect(input.getAttribute("aria-label")).toBe("Tool");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("rides the compact control stock (sm variants) in the dense rows", () => {
|
|
223
|
+
const c = mount(
|
|
224
|
+
h(HkFilterPanel, {
|
|
225
|
+
fields: FIELDS,
|
|
226
|
+
modelValue: ALL_SEEDED,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
expect(c.querySelector(".hk-popup-select-trigger-sm")).toBeTruthy();
|
|
230
|
+
expect(c.querySelector(".hk-input-box-sm")).toBeTruthy();
|
|
231
|
+
expect(c.querySelector(".hk-dp-trigger-sm")).toBeTruthy();
|
|
232
|
+
});
|
|
233
|
+
|
|
146
234
|
it("sanitizes number shapes: sign leading-only, one dot, junk stripped", () => {
|
|
147
235
|
expect(sanitizeNumberInput("12a.5b9")).toBe("12.59");
|
|
148
236
|
expect(sanitizeNumberInput("-3-x.1.2")).toBe("-3.12");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed, defineComponent, type PropType } from "vue";
|
|
2
|
+
import { Plus, X } from "lucide-vue-next";
|
|
2
3
|
|
|
3
4
|
import HkDatePicker from "./HkDatePicker";
|
|
4
5
|
import HkInput from "./HkInput";
|
|
@@ -30,17 +31,20 @@ export interface HkFilterFieldDef {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** One condition: the operator and the (string) value. An EMPTY value
|
|
33
|
-
* means the field filters nothing —
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* means the field filters nothing — pages map absent/empty onto "no
|
|
35
|
+
* filter" at the RPC boundary; the row's own remove button is the UI
|
|
36
|
+
* verb that drops the condition entirely. */
|
|
36
37
|
export interface HkFilterCondition {
|
|
37
38
|
op: HkFilterOperator;
|
|
38
39
|
value: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/** Per-field condition state, keyed by the field def's key.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
/** Per-field condition state, keyed by the field def's key. The record
|
|
43
|
+
* IS the list of visible condition rows: a declared field the record
|
|
44
|
+
* does not mention is latent — it only materializes when the user adds
|
|
45
|
+
* it from the panel's add row (user direction 2026-10-12: conditions
|
|
46
|
+
* are ADDED, not all rendered at once), and a row the user removes
|
|
47
|
+
* leaves the record. */
|
|
44
48
|
export type HkFilterState = Record<string, HkFilterCondition>;
|
|
45
49
|
|
|
46
50
|
const OPERATOR_SYMBOL: Record<HkFilterOperator, string> = {
|
|
@@ -136,9 +140,13 @@ export default defineComponent({
|
|
|
136
140
|
|
|
137
141
|
return () => {
|
|
138
142
|
const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
|
|
143
|
+
// The visible rows ARE the state record's declared fields, in
|
|
144
|
+
// declaration order; the rest stay latent behind the add row.
|
|
145
|
+
const rows = props.fields.filter((f) => props.modelValue[f.key] !== undefined);
|
|
146
|
+
const addable = props.fields.filter((f) => props.modelValue[f.key] === undefined);
|
|
139
147
|
return (
|
|
140
148
|
<div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} aria-label={label}>
|
|
141
|
-
{
|
|
149
|
+
{rows.map((field) => {
|
|
142
150
|
const condition = conditionFor(field);
|
|
143
151
|
const ops = operatorsFor(field);
|
|
144
152
|
return (
|
|
@@ -161,48 +169,97 @@ export default defineComponent({
|
|
|
161
169
|
))}
|
|
162
170
|
</div>
|
|
163
171
|
<div class="hk-filter-panel-value">
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
172
|
+
<div class="hk-filter-panel-control">
|
|
173
|
+
{field.kind === "select" ? (
|
|
174
|
+
<HkPopupSelect
|
|
175
|
+
modelValue={condition.value}
|
|
176
|
+
onUpdate:modelValue={(v: string) => emitCondition(field, { value: v })}
|
|
177
|
+
label={field.label}
|
|
178
|
+
// The row's left cell already names the field —
|
|
179
|
+
// the control's own caption above it reads as
|
|
180
|
+
// duplication (user direction 2026-10-12), and
|
|
181
|
+
// the compact variant keeps the dense row stock.
|
|
182
|
+
hideLabel
|
|
183
|
+
size="sm"
|
|
184
|
+
disabled={props.disabled}
|
|
185
|
+
options={selectOptions(field)}
|
|
186
|
+
/>
|
|
187
|
+
) : field.kind === "date" ? (
|
|
188
|
+
// The calendar picker — never the OS native input
|
|
189
|
+
// (2026-10-11 direction); the wire format is the same
|
|
190
|
+
// ISO YYYY-MM-DD the native input spoke.
|
|
191
|
+
<HkDatePicker
|
|
192
|
+
modelValue={condition.value || null}
|
|
193
|
+
onUpdate:modelValue={(v: string | null) =>
|
|
194
|
+
emitCondition(field, { value: v ?? "" })
|
|
195
|
+
}
|
|
196
|
+
size="sm"
|
|
197
|
+
disabled={props.disabled}
|
|
198
|
+
/>
|
|
199
|
+
) : (
|
|
200
|
+
<HkInput
|
|
201
|
+
type="text"
|
|
202
|
+
variant={field.kind === "number" ? "number" : "text"}
|
|
203
|
+
modelValue={condition.value}
|
|
204
|
+
onUpdate:modelValue={(v: string) =>
|
|
205
|
+
emitCondition(field, {
|
|
206
|
+
// Belt over the browser's native number filtering:
|
|
207
|
+
// the stored model keeps a clean number SHAPE only
|
|
208
|
+
// (sign, digits, one dot), so pages can hand it to
|
|
209
|
+
// RPC params verbatim.
|
|
210
|
+
value: field.kind === "number" ? sanitizeNumberInput(v) : v,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
label={field.label}
|
|
214
|
+
hideLabel
|
|
215
|
+
size="sm"
|
|
216
|
+
placeholder={field.placeholder ?? ""}
|
|
217
|
+
disabled={props.disabled}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
{/* Drop the condition entirely (the row leaves the
|
|
222
|
+
* panel; the page refetches unfiltered). Clearing the
|
|
223
|
+
* VALUE still means "filters nothing" for the wire —
|
|
224
|
+
* removal is just the tidy-up verb on top. */}
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
class="hk-filter-panel-remove"
|
|
228
|
+
aria-label={t("hikari::filterPanel.removeCondition", "Remove condition")}
|
|
229
|
+
title={t("hikari::filterPanel.removeCondition", "Remove condition")}
|
|
230
|
+
disabled={props.disabled}
|
|
231
|
+
onClick={() => {
|
|
232
|
+
const next: HkFilterState = { ...props.modelValue };
|
|
233
|
+
delete next[field.key];
|
|
234
|
+
emit("update:modelValue", next);
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
<X size={14} aria-hidden="true" />
|
|
238
|
+
</button>
|
|
202
239
|
</div>
|
|
203
240
|
</div>
|
|
204
241
|
);
|
|
205
242
|
})}
|
|
243
|
+
{addable.length > 0 && (
|
|
244
|
+
<div class="hk-filter-panel-addrow">
|
|
245
|
+
{/* The ONE growth verb, parked in the operator column so
|
|
246
|
+
* the row grid reads as one aligned axis (user direction
|
|
247
|
+
* 2026-10-12). Appends the next unused field with its
|
|
248
|
+
* kind's default operator and an empty value. */}
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
class="hk-filter-panel-add"
|
|
252
|
+
disabled={props.disabled}
|
|
253
|
+
onClick={() => {
|
|
254
|
+
const field = addable[0]!;
|
|
255
|
+
emitCondition(field, { op: operatorsFor(field)[0]!, value: "" });
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<Plus size={14} aria-hidden="true" />
|
|
259
|
+
<span>{t("hikari::filterPanel.addCondition", "Add condition")}</span>
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
206
263
|
</div>
|
|
207
264
|
);
|
|
208
265
|
};
|
|
@@ -47,7 +47,10 @@ describe("HkFilterTrigger", () => {
|
|
|
47
47
|
const c = mount(
|
|
48
48
|
h(HkFilterTrigger, {
|
|
49
49
|
fields: FIELDS,
|
|
50
|
-
|
|
50
|
+
// Seed one condition so the panel opens with its row (an empty
|
|
51
|
+
// state renders only the add row — conditions are added, not
|
|
52
|
+
// all rendered at once).
|
|
53
|
+
modelValue: { tool: { op: "eq", value: "" } },
|
|
51
54
|
"onUpdate:modelValue": (s: Record<string, unknown>) => (seen = s),
|
|
52
55
|
}),
|
|
53
56
|
);
|