@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 +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/components/HkUserPicker.scss +184 -0
- package/src/components/HkUserPicker.test.tsx +208 -0
- package/src/components/HkUserPicker.tsx +267 -0
- package/src/i18n/locales/ar/components.json +11 -1
- package/src/i18n/locales/de/components.json +11 -1
- package/src/i18n/locales/en/components.json +11 -1
- package/src/i18n/locales/es/components.json +11 -1
- package/src/i18n/locales/fr/components.json +11 -1
- package/src/i18n/locales/ja/components.json +11 -1
- package/src/i18n/locales/ko/components.json +11 -1
- package/src/i18n/locales/pt/components.json +11 -1
- package/src/i18n/locales/ru/components.json +11 -1
- package/src/i18n/locales/zh-Hans/components.json +11 -1
- package/src/i18n/locales/zh-Hant/components.json +11 -1
- package/src/index.ts +1 -0
- 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`,
|
|
@@ -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
|
+
}
|