@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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkFilterPanel.scss +100 -20
  3. package/src/components/HkFilterPanel.styles.test.ts +82 -0
  4. package/src/components/HkFilterPanel.test.tsx +105 -17
  5. package/src/components/HkFilterPanel.tsx +102 -45
  6. package/src/components/HkFilterTrigger.test.tsx +4 -1
  7. package/src/components/HkInput.hideLabel.test.tsx +72 -0
  8. package/src/components/HkInput.tsx +18 -1
  9. package/src/components/HkPopupSelect.mobile.test.tsx +50 -0
  10. package/src/components/HkPopupSelect.scss +14 -0
  11. package/src/components/HkPopupSelect.test.tsx +84 -0
  12. package/src/components/HkPopupSelect.tsx +18 -3
  13. package/src/components/HkScrollContainer.test.ts +42 -0
  14. package/src/components/HkScrollContainer.tsx +15 -2
  15. package/src/components/overlayScrollbarChrome.contract.test.ts +41 -0
  16. package/src/composables/useOverlayScrollbar.test.ts +262 -3
  17. package/src/composables/useOverlayScrollbar.ts +185 -2
  18. package/src/i18n/locales/ar/components.json +2 -0
  19. package/src/i18n/locales/de/components.json +2 -0
  20. package/src/i18n/locales/en/components.json +2 -0
  21. package/src/i18n/locales/es/components.json +2 -0
  22. package/src/i18n/locales/fr/components.json +2 -0
  23. package/src/i18n/locales/ja/components.json +2 -0
  24. package/src/i18n/locales/ko/components.json +2 -0
  25. package/src/i18n/locales/pt/components.json +2 -0
  26. package/src/i18n/locales/ru/components.json +2 -0
  27. package/src/i18n/locales/zh-Hans/components.json +2 -0
  28. package/src/i18n/locales/zh-Hant/components.json +2 -0
  29. package/src/index.ts +3 -0
  30. package/src/styles/theme/scrollbar.scss +76 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.152",
3
+ "version": "0.55.154",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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
- * Edits commit LIVE (the desktop panel has no confirm button); the
11
- * mobile sheet's single confirm merely closes — the standard HkPopover
12
- * sheetOnMobile split renders the same panel in both shapes.
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: flex;
17
- flex-direction: column;
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
- padding: var(--space-4, 0.25rem);
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
- .hk-filter-panel-row {
32
- display: grid;
33
- grid-template-columns: minmax(4.5rem, auto) minmax(0, auto) minmax(0, 1fr);
34
- align-items: center;
35
- gap: var(--space-8, 0.5rem);
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
- /* Phones: the operator column narrows hard (about two symbols visible)
101
- * and the row lets the value control take the remaining width. The
102
- * group itself scrolls — same interaction, smaller footprint. */
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: 4.25rem;
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 one condition row per declared field", () => {
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
- const rows = c.querySelectorAll(".hk-filter-panel-row");
35
- expect(rows).toHaveLength(FIELDS.length);
36
- const labels = [...c.querySelectorAll(".hk-filter-panel-field")].map((el) => el.textContent);
37
- expect(labels).toEqual(["Tool", "Calls", "Agent", "Since"]);
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")[1]!;
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")[2]!;
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
- // there is no reset verb in the header trigger. A page without its
106
- // own "all" entry must therefore always find the prepended
107
- // {value:"", label:All} row; a page that ships one keeps its own
108
- // label. (R1 mutation f2 removed the prepend and NOTHING went red —
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 c = mount(h(HkFilterPanel, { fields: withOwnEmpty, modelValue: {} }));
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 — this is also how a condition is
34
- * cleared (re-open the panel, empty the value; there is no separate
35
- * reset verb in the header affordance). */
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. Fields the
42
- * record does not mention render with their kind's default operator and
43
- * an empty value. */
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
- {props.fields.map((field) => {
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
- {field.kind === "select" ? (
165
- <HkPopupSelect
166
- modelValue={condition.value}
167
- onUpdate:modelValue={(v: string) => emitCondition(field, { value: v })}
168
- label={field.label}
169
- disabled={props.disabled}
170
- options={selectOptions(field)}
171
- />
172
- ) : field.kind === "date" ? (
173
- // The calendar picker — never the OS native input
174
- // (2026-10-11 direction); the wire format is the same
175
- // ISO YYYY-MM-DD the native input spoke.
176
- <HkDatePicker
177
- modelValue={condition.value || null}
178
- onUpdate:modelValue={(v: string | null) =>
179
- emitCondition(field, { value: v ?? "" })
180
- }
181
- disabled={props.disabled}
182
- />
183
- ) : (
184
- <HkInput
185
- type="text"
186
- variant={field.kind === "number" ? "number" : "text"}
187
- modelValue={condition.value}
188
- onUpdate:modelValue={(v: string) =>
189
- emitCondition(field, {
190
- // Belt over the browser's native number filtering:
191
- // the stored model keeps a clean number SHAPE only
192
- // (sign, digits, one dot), so pages can hand it to
193
- // RPC params verbatim.
194
- value: field.kind === "number" ? sanitizeNumberInput(v) : v,
195
- })
196
- }
197
- label={field.label}
198
- placeholder={field.placeholder ?? ""}
199
- disabled={props.disabled}
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
- modelValue: {},
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
  );