@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.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAdminShell.tsx +1 -1
  3. package/src/components/HkBlockingToast.scss +1 -1
  4. package/src/components/HkBlockingToast.tsx +2 -1
  5. package/src/components/HkBreadcrumb.scss +1 -1
  6. package/src/components/HkBreadcrumb.tsx +2 -1
  7. package/src/components/HkCheckbox.scss +3 -3
  8. package/src/components/HkDraggableGrid.scss +1 -1
  9. package/src/components/HkDraggableList.scss +1 -1
  10. package/src/components/HkDrawer.tsx +2 -1
  11. package/src/components/HkFab.scss +1 -1
  12. package/src/components/HkFab.test.tsx +5 -5
  13. package/src/components/HkFilterPanel.scss +65 -4
  14. package/src/components/HkFilterPanel.styles.test.ts +11 -0
  15. package/src/components/HkFilterPanel.test.tsx +74 -3
  16. package/src/components/HkFilterPanel.tsx +138 -12
  17. package/src/components/HkFilterTrigger.test.tsx +248 -0
  18. package/src/components/HkFloatingLayer.scss +1 -1
  19. package/src/components/HkFloatingLayer.styles.test.ts +1 -1
  20. package/src/components/HkFloatingLayer.test.tsx +1 -1
  21. package/src/components/HkFloatingLayer.tsx +2 -1
  22. package/src/components/HkLoadingVeil.scss +1 -1
  23. package/src/components/HkLocalePicker.tsx +1 -1
  24. package/src/components/HkLogo.tsx +2 -1
  25. package/src/components/HkMarkdownRenderer.tsx +6 -2
  26. package/src/components/HkModal.tsx +2 -1
  27. package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +3 -4
  28. package/src/components/HkModalBreadcrumb.scss +1 -1
  29. package/src/components/HkModalBreadcrumb.tsx +2 -1
  30. package/src/components/HkNavItem.tsx +3 -2
  31. package/src/components/HkPersistentToast.tsx +2 -1
  32. package/src/components/HkPopover.shield.test.tsx +124 -0
  33. package/src/components/HkPopover.tsx +38 -2
  34. package/src/components/HkPopupSelect.tsx +10 -0
  35. package/src/components/HkScrollPin.scrollcontract.test.ts +1 -1
  36. package/src/components/HkScrollPin.scss +1 -1
  37. package/src/components/HkSelectPanel.test.tsx +2 -2
  38. package/src/components/HkSelectPanel.tsx +3 -2
  39. package/src/components/HkSidebar.scss +1 -1
  40. package/src/components/HkStatusBar.test.tsx +26 -0
  41. package/src/components/HkStatusBar.tsx +8 -0
  42. package/src/components/HkTable.drag.test.tsx +1 -1
  43. package/src/components/HkTable.scss +1 -1
  44. package/src/components/HkTitleBar.scss +1 -1
  45. package/src/components/HkToast.scss +1 -1
  46. package/src/components/HkToast.tsx +2 -1
  47. package/src/components/HkTooltip.scss +1 -1
  48. package/src/components/HkTooltip.test.tsx +3 -3
  49. package/src/components/HkTooltip.tsx +2 -1
  50. package/src/components/__tests__/hkMarkdownLang.test.ts +29 -0
  51. package/src/components/_menu-item.scss +1 -0
  52. package/src/errorReporting/HkErrorReportingOverlay.scss +1 -1
  53. package/src/i18n/locales/ar/components.json +1 -0
  54. package/src/i18n/locales/de/components.json +1 -0
  55. package/src/i18n/locales/en/components.json +1 -0
  56. package/src/i18n/locales/es/components.json +1 -0
  57. package/src/i18n/locales/fr/components.json +1 -0
  58. package/src/i18n/locales/ja/components.json +1 -0
  59. package/src/i18n/locales/ko/components.json +1 -0
  60. package/src/i18n/locales/pt/components.json +1 -0
  61. package/src/i18n/locales/ru/components.json +1 -0
  62. package/src/i18n/locales/zh-Hans/components.json +1 -0
  63. package/src/i18n/locales/zh-Hant/components.json +1 -0
  64. package/src/index.ts +2 -0
  65. package/src/overlay/overlayRoot.scss +35 -0
  66. package/src/overlay/overlayRoot.ts +74 -0
  67. package/src/runtime/usePopupManager.test.ts +12 -8
  68. package/src/runtime/usePopupManager.ts +15 -5
  69. package/src/scale.scss +23 -4
  70. package/src/styles/admin-tokens.scss +2 -2
  71. package/src/styles/index.scss +1 -0
  72. package/src/styles/theme/scale.scss +23 -4
  73. package/src/theme/theme.scss +0 -3
  74. package/src/utils/safeHref.test.ts +48 -0
  75. package/src/utils/safeHref.ts +34 -0
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.163",
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",
@@ -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-footer, 110)" }}>
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, 4000);
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="body">
179
+ <Teleport to={ensureOverlayRoot()}>
179
180
  <div class="hk-blocking-toast-container">
180
181
  <TransitionGroup
181
182
  ref={hostRef}
@@ -212,7 +212,7 @@
212
212
  top: calc(100% + 4px);
213
213
  left: 0;
214
214
  min-width: 200px;
215
- z-index: 100;
215
+ z-index: var(--hk-z-header);
216
216
 
217
217
  // Appearance
218
218
  border-radius: vars.$hikari-radius-fui-md;
@@ -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(--hi-z-above, 1);
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(--hi-z-above, 1);
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(--hi-z-above, 1);
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, 950);
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, 950);
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="body">
280
+ <Teleport to={ensureOverlayRoot()}>
280
281
  {props.overlay ? (
281
282
  <div
282
283
  ref={overlayEl}
@@ -4,7 +4,7 @@
4
4
  // safe-area-aware base per instance (set from offsetX / offsetY).
5
5
  .hk-fab {
6
6
  position: absolute;
7
- z-index: var(--hk-fab-z, 40);
7
+ z-index: var(--hk-z-fab);
8
8
  display: flex;
9
9
  align-items: center;
10
10
  gap: var(--space-8, 0.5rem);
@@ -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.body.querySelector<HTMLElement>(".hk-fab");
49
+ document.querySelector<HTMLElement>(".hk-fab");
50
50
  const button =
51
51
  container.querySelector<HTMLButtonElement>(".hk-fab-button") ??
52
- document.body.querySelector<HTMLButtonElement>(".hk-fab-button");
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.body.querySelector(".hk-floating-layer")).toBeNull();
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.body.querySelector<HTMLElement>(".hk-floating-layer");
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
- expect(z).toBeLessThan(POPUP_Z_BANDS.toast);
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
- * 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>