@celestia-island/hikari 0.55.130 → 0.55.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.130",
3
+ "version": "0.55.132",
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",
@@ -0,0 +1,52 @@
1
+ // Unit tests for the context-menu quadrant pick: the pointer's nearest
2
+ // halves split the POPUP viewport (the window minus the app-chrome band
3
+ // the host configured), so a menu opened over a reserved strip grows
4
+ // into the usable space.
5
+ //
6
+ // AI disclosure: drafted by GLM via the ZCode agent, directed and
7
+ // reviewed by langyo — SySL-1.0 §2.3.
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+
10
+ import { configurePopupInsets } from "../runtime/popupBounds";
11
+ import { quadrantPlacement } from "./HkContextMenuProvider";
12
+
13
+ const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
14
+
15
+ afterEach(() => {
16
+ configurePopupInsets(null);
17
+ window.innerWidth = PREV_INNER.width;
18
+ window.innerHeight = PREV_INNER.height;
19
+ });
20
+
21
+ describe("quadrantPlacement", () => {
22
+ it("splits the raw window in half when no band is configured", () => {
23
+ window.innerWidth = 800;
24
+ window.innerHeight = 600;
25
+ expect(quadrantPlacement(100, 100)).toBe("bottom-start");
26
+ expect(quadrantPlacement(700, 100)).toBe("bottom-end");
27
+ expect(quadrantPlacement(100, 500)).toBe("top-start");
28
+ expect(quadrantPlacement(700, 500)).toBe("top-end");
29
+ // On the midlines the lower/right halves win (<=), unchanged.
30
+ expect(quadrantPlacement(400, 300)).toBe("bottom-start");
31
+ });
32
+
33
+ it("splits the popup viewport when an app-chrome band is configured", () => {
34
+ window.innerWidth = 800;
35
+ window.innerHeight = 600;
36
+ // A 400px title-bar stack: popups live in y 400..600, so the frame
37
+ // midline is y=500 — a pointer at y=550 (raw-window TOP half) now
38
+ // grows UP, staying inside the frame's lower half.
39
+ configurePopupInsets({ top: 400 });
40
+ expect(quadrantPlacement(500, 550)).toBe("top-end");
41
+ expect(quadrantPlacement(100, 450)).toBe("bottom-start");
42
+ expect(quadrantPlacement(700, 450)).toBe("bottom-end");
43
+ });
44
+
45
+ it("honors a side band on the horizontal split", () => {
46
+ window.innerWidth = 800;
47
+ window.innerHeight = 600;
48
+ configurePopupInsets({ left: 600 }); // frame x 600..800, midline 700
49
+ expect(quadrantPlacement(650, 100)).toBe("bottom-start");
50
+ expect(quadrantPlacement(750, 100)).toBe("bottom-end");
51
+ });
52
+ });
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { computed, defineComponent, provide, ref, shallowRef } from "vue";
26
26
 
27
+ import { popupViewportRect } from "../runtime/popupBounds";
27
28
  import HkMenu from "./HkMenu";
28
29
  import {
29
30
  CONTEXT_MENU_KEY,
@@ -33,10 +34,15 @@ import {
33
34
 
34
35
  /** Pick the HkMenu placement that grows away from the pointer's nearest
35
36
  * edges: `bottom-*` when there is more room below, `*-start` when there
36
- * is more room to the right. */
37
- function quadrantPlacement(x: number, y: number): "bottom-start" | "bottom-end" | "top-start" | "top-end" {
38
- const below = y <= (typeof window !== "undefined" ? window.innerHeight : 0) / 2;
39
- const right = x <= (typeof window !== "undefined" ? window.innerWidth : 0) / 2;
37
+ * is more room to the right. The halves split the POPUP viewport (the
38
+ * window minus the app-chrome band the host configured — popupBounds),
39
+ * so a menu opened over a desktop shell's reserved strip grows into the
40
+ * usable space like every other popup. */
41
+ export function quadrantPlacement(x: number, y: number): "bottom-start" | "bottom-end" | "top-start" | "top-end" {
42
+ if (typeof window === "undefined") return "bottom-start";
43
+ const frame = popupViewportRect();
44
+ const below = y <= frame.y + frame.height / 2;
45
+ const right = x <= frame.x + frame.width / 2;
40
46
  if (below && right) return "bottom-start";
41
47
  if (below) return "bottom-end";
42
48
  return right ? "top-start" : "top-end";
@@ -1,6 +1,7 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref, type ComponentPublicInstance } from "vue";
3
3
 
4
+ import { configurePopupInsets } from "../runtime/popupBounds";
4
5
  import HkMenu, { type HkMenuItem } from "./HkMenu";
5
6
 
6
7
  /** Regional-indicator flag pair for an ISO 3166-1 alpha-2 code, built
@@ -186,6 +187,7 @@ afterEach(async () => {
186
187
  .querySelectorAll(".hk-select-popout, .hk-select-sheet-panel, .hk-select-sheet-scrim")
187
188
  .forEach((el) => el.remove());
188
189
  while (containers.length) containers.pop()?.remove();
190
+ configurePopupInsets(null);
189
191
  window.innerWidth = 1024;
190
192
  });
191
193
 
@@ -1081,3 +1083,45 @@ describe("HkMenu close linger (leave-transition window)", () => {
1081
1083
  expect(popouts().length).toBe(1);
1082
1084
  });
1083
1085
  });
1086
+
1087
+ describe("HkMenu cascade against an app-chrome band", () => {
1088
+ it("opens a right-start cascade on the side the band leaves room for", async () => {
1089
+ // An 850px right-docked band on a 1200px window leaves the popup
1090
+ // frame 0..350. The anchor's right edge sits at 130: the RAW window
1091
+ // would host the 224px cascade beside it (130 + 6 + 224 = 360 ≤
1092
+ // 1184), but the frame's gutter line is 350 - 16 = 334 — so the
1093
+ // cascade flips to the anchor's left, pinned at the frame's leading
1094
+ // gutter instead of the window's.
1095
+ const rect = (x: number, y: number, w: number, h: number): DOMRect =>
1096
+ ({ x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h, toJSON: () => ({}) }) as DOMRect;
1097
+
1098
+ window.innerWidth = 1200;
1099
+ const openRef = ref(false);
1100
+ const first = mountMenu(openRef, items, {}, { props: { placement: "right-start" } });
1101
+ vi.spyOn(first.anchor, "getBoundingClientRect").mockReturnValue(rect(50, 100, 80, 40));
1102
+ openRef.value = true;
1103
+ await settle();
1104
+ expect(popoutHosts()[0].style.left).toBe("136px"); // r.right + gap
1105
+ first.unmount();
1106
+
1107
+ configurePopupInsets({ right: 850 });
1108
+ const openRef2 = ref(false);
1109
+ const second = mountMenu(openRef2, items, {}, { props: { placement: "right-start" } });
1110
+ vi.spyOn(second.anchor, "getBoundingClientRect").mockReturnValue(rect(50, 100, 80, 40));
1111
+ openRef2.value = true;
1112
+ await settle();
1113
+ expect(popoutHosts()[0]?.style.left).toBe("16px"); // frame.x + gutter
1114
+ second.unmount();
1115
+
1116
+ // A LEFT band pins the flipped cascade at the frame's leading edge —
1117
+ // max(frame.x + pad, …), not max(pad, …): a swapped origin would land
1118
+ // this at 820 (16 would fail the frame's own leading gutter).
1119
+ configurePopupInsets({ left: 1000 });
1120
+ const openRef3 = ref(false);
1121
+ const third = mountMenu(openRef3, items, {}, { props: { placement: "right-start" } });
1122
+ vi.spyOn(third.anchor, "getBoundingClientRect").mockReturnValue(rect(1050, 100, 80, 40));
1123
+ openRef3.value = true;
1124
+ await settle();
1125
+ expect(popoutHosts()[0]?.style.left).toBe("1016px"); // frame.x + gutter
1126
+ });
1127
+ });
@@ -14,6 +14,7 @@ import { Check, ChevronRight } from "lucide-vue-next";
14
14
 
15
15
  import { useBreakpoint } from "../runtime/useBreakpoint";
16
16
  import { ancestorZoom } from "../runtime/cssZoom";
17
+ import { popupViewportRect } from "../runtime/popupBounds";
17
18
  import { viewportGutterPx } from "../runtime/viewportGutter";
18
19
  import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
19
20
  import "./HkMenu.scss";
@@ -426,8 +427,9 @@ export default defineComponent({
426
427
  const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
427
428
  // Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
428
429
  const pad = viewportGutterPx();
429
- const openRight = r.right + cascadeW <= window.innerWidth - pad;
430
- const left = openRight ? r.right : Math.max(pad, r.left - cascadeW);
430
+ const frame = popupViewportRect();
431
+ const openRight = r.right + cascadeW <= frame.x + frame.width - pad;
432
+ const left = openRight ? r.right : Math.max(frame.x + pad, r.left - cascadeW);
431
433
  return pointRect(left, r.top);
432
434
  },
433
435
  contains: (node) => !!rowRefs.value[id]?.contains(node),
@@ -451,11 +453,12 @@ export default defineComponent({
451
453
  const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
452
454
  // Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
453
455
  const pad = viewportGutterPx();
454
- const openRight = r.right + gap + cascadeW <= window.innerWidth - pad;
456
+ const frame = popupViewportRect();
457
+ const openRight = r.right + gap + cascadeW <= frame.x + frame.width - pad;
455
458
  const left =
456
459
  side === "right" && openRight
457
460
  ? r.right + gap
458
- : Math.max(pad, r.left - cascadeW - gap);
461
+ : Math.max(frame.x + pad, r.left - cascadeW - gap);
459
462
  return pointRect(left, r.top);
460
463
  },
461
464
  contains: (node) => !!props.anchorRef?.contains(node),
@@ -11,6 +11,7 @@
11
11
  import { afterEach, describe, expect, it, vi } from "vitest";
12
12
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
13
13
 
14
+ import { configurePopupInsets } from "../runtime/popupBounds";
14
15
  import HkPopover from "./HkPopover";
15
16
 
16
17
  const mounts: ReturnType<typeof createApp>[] = [];
@@ -64,7 +65,7 @@ async function flushFrames() {
64
65
  }
65
66
  }
66
67
 
67
- function mountPopover(anchor: HTMLElement, placement: "bottom" | "top-start" = "bottom") {
68
+ function mountPopover(anchor: HTMLElement, placement: "bottom" | "top-start" | "bottom-start" = "bottom") {
68
69
  const container = document.createElement("div");
69
70
  document.body.appendChild(container);
70
71
  containers.push(container);
@@ -152,3 +153,72 @@ describe("HkPopover anchoring under root CSS zoom", () => {
152
153
  expect(parseFloat(host.style.left)).toBeCloseTo(20 / 3, 2);
153
154
  });
154
155
  });
156
+
157
+ describe("HkPopover anchoring against an app-chrome band", () => {
158
+ const PREV_HEIGHT = window.innerHeight;
159
+
160
+ afterEach(() => {
161
+ configurePopupInsets(null);
162
+ window.innerHeight = PREV_HEIGHT;
163
+ });
164
+
165
+ it("clamps the panel inside the band's frame, not the raw window", async () => {
166
+ // The band reserves the BOTTOM 700px of the window — the popup frame
167
+ // is the top 100px strip. The anchor sits inside the band's territory
168
+ // (y=700): both sides are starved inside the frame, the flip picks
169
+ // top (more room), and the main-axis clamp pins the panel bottom at
170
+ // the FRAME's lower gutter (y=84) instead of the window's.
171
+ window.innerWidth = 1200;
172
+ window.innerHeight = 800;
173
+ patchRootZoom("1");
174
+
175
+ // Control, no band: the same anchor clamps against the WINDOW bottom
176
+ // edge — panel bottom 104px above it.
177
+ const anchor = document.createElement("button");
178
+ patchRects(anchor, rect(100, 700, 80, 32), rect(0, 0, 200, 60));
179
+ const open = mountPopover(anchor, "bottom");
180
+ open.value = true;
181
+ await flushFrames();
182
+ const panels = document.body.querySelectorAll<HTMLElement>(".hk-popover-panel");
183
+ const host = panels[panels.length - 1]!.parentElement!;
184
+ expect(host.style.bottom).toBe("104px");
185
+ open.value = false;
186
+ await flushFrames();
187
+
188
+ configurePopupInsets({ bottom: 700 });
189
+ const anchor2 = document.createElement("button");
190
+ patchRects(anchor2, rect(100, 700, 80, 32), rect(0, 0, 200, 60));
191
+ const open2 = mountPopover(anchor2, "bottom");
192
+ open2.value = true;
193
+ await flushFrames();
194
+ // happy-dom never fires transitionend, so the control panel may
195
+ // linger — the freshly positioned one is the LAST in the body.
196
+ const panels2 = document.body.querySelectorAll<HTMLElement>(".hk-popover-panel");
197
+ const host2 = panels2[panels2.length - 1]!.parentElement!;
198
+ // Panel bottom pinned at frame.y + frame.height - gutter = 100 - 16,
199
+ // written as the viewport-bottom distance 800 - 84 = 716.
200
+ expect(host2.style.bottom).toBe("716px");
201
+ // Cross axis stays HORIZONTAL: centered at 40, inside the frame's
202
+ // x-gutter as-is (an axis-swapped frame would pin it to 16).
203
+ expect(host2.style.left).toBe("40px");
204
+ });
205
+
206
+ it("keeps the no-band cross clamp on the horizontal axis of a landscape window", async () => {
207
+ // Regression pin for the cross-axis frame mapping: on a non-square
208
+ // window the cross clamp of a vertically-stacked panel must measure
209
+ // against the WIDTH (1440), not the height (900) — the swapped
210
+ // mapping clamped this start-aligned panel to 634px.
211
+ window.innerWidth = 1440;
212
+ window.innerHeight = 900;
213
+ patchRootZoom("1");
214
+ const anchor = document.createElement("button");
215
+ patchRects(anchor, rect(1300, 500, 40, 40), rect(0, 0, 250, 90));
216
+ const open = mountPopover(anchor, "bottom-start");
217
+ open.value = true;
218
+ await flushFrames();
219
+ const panels = document.body.querySelectorAll<HTMLElement>(".hk-popover-panel");
220
+ const host = panels[panels.length - 1]!.parentElement!;
221
+ expect(host.style.left).toBe("1174px"); // 1440 - 250 - 16
222
+ expect(host.style.top).toBe("544px"); // anchor bottom 540 + offset 4
223
+ });
224
+ });
@@ -15,6 +15,7 @@ import {
15
15
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
16
16
  import { useBreakpoint } from "../runtime/useBreakpoint";
17
17
  import { ancestorZoom } from "../runtime/cssZoom";
18
+ import { popupViewportRect } from "../runtime/popupBounds";
18
19
  import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
19
20
  import { useI18n } from "../i18n/context";
20
21
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
@@ -342,6 +343,12 @@ export default defineComponent({
342
343
  }
343
344
  const vw = window.innerWidth;
344
345
  const vh = window.innerHeight;
346
+ // The popup viewport: the window minus the app-chrome band the host
347
+ // configured (popupBounds) — full-window when none, in which case
348
+ // every space check and clamp below is the raw-window math. vw/vh
349
+ // stay for the CSS-coordinate writes (`bottom`/`right` are measured
350
+ // from the VIEWPORT edges regardless of the band).
351
+ const frame = popupViewportRect();
345
352
  // The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
346
353
  // desktop) — read per reposition so a viewport crossing the
347
354
  // breakpoint re-clamps with the right value on the next pass.
@@ -352,26 +359,26 @@ export default defineComponent({
352
359
 
353
360
  if (props.autoFlip) {
354
361
  if (side === "bottom") {
355
- const spaceBelow = vh - anchorRect.bottom;
356
- const spaceAbove = anchorRect.top;
362
+ const spaceBelow = frame.y + frame.height - anchorRect.bottom;
363
+ const spaceAbove = anchorRect.top - frame.y;
357
364
  if (spaceBelow < panelRect.height + pad && spaceAbove > spaceBelow) {
358
365
  side = "top";
359
366
  }
360
367
  } else if (side === "top") {
361
- const spaceAbove = anchorRect.top;
362
- const spaceBelow = vh - anchorRect.bottom;
368
+ const spaceAbove = anchorRect.top - frame.y;
369
+ const spaceBelow = frame.y + frame.height - anchorRect.bottom;
363
370
  if (spaceAbove < panelRect.height + pad && spaceBelow > spaceAbove) {
364
371
  side = "bottom";
365
372
  }
366
373
  } else if (side === "right") {
367
- const spaceRight = vw - anchorRect.right;
368
- const spaceLeft = anchorRect.left;
374
+ const spaceRight = frame.x + frame.width - anchorRect.right;
375
+ const spaceLeft = anchorRect.left - frame.x;
369
376
  if (spaceRight < panelRect.width + pad && spaceLeft > spaceRight) {
370
377
  side = "left";
371
378
  }
372
379
  } else if (side === "left") {
373
- const spaceLeft = anchorRect.left;
374
- const spaceRight = vw - anchorRect.right;
380
+ const spaceLeft = anchorRect.left - frame.x;
381
+ const spaceRight = frame.x + frame.width - anchorRect.right;
375
382
  if (spaceLeft < panelRect.width + pad && spaceRight > spaceLeft) {
376
383
  side = "right";
377
384
  }
@@ -387,7 +394,8 @@ export default defineComponent({
387
394
  const anchorStart = isVertical ? anchorRect.left : anchorRect.top;
388
395
  const anchorEnd = isVertical ? anchorRect.right : anchorRect.bottom;
389
396
  const anchorSize = isVertical ? anchorRect.width : anchorRect.height;
390
- const viewportSize = isVertical ? vw : vh;
397
+ const frameSize = isVertical ? frame.width : frame.height;
398
+ const frameOrigin = isVertical ? frame.x : frame.y;
391
399
 
392
400
  let crossPos: number;
393
401
  if (align === "start") {
@@ -397,7 +405,10 @@ export default defineComponent({
397
405
  } else {
398
406
  crossPos = anchorStart + (anchorSize - panelSize) / 2;
399
407
  }
400
- crossPos = clampWithGutter(crossPos, panelSize, viewportSize, pad);
408
+ // Clamps run against the popup viewport, frame-relative and re-based
409
+ // onto the frame origin — with no band configured the origin is 0
410
+ // and every clamp is the plain viewport-gutter clamp.
411
+ crossPos = clampWithGutter(crossPos - frameOrigin, panelSize, frameSize, pad) + frameOrigin;
401
412
 
402
413
  if (side === "bottom") {
403
414
  c.top = anchorRect.bottom + off;
@@ -414,13 +425,21 @@ export default defineComponent({
414
425
  }
415
426
 
416
427
  if (side === "bottom") {
417
- c.top = clampWithGutter(c.top!, panelRect.height, vh, pad);
428
+ c.top = clampWithGutter(c.top! - frame.y, panelRect.height, frame.height, pad) + frame.y;
418
429
  } else if (side === "top") {
419
- c.bottom = clampWithGutter(c.bottom ?? 0, panelRect.height, vh, pad);
430
+ // `bottom` counts from the viewport's bottom edge — shift it into
431
+ // frame-relative space (0 when no band is configured) and back.
432
+ const fromFrameBottom = vh - (frame.y + frame.height);
433
+ c.bottom =
434
+ clampWithGutter((c.bottom ?? 0) - fromFrameBottom, panelRect.height, frame.height, pad) +
435
+ fromFrameBottom;
420
436
  } else if (side === "right") {
421
- c.left = clampWithGutter(c.left!, panelRect.width, vw, pad);
437
+ c.left = clampWithGutter(c.left! - frame.x, panelRect.width, frame.width, pad) + frame.x;
422
438
  } else {
423
- c.right = clampWithGutter(c.right ?? 0, panelRect.width, vw, pad);
439
+ const fromFrameRight = vw - (frame.x + frame.width);
440
+ c.right =
441
+ clampWithGutter((c.right ?? 0) - fromFrameRight, panelRect.width, frame.width, pad) +
442
+ fromFrameRight;
424
443
  }
425
444
 
426
445
  coords.value = c;
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
11
11
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
12
12
  import type { ComponentPublicInstance } from "vue";
13
13
 
14
+ import { configurePopupInsets } from "../runtime/popupBounds";
14
15
  import HkSelectPanel from "./HkSelectPanel";
15
16
 
16
17
  const mounts: ReturnType<typeof createApp>[] = [];
@@ -123,3 +124,34 @@ describe("HkSelectPanel popout under root CSS zoom", () => {
123
124
  expect(host.style.left).toBe("100px");
124
125
  });
125
126
  });
127
+
128
+ describe("HkSelectPanel popout against an app-chrome band", () => {
129
+ const PREV_HEIGHT = window.innerHeight;
130
+
131
+ afterEach(() => {
132
+ configurePopupInsets(null);
133
+ window.innerHeight = PREV_HEIGHT;
134
+ });
135
+
136
+ it("flips a top-placed panel below its anchor when the band eats the space", async () => {
137
+ // A 260px band (a docked header stack) leaves frame 260..800. The
138
+ // anchor at y=300 with a top placement: the RAW-viewport top space
139
+ // (300 - 16) holds the 200px panel — the pre-bounds math pinned it
140
+ // at top 96, across the band. Inside the frame only 300 - 260 - 16 =
141
+ // 24 remains → flip below; the clamp keeps the flipped panel inside
142
+ // the frame (344 + 200 = 544 ≤ 800 - 16).
143
+ window.innerWidth = 1200;
144
+ window.innerHeight = 800;
145
+ patchRootZoom("1");
146
+ configurePopupInsets({ top: 260 });
147
+ const { button } = mountPanel();
148
+ vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(200, 300, 100, 40));
149
+
150
+ button.click();
151
+ await flush();
152
+
153
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
154
+ expect(host.style.top).toBe("344px"); // anchor bottom 340 + offset 4
155
+ expect(host.style.left).toBe("200px"); // start, inside the frame as-is
156
+ });
157
+ });
@@ -14,6 +14,7 @@ import { useOverlay } from "../runtime/useOverlay";
14
14
  import { useBreakpoint } from "../runtime/useBreakpoint";
15
15
  import { createBackGuard } from "../runtime/backStack";
16
16
  import { ancestorZoom } from "../runtime/cssZoom";
17
+ import { popupViewportRect } from "../runtime/popupBounds";
17
18
  import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
18
19
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
19
20
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
@@ -547,16 +548,20 @@ export default defineComponent({
547
548
  // The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
548
549
  // desktop), read per positioning pass.
549
550
  const pad = viewportGutterPx();
551
+ // The popup viewport: the window minus the app-chrome band the host
552
+ // configured (popupBounds) — full-window when none, in which case
553
+ // every flip and clamp below is the raw-window math.
554
+ const frame = popupViewportRect();
550
555
  let side: "top" | "bottom" = props.placement.startsWith("top-") ? "top" : "bottom";
551
556
  let top =
552
557
  side === "top"
553
558
  ? r.top - props.offset - ph
554
559
  : r.bottom + props.offset;
555
560
  // Auto-flip when the chosen side cannot host the panel.
556
- if (side === "bottom" && top + ph > window.innerHeight - pad) {
561
+ if (side === "bottom" && top + ph > frame.y + frame.height - pad) {
557
562
  side = "top";
558
563
  top = r.top - props.offset - ph;
559
- } else if (side === "top" && top < pad) {
564
+ } else if (side === "top" && top < frame.y + pad) {
560
565
  side = "bottom";
561
566
  top = r.bottom + props.offset;
562
567
  }
@@ -571,7 +576,7 @@ export default defineComponent({
571
576
  // bottom→top into a negative top that was applied verbatim. Clamp so
572
577
  // the whole panel stays on-screen; when content exceeds the CSS cap
573
578
  // the panel's own internal scroll takes over.
574
- top = clampWithGutter(top, ph, window.innerHeight, pad);
579
+ top = clampWithGutter(top - frame.y, ph, frame.height, pad) + frame.y;
575
580
  // -center balances the panel on the anchor's horizontal midpoint
576
581
  // (still clamped, so a half-off-screen anchor keeps the panel
577
582
  // readable instead of mirroring the overflow to both edges).
@@ -581,7 +586,7 @@ export default defineComponent({
581
586
  : props.placement.endsWith("-end")
582
587
  ? r.right - pw
583
588
  : r.left;
584
- const clampedLeft = clampWithGutter(left, pw, window.innerWidth, pad);
589
+ const clampedLeft = clampWithGutter(left - frame.x, pw, frame.width, pad) + frame.x;
585
590
  coords.value = {
586
591
  top: `${Math.round(top / z)}px`,
587
592
  left: `${Math.round(clampedLeft / z)}px`,
@@ -0,0 +1,184 @@
1
+ // HkUserPicker (components/HkUserPicker.tsx) — the tag field + search
2
+ // dialog pair. Tokens follow the house scale (--space-*, --radius-*,
3
+ // rgb(var(--color-*)) — the HkKeywordSearchModal.scss conventions).
4
+
5
+ .hk-user-picker {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--space-8, 0.5rem);
9
+
10
+ &.is-disabled {
11
+ opacity: 0.55;
12
+ pointer-events: none;
13
+ }
14
+ }
15
+
16
+ .hk-user-picker-label {
17
+ font-size: 0.75rem;
18
+ color: rgb(var(--color-text) / 70%);
19
+ }
20
+
21
+ .hk-user-picker-field {
22
+ display: flex;
23
+ flex-wrap: wrap;
24
+ align-items: center;
25
+ gap: var(--space-8, 0.5rem);
26
+ min-height: 2.5rem;
27
+ padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
28
+ background: var(--hi-color-surface, rgba(240, 244, 248, 0.95));
29
+ border: 1px solid var(--hk-kw-border, rgb(var(--color-border) / 8%));
30
+ border-radius: var(--radius-lg, 0.75rem);
31
+ cursor: pointer;
32
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
33
+
34
+ &:focus-visible {
35
+ outline: none;
36
+ border-color: var(--hi-color-primary, #7aa2f7);
37
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 12%, transparent);
38
+ }
39
+ }
40
+
41
+ .hk-user-picker-tag {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ gap: var(--space-4, 0.25rem);
45
+ max-width: 100%;
46
+ padding: var(--space-4, 0.25rem) var(--space-8, 0.5rem);
47
+ border-radius: var(--radius-md, 0.5rem);
48
+ background: rgb(var(--color-primary) / 12%);
49
+ border: 1px solid rgb(var(--color-primary) / 20%);
50
+ }
51
+
52
+ .hk-user-picker-tag-body {
53
+ display: flex;
54
+ flex-direction: column;
55
+ min-width: 0;
56
+ }
57
+
58
+ .hk-user-picker-tag-name {
59
+ font-size: 0.75rem;
60
+ font-weight: 600;
61
+ color: rgb(var(--color-text));
62
+ white-space: nowrap;
63
+ overflow: hidden;
64
+ text-overflow: ellipsis;
65
+ }
66
+
67
+ .hk-user-picker-tag-email {
68
+ font-size: 0.625rem;
69
+ color: rgb(var(--color-text) / 60%);
70
+ white-space: nowrap;
71
+ overflow: hidden;
72
+ text-overflow: ellipsis;
73
+ }
74
+
75
+ .hk-user-picker-tag-x {
76
+ flex: none;
77
+ display: inline-flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ width: 1rem;
81
+ height: 1rem;
82
+ border: none;
83
+ border-radius: 999px;
84
+ background: transparent;
85
+ color: rgb(var(--color-text) / 60%);
86
+ font-size: 0.875rem;
87
+ line-height: 1;
88
+ cursor: pointer;
89
+
90
+ &:hover {
91
+ background: rgb(var(--color-text) / 10%);
92
+ color: rgb(var(--color-text));
93
+ }
94
+ }
95
+
96
+ .hk-user-picker-add {
97
+ font-size: 0.75rem;
98
+ color: rgb(var(--color-text) / 60%);
99
+
100
+ &.is-placeholder {
101
+ color: rgb(var(--color-text) / 45%);
102
+ }
103
+ }
104
+
105
+ .hk-user-picker-dialog {
106
+ display: flex;
107
+ flex-direction: column;
108
+ gap: var(--space-12, 0.75rem);
109
+ padding: var(--space-4, 0.25rem) 0;
110
+ }
111
+
112
+ .hk-user-picker-rows {
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: var(--space-4, 0.25rem);
116
+ min-height: 6rem;
117
+ }
118
+
119
+ .hk-user-picker-note {
120
+ margin: 0;
121
+ padding: var(--space-12, 0.75rem) 0;
122
+ font-size: 0.75rem;
123
+ color: rgb(var(--color-text) / 55%);
124
+ text-align: center;
125
+ }
126
+
127
+ .hk-user-picker-row {
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: space-between;
131
+ gap: var(--space-8, 0.5rem);
132
+ width: 100%;
133
+ padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
134
+ background: transparent;
135
+ border: 1px solid transparent;
136
+ border-radius: var(--radius-md, 0.5rem);
137
+ text-align: left;
138
+ cursor: pointer;
139
+
140
+ &:hover {
141
+ background: rgb(var(--color-text) / 6%);
142
+ }
143
+
144
+ &.is-selected {
145
+ background: rgb(var(--color-primary) / 12%);
146
+ border-color: rgb(var(--color-primary) / 25%);
147
+ }
148
+ }
149
+
150
+ .hk-user-picker-row-main {
151
+ display: flex;
152
+ flex-direction: column;
153
+ min-width: 0;
154
+ }
155
+
156
+ .hk-user-picker-row-name {
157
+ font-size: 0.8125rem;
158
+ font-weight: 600;
159
+ color: rgb(var(--color-text));
160
+ white-space: nowrap;
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ }
164
+
165
+ .hk-user-picker-row-email {
166
+ font-size: 0.6875rem;
167
+ color: rgb(var(--color-text) / 60%);
168
+ white-space: nowrap;
169
+ overflow: hidden;
170
+ text-overflow: ellipsis;
171
+ }
172
+
173
+ .hk-user-picker-row-check {
174
+ flex: none;
175
+ color: var(--hi-color-primary, #7aa2f7);
176
+ font-weight: 700;
177
+ }
178
+
179
+ .hk-user-picker-count {
180
+ margin: 0;
181
+ font-size: 0.6875rem;
182
+ color: rgb(var(--color-text) / 55%);
183
+ text-align: right;
184
+ }