@celestia-island/hikari 0.55.161 → 0.55.163
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/HkAdminShell.tsx +1 -1
- package/src/components/HkBlockingToast.scss +1 -1
- package/src/components/HkBlockingToast.tsx +2 -1
- package/src/components/HkBreadcrumb.scss +1 -1
- package/src/components/HkBreadcrumb.tsx +2 -1
- package/src/components/HkCheckbox.scss +3 -3
- package/src/components/HkDraggableGrid.scss +1 -1
- package/src/components/HkDraggableList.scss +1 -1
- package/src/components/HkDrawer.tsx +2 -1
- package/src/components/HkFab.scss +1 -1
- package/src/components/HkFab.test.tsx +5 -5
- 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/HkFloatingLayer.scss +1 -1
- package/src/components/HkFloatingLayer.styles.test.ts +1 -1
- package/src/components/HkFloatingLayer.test.tsx +1 -1
- package/src/components/HkFloatingLayer.tsx +2 -1
- package/src/components/HkLoadingVeil.scss +1 -1
- package/src/components/HkLocalePicker.tsx +1 -1
- package/src/components/HkLogo.tsx +2 -1
- package/src/components/HkMarkdownRenderer.tsx +6 -2
- package/src/components/HkModal.tsx +2 -1
- package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +3 -4
- package/src/components/HkModalBreadcrumb.scss +1 -1
- package/src/components/HkModalBreadcrumb.tsx +2 -1
- package/src/components/HkNavItem.tsx +3 -2
- package/src/components/HkPersistentToast.tsx +2 -1
- package/src/components/HkPopover.shield.test.tsx +124 -0
- package/src/components/HkPopover.tsx +38 -2
- package/src/components/HkPopupSelect.tsx +10 -0
- package/src/components/HkScrollPin.scrollcontract.test.ts +1 -1
- package/src/components/HkScrollPin.scss +1 -1
- package/src/components/HkSelectPanel.test.tsx +2 -2
- package/src/components/HkSelectPanel.tsx +3 -2
- package/src/components/HkSidebar.scss +1 -1
- package/src/components/HkStatusBar.test.tsx +26 -0
- package/src/components/HkStatusBar.tsx +8 -0
- package/src/components/HkTable.drag.test.tsx +1 -1
- package/src/components/HkTable.scss +1 -1
- package/src/components/HkTitleBar.scss +1 -1
- package/src/components/HkToast.scss +1 -1
- package/src/components/HkToast.tsx +2 -1
- package/src/components/HkTooltip.scss +1 -1
- package/src/components/HkTooltip.test.tsx +3 -3
- package/src/components/HkTooltip.tsx +2 -1
- package/src/components/__tests__/hkMarkdownLang.test.ts +29 -0
- package/src/components/_menu-item.scss +1 -0
- package/src/errorReporting/HkErrorReportingOverlay.scss +1 -1
- 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 +2 -0
- package/src/overlay/overlayRoot.scss +35 -0
- package/src/overlay/overlayRoot.ts +74 -0
- package/src/runtime/usePopupManager.test.ts +12 -8
- package/src/runtime/usePopupManager.ts +15 -5
- package/src/scale.scss +23 -4
- package/src/styles/admin-tokens.scss +2 -2
- package/src/styles/index.scss +1 -0
- package/src/styles/theme/scale.scss +23 -4
- package/src/theme/theme.scss +0 -3
- package/src/utils/safeHref.test.ts +48 -0
- package/src/utils/safeHref.ts +34 -0
package/package.json
CHANGED
|
@@ -268,7 +268,7 @@ export const HkAdminShell = defineComponent({
|
|
|
268
268
|
|
|
269
269
|
{slots.footer && (
|
|
270
270
|
<footer class="s-status-bar" style={{ position: "fixed", bottom: 0, left: 0, right: 0, // Footer band (was a bare 40 — the pre-band chrome value).
|
|
271
|
-
zIndex: "var(--z-
|
|
271
|
+
zIndex: "var(--hk-z-toolbar)" }}>
|
|
272
272
|
{slots.footer()}
|
|
273
273
|
</footer>
|
|
274
274
|
)}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
/* Same viewport gutter the anchored popups clamp to (16px desktop /
|
|
13
13
|
* 8px mobile) — the toast stack is a floating layer too. */
|
|
14
14
|
inset-inline-end: var(--viewport-gutter, 16px);
|
|
15
|
-
z-index: var(--hk-z-toast,
|
|
15
|
+
z-index: var(--hk-overlay-z-blocking-toast, 50);
|
|
16
16
|
max-width: 24rem;
|
|
17
17
|
pointer-events: none;
|
|
18
18
|
}
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "../runtime/useBlockingToast";
|
|
46
46
|
import "./HkBlockingToast.scss";
|
|
47
47
|
import HButton from "./HkButton";
|
|
48
|
+
import { ensureOverlayRoot } from "../overlay/overlayRoot";
|
|
48
49
|
|
|
49
50
|
const ICON_SIZE = 18;
|
|
50
51
|
|
|
@@ -175,7 +176,7 @@ export default defineComponent({
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
return () => (
|
|
178
|
-
<Teleport to=
|
|
179
|
+
<Teleport to={ensureOverlayRoot()}>
|
|
179
180
|
<div class="hk-blocking-toast-container">
|
|
180
181
|
<TransitionGroup
|
|
181
182
|
ref={hostRef}
|
|
@@ -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
|
)}
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
|
|
104
104
|
.hk-checkbox-icon {
|
|
105
105
|
position: relative;
|
|
106
|
-
z-index: var(--
|
|
106
|
+
z-index: var(--hk-z-above);
|
|
107
107
|
color: rgb(var(--color-on-solid-icon, 255 255 255));
|
|
108
108
|
width: 14px;
|
|
109
109
|
height: 14px;
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
height: 6px;
|
|
125
125
|
border-radius: 50%;
|
|
126
126
|
background: rgb(var(--color-on-solid-icon, 255 255 255));
|
|
127
|
-
z-index: var(--
|
|
127
|
+
z-index: var(--hk-z-above);
|
|
128
128
|
|
|
129
129
|
.hk-checkbox-sm & {
|
|
130
130
|
width: 5px;
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
height: 2px;
|
|
143
143
|
border-radius: 1px;
|
|
144
144
|
background: rgb(var(--color-on-solid-icon, 255 255 255));
|
|
145
|
-
z-index: var(--
|
|
145
|
+
z-index: var(--hk-z-above);
|
|
146
146
|
|
|
147
147
|
.hk-checkbox-sm & {
|
|
148
148
|
width: 6px;
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
/* Drag ghost: above page chrome (--z-sidebar 900) but below every
|
|
137
137
|
popup band (overlay 1000 < dropdown 2000 < tooltip 3000 < toast 4000)
|
|
138
138
|
so a drag never buries toasts or open panels. */
|
|
139
|
-
z-index: var(--hk-z-drag
|
|
139
|
+
z-index: var(--hk-z-drag);
|
|
140
140
|
will-change: transform;
|
|
141
141
|
|
|
142
142
|
> .hk-draggable-grid-cell {
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
/* Drag ghost: above page chrome (--z-sidebar 900) but below every
|
|
111
111
|
popup band (overlay 1000 < dropdown 2000 < tooltip 3000 < toast 4000)
|
|
112
112
|
so a drag never buries toasts or open panels. */
|
|
113
|
-
z-index: var(--hk-z-drag
|
|
113
|
+
z-index: var(--hk-z-drag);
|
|
114
114
|
will-change: transform;
|
|
115
115
|
|
|
116
116
|
> .hk-draggable-list-item {
|
|
@@ -23,6 +23,7 @@ import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
|
23
23
|
import HIconButton from "./HkIconButton";
|
|
24
24
|
import HIcon from "./HkIcon";
|
|
25
25
|
import "./window-close.scss";
|
|
26
|
+
import { ensureOverlayRoot } from "../overlay/overlayRoot";
|
|
26
27
|
|
|
27
28
|
type DrawerSide = "left" | "right" | "top" | "bottom";
|
|
28
29
|
|
|
@@ -276,7 +277,7 @@ export default defineComponent({
|
|
|
276
277
|
if (!machine.mounted.value) return null;
|
|
277
278
|
const panelPrefix = `hk-drawer-${props.side}`;
|
|
278
279
|
return (
|
|
279
|
-
<Teleport to=
|
|
280
|
+
<Teleport to={ensureOverlayRoot()}>
|
|
280
281
|
{props.overlay ? (
|
|
281
282
|
<div
|
|
282
283
|
ref={overlayEl}
|
|
@@ -46,10 +46,10 @@ function mountFab(props: Record<string, unknown> = {}): Harness {
|
|
|
46
46
|
// fall back to the document scope so both modes resolve.
|
|
47
47
|
const root =
|
|
48
48
|
container.querySelector<HTMLElement>(".hk-fab") ??
|
|
49
|
-
document.
|
|
49
|
+
document.querySelector<HTMLElement>(".hk-fab");
|
|
50
50
|
const button =
|
|
51
51
|
container.querySelector<HTMLButtonElement>(".hk-fab-button") ??
|
|
52
|
-
document.
|
|
52
|
+
document.querySelector<HTMLButtonElement>(".hk-fab-button");
|
|
53
53
|
if (!root || !button) throw new Error("HkFab did not render its shell");
|
|
54
54
|
harness.root = root;
|
|
55
55
|
harness.button = button;
|
|
@@ -226,13 +226,13 @@ describe("HkFab speed dial", () => {
|
|
|
226
226
|
describe("HkFab layer", () => {
|
|
227
227
|
it("host (default) keeps the fab anchored in the host tree", () => {
|
|
228
228
|
const m = mountFab({ ariaLabel: "Host", positioning: "fixed" });
|
|
229
|
-
expect(document.
|
|
229
|
+
expect(document.querySelector(".hk-floating-layer")).toBeNull();
|
|
230
230
|
expect(m.root.getAttribute("data-positioning")).toBe("fixed");
|
|
231
231
|
});
|
|
232
232
|
|
|
233
233
|
it("top wraps the fab in an HkFloatingLayer above the window band", () => {
|
|
234
234
|
const m = mountFab({ ariaLabel: "Float", layer: "top", corner: "bottom-right" });
|
|
235
|
-
const layer = document.
|
|
235
|
+
const layer = document.querySelector<HTMLElement>(".hk-floating-layer");
|
|
236
236
|
expect(layer).not.toBeNull();
|
|
237
237
|
expect(layer!.contains(m.root)).toBe(true);
|
|
238
238
|
// Geometry is owned by the layer alone once floated.
|
|
@@ -244,6 +244,6 @@ describe("HkFab layer", () => {
|
|
|
244
244
|
expect(layer!.getAttribute("data-corner")).toBe("bottom-right");
|
|
245
245
|
const z = Number(layer!.style.getPropertyValue("--hk-float-z"));
|
|
246
246
|
expect(z).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
|
|
247
|
-
|
|
247
|
+
// Tooltip is the topmost band (2026-10-15): no upper bound.
|
|
248
248
|
});
|
|
249
249
|
});
|
|
@@ -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>
|