@celestia-island/hikari 0.55.130 → 0.55.131
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/HkContextMenuProvider.quadrant.test.ts +52 -0
- package/src/components/HkContextMenuProvider.tsx +10 -4
- package/src/components/HkMenu.test.ts +45 -1
- package/src/components/HkMenu.tsx +7 -4
- package/src/components/HkPopover.rootzoom.test.tsx +71 -1
- package/src/components/HkPopover.tsx +33 -14
- package/src/components/HkSelectPanel.rootzoom.test.tsx +32 -0
- package/src/components/HkSelectPanel.tsx +9 -4
- package/src/runtime/index.ts +9 -0
- package/src/runtime/popupBounds.test.ts +90 -0
- package/src/runtime/popupBounds.ts +146 -0
- package/src/runtime/tooltipPosition.test.ts +100 -6
- package/src/runtime/tooltipPosition.ts +42 -27
package/package.json
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
430
|
-
const
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
428
|
+
c.top = clampWithGutter(c.top! - frame.y, panelRect.height, frame.height, pad) + frame.y;
|
|
418
429
|
} else if (side === "top") {
|
|
419
|
-
|
|
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
|
|
437
|
+
c.left = clampWithGutter(c.left! - frame.x, panelRect.width, frame.width, pad) + frame.x;
|
|
422
438
|
} else {
|
|
423
|
-
|
|
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 >
|
|
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,
|
|
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,
|
|
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`,
|
package/src/runtime/index.ts
CHANGED
|
@@ -19,6 +19,15 @@ export {
|
|
|
19
19
|
TOOLTIP_GAP_PX,
|
|
20
20
|
type TooltipPlacement,
|
|
21
21
|
} from "./tooltipPosition";
|
|
22
|
+
export {
|
|
23
|
+
configurePopupInsets,
|
|
24
|
+
popupInsets,
|
|
25
|
+
popupViewportRect,
|
|
26
|
+
POPUP_INSET_VARS,
|
|
27
|
+
type PopupInsets,
|
|
28
|
+
type ResolvedPopupInsets,
|
|
29
|
+
type PopupViewportRect,
|
|
30
|
+
} from "./popupBounds";
|
|
22
31
|
export {
|
|
23
32
|
viewportGutterPx,
|
|
24
33
|
clampWithGutter,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Contract tests for the app-chrome popup bounds context.
|
|
2
|
+
//
|
|
3
|
+
// AI disclosure: drafted by GLM via the ZCode agent, directed and
|
|
4
|
+
// reviewed by langyo — SySL-1.0 §2.3.
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { readHkRuntime, writeHkRuntime } from "./registry";
|
|
8
|
+
import {
|
|
9
|
+
configurePopupInsets,
|
|
10
|
+
popupInsets,
|
|
11
|
+
popupViewportRect,
|
|
12
|
+
POPUP_INSET_VARS,
|
|
13
|
+
} from "./popupBounds";
|
|
14
|
+
|
|
15
|
+
const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
configurePopupInsets(null);
|
|
19
|
+
window.innerWidth = PREV_INNER.width;
|
|
20
|
+
window.innerHeight = PREV_INNER.height;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("popupBounds", () => {
|
|
24
|
+
it("defaults to a zero band and the full-window frame", () => {
|
|
25
|
+
window.innerWidth = 1280;
|
|
26
|
+
window.innerHeight = 800;
|
|
27
|
+
expect(popupInsets()).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
28
|
+
expect(popupViewportRect()).toEqual({ x: 0, y: 0, width: 1280, height: 800 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("shrinks the popup frame by the configured band", () => {
|
|
32
|
+
window.innerWidth = 1280;
|
|
33
|
+
window.innerHeight = 800;
|
|
34
|
+
configurePopupInsets({ top: 32, left: 48 });
|
|
35
|
+
expect(popupViewportRect()).toEqual({ x: 48, y: 32, width: 1232, height: 768 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("replaces the whole band on re-configure and clears on null", () => {
|
|
39
|
+
window.innerWidth = 1280;
|
|
40
|
+
window.innerHeight = 800;
|
|
41
|
+
configurePopupInsets({ top: 32, bottom: 10 });
|
|
42
|
+
configurePopupInsets({ left: 64 });
|
|
43
|
+
expect(popupInsets()).toEqual({ top: 0, right: 0, bottom: 0, left: 64 });
|
|
44
|
+
expect(popupViewportRect()).toEqual({ x: 64, y: 0, width: 1216, height: 800 });
|
|
45
|
+
configurePopupInsets(null);
|
|
46
|
+
expect(popupViewportRect()).toEqual({ x: 0, y: 0, width: 1280, height: 800 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("sanitizes non-finite and non-positive sides to 0", () => {
|
|
50
|
+
configurePopupInsets({ top: Number.NaN, right: -5, bottom: 12, left: 0 });
|
|
51
|
+
expect(popupInsets()).toEqual({ top: 0, right: 0, bottom: 12, left: 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("mirrors the band onto :root variables, zeroes included", () => {
|
|
55
|
+
configurePopupInsets({ top: 32 });
|
|
56
|
+
expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.top)).toBe("32px");
|
|
57
|
+
expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.left)).toBe("0px");
|
|
58
|
+
// Re-configure rewrites every side, so a dropped side cannot keep a
|
|
59
|
+
// stale larger value.
|
|
60
|
+
configurePopupInsets({ left: 64 });
|
|
61
|
+
expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.top)).toBe("0px");
|
|
62
|
+
expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.left)).toBe("64px");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("collapses the frame when the band overruns the window", () => {
|
|
66
|
+
window.innerWidth = 800;
|
|
67
|
+
window.innerHeight = 600;
|
|
68
|
+
configurePopupInsets({ top: 500, bottom: 500 });
|
|
69
|
+
expect(popupViewportRect()).toEqual({ x: 0, y: 500, width: 800, height: 0 });
|
|
70
|
+
configurePopupInsets({ left: 900 });
|
|
71
|
+
expect(popupViewportRect()).toEqual({ x: 800, y: 0, width: 0, height: 600 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("reports its live band and frame to the runtime registry", () => {
|
|
75
|
+
window.innerWidth = 1280;
|
|
76
|
+
window.innerHeight = 800;
|
|
77
|
+
configurePopupInsets({ top: 32 });
|
|
78
|
+
expect(readHkRuntime("popupBounds")).toEqual({
|
|
79
|
+
insets: { top: 32, right: 0, bottom: 0, left: 0 },
|
|
80
|
+
viewport: { x: 0, y: 32, width: 1280, height: 768 },
|
|
81
|
+
});
|
|
82
|
+
// The write facet rides the same registry entry — exercise it through
|
|
83
|
+
// writeHkRuntime so the op contract stays honest.
|
|
84
|
+
expect(writeHkRuntime("popupBounds", { type: "configure", insets: { top: 8 } })).toBe(true);
|
|
85
|
+
expect(readHkRuntime("popupBounds")).toMatchObject({
|
|
86
|
+
insets: { top: 8, right: 0, bottom: 0, left: 0 },
|
|
87
|
+
});
|
|
88
|
+
expect(writeHkRuntime("popupBounds", { type: "nonsense" })).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// App-chrome popup bounds — the developer-facing inset that shrinks the
|
|
2
|
+
// region every JS-positioned popup may occupy.
|
|
3
|
+
//
|
|
4
|
+
// A desktop shell routinely reserves part of the window for chrome a
|
|
5
|
+
// popup must never cover. The report that motivated this module: a
|
|
6
|
+
// frameless Tauri app draws its own title bar and anchors tooltips on
|
|
7
|
+
// the first row beneath it — every positioner here measured against the
|
|
8
|
+
// raw window plus the gutter alone, so those tooltips pinned up over the
|
|
9
|
+
// caption strip. The host declares the occupied band ONCE and every
|
|
10
|
+
// positioner (both tooltip surfaces, HkPopover, HkSelectPanel / HkMenu
|
|
11
|
+
// cascades, the context-menu quadrant pick) centers, flips and clamps
|
|
12
|
+
// inside the remainder. The configuration is per JS realm — a Tauri
|
|
13
|
+
// tray or overlay window is its own realm and simply configures nothing.
|
|
14
|
+
//
|
|
15
|
+
// The pattern is the one --viewport-gutter established: one magnitude
|
|
16
|
+
// shared by declarative consumers and the JS math. configurePopupInsets
|
|
17
|
+
// mirrors the four numbers onto :root as
|
|
18
|
+
// `--hk-popup-inset-{top,right,bottom,left}` (the useSafeArea variable
|
|
19
|
+
// convention), and popupViewportRect() is the JS side of the same
|
|
20
|
+
// contract — the window rect inset by the band. Values are root visual
|
|
21
|
+
// px (measure chrome with getBoundingClientRect), deliberately NOT
|
|
22
|
+
// zoom-corrected — same story as the gutter.
|
|
23
|
+
//
|
|
24
|
+
// This is developer configuration, not user theming: the shell sets it
|
|
25
|
+
// at bootstrap from its real layout and re-sets it when that layout
|
|
26
|
+
// changes (maximize, a phone-layout bar raising). null clears the band.
|
|
27
|
+
// Scope: the JS-positioned floating surfaces honor the band today
|
|
28
|
+
// (tooltips, popovers, select/menu popouts, context-menu quadrants);
|
|
29
|
+
// CSS-positioned overlays (modals, drawers, bottom sheets) do not yet —
|
|
30
|
+
// a bottom band will underlap them until they read the mirrored vars.
|
|
31
|
+
//
|
|
32
|
+
// AI disclosure: drafted by GLM via the ZCode agent, directed and
|
|
33
|
+
// reviewed by langyo — SySL-1.0 §2.3.
|
|
34
|
+
import { reportHkRuntime } from "./registry";
|
|
35
|
+
|
|
36
|
+
/** The app-chrome band: how much of the window each side reserves. */
|
|
37
|
+
export interface PopupInsets {
|
|
38
|
+
top?: number;
|
|
39
|
+
right?: number;
|
|
40
|
+
bottom?: number;
|
|
41
|
+
left?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The band with every side resolved (unset sides are 0). */
|
|
45
|
+
export interface ResolvedPopupInsets {
|
|
46
|
+
top: number;
|
|
47
|
+
right: number;
|
|
48
|
+
bottom: number;
|
|
49
|
+
left: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The :root variables the insets mirror onto (useSafeArea convention). */
|
|
53
|
+
export const POPUP_INSET_VARS: Record<keyof ResolvedPopupInsets, string> = {
|
|
54
|
+
top: "--hk-popup-inset-top",
|
|
55
|
+
right: "--hk-popup-inset-right",
|
|
56
|
+
bottom: "--hk-popup-inset-bottom",
|
|
57
|
+
left: "--hk-popup-inset-left",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The region popups may occupy, in root visual px. */
|
|
61
|
+
export interface PopupViewportRect {
|
|
62
|
+
x: number;
|
|
63
|
+
y: number;
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let current: Readonly<ResolvedPopupInsets> = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
69
|
+
let runtimeReport: ReturnType<typeof reportHkRuntime> | null = null;
|
|
70
|
+
|
|
71
|
+
function sanitize(value: number | undefined): number {
|
|
72
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function reportOnce(): void {
|
|
76
|
+
if (runtimeReport) return;
|
|
77
|
+
runtimeReport = reportHkRuntime("popupBounds", {
|
|
78
|
+
kind: "context",
|
|
79
|
+
description:
|
|
80
|
+
"App-chrome popup bounds — the inset band every popup positions inside",
|
|
81
|
+
read: () => ({ insets: { ...current }, viewport: popupViewportRect() }),
|
|
82
|
+
write: (op) => {
|
|
83
|
+
if (op.type === "configure") {
|
|
84
|
+
configurePopupInsets((op.insets as PopupInsets | null) ?? null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`unknown popupBounds write op: ${op.type}`);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Declare the app-chrome band popups must stay out of, in root visual px
|
|
94
|
+
* (measure the chrome with getBoundingClientRect). Re-callable — the new
|
|
95
|
+
* insets REPLACE the old ones wholesale, so a shell re-declares on every
|
|
96
|
+
* layout change; null clears the band. Non-finite and non-positive sides
|
|
97
|
+
* sanitize to 0, so a mis-measured bar degrades to "no band on that
|
|
98
|
+
* side" instead of corrupting the frame.
|
|
99
|
+
*/
|
|
100
|
+
export function configurePopupInsets(insets: PopupInsets | null): void {
|
|
101
|
+
current = Object.freeze(
|
|
102
|
+
insets
|
|
103
|
+
? {
|
|
104
|
+
top: sanitize(insets.top),
|
|
105
|
+
right: sanitize(insets.right),
|
|
106
|
+
bottom: sanitize(insets.bottom),
|
|
107
|
+
left: sanitize(insets.left),
|
|
108
|
+
}
|
|
109
|
+
: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
110
|
+
);
|
|
111
|
+
if (typeof document !== "undefined") {
|
|
112
|
+
const rootStyle = document.documentElement.style;
|
|
113
|
+
for (const side of Object.keys(POPUP_INSET_VARS) as Array<keyof ResolvedPopupInsets>) {
|
|
114
|
+
// Zeroes are WRITTEN, not removed, so re-configuring can never
|
|
115
|
+
// leave a stale larger value on a side the new band dropped.
|
|
116
|
+
rootStyle.setProperty(POPUP_INSET_VARS[side], `${current[side]}px`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
reportOnce();
|
|
120
|
+
runtimeReport?.pulse();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The currently configured band — zeros when nothing was configured. */
|
|
124
|
+
export function popupInsets(): Readonly<ResolvedPopupInsets> {
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The region popups may occupy: the window inset by the configured band.
|
|
130
|
+
* Width and height floor at 0 — an over-wide band collapses the frame,
|
|
131
|
+
* and every clamp then pins to its leading edge, the honest answer to
|
|
132
|
+
* an impossible configuration.
|
|
133
|
+
*/
|
|
134
|
+
export function popupViewportRect(win?: Window | null): PopupViewportRect {
|
|
135
|
+
reportOnce();
|
|
136
|
+
if (typeof window === "undefined") return { x: 0, y: 0, width: 0, height: 0 };
|
|
137
|
+
const w = win ?? window;
|
|
138
|
+
const x = Math.min(current.left, w.innerWidth);
|
|
139
|
+
const y = Math.min(current.top, w.innerHeight);
|
|
140
|
+
return {
|
|
141
|
+
x,
|
|
142
|
+
y,
|
|
143
|
+
width: Math.max(0, w.innerWidth - x - Math.min(current.right, w.innerWidth - x)),
|
|
144
|
+
height: Math.max(0, w.innerHeight - y - Math.min(current.bottom, w.innerHeight - y)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import { configurePopupInsets } from "./popupBounds";
|
|
3
4
|
import {
|
|
4
5
|
TOOLTIP_GAP_PX,
|
|
5
6
|
applyTooltipPosition,
|
|
@@ -38,6 +39,7 @@ function probePopup(rects: DOMRect[]): HTMLElement {
|
|
|
38
39
|
const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
|
|
39
40
|
|
|
40
41
|
afterEach(() => {
|
|
42
|
+
configurePopupInsets(null);
|
|
41
43
|
window.innerWidth = PREV_INNER.width;
|
|
42
44
|
window.innerHeight = PREV_INNER.height;
|
|
43
45
|
vi.restoreAllMocks();
|
|
@@ -227,26 +229,118 @@ describe("applyTooltipPosition viewport clamping", () => {
|
|
|
227
229
|
});
|
|
228
230
|
|
|
229
231
|
describe("resolveTooltipFlip", () => {
|
|
230
|
-
const
|
|
231
|
-
const vh = 667;
|
|
232
|
+
const frame = { x: 0, y: 0, width: 375, height: 667 };
|
|
232
233
|
const gutter = 8;
|
|
233
234
|
|
|
234
235
|
it("keeps the placement when the preferred side fits", () => {
|
|
235
236
|
const trigger = box(100, 100, 40, 40);
|
|
236
|
-
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 },
|
|
237
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("top");
|
|
237
238
|
});
|
|
238
239
|
|
|
239
240
|
it("flips to the opposite side when it has more room", () => {
|
|
240
241
|
const trigger = box(100, 2, 40, 18);
|
|
241
|
-
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 },
|
|
242
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("bottom");
|
|
242
243
|
const floorTrigger = box(100, 640, 40, 20);
|
|
243
|
-
expect(resolveTooltipFlip(floorTrigger, "bottom", { width: 200, height: 32 },
|
|
244
|
+
expect(resolveTooltipFlip(floorTrigger, "bottom", { width: 200, height: 32 }, frame, gutter)).toBe("top");
|
|
244
245
|
});
|
|
245
246
|
|
|
246
247
|
it("keeps the placement when the opposite side is no better (the clamp handles it)", () => {
|
|
247
248
|
// Both sides starved, opposite NOT strictly larger → keep the author's
|
|
248
249
|
// placement; the gutter clamp pins the popup on-screen.
|
|
249
250
|
const trigger = box(100, 2, 40, 663); // spaceAbove -6, spaceBelow -6
|
|
250
|
-
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 400 },
|
|
251
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 400 }, frame, gutter)).toBe("top");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("resolveTooltipFlip against an app-chrome band", () => {
|
|
256
|
+
// A 40px desktop title bar configured as the popup bounds.
|
|
257
|
+
const frame = { x: 0, y: 40, width: 375, height: 627 };
|
|
258
|
+
const gutter = 8;
|
|
259
|
+
|
|
260
|
+
it("flips down when the popup would cross the band but fits the raw viewport above", () => {
|
|
261
|
+
// Trigger top 84: the RAW-viewport top space (84 - 8 = 76) would hold
|
|
262
|
+
// a 32px popup, but inside the frame only 84 - 40 - 8 = 36 remains —
|
|
263
|
+
// the caption strip wins, the tooltip pops below the trigger.
|
|
264
|
+
const trigger = box(100, 84, 40, 18);
|
|
265
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("bottom");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("keeps top when the space below the band edge still holds the popup", () => {
|
|
269
|
+
const trigger = box(100, 300, 40, 40);
|
|
270
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("top");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("measures the horizontal sides against the band, not the window", () => {
|
|
274
|
+
const docked = { x: 200, y: 0, width: 175, height: 667 };
|
|
275
|
+
const trigger = box(210, 300, 40, 40);
|
|
276
|
+
// Right space inside the frame 117 < 120 + 8 → checks the left
|
|
277
|
+
// alternate (2), which is not larger → keeps the author's side.
|
|
278
|
+
expect(resolveTooltipFlip(trigger, "right", { width: 120, height: 32 }, docked, gutter)).toBe("right");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("applyTooltipPosition against an app-chrome band", () => {
|
|
283
|
+
it("pops a first-row tooltip down instead of over the configured band", () => {
|
|
284
|
+
window.innerWidth = 375;
|
|
285
|
+
window.innerHeight = 667;
|
|
286
|
+
configurePopupInsets({ top: 40 });
|
|
287
|
+
// Raw-viewport top space is 84 - 8 = 76 ≥ 40 — the pre-bounds math
|
|
288
|
+
// kept this popup UP (visual box 44..76, across the 40px band); the
|
|
289
|
+
// band-aware flip sends it below the trigger instead.
|
|
290
|
+
const trigger = box(100, 84, 40, 18);
|
|
291
|
+
// Measures: the base-top box, then the flipped-bottom box the real
|
|
292
|
+
// DOM would report after the base rewrite.
|
|
293
|
+
const popup = probePopup([box(120, 44, 200, 32), box(120, 110, 200, 32)]);
|
|
294
|
+
applyTooltipPosition(popup, trigger, "top");
|
|
295
|
+
expect(popup.style.transform).toBe("translate(-50%, 0)");
|
|
296
|
+
expect(popup.style.top).toBe("110px"); // rect.bottom + 8
|
|
297
|
+
expect(popup.style.left).toBe("120px"); // hcenter, unshifted
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("lands inside the band under root zoom (band + zoom combined)", () => {
|
|
301
|
+
// chest's root-level DPI zoom AND a title-bar band together: style
|
|
302
|
+
// px are local (divided by zoom 2), the band and the viewport are
|
|
303
|
+
// visual — the flip must clear the band in VISUAL space and the
|
|
304
|
+
// shift must be divided back to local.
|
|
305
|
+
window.innerWidth = 1200;
|
|
306
|
+
window.innerHeight = 800;
|
|
307
|
+
configurePopupInsets({ top: 32 });
|
|
308
|
+
const original = window.getComputedStyle.bind(window);
|
|
309
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(((el: Element) => {
|
|
310
|
+
const decl = original(el);
|
|
311
|
+
return new Proxy(decl, {
|
|
312
|
+
get(target, prop, recv) {
|
|
313
|
+
if (prop === "zoom") return el === document.documentElement ? "2" : undefined;
|
|
314
|
+
const v = Reflect.get(target, prop, recv);
|
|
315
|
+
return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}) as typeof window.getComputedStyle);
|
|
319
|
+
// Trigger top 100: frame space above is 100 - 32 - 8 = 60 < 48 + 16
|
|
320
|
+
// → flip below. Base-top visual box 136..184 crosses the band; the
|
|
321
|
+
// flipped-bottom box sits at 296..344, already inside the frame.
|
|
322
|
+
const trigger = box(600, 100, 40, 40);
|
|
323
|
+
const popup = probePopup([box(1120, 136, 240, 48), box(1120, 296, 240, 48)]);
|
|
324
|
+
applyTooltipPosition(popup, trigger, "top");
|
|
325
|
+
expect(popup.style.transform).toBe("translate(-50%, 0)");
|
|
326
|
+
expect(popup.style.top).toBe("74px"); // (140 + 8) visual, written /2
|
|
327
|
+
// Center 620 visual, written 310 local; visual box 1120..1360
|
|
328
|
+
// overruns the 1200-16 gutter by 176 visual → 88 local off:
|
|
329
|
+
// 310 - 88 = 222.
|
|
330
|
+
expect(popup.style.left).toBe("222px");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("shifts a centered bubble inside the band's side gutter", () => {
|
|
334
|
+
window.innerWidth = 375;
|
|
335
|
+
window.innerHeight = 667;
|
|
336
|
+
configurePopupInsets({ left: 200 });
|
|
337
|
+
// A docked 200px sidebar: the centered 120px bubble spans 170..290,
|
|
338
|
+
// 38px into the band — the shift re-bases onto the frame origin and
|
|
339
|
+
// pins it at the frame's leading gutter (208).
|
|
340
|
+
const trigger = box(210, 300, 40, 40);
|
|
341
|
+
const popup = probePopup([box(170, 348, 120, 32)]);
|
|
342
|
+
applyTooltipPosition(popup, trigger, "bottom");
|
|
343
|
+
expect(popup.style.left).toBe("268px");
|
|
344
|
+
expect(popup.style.top).toBe("348px");
|
|
251
345
|
});
|
|
252
346
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// positions it through this helper so the two surfaces can never drift.
|
|
5
5
|
import type { CSSProperties } from "vue";
|
|
6
6
|
import { ancestorZoom } from "./cssZoom";
|
|
7
|
+
import { popupViewportRect, type PopupViewportRect } from "./popupBounds";
|
|
7
8
|
import { clampWithGutter, viewportGutterPx } from "./viewportGutter";
|
|
8
9
|
|
|
9
10
|
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
|
|
@@ -70,22 +71,22 @@ export function tooltipPositionStyle(
|
|
|
70
71
|
return style;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
/** Space left on
|
|
74
|
+
/** Space left on the given side of the trigger rect, inside the popup
|
|
75
|
+
* viewport, minus the trigger gap. */
|
|
74
76
|
function sideSpace(
|
|
75
77
|
rect: DOMRect,
|
|
76
78
|
side: TooltipPlacement,
|
|
77
|
-
|
|
78
|
-
vh: number,
|
|
79
|
+
frame: PopupViewportRect,
|
|
79
80
|
): number {
|
|
80
81
|
switch (side) {
|
|
81
82
|
case "top":
|
|
82
|
-
return rect.top - TOOLTIP_GAP_PX;
|
|
83
|
+
return rect.top - frame.y - TOOLTIP_GAP_PX;
|
|
83
84
|
case "bottom":
|
|
84
|
-
return
|
|
85
|
+
return frame.y + frame.height - rect.bottom - TOOLTIP_GAP_PX;
|
|
85
86
|
case "left":
|
|
86
|
-
return rect.left - TOOLTIP_GAP_PX;
|
|
87
|
+
return rect.left - frame.x - TOOLTIP_GAP_PX;
|
|
87
88
|
case "right":
|
|
88
|
-
return
|
|
89
|
+
return frame.x + frame.width - rect.right - TOOLTIP_GAP_PX;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
@@ -105,23 +106,26 @@ function oppositeSide(placement: TooltipPlacement): TooltipPlacement {
|
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
108
|
* Flip a top↔bottom / left↔right preference when the requested side
|
|
108
|
-
* cannot hold the measured popup inside the viewport gutter but
|
|
109
|
-
* opposite side can — same criterion as HkPopover's autoFlip, so the
|
|
110
|
-
* surfaces rule their geometry identically.
|
|
109
|
+
* cannot hold the measured popup inside the popup viewport's gutter but
|
|
110
|
+
* the opposite side can — same criterion as HkPopover's autoFlip, so the
|
|
111
|
+
* surfaces rule their geometry identically. The popup viewport is the
|
|
112
|
+
* full window minus the app-chrome band the host configured
|
|
113
|
+
* (popupBounds), so a tooltip anchored on the first row under a custom
|
|
114
|
+
* title bar flips below its trigger instead of pinning over the strip.
|
|
115
|
+
* Returns the placement to use.
|
|
111
116
|
*/
|
|
112
117
|
export function resolveTooltipFlip(
|
|
113
118
|
rect: DOMRect,
|
|
114
119
|
placement: TooltipPlacement,
|
|
115
120
|
popup: { width: number; height: number },
|
|
116
|
-
|
|
117
|
-
vh: number,
|
|
121
|
+
frame: PopupViewportRect,
|
|
118
122
|
gutter: number,
|
|
119
123
|
): TooltipPlacement {
|
|
120
|
-
const preferred = sideSpace(rect, placement,
|
|
124
|
+
const preferred = sideSpace(rect, placement, frame);
|
|
121
125
|
const need = (placement === "top" || placement === "bottom" ? popup.height : popup.width) + gutter;
|
|
122
126
|
if (preferred >= need) return placement;
|
|
123
127
|
const alternate = oppositeSide(placement);
|
|
124
|
-
const alternateSpace = sideSpace(rect, alternate,
|
|
128
|
+
const alternateSpace = sideSpace(rect, alternate, frame);
|
|
125
129
|
return alternateSpace > preferred ? alternate : placement;
|
|
126
130
|
}
|
|
127
131
|
|
|
@@ -156,8 +160,10 @@ export function applyTooltipPosition(
|
|
|
156
160
|
const box = popup.getBoundingClientRect();
|
|
157
161
|
if (!(box.width > 0 && box.height > 0)) return; // no layout — pure base stands
|
|
158
162
|
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
// The popup viewport: the window minus the app-chrome band the host
|
|
164
|
+
// configured (a frameless shell's title bar), full-window when none —
|
|
165
|
+
// in which case every number below is exactly the raw-window math.
|
|
166
|
+
const frame = popupViewportRect();
|
|
161
167
|
const gutter = viewportGutterPx();
|
|
162
168
|
const z = ancestorZoom(popup);
|
|
163
169
|
|
|
@@ -171,8 +177,11 @@ export function applyTooltipPosition(
|
|
|
171
177
|
let capW = "";
|
|
172
178
|
let capH = "";
|
|
173
179
|
let clip = false;
|
|
174
|
-
|
|
175
|
-
|
|
180
|
+
// Floor at 0: a band so tight the gutters overrun the frame must not
|
|
181
|
+
// write negative (invalid) inline lengths — the cap degrades to 0 and
|
|
182
|
+
// collapses the bubble, the honest reading of an impossible frame.
|
|
183
|
+
const maxW = Math.max(0, frame.width - 2 * gutter);
|
|
184
|
+
const maxH = Math.max(0, frame.height - 2 * gutter);
|
|
176
185
|
if (box.width > maxW) {
|
|
177
186
|
capW = `${maxW / z}px`;
|
|
178
187
|
}
|
|
@@ -194,7 +203,7 @@ export function applyTooltipPosition(
|
|
|
194
203
|
measured = popup.getBoundingClientRect();
|
|
195
204
|
}
|
|
196
205
|
|
|
197
|
-
const side = resolveTooltipFlip(rect, placement, measured,
|
|
206
|
+
const side = resolveTooltipFlip(rect, placement, measured, frame, gutter);
|
|
198
207
|
let final = measured;
|
|
199
208
|
if (side !== placement) {
|
|
200
209
|
applyBase(side);
|
|
@@ -203,14 +212,20 @@ export function applyTooltipPosition(
|
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
// Shift whatever is left over (centered under a near-edge trigger, the
|
|
206
|
-
// flipped side, a cap) so both edges sit inside the gutter
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
// flipped side, a cap) so both edges sit inside the gutter — measured
|
|
216
|
+
// against the popup viewport, so the gutter rides just inside the
|
|
217
|
+
// app-chrome band rather than inside the raw window edge. The clamp is
|
|
218
|
+
// computed frame-relative and re-based onto the frame origin (with no
|
|
219
|
+
// band configured the origin is 0 and this is the plain gutter clamp).
|
|
220
|
+
// The delta is measured in root visual px but the written coordinates
|
|
221
|
+
// are local (divided by the cumulative zoom) AND offset from the box
|
|
222
|
+
// edge by the placement transform (translate(-50%) makes style.left
|
|
223
|
+
// the box CENTER) — so the shift adds onto the current written value
|
|
224
|
+
// instead of the measured edge.
|
|
225
|
+
const dx =
|
|
226
|
+
clampWithGutter(final.left - frame.x, final.width, frame.width, gutter) - (final.left - frame.x);
|
|
227
|
+
const dy =
|
|
228
|
+
clampWithGutter(final.top - frame.y, final.height, frame.height, gutter) - (final.top - frame.y);
|
|
214
229
|
if (dx !== 0 || dy !== 0) {
|
|
215
230
|
const curLeft = Number.parseFloat(popup.style.left) || 0;
|
|
216
231
|
const curTop = Number.parseFloat(popup.style.top) || 0;
|