@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 +1 -1
- package/src/components/HkBreadcrumb.tsx +2 -1
- package/src/components/HkFilterPanel.scss +65 -4
- package/src/components/HkFilterPanel.styles.test.ts +11 -0
- package/src/components/HkFilterPanel.test.tsx +74 -3
- package/src/components/HkFilterPanel.tsx +138 -12
- package/src/components/HkFilterTrigger.test.tsx +248 -0
- package/src/components/HkLogo.tsx +2 -1
- package/src/components/HkNavItem.tsx +3 -2
- package/src/components/HkPopover.shield.test.tsx +124 -0
- package/src/components/HkPopover.tsx +36 -1
- package/src/components/HkPopupSelect.tsx +10 -0
- package/src/components/_menu-item.scss +1 -0
- package/src/i18n/locales/ar/components.json +1 -0
- package/src/i18n/locales/de/components.json +1 -0
- package/src/i18n/locales/en/components.json +1 -0
- package/src/i18n/locales/es/components.json +1 -0
- package/src/i18n/locales/fr/components.json +1 -0
- package/src/i18n/locales/ja/components.json +1 -0
- package/src/i18n/locales/ko/components.json +1 -0
- package/src/i18n/locales/pt/components.json +1 -0
- package/src/i18n/locales/ru/components.json +1 -0
- package/src/i18n/locales/zh-Hans/components.json +1 -0
- package/src/i18n/locales/zh-Hant/components.json +1 -0
- package/src/index.ts +1 -0
- package/src/utils/safeHref.test.ts +48 -0
- package/src/utils/safeHref.ts +34 -0
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* panel
|
|
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("
|
|
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
|
-
//
|
|
74
|
-
//
|
|
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).
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|