@celestia-island/hikari 0.55.161 → 0.55.162

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.161",
3
+ "version": "0.55.162",
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",
@@ -1,4 +1,5 @@
1
1
  import { computed, defineComponent, type PropType } from "vue";
2
+ import { safeHref } from "../utils/safeHref";
2
3
  import { useI18n } from "../i18n/context";
3
4
  import "./HkBreadcrumb.scss";
4
5
 
@@ -19,7 +20,7 @@ export default defineComponent({
19
20
  {props.items.map((item, index) => (
20
21
  <li key={index} class="hk-breadcrumb-item">
21
22
  {item.to ? (
22
- <a href={item.to} class="hk-breadcrumb-link">{item.label}</a>
23
+ <a href={safeHref(item.to)} class="hk-breadcrumb-link">{item.label}</a>
23
24
  ) : (
24
25
  <span class="hk-breadcrumb-link">{item.label}</span>
25
26
  )}
@@ -9,10 +9,12 @@
9
9
  * Row anatomy: [field label | operator symbol group | value control +
10
10
  * remove]. Rows are the page's ACTIVE conditions; the trailing add row
11
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.
12
+ * opens the FIELD CHOOSER — a popover of the latent fields (user
13
+ * direction 2026-10-14: add means pick WHO, never append by declaration
14
+ * order) and the picked field materializes as a row. Edits commit LIVE
15
+ * (the desktop panel has no confirm button); the mobile sheet's single
16
+ * confirm merely closes — the standard HkPopover sheetOnMobile split
17
+ * renders the same panel in both shapes.
16
18
  *
17
19
  * ONE GRID, ONE AXIS (user direction 2026-10-12): the panel itself is
18
20
  * the grid and each row contributes `display: contents` cells, so the
@@ -21,6 +23,7 @@
21
23
  * puts every cell of every row on the same vertical axis — label,
22
24
  * symbol group, control, and remove button all share it.
23
25
  */
26
+ @use "./menu-item" as mi;
24
27
 
25
28
  .hk-filter-panel {
26
29
  display: grid;
@@ -179,6 +182,64 @@
179
182
  cursor: not-allowed;
180
183
  }
181
184
 
185
+ /* The trigger's open face while the chooser is up (the popup-select
186
+ * trigger's [data-state="open"] grammar — the user must see the button
187
+ * and its menu as one surface). */
188
+ .hk-filter-panel-add[data-state="open"] {
189
+ background: rgb(var(--color-primary) / 12%);
190
+ color: rgb(var(--color-primary));
191
+ }
192
+
193
+ /* ── The add-condition field chooser ───────────────────────────────
194
+ * A popover of the LATENT fields (user direction 2026-10-14): the
195
+ * add button opens it, the picked field materializes as a row. Same
196
+ * popout geometry as the panel's popup selects (min-width floor, one
197
+ * 240px content cap, then viewport scroll); item rows speak the
198
+ * unified menu-item spec, so the list reads as one family with select
199
+ * options and menu rows around it. The mobile sheet form flows from
200
+ * the shared `.hk-is-sheet` token context — touch geometry comes
201
+ * free, no per-component media query. */
202
+ .hk-filter-add-content {
203
+ min-width: 180px;
204
+ max-height: 240px;
205
+ display: flex;
206
+ flex-direction: column;
207
+ border-radius: var(--radius-sm);
208
+ overflow: hidden;
209
+ }
210
+
211
+ .hk-filter-add-viewport .hk-scroll-container-viewport {
212
+ display: flex;
213
+ flex-direction: column;
214
+ }
215
+
216
+ .hk-filter-add-list {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: var(--space-2);
220
+ padding: var(--space-2);
221
+ }
222
+
223
+ .hk-filter-add-item {
224
+ @include mi.item;
225
+ width: 100%;
226
+ justify-content: flex-start;
227
+ text-align: start;
228
+ }
229
+
230
+ /* Keyboard highlight face — identical values to the popup-select item's
231
+ * [data-highlighted]; hover comes from mi.item's own guarded :hover. */
232
+ .hk-filter-add-item[data-highlighted] {
233
+ background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
234
+ border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
235
+ color: rgb(var(--color-text));
236
+ }
237
+
238
+ .hk-filter-add-item:disabled {
239
+ opacity: 0.45;
240
+ cursor: not-allowed;
241
+ }
242
+
182
243
  /* Phones: the operator column widens a touch (user direction
183
244
  * 2026-10-12 — the old 4.25rem cap cramped the symbol group) and the
184
245
  * row lets the value control take the remaining width. The group itself
@@ -79,4 +79,15 @@ describe("HkFilterPanel stylesheet contract", () => {
79
79
  expect(media).toMatch(/max-width:\s*5\.25rem/);
80
80
  expect(media).toMatch(/minmax\(4\.75rem,\s*auto\)/);
81
81
  });
82
+
83
+ it("registers the chooser rows in the sheet-context token swap (44px touch rows)", () => {
84
+ // The add-condition chooser docks as a bottom sheet on mobile; its
85
+ // items must ride the unified menu-item SHEET geometry. The swap
86
+ // lives in _menu-item.scss's adopter list (pulled in via the
87
+ // @use chain) — an unregistered class silently keeps the 34px
88
+ // desktop rows (R1 2026-10-14 finding: exactly that happened).
89
+ expect(css).toMatch(
90
+ /\.hk-popover-panel\.hk-is-sheet[^{]*\.hk-filter-add-item[^{]*\{[^}]*--hk-menu-item-min-h:\s*44px/,
91
+ );
92
+ });
82
93
  });
@@ -19,6 +19,11 @@ afterEach(() => {
19
19
  app.unmount();
20
20
  container.remove();
21
21
  }
22
+ // The field chooser (and any popout it opened) teleports to <body>
23
+ // (HkPopover) — sweep the leftovers so the next test starts clean.
24
+ document.body
25
+ .querySelectorAll(".hk-filter-add-item, .hk-popover-panel, .hk-select-popout-host")
26
+ .forEach((el) => el.remove());
22
27
  });
23
28
 
24
29
  const FIELDS = [
@@ -59,7 +64,34 @@ describe("HkFilterPanel", () => {
59
64
  expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(FIELDS.length);
60
65
  });
61
66
 
62
- it("adds the next latent field with its kind's default operator and an empty value", () => {
67
+ it("opens a field chooser on the add verb instead of appending anything", async () => {
68
+ let seen: HkFilterState | null = null;
69
+ const c = mount(
70
+ h(HkFilterPanel, {
71
+ fields: FIELDS,
72
+ modelValue: { tool: { op: "eq", value: "web" } },
73
+ "onUpdate:modelValue": (s: HkFilterState) => (seen = s),
74
+ }),
75
+ );
76
+ const add = c.querySelector<HTMLButtonElement>(".hk-filter-panel-add")!;
77
+ expect(add.getAttribute("aria-haspopup")).toBe("menu");
78
+ expect(add.getAttribute("aria-expanded")).toBe("false");
79
+ add.dispatchEvent(new MouseEvent("click", { bubbles: true }));
80
+ await Promise.resolve();
81
+ await Promise.resolve();
82
+ // The click itself appends NOTHING (user direction 2026-10-14: add
83
+ // means pick WHO — never the next field in declaration order)…
84
+ expect(seen).toBeNull();
85
+ // …and the chooser lists ONLY the latent fields, in declaration
86
+ // order, labeled by the field defs.
87
+ const items = [...document.body.querySelectorAll(".hk-filter-add-item")].map(
88
+ (el) => el.textContent,
89
+ );
90
+ expect(items).toEqual(["Calls", "Agent", "Since"]);
91
+ expect(add.getAttribute("aria-expanded")).toBe("true");
92
+ });
93
+
94
+ it("materializes the picked field with its kind's default operator and closes the chooser", async () => {
63
95
  let seen: HkFilterState | null = null;
64
96
  const c = mount(
65
97
  h(HkFilterPanel, {
@@ -69,9 +101,48 @@ describe("HkFilterPanel", () => {
69
101
  }),
70
102
  );
71
103
  c.querySelector(".hk-filter-panel-add")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
104
+ await Promise.resolve();
105
+ await Promise.resolve();
106
+ const item = [...document.body.querySelectorAll(".hk-filter-add-item")].find(
107
+ (el) => el.textContent === "Agent",
108
+ )!;
109
+ expect(item, "chooser lists the latent Agent field").toBeTruthy();
110
+ item.dispatchEvent(new MouseEvent("click", { bubbles: true }));
111
+ await Promise.resolve();
72
112
  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.
113
+ // The picked field joins with its kind's first operator (select →
114
+ // eq) and an empty value; the existing condition is untouched.
115
+ expect(seen!["agent"]).toEqual({ op: "eq", value: "" });
116
+ expect(seen!["tool"]).toEqual({ op: "eq", value: "web" });
117
+ // The chooser leaves with the pick (after the leave settle).
118
+ await new Promise((r) => setTimeout(r, 60));
119
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeNull();
120
+ });
121
+
122
+ it("drives the chooser from the add button — arrows highlight, Enter picks", async () => {
123
+ let seen: HkFilterState | null = null;
124
+ const c = mount(
125
+ h(HkFilterPanel, {
126
+ fields: FIELDS,
127
+ modelValue: {},
128
+ "onUpdate:modelValue": (s: HkFilterState) => (seen = s),
129
+ }),
130
+ );
131
+ const add = c.querySelector<HTMLButtonElement>(".hk-filter-panel-add")!;
132
+ // ArrowDown opens the chooser with the first item highlighted.
133
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
134
+ await Promise.resolve();
135
+ await Promise.resolve();
136
+ const items = [...document.body.querySelectorAll(".hk-filter-add-item")];
137
+ expect(items).toHaveLength(4);
138
+ expect(items[0]!.getAttribute("data-highlighted")).toBe("true");
139
+ // ArrowDown again moves the highlight to the second item.
140
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
141
+ await Promise.resolve();
142
+ expect(items[1]!.getAttribute("data-highlighted")).toBe("true");
143
+ // Enter confirms the highlighted field — "Calls" (number kind → lt).
144
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
145
+ await Promise.resolve();
75
146
  expect(seen!["count"]).toEqual({ op: "lt", value: "" });
76
147
  });
77
148
 
@@ -1,9 +1,11 @@
1
- import { computed, defineComponent, type PropType } from "vue";
1
+ import { computed, defineComponent, nextTick, ref, watch, type PropType } from "vue";
2
2
  import { Plus, X } from "lucide-vue-next";
3
3
 
4
4
  import HkDatePicker from "./HkDatePicker";
5
5
  import HkInput from "./HkInput";
6
+ import HkPopover from "./HkPopover";
6
7
  import HkPopupSelect, { type HkPopupSelectOption } from "./HkPopupSelect";
8
+ import HkScrollContainer from "./HkScrollContainer";
7
9
  import { useI18n } from "../i18n/context";
8
10
  import "./HkFilterPanel.scss";
9
11
 
@@ -138,15 +140,91 @@ export default defineComponent({
138
140
  () => props.fields.filter((f) => conditionFor(f).value !== "").length,
139
141
  );
140
142
 
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 = computed(() => props.fields.filter((f) => props.modelValue[f.key] !== undefined));
146
+ const addable = computed(() => props.fields.filter((f) => props.modelValue[f.key] === undefined));
147
+
148
+ // ── The add-condition field chooser (user direction 2026-10-14) ──
149
+ // "Add condition" NEVER appends by declaration order — it opens a
150
+ // menu of the latent fields and the user picks WHO joins the panel.
151
+ // The menu is a popover anchored to the add button (a bottom sheet
152
+ // on mobile, same split as every other panel popout); the keyboard
153
+ // model mirrors HkPopupSelect: focus stays on the trigger, arrows
154
+ // move a highlight, Enter confirms it.
155
+ const addMenuOpen = ref(false);
156
+ const addBtnRef = ref<HTMLElement | null>(null);
157
+ const addHighlight = ref(-1);
158
+
159
+ function openAddMenu() {
160
+ addMenuOpen.value = true;
161
+ // First item pre-highlighted: arrows and Enter work immediately.
162
+ addHighlight.value = addable.value.length > 0 ? 0 : -1;
163
+ }
164
+
165
+ function closeAddMenu() {
166
+ addMenuOpen.value = false;
167
+ addHighlight.value = -1;
168
+ }
169
+
170
+ function pickAddable(field: HkFilterFieldDef) {
171
+ // Materialize the row: the kind's default operator, empty value.
172
+ emitCondition(field, { op: operatorsFor(field)[0]!, value: "" });
173
+ closeAddMenu();
174
+ }
175
+
176
+ // The latent list can change under an open menu (a row removed while
177
+ // the menu is open grows it; the last latent field picked empties
178
+ // it) — keep the highlight in range and close on an empty menu.
179
+ watch(addable, (list) => {
180
+ if (!addMenuOpen.value) return;
181
+ if (list.length === 0) {
182
+ closeAddMenu();
183
+ return;
184
+ }
185
+ if (addHighlight.value >= list.length) addHighlight.value = list.length - 1;
186
+ });
187
+
188
+ watch(addHighlight, (i) => {
189
+ if (i < 0) return;
190
+ nextTick(() => {
191
+ document
192
+ .querySelector(`[data-add-index="${i}"]`)
193
+ ?.scrollIntoView({ block: "nearest" });
194
+ });
195
+ });
196
+
197
+ function onAddTriggerKeydown(e: KeyboardEvent) {
198
+ if (props.disabled) return;
199
+ const count = addable.value.length;
200
+ if (e.key === "ArrowDown") {
201
+ e.preventDefault();
202
+ if (!addMenuOpen.value) openAddMenu();
203
+ else addHighlight.value = (addHighlight.value + 1) % count;
204
+ } else if (e.key === "ArrowUp") {
205
+ e.preventDefault();
206
+ if (!addMenuOpen.value) openAddMenu();
207
+ else addHighlight.value = (addHighlight.value - 1 + count) % count;
208
+ } else if ((e.key === "Enter" || e.key === " ") && addMenuOpen.value) {
209
+ // With the menu open, Enter/Space CONFIRM the highlighted field;
210
+ // the native button activation (a toggle click) is suppressed.
211
+ e.preventDefault();
212
+ const field = addable.value[addHighlight.value];
213
+ if (field) pickAddable(field);
214
+ } else if (e.key === "Escape" && addMenuOpen.value) {
215
+ // Escape closes ONE layer — the menu, not the host panel: stop
216
+ // the bubble before the host popover's own Escape handler.
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ closeAddMenu();
220
+ }
221
+ }
222
+
141
223
  return () => {
142
224
  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);
147
225
  return (
148
226
  <div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} aria-label={label}>
149
- {rows.map((field) => {
227
+ {rows.value.map((field) => {
150
228
  const condition = conditionFor(field);
151
229
  const ops = operatorsFor(field);
152
230
  return (
@@ -240,24 +318,72 @@ export default defineComponent({
240
318
  </div>
241
319
  );
242
320
  })}
243
- {addable.length > 0 && (
321
+ {addable.value.length > 0 && (
244
322
  <div class="hk-filter-panel-addrow">
245
323
  {/* The ONE growth verb, parked in the operator column so
246
324
  * the row grid reads as one aligned axis (user direction
247
- * 2026-10-12). Appends the next unused field with its
325
+ * 2026-10-12). It no longer appends by declaration order
326
+ * (user direction 2026-10-14): the click opens the field
327
+ * chooser and the picked field materializes with its
248
328
  * kind's default operator and an empty value. */}
249
329
  <button
330
+ ref={addBtnRef}
250
331
  type="button"
251
332
  class="hk-filter-panel-add"
252
333
  disabled={props.disabled}
253
- onClick={() => {
254
- const field = addable[0]!;
255
- emitCondition(field, { op: operatorsFor(field)[0]!, value: "" });
256
- }}
334
+ aria-haspopup="menu"
335
+ aria-expanded={addMenuOpen.value ? "true" : "false"}
336
+ data-state={addMenuOpen.value ? "open" : "closed"}
337
+ onClick={() => (addMenuOpen.value ? closeAddMenu() : openAddMenu())}
338
+ onKeydown={onAddTriggerKeydown}
257
339
  >
258
340
  <Plus size={14} aria-hidden="true" />
259
341
  <span>{t("hikari::filterPanel.addCondition", "Add condition")}</span>
260
342
  </button>
343
+ {/* The chooser itself: a popover anchored to the add button
344
+ * (top-start — the button sits at the panel's bottom edge,
345
+ * so the list opens over the panel's own rows; autoFlip
346
+ * drops it below when the viewport says otherwise), a
347
+ * bottom sheet on mobile — the same split as every other
348
+ * popout this panel hosts. */}
349
+ <HkPopover
350
+ modelValue={addMenuOpen.value}
351
+ onUpdate:modelValue={(v: boolean) => (v ? openAddMenu() : closeAddMenu())}
352
+ anchorRef={addBtnRef.value}
353
+ placement="top-start"
354
+ offset={4}
355
+ backdrop={false}
356
+ sheetOnMobile
357
+ title={t("hikari::filterPanel.addCondition", "Add condition")}
358
+ class="hk-filter-add-content"
359
+ >
360
+ <HkScrollContainer class="hk-filter-add-viewport">
361
+ {/* role=menu lives on the inner list (HkScrollContainer
362
+ * owns its root's attr surface); items carry menuitem. */}
363
+ <div
364
+ class="hk-filter-add-list"
365
+ role="menu"
366
+ aria-label={t("hikari::filterPanel.chooseField", "Choose a field to add")}
367
+ >
368
+ {addable.value.map((field, i) => (
369
+ <button
370
+ key={field.key}
371
+ type="button"
372
+ class="hk-filter-add-item"
373
+ role="menuitem"
374
+ data-kind={field.kind}
375
+ data-highlighted={addHighlight.value === i || undefined}
376
+ data-add-index={i}
377
+ disabled={props.disabled}
378
+ onClick={() => pickAddable(field)}
379
+ onPointerenter={() => { addHighlight.value = i; }}
380
+ >
381
+ <span class="hk-filter-add-item-label">{field.label}</span>
382
+ </button>
383
+ ))}
384
+ </div>
385
+ </HkScrollContainer>
386
+ </HkPopover>
261
387
  </div>
262
388
  )}
263
389
  </div>
@@ -80,4 +80,252 @@ describe("HkFilterTrigger", () => {
80
80
  const empty = mount(h(HkFilterTrigger, { fields: FIELDS, modelValue: {} }));
81
81
  expect(empty.querySelector(".hk-filter-trigger-dot")).toBeNull();
82
82
  });
83
+
84
+ // ── Nested-popout regression (2026-10-14) ──────────────────────────
85
+ // A popout opened from INSIDE the panel teleports to <body>, so the
86
+ // panel popover's outside-click shield cannot see the nesting by DOM
87
+ // containment — until the shield learned data-hk-popout-layer, picking
88
+ // from the add-condition chooser (or a select row's popup) slammed the
89
+ // whole panel shut and swallowed the pick. Both paths pinned here.
90
+
91
+ it("keeps the panel open when a field is picked from the add-condition chooser", async () => {
92
+ let seen: Record<string, unknown> | null = null;
93
+ const c = mount(
94
+ h(HkFilterTrigger, {
95
+ fields: FIELDS,
96
+ modelValue: {},
97
+ "onUpdate:modelValue": (s: Record<string, unknown>) => (seen = s),
98
+ }),
99
+ );
100
+ c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!.dispatchEvent(
101
+ new MouseEvent("click", { bubbles: true }),
102
+ );
103
+ await Promise.resolve();
104
+ await Promise.resolve();
105
+ document.body
106
+ .querySelector<HTMLButtonElement>(".hk-filter-panel .hk-filter-panel-add")!
107
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
108
+ await Promise.resolve();
109
+ await Promise.resolve();
110
+ const item = document.body.querySelector<HTMLElement>(".hk-filter-add-item")!;
111
+ expect(item, "chooser mounted").toBeTruthy();
112
+ item.dispatchEvent(new MouseEvent("click", { bubbles: true }));
113
+ await Promise.resolve();
114
+ await new Promise((r) => setTimeout(r, 60));
115
+ // The pick landed…
116
+ expect(seen).not.toBeNull();
117
+ expect(Object.keys(seen!)).toContain("tool");
118
+ // …and the PANEL is still open — the chooser's click is not a
119
+ // backdrop dismissal for it.
120
+ expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
121
+ });
122
+
123
+ it("keeps the panel open when an option is picked from a select condition row", async () => {
124
+ const selectFields = [
125
+ { key: "tool", label: "Tool", kind: "text" as const },
126
+ { key: "agent", label: "Agent", kind: "select" as const, options: [{ value: "a1", label: "Agent One" }] },
127
+ ];
128
+ let seen: Record<string, unknown> | null = null;
129
+ const c = mount(
130
+ h(HkFilterTrigger, {
131
+ fields: selectFields,
132
+ modelValue: { agent: { op: "eq", value: "" } },
133
+ "onUpdate:modelValue": (s: Record<string, unknown>) => (seen = s),
134
+ }),
135
+ );
136
+ c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!.dispatchEvent(
137
+ new MouseEvent("click", { bubbles: true }),
138
+ );
139
+ await Promise.resolve();
140
+ await Promise.resolve();
141
+ document.body
142
+ .querySelector<HTMLButtonElement>(".hk-filter-panel .hk-popup-select-trigger")!
143
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
144
+ await Promise.resolve();
145
+ await Promise.resolve();
146
+ // The popout's FIRST item is the prepended "All" clear row — pick
147
+ // the real option by its label.
148
+ const option = [...document.body.querySelectorAll<HTMLElement>(".hk-popup-select-item")].find(
149
+ (el) => el.textContent?.trim() === "Agent One",
150
+ )!;
151
+ expect(option, "select popout mounted").toBeTruthy();
152
+ option.dispatchEvent(new MouseEvent("click", { bubbles: true }));
153
+ await Promise.resolve();
154
+ await new Promise((r) => setTimeout(r, 60));
155
+ // The option landed on the condition…
156
+ expect((seen! as { agent: { value: string } }).agent.value).toBe("a1");
157
+ // …and the panel survived the nested pick.
158
+ expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
159
+ document.body.querySelectorAll(".hk-popup-select-item, .hk-popover-panel").forEach((el) => el.remove());
160
+ });
161
+
162
+ // ── Escape closes ONE layer (2026-10-14) ──────────────────────────
163
+ // An open nested popout absorbs the Escape itself (stopPropagation
164
+ // before the host popover's panel keydown); a closed one lets it
165
+ // bubble so the host panel closes. Both nested popout kinds pinned —
166
+ // HkPopupSelect used to close BOTH layers on one Escape.
167
+
168
+ it("Escape with the chooser open closes the chooser but keeps the panel", async () => {
169
+ const c = mount(h(HkFilterTrigger, { fields: FIELDS, modelValue: {} }));
170
+ c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!.dispatchEvent(
171
+ new MouseEvent("click", { bubbles: true }),
172
+ );
173
+ await Promise.resolve();
174
+ await Promise.resolve();
175
+ const add = document.body.querySelector<HTMLButtonElement>(
176
+ ".hk-filter-panel .hk-filter-panel-add",
177
+ )!;
178
+ add.dispatchEvent(new MouseEvent("click", { bubbles: true }));
179
+ await Promise.resolve();
180
+ await Promise.resolve();
181
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeTruthy();
182
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
183
+ await new Promise((r) => setTimeout(r, 60));
184
+ // The chooser left…
185
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeNull();
186
+ // …the panel stayed.
187
+ expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
188
+ // A second Escape (nothing nested open) bubbles on to close the panel.
189
+ add.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
190
+ await new Promise((r) => setTimeout(r, 60));
191
+ expect(document.body.querySelector(".hk-filter-panel")).toBeNull();
192
+ });
193
+
194
+ it("Escape with a select popout open closes the popout but keeps the panel", async () => {
195
+ const selectFields = [
196
+ { key: "tool", label: "Tool", kind: "text" as const },
197
+ { key: "agent", label: "Agent", kind: "select" as const, options: [{ value: "a1", label: "Agent One" }] },
198
+ ];
199
+ const c = mount(
200
+ h(HkFilterTrigger, { fields: selectFields, modelValue: { agent: { op: "eq", value: "" } } }),
201
+ );
202
+ c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!.dispatchEvent(
203
+ new MouseEvent("click", { bubbles: true }),
204
+ );
205
+ await Promise.resolve();
206
+ await Promise.resolve();
207
+ const selectTrigger = document.body.querySelector<HTMLButtonElement>(
208
+ ".hk-filter-panel .hk-popup-select-trigger",
209
+ )!;
210
+ selectTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
211
+ await Promise.resolve();
212
+ await Promise.resolve();
213
+ expect(document.body.querySelector(".hk-popup-select-item")).toBeTruthy();
214
+ selectTrigger.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
215
+ await new Promise((r) => setTimeout(r, 60));
216
+ // The popout left…
217
+ expect(document.body.querySelector(".hk-popup-select-item")).toBeNull();
218
+ // …the panel stayed.
219
+ expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
220
+ document.body.querySelectorAll(".hk-popover-panel").forEach((el) => el.remove());
221
+ });
222
+
223
+ // ── One popout at a time (R2 2026-10-14 finding) ──────────────────
224
+ // The chooser and a select popout are SIBLINGS riding the same host
225
+ // panel. The shield must not treat the shared HOST layer as "above":
226
+ // opening one closes the other, in both directions — never both at
227
+ // once, and an open chooser must not swallow in-panel clicks.
228
+
229
+ it("opening a select popout closes an open chooser (never both at once)", async () => {
230
+ const selectFields = [
231
+ { key: "tool", label: "Tool", kind: "text" as const },
232
+ { key: "agent", label: "Agent", kind: "select" as const, options: [{ value: "a1", label: "Agent One" }] },
233
+ ];
234
+ const c = mount(
235
+ h(HkFilterTrigger, { fields: selectFields, modelValue: { agent: { op: "eq", value: "" } } }),
236
+ );
237
+ c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!.dispatchEvent(
238
+ new MouseEvent("click", { bubbles: true }),
239
+ );
240
+ await Promise.resolve();
241
+ await Promise.resolve();
242
+ document.body
243
+ .querySelector<HTMLButtonElement>(".hk-filter-panel .hk-filter-panel-add")!
244
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
245
+ await Promise.resolve();
246
+ await Promise.resolve();
247
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeTruthy();
248
+ // Now open a select row's popout from inside the host panel…
249
+ document.body
250
+ .querySelector<HTMLButtonElement>(".hk-filter-panel .hk-popup-select-trigger")!
251
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
252
+ await Promise.resolve();
253
+ await new Promise((r) => setTimeout(r, 60));
254
+ // …the chooser left…
255
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeNull();
256
+ // …the select popout is the one live surface…
257
+ expect(document.body.querySelector(".hk-popup-select-item")).toBeTruthy();
258
+ // …and the panel stayed open through the swap.
259
+ expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
260
+ document.body.querySelectorAll(".hk-popover-panel").forEach((el) => el.remove());
261
+ });
262
+
263
+ it("opening the chooser closes an open select popout (the reverse swap)", async () => {
264
+ const selectFields = [
265
+ { key: "tool", label: "Tool", kind: "text" as const },
266
+ { key: "agent", label: "Agent", kind: "select" as const, options: [{ value: "a1", label: "Agent One" }] },
267
+ ];
268
+ const c = mount(
269
+ h(HkFilterTrigger, { fields: selectFields, modelValue: { agent: { op: "eq", value: "" } } }),
270
+ );
271
+ c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!.dispatchEvent(
272
+ new MouseEvent("click", { bubbles: true }),
273
+ );
274
+ await Promise.resolve();
275
+ await Promise.resolve();
276
+ document.body
277
+ .querySelector<HTMLButtonElement>(".hk-filter-panel .hk-popup-select-trigger")!
278
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
279
+ await Promise.resolve();
280
+ await Promise.resolve();
281
+ expect(document.body.querySelector(".hk-popup-select-item")).toBeTruthy();
282
+ // Now click the add verb from inside the host panel…
283
+ document.body
284
+ .querySelector<HTMLButtonElement>(".hk-filter-panel .hk-filter-panel-add")!
285
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
286
+ await Promise.resolve();
287
+ await new Promise((r) => setTimeout(r, 60));
288
+ // …the select popout left…
289
+ expect(document.body.querySelector(".hk-popup-select-item")).toBeNull();
290
+ // …the chooser is the one live surface…
291
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeTruthy();
292
+ // …and the panel stayed open through the swap.
293
+ expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
294
+ document.body.querySelectorAll(".hk-popover-panel").forEach((el) => el.remove());
295
+ });
296
+
297
+ it("an in-panel click while the chooser is open dismisses the chooser", async () => {
298
+ let seen: Record<string, unknown> | null = null;
299
+ const c = mount(
300
+ h(HkFilterTrigger, {
301
+ fields: FIELDS,
302
+ modelValue: { tool: { op: "eq", value: "" } },
303
+ "onUpdate:modelValue": (s: Record<string, unknown>) => (seen = s),
304
+ }),
305
+ );
306
+ c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!.dispatchEvent(
307
+ new MouseEvent("click", { bubbles: true }),
308
+ );
309
+ await Promise.resolve();
310
+ await Promise.resolve();
311
+ document.body
312
+ .querySelector<HTMLButtonElement>(".hk-filter-panel .hk-filter-panel-add")!
313
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
314
+ await Promise.resolve();
315
+ await Promise.resolve();
316
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeTruthy();
317
+ // A row's operator toggle is an in-panel click — the chooser must
318
+ // stand down for it, not swallow it. (Pick the ≈ toggle; the first
319
+ // op button is the already-pressed =.)
320
+ const op = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-filter-panel .hk-filter-panel-op")].find(
321
+ (b) => b.textContent === "≈",
322
+ )!;
323
+ op.dispatchEvent(new MouseEvent("click", { bubbles: true }));
324
+ await Promise.resolve();
325
+ await new Promise((r) => setTimeout(r, 60));
326
+ expect(document.body.querySelector(".hk-filter-add-item")).toBeNull();
327
+ expect((seen! as { tool: { op: string } }).tool.op).toBe("approx");
328
+ expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
329
+ document.body.querySelectorAll(".hk-popover-panel").forEach((el) => el.remove());
330
+ });
83
331
  });
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, type PropType } from "vue";
2
2
  import { useI18n } from "../i18n/context";
3
+ import { safeHref } from "../utils/safeHref";
3
4
  import "./HkLogo.scss";
4
5
 
5
6
  const sizeMap: Record<string, string> = {
@@ -71,7 +72,7 @@ export default defineComponent({
71
72
  return () => (
72
73
  <a
73
74
  class="hk-logo"
74
- href={props.href}
75
+ href={safeHref(props.href)}
75
76
  onClick={(e: MouseEvent) => emit("click", e)}
76
77
  style={{ lineHeight: 0 }}
77
78
  >
@@ -1,4 +1,5 @@
1
1
  import { ChevronRight } from "lucide-vue-next";
2
+ import { safeHref } from "../utils/safeHref";
2
3
  import { defineComponent, computed, ref, type PropType } from "vue";
3
4
  import type { Component } from "vue";
4
5
 
@@ -116,7 +117,7 @@ export default defineComponent({
116
117
  if (props.to) {
117
118
  return (
118
119
  <a
119
- href={props.to}
120
+ href={safeHref(props.to)}
120
121
  class={cls}
121
122
  {...dataAttrs.value}
122
123
  aria-disabled={props.disabled}
@@ -131,7 +132,7 @@ export default defineComponent({
131
132
  if (props.href) {
132
133
  return (
133
134
  <a
134
- href={props.href}
135
+ href={safeHref(props.href)}
135
136
  class={cls}
136
137
  {...dataAttrs.value}
137
138
  aria-disabled={props.disabled}
@@ -0,0 +1,124 @@
1
+ /**
2
+ * HkPopover outside-click shield × nested popout layers: a popover
3
+ * opened from INSIDE another popover teleports to <body>, so the outer
4
+ * shield cannot see the nesting by DOM containment. The host marks
5
+ * every layer with data-hk-popout-layer and the shield honors it — a
6
+ * click inside a nested layer is that layer's business, never a
7
+ * backdrop dismissal for the ancestor (2026-10-14: picking a select
8
+ * option inside a filter panel used to slam the whole panel shut).
9
+ */
10
+ import { afterEach, describe, expect, it } from "vitest";
11
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
12
+
13
+ import HkPopover from "./HkPopover";
14
+
15
+ const apps: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
16
+
17
+ afterEach(() => {
18
+ for (const { app, container } of apps.splice(0)) {
19
+ app.unmount();
20
+ container.remove();
21
+ }
22
+ document.body
23
+ .querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
24
+ .forEach((el) => el.remove());
25
+ });
26
+
27
+ /** Outer popover hosting a trigger that opens an inner (teleported)
28
+ * popover — the filter-panel nesting shape in miniature. */
29
+ const Host = defineComponent({
30
+ name: "ShieldProbeHost",
31
+ setup() {
32
+ const outerOpen = ref(false);
33
+ const innerOpen = ref(false);
34
+ const outerAnchor = ref<HTMLElement | null>(null);
35
+ const innerAnchor = ref<HTMLElement | null>(null);
36
+ return () => (
37
+ <div>
38
+ <button ref={outerAnchor} type="button" class="outer-toggle" onClick={() => (outerOpen.value = !outerOpen.value)}>
39
+ outer
40
+ </button>
41
+ <HkPopover
42
+ modelValue={outerOpen.value}
43
+ onUpdate:modelValue={(v: boolean) => (outerOpen.value = v)}
44
+ anchorRef={outerAnchor.value}
45
+ title="outer"
46
+ >
47
+ <button ref={innerAnchor} type="button" class="inner-toggle" onClick={() => (innerOpen.value = !innerOpen.value)}>
48
+ inner
49
+ </button>
50
+ <HkPopover
51
+ modelValue={innerOpen.value}
52
+ onUpdate:modelValue={(v: boolean) => (innerOpen.value = v)}
53
+ anchorRef={innerAnchor.value}
54
+ title="inner"
55
+ >
56
+ <div class="inner-body">inner content</div>
57
+ </HkPopover>
58
+ </HkPopover>
59
+ </div>
60
+ );
61
+ },
62
+ });
63
+
64
+ function mountHost() {
65
+ const container = document.createElement("div");
66
+ document.body.appendChild(container);
67
+ const app = createApp(Host);
68
+ app.mount(container);
69
+ apps.push({ app, container });
70
+ return container;
71
+ }
72
+
73
+ describe("HkPopover outside-click shield vs nested layers", () => {
74
+ it("marks its teleported host as a popout layer", async () => {
75
+ const c = mountHost();
76
+ c.querySelector<HTMLButtonElement>(".outer-toggle")!.dispatchEvent(
77
+ new MouseEvent("click", { bubbles: true }),
78
+ );
79
+ await nextTick();
80
+ await nextTick();
81
+ const hosts = [...document.body.querySelectorAll("[data-hk-popout-layer]")];
82
+ expect(hosts.length).toBeGreaterThanOrEqual(1);
83
+ });
84
+
85
+ it("a click inside a nested popout does not dismiss the ancestor", async () => {
86
+ const c = mountHost();
87
+ // Open both layers.
88
+ c.querySelector<HTMLButtonElement>(".outer-toggle")!.dispatchEvent(
89
+ new MouseEvent("click", { bubbles: true }),
90
+ );
91
+ await nextTick();
92
+ await nextTick();
93
+ // The outer panel content (including the inner toggle) teleports to
94
+ // body — query there, not in the host container.
95
+ document.body
96
+ .querySelector<HTMLButtonElement>(".inner-toggle")!
97
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
98
+ await nextTick();
99
+ await nextTick();
100
+ const innerBody = document.body.querySelector<HTMLElement>(".inner-body")!;
101
+ expect(innerBody, "inner layer teleported to body").toBeTruthy();
102
+ // The nested click: NOT a backdrop dismissal for the outer popover.
103
+ innerBody.dispatchEvent(new MouseEvent("click", { bubbles: true }));
104
+ await nextTick();
105
+ await new Promise((r) => setTimeout(r, 60));
106
+ expect(document.body.querySelector(".inner-body")).toBeTruthy();
107
+ expect(document.body.querySelector(".outer-toggle")).toBeTruthy(); // outer panel content
108
+ });
109
+
110
+ it("a true outside click still dismisses the ancestor", async () => {
111
+ const c = mountHost();
112
+ c.querySelector<HTMLButtonElement>(".outer-toggle")!.dispatchEvent(
113
+ new MouseEvent("click", { bubbles: true }),
114
+ );
115
+ await nextTick();
116
+ await nextTick();
117
+ expect(document.body.querySelector(".inner-toggle")).toBeTruthy();
118
+ // A click on plain body (no layer, no anchor): the classic dismissal.
119
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
120
+ await nextTick();
121
+ await new Promise((r) => setTimeout(r, 60));
122
+ expect(document.body.querySelector(".inner-toggle")).toBeNull();
123
+ });
124
+ });
@@ -568,6 +568,30 @@ export default defineComponent({
568
568
  if (panelHostRef.value?.contains(target)) return;
569
569
  if (panelRef.value?.contains(target)) return;
570
570
  if (props.anchorRef?.contains(target)) return;
571
+ // A click inside another popout LAYER stacked ABOVE this one — a
572
+ // popover opened from inside this surface (the filter panel's
573
+ // popup selects, its add-condition field menu) teleports to
574
+ // <body>, so DOM containment cannot see the nesting. Every host
575
+ // marks its layer (data-hk-popout-layer) with its popup-manager
576
+ // id; a HIGHER live layer governs its own clicks, never a
577
+ // backdrop dismissal for THIS popover (2026-10-14: picking a
578
+ // select option inside a filter panel used to slam the whole
579
+ // panel shut and, in engines that detach mid-dispatch, swallow
580
+ // the pick itself). Layers NOT above — the HOST panel a nested
581
+ // surface rides in, or a sibling below — are "the page" for this
582
+ // popover: clicks there dismiss it (R2 2026-10-14: matching any
583
+ // layer left the chooser and a select popout open at once and
584
+ // made an open chooser ignore every in-panel click).
585
+ if (target instanceof Element) {
586
+ const layerEl = target.closest("[data-hk-popout-layer]");
587
+ if (layerEl) {
588
+ const layerId = layerEl.getAttribute("data-hk-popout-id");
589
+ const mine = handle.value;
590
+ if (mine && layerId === mine.id) return;
591
+ const above = mine && layerId ? manager.registry.value.get(layerId) : null;
592
+ if (above && above.zIndex > mine!.zIndex) return;
593
+ }
594
+ }
571
595
  close();
572
596
  }
573
597
 
@@ -660,7 +684,18 @@ export default defineComponent({
660
684
  style={{ zIndex: backdropZ.value }}
661
685
  />
662
686
  )}
663
- <div ref={panelHostRef} style={panelStyle.value}>
687
+ {/* data-hk-popout-layer marks this host as a live popup layer so
688
+ ANCESTOR popovers' outside-click shields can tell a click
689
+ inside a nested layer (never a backdrop dismissal — see
690
+ onDocumentClick) from a true outside click. The -id pairing
691
+ with the popup-manager registration lets those shields tell
692
+ ABOVE layers (ignore) from the host/siblings (dismiss). */}
693
+ <div
694
+ ref={panelHostRef}
695
+ data-hk-popout-layer=""
696
+ data-hk-popout-id={handle.value?.id}
697
+ style={panelStyle.value}
698
+ >
664
699
  <div
665
700
  ref={panelRef}
666
701
  class={[
@@ -142,6 +142,16 @@ export default defineComponent({
142
142
  normalizedOptions.value.length;
143
143
  }
144
144
  } else if (e.key === "Escape") {
145
+ if (isOpen.value) {
146
+ // Escape closes ONE layer — the popout only, not the host
147
+ // surface it rides in (a select inside a filter panel row must
148
+ // not take the whole panel with it; same contract as the
149
+ // filter panel's own add-condition chooser). A closed popout
150
+ // keeps bubbling: that Escape was meant for whatever hosts
151
+ // this control.
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ }
145
155
  isOpen.value = false;
146
156
  }
147
157
  }
@@ -79,6 +79,7 @@
79
79
  .hk-menu-action-item,
80
80
  .hk-select-option,
81
81
  .hk-popup-select-item,
82
+ .hk-filter-add-item,
82
83
  .s-theme-item-row,
83
84
  .s-theme-item-btn,
84
85
  .s-popup-menu-item,
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "تطبيق",
202
202
  "hikari::filterPanel.all": "الكل",
203
203
  "hikari::filterPanel.addCondition": "إضافة شرط",
204
+ "hikari::filterPanel.chooseField": "اختر حقلًا لإضافته",
204
205
  "hikari::filterPanel.removeCondition": "إزالة الشرط",
205
206
  "hikari::filterBar.reset": "إعادة تعيين",
206
207
  "hikari::filterPanel.op.eq": "يساوي",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "Anwenden",
202
202
  "hikari::filterPanel.all": "Alle",
203
203
  "hikari::filterPanel.addCondition": "Bedingung hinzufügen",
204
+ "hikari::filterPanel.chooseField": "Feld zum Hinzufügen wählen",
204
205
  "hikari::filterPanel.removeCondition": "Bedingung entfernen",
205
206
  "hikari::filterBar.reset": "Zurücksetzen",
206
207
  "hikari::filterPanel.op.eq": "gleich",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "Apply",
202
202
  "hikari::filterPanel.all": "All",
203
203
  "hikari::filterPanel.addCondition": "Add condition",
204
+ "hikari::filterPanel.chooseField": "Choose a field to add",
204
205
  "hikari::filterPanel.removeCondition": "Remove condition",
205
206
  "hikari::filterBar.reset": "Reset",
206
207
  "hikari::filterPanel.op.eq": "equals",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "Aplicar",
202
202
  "hikari::filterPanel.all": "Todos",
203
203
  "hikari::filterPanel.addCondition": "Añadir condición",
204
+ "hikari::filterPanel.chooseField": "Elige un campo para añadir",
204
205
  "hikari::filterPanel.removeCondition": "Eliminar condición",
205
206
  "hikari::filterBar.reset": "Restablecer",
206
207
  "hikari::filterPanel.op.eq": "igual a",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "Appliquer",
202
202
  "hikari::filterPanel.all": "Tous",
203
203
  "hikari::filterPanel.addCondition": "Ajouter une condition",
204
+ "hikari::filterPanel.chooseField": "Choisir un champ à ajouter",
204
205
  "hikari::filterPanel.removeCondition": "Supprimer la condition",
205
206
  "hikari::filterBar.reset": "Réinitialiser",
206
207
  "hikari::filterPanel.op.eq": "égal à",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "適用",
202
202
  "hikari::filterPanel.all": "すべて",
203
203
  "hikari::filterPanel.addCondition": "条件を追加",
204
+ "hikari::filterPanel.chooseField": "追加するフィールドを選択",
204
205
  "hikari::filterPanel.removeCondition": "条件を削除",
205
206
  "hikari::filterBar.reset": "リセット",
206
207
  "hikari::filterPanel.op.eq": "等しい",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "적용",
202
202
  "hikari::filterPanel.all": "전체",
203
203
  "hikari::filterPanel.addCondition": "조건 추가",
204
+ "hikari::filterPanel.chooseField": "추가할 필드 선택",
204
205
  "hikari::filterPanel.removeCondition": "조건 삭제",
205
206
  "hikari::filterBar.reset": "초기화",
206
207
  "hikari::filterPanel.op.eq": "같음",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "Aplicar",
202
202
  "hikari::filterPanel.all": "Todos",
203
203
  "hikari::filterPanel.addCondition": "Adicionar condição",
204
+ "hikari::filterPanel.chooseField": "Escolha um campo para adicionar",
204
205
  "hikari::filterPanel.removeCondition": "Remover condição",
205
206
  "hikari::filterBar.reset": "Redefinir",
206
207
  "hikari::filterPanel.op.eq": "igual a",
@@ -201,6 +201,7 @@
201
201
  "hikari::filterBar.apply": "Применить",
202
202
  "hikari::filterPanel.all": "Все",
203
203
  "hikari::filterPanel.addCondition": "Добавить условие",
204
+ "hikari::filterPanel.chooseField": "Выберите поле для добавления",
204
205
  "hikari::filterPanel.removeCondition": "Удалить условие",
205
206
  "hikari::filterBar.reset": "Сбросить",
206
207
  "hikari::filterPanel.op.eq": "равно",
@@ -205,6 +205,7 @@
205
205
  "hikari::filterBar.apply": "应用",
206
206
  "hikari::filterPanel.all": "全部",
207
207
  "hikari::filterPanel.addCondition": "添加条件",
208
+ "hikari::filterPanel.chooseField": "选择要添加的字段",
208
209
  "hikari::filterPanel.removeCondition": "移除条件",
209
210
  "hikari::filterBar.reset": "重置",
210
211
  "hikari::filterPanel.op.eq": "等于",
@@ -205,6 +205,7 @@
205
205
  "hikari::filterBar.apply": "套用",
206
206
  "hikari::filterPanel.all": "全部",
207
207
  "hikari::filterPanel.addCondition": "新增條件",
208
+ "hikari::filterPanel.chooseField": "選擇要新增的欄位",
208
209
  "hikari::filterPanel.removeCondition": "移除條件",
209
210
  "hikari::filterBar.reset": "重設",
210
211
  "hikari::filterPanel.op.eq": "等於",
package/src/index.ts CHANGED
@@ -173,6 +173,7 @@ export { default as HLogo } from "./components/HkLogo";
173
173
  // Component types
174
174
  export { type BadgeVariant } from "./components/HkBadge";
175
175
  export { type BoardNodeInput, type BoardEdgeInput } from "./components/HkBoard";
176
+ export { isSafeHref, safeHref } from "./utils/safeHref";
176
177
  export { type BoardAnchorMode, type BoardEdgeStyle, type BoardPoint } from "./utils/boardEdges";
177
178
  export { type BoardCamera, type BoardRect, type BoardViewport } from "./utils/boardCamera";
178
179
  export { type ModalAction } from "./components/HkModal";
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isSafeHref, safeHref } from "./safeHref";
3
+
4
+ describe("isSafeHref (audit hikari-R1-4)", () => {
5
+ it("accepts the allowlisted schemes and relative forms", () => {
6
+ for (const href of [
7
+ "https://example.com/x",
8
+ "http://example.com/",
9
+ "mailto:ops@example.com",
10
+ "tel:+810000000000",
11
+ "/dashboard",
12
+ "#section",
13
+ "./relative",
14
+ "../parent",
15
+ ]) {
16
+ expect(isSafeHref(href)).toBe(true);
17
+ }
18
+ });
19
+
20
+ it("rejects executable and exotic schemes", () => {
21
+ for (const href of [
22
+ "javascript:alert(1)",
23
+ " jAvAsCrIpT:alert(1)", // case + leading whitespace
24
+ "vbscript:msgbox",
25
+ "data:text/html,<script>alert(1)</script>",
26
+ "file:///etc/passwd",
27
+ ]) {
28
+ expect(isSafeHref(href)).toBe(false);
29
+ }
30
+ });
31
+
32
+ it("rejects empty input", () => {
33
+ expect(isSafeHref("")).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("safeHref (audit hikari-R1-4)", () => {
38
+ it("passes safe values through unchanged", () => {
39
+ expect(safeHref("https://example.com/x")).toBe("https://example.com/x");
40
+ expect(safeHref("/dashboard")).toBe("/dashboard");
41
+ });
42
+
43
+ it("neutralizes dangerous schemes to an inert anchor", () => {
44
+ expect(safeHref("javascript:alert(1)")).toBe("#");
45
+ expect(safeHref(undefined)).toBe("#");
46
+ expect(safeHref(null)).toBe("#");
47
+ });
48
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Audit hikari-R1-4: URL scheme allowlist for component `href` props.
3
+ *
4
+ * The component library renders caller-supplied `href` values verbatim —
5
+ * a caller that routes user data into a nav item or breadcrumb gets a
6
+ * `javascript:` XSS for free. This helper enforces a scheme allowlist
7
+ * (http/https/mailto/tel/relative/fragment) and neutralizes anything
8
+ * else to an inert `"#"` so the anchor stays clickable but harmless.
9
+ */
10
+ const SAFE_HREF_PATTERN = /^(?:https?:|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
11
+ const DANGEROUS_SCHEME_PATTERN = /^\s*(?:javascript|vbscript|data):/i;
12
+
13
+ export function isSafeHref(href: string): boolean {
14
+ const trimmed = href.trim();
15
+ if (trimmed.length === 0) {
16
+ return false;
17
+ }
18
+ if (DANGEROUS_SCHEME_PATTERN.test(trimmed)) {
19
+ return false;
20
+ }
21
+ return SAFE_HREF_PATTERN.test(trimmed);
22
+ }
23
+
24
+ /**
25
+ * Return `href` when it passes the scheme allowlist, otherwise `"#"`.
26
+ * Relative paths, fragments, and absolute http(s)/mailto/tel URLs pass
27
+ * through unchanged.
28
+ */
29
+ export function safeHref(href: string | undefined | null): string {
30
+ if (!href) {
31
+ return "#";
32
+ }
33
+ return isSafeHref(href) ? href : "#";
34
+ }