@celestia-island/hikari 0.43.4 → 0.43.5
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/HkDraggableGrid.tsx +15 -4
- package/src/components/HkDraggableList.tsx +15 -4
- package/src/components/HkMenu.tsx +13 -6
- package/src/components/HkModalBreadcrumb.tsx +17 -1
- package/src/components/HkPopover.rootzoom.test.tsx +154 -0
- package/src/components/HkPopover.tsx +20 -4
- package/src/components/HkSelectPanel.rootzoom.test.tsx +125 -0
- package/src/components/HkSelectPanel.tsx +20 -6
- package/src/components/HkTooltip.tsx +18 -8
- package/src/runtime/cssZoom.test.ts +96 -0
- package/src/runtime/cssZoom.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineComponent, onBeforeUnmount, ref, shallowRef, watch, type PropType } from "vue";
|
|
2
2
|
import { onceFrame } from "../runtime/animationBus";
|
|
3
3
|
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
4
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
4
5
|
import "./HkDraggableGrid.scss";
|
|
5
6
|
|
|
6
7
|
const MAX_COLS = 3;
|
|
@@ -114,8 +115,14 @@ export default defineComponent({
|
|
|
114
115
|
const node = document.createElement("div");
|
|
115
116
|
node.className = "hk-draggable-grid-ghost";
|
|
116
117
|
node.innerHTML = html;
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// The ghost is appended to <body> — inside any root CSS zoom
|
|
119
|
+
// subtree — so its px are LOCAL while sourceRect (gBCR) reports the
|
|
120
|
+
// root VISUAL space (standardized zoom applies ancestor zoom).
|
|
121
|
+
// Divide at the write layer or the ghost paints zoom× too large and
|
|
122
|
+
// zoom× off the pointer (chest's root-level manual DPI scale).
|
|
123
|
+
const z = ancestorZoom(document.body);
|
|
124
|
+
node.style.width = `${sourceRect.width / z}px`;
|
|
125
|
+
node.style.transform = `translate3d(${sourceRect.left / z}px, ${sourceRect.top / z}px, 0)`;
|
|
119
126
|
node.style.pointerEvents = "none";
|
|
120
127
|
document.body.appendChild(node);
|
|
121
128
|
ghostNode = node;
|
|
@@ -131,8 +138,12 @@ export default defineComponent({
|
|
|
131
138
|
function moveGhost(clientX: number, clientY: number) {
|
|
132
139
|
const s = drag.value;
|
|
133
140
|
if (!s || !ghostNode) return;
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
// gx/gy are the ghost's visual top-left (client coords minus the
|
|
142
|
+
// visual grab offset); the teleported ghost paints local px scaled
|
|
143
|
+
// by the cumulative zoom — divide once at the write layer.
|
|
144
|
+
const z = ancestorZoom(document.body);
|
|
145
|
+
const gx = (clientX - s.grabOffsetX) / z;
|
|
146
|
+
const gy = (clientY - s.grabOffsetY) / z;
|
|
136
147
|
ghostNode.style.transform = `translate3d(${gx}px, ${gy}px, 0)`;
|
|
137
148
|
}
|
|
138
149
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineComponent, onBeforeUnmount, ref, shallowRef, type PropType } from "vue";
|
|
2
2
|
import { onceFrame } from "../runtime/animationBus";
|
|
3
3
|
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
4
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
4
5
|
import "./HkDraggableList.scss";
|
|
5
6
|
|
|
6
7
|
export interface DragListItem {
|
|
@@ -96,8 +97,14 @@ export default defineComponent({
|
|
|
96
97
|
const node = document.createElement("div");
|
|
97
98
|
node.className = "hk-draggable-list-ghost";
|
|
98
99
|
node.innerHTML = html;
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
// The ghost is appended to <body> — inside any root CSS zoom
|
|
101
|
+
// subtree — so its px are LOCAL while sourceRect (gBCR) reports the
|
|
102
|
+
// root VISUAL space (standardized zoom applies ancestor zoom).
|
|
103
|
+
// Divide at the write layer or the ghost paints zoom× too large and
|
|
104
|
+
// zoom× off the pointer (chest's root-level manual DPI scale).
|
|
105
|
+
const z = ancestorZoom(document.body);
|
|
106
|
+
node.style.width = `${sourceRect.width / z}px`;
|
|
107
|
+
node.style.transform = `translate3d(${sourceRect.left / z}px, ${sourceRect.top / z}px, 0)`;
|
|
101
108
|
node.style.pointerEvents = "none";
|
|
102
109
|
document.body.appendChild(node);
|
|
103
110
|
ghostNode = node;
|
|
@@ -113,8 +120,12 @@ export default defineComponent({
|
|
|
113
120
|
function moveGhost(clientY: number) {
|
|
114
121
|
const s = drag.value;
|
|
115
122
|
if (!s || !ghostNode) return;
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
// gx/gy are the ghost's visual top-left (client coords minus the
|
|
124
|
+
// visual grab offset); the teleported ghost paints local px scaled
|
|
125
|
+
// by the cumulative zoom — divide once at the write layer.
|
|
126
|
+
const z = ancestorZoom(document.body);
|
|
127
|
+
const gx = (s.originX - s.grabOffsetX) / z;
|
|
128
|
+
const gy = (clientY - s.grabOffsetY) / z;
|
|
118
129
|
ghostNode.style.transform = `translate3d(${gx}px, ${gy}px, 0)`;
|
|
119
130
|
}
|
|
120
131
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { Check, ChevronRight } from "lucide-vue-next";
|
|
14
14
|
|
|
15
15
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
16
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
16
17
|
import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
|
|
17
18
|
import "./HkMenu.scss";
|
|
18
19
|
|
|
@@ -398,7 +399,11 @@ export default defineComponent({
|
|
|
398
399
|
/** Synthetic anchor placing a sub-panel beside its parent row:
|
|
399
400
|
* popout to the right, flipping to the left when the viewport runs
|
|
400
401
|
* out (classic menubar cascade). The rect stays LIVE — HkSelectPanel
|
|
401
|
-
* re-reads it on every reposition (open, resize).
|
|
402
|
+
* re-reads it on every reposition (open, resize). The fixed panel
|
|
403
|
+
* width is a LOCAL constant while the row rect and the viewport are
|
|
404
|
+
* in the root VISUAL space (standardized zoom applies ancestor zoom
|
|
405
|
+
* to gBCR and innerWidth alike), so the width is scaled by the
|
|
406
|
+
* cumulative zoom before the flip comparison. */
|
|
402
407
|
function rowAnchor(level: number, key: string): PanelAnchor {
|
|
403
408
|
const id = `${level}:${key}`;
|
|
404
409
|
let anchor = anchorCache.get(id);
|
|
@@ -409,8 +414,9 @@ export default defineComponent({
|
|
|
409
414
|
if (!row) return pointRect(0, 0);
|
|
410
415
|
const r = row.getBoundingClientRect();
|
|
411
416
|
if (!r.width && !r.height) return pointRect(0, 0); // detached
|
|
412
|
-
const
|
|
413
|
-
const
|
|
417
|
+
const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
|
|
418
|
+
const openRight = r.right + cascadeW <= window.innerWidth - VIEWPORT_PAD;
|
|
419
|
+
const left = openRight ? r.right : Math.max(VIEWPORT_PAD, r.left - cascadeW);
|
|
414
420
|
return pointRect(left, r.top);
|
|
415
421
|
},
|
|
416
422
|
contains: (node) => !!rowRefs.value[id]?.contains(node),
|
|
@@ -430,12 +436,13 @@ export default defineComponent({
|
|
|
430
436
|
if (!real) return pointRect(0, 0);
|
|
431
437
|
const r = real.getBoundingClientRect();
|
|
432
438
|
if (!r.width && !r.height) return pointRect(0, 0);
|
|
433
|
-
const gap = props.offset;
|
|
434
|
-
const
|
|
439
|
+
const gap = props.offset; // visual-space gap, same as the native placements
|
|
440
|
+
const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
|
|
441
|
+
const openRight = r.right + gap + cascadeW <= window.innerWidth - VIEWPORT_PAD;
|
|
435
442
|
const left =
|
|
436
443
|
side === "right" && openRight
|
|
437
444
|
? r.right + gap
|
|
438
|
-
: Math.max(VIEWPORT_PAD, r.left -
|
|
445
|
+
: Math.max(VIEWPORT_PAD, r.left - cascadeW - gap);
|
|
439
446
|
return pointRect(left, r.top);
|
|
440
447
|
},
|
|
441
448
|
contains: (node) => !!props.anchorRef?.contains(node),
|
|
@@ -3,6 +3,7 @@ import { usePopupManager, type PopupEntry } from "../runtime/usePopupManager";
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
4
|
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
5
5
|
import { scheduleEvery } from "../runtime/animationBus";
|
|
6
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
6
7
|
import "./HkModalBreadcrumb.scss";
|
|
7
8
|
|
|
8
9
|
/** Window kinds always participate in the stack: modals and drawers block
|
|
@@ -59,9 +60,24 @@ export default defineComponent({
|
|
|
59
60
|
function resyncTop() {
|
|
60
61
|
const app = document.getElementById(props.appRootId);
|
|
61
62
|
if (!app) return;
|
|
63
|
+
// The strip teleports to <body> — inside any root CSS zoom subtree
|
|
64
|
+
// — so its inline top is LOCAL px. Keep every term in that local
|
|
65
|
+
// space: the app root's computed top is already local, while the
|
|
66
|
+
// header's gBCR height reports the root VISUAL space (standardized
|
|
67
|
+
// zoom applies ancestor zoom), so it comes back divided by the
|
|
68
|
+
// cumulative zoom. Without the conversion the strip drifts
|
|
69
|
+
// (zoom-1)·headerHeight/2 down its window (chest's root-level
|
|
70
|
+
// manual DPI scale). The fallback height is a layout constant and
|
|
71
|
+
// stays local.
|
|
62
72
|
const appTop = parseFloat(getComputedStyle(app).top) || 0;
|
|
63
73
|
const header = app.querySelector(props.headerSelector) as HTMLElement | null;
|
|
64
|
-
|
|
74
|
+
// Measure the zoom AT the header (not just the root): the header's
|
|
75
|
+
// gBCR carries the zoom of every ancestor between it and the root,
|
|
76
|
+
// so the conversion factor is read from the same chain.
|
|
77
|
+
const z = ancestorZoom(header ?? document.body);
|
|
78
|
+
const headerH = header
|
|
79
|
+
? header.getBoundingClientRect().height / z
|
|
80
|
+
: props.headerFallbackHeight;
|
|
65
81
|
topPx.value = appTop + headerH / 2;
|
|
66
82
|
}
|
|
67
83
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression rig for popover anchoring under standardized CSS zoom
|
|
3
|
+
* (chest's root-level manual DPI scale): the anchor rect comes back in
|
|
4
|
+
* the root VISUAL space (zoom already applied) while the teleported
|
|
5
|
+
* fixed host lives inside the zoomed subtree and paints its px scaled
|
|
6
|
+
* back up. The host style must carry the VISUAL coords DIVIDED by the
|
|
7
|
+
* cumulative zoom — at a 300% root zoom the theme menu used to open
|
|
8
|
+
* three viewport-widths off screen and a top-anchored sheet floated a
|
|
9
|
+
* zoom× gap above its footer bar.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
13
|
+
|
|
14
|
+
import HkPopover from "./HkPopover";
|
|
15
|
+
|
|
16
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
17
|
+
const containers: HTMLElement[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
21
|
+
for (const el of containers.splice(0)) el.remove();
|
|
22
|
+
document.body
|
|
23
|
+
.querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
|
|
24
|
+
.forEach((el) => el.remove());
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function rect(x: number, y: number, w: number, h: number): DOMRect {
|
|
29
|
+
return { x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h, toJSON: () => ({}) } as DOMRect;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Report `zoom` on the document element, nothing elsewhere. */
|
|
33
|
+
function patchRootZoom(zoom: string) {
|
|
34
|
+
const original = window.getComputedStyle.bind(window);
|
|
35
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
|
36
|
+
(el: Element, pseudo?: string | null): CSSStyleDeclaration => {
|
|
37
|
+
const decl = original(el, pseudo ?? undefined);
|
|
38
|
+
return new Proxy(decl, {
|
|
39
|
+
get(target, prop, recv) {
|
|
40
|
+
if (prop === "zoom") return el === document.documentElement ? zoom : undefined;
|
|
41
|
+
const v = Reflect.get(target, prop, recv);
|
|
42
|
+
return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** gBCR rig: the anchor answers with a fixed visual rect, the teleported
|
|
50
|
+
* popover panel with its own, everything else with an empty rect. */
|
|
51
|
+
function patchRects(anchor: HTMLElement, anchorRect: DOMRect, panelRect: DOMRect) {
|
|
52
|
+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
|
53
|
+
if (this === anchor) return anchorRect;
|
|
54
|
+
if (this.classList.contains("hk-popover-panel")) return panelRect;
|
|
55
|
+
return rect(0, 0, 0, 0);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function flushFrames() {
|
|
60
|
+
await nextTick();
|
|
61
|
+
for (let i = 0; i < 3; i++) {
|
|
62
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
63
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mountPopover(anchor: HTMLElement, placement: "bottom" | "top-start" = "bottom") {
|
|
68
|
+
const container = document.createElement("div");
|
|
69
|
+
document.body.appendChild(container);
|
|
70
|
+
containers.push(container);
|
|
71
|
+
anchor.textContent = "anchor";
|
|
72
|
+
container.appendChild(anchor);
|
|
73
|
+
|
|
74
|
+
const open = ref(false);
|
|
75
|
+
const Wrapper = defineComponent({
|
|
76
|
+
setup() {
|
|
77
|
+
return () =>
|
|
78
|
+
h(HkPopover, {
|
|
79
|
+
modelValue: open.value,
|
|
80
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
81
|
+
anchorRef: anchor,
|
|
82
|
+
placement,
|
|
83
|
+
}, { default: () => h("div", { class: "pop-content" }, "content") });
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const app = createApp(Wrapper);
|
|
87
|
+
mounts.push(app);
|
|
88
|
+
app.mount(container);
|
|
89
|
+
return open;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ANCHOR = { x: 100, y: 20, w: 80, h: 32 };
|
|
93
|
+
const PANEL = { x: 0, y: 0, w: 200, h: 100 };
|
|
94
|
+
|
|
95
|
+
describe("HkPopover anchoring under root CSS zoom", () => {
|
|
96
|
+
it("writes visual px verbatim at zoom 1 (identity)", async () => {
|
|
97
|
+
window.innerWidth = 1200;
|
|
98
|
+
window.innerHeight = 800;
|
|
99
|
+
patchRootZoom("1");
|
|
100
|
+
const anchor = document.createElement("button");
|
|
101
|
+
patchRects(anchor, rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h), rect(PANEL.x, PANEL.y, PANEL.w, PANEL.h));
|
|
102
|
+
const open = mountPopover(anchor);
|
|
103
|
+
|
|
104
|
+
open.value = true;
|
|
105
|
+
await flushFrames();
|
|
106
|
+
|
|
107
|
+
const host = document.body.querySelector<HTMLElement>(".hk-popover-panel")!.parentElement!;
|
|
108
|
+
// crossPos = 100 + (80-200)/2 = 40 (clamped ≥8), top = 32+4 = 56 —
|
|
109
|
+
// zoom 1 divides by 1, so the legacy values survive unchanged.
|
|
110
|
+
expect(host.style.top).toBe("56px");
|
|
111
|
+
expect(host.style.left).toBe("40px");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("divides the fixed coords by the cumulative root zoom", async () => {
|
|
115
|
+
window.innerWidth = 1200;
|
|
116
|
+
window.innerHeight = 800;
|
|
117
|
+
patchRootZoom("2");
|
|
118
|
+
const anchor = document.createElement("button");
|
|
119
|
+
patchRects(anchor, rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h), rect(PANEL.x, PANEL.y, PANEL.w, PANEL.h));
|
|
120
|
+
const open = mountPopover(anchor);
|
|
121
|
+
|
|
122
|
+
open.value = true;
|
|
123
|
+
await flushFrames();
|
|
124
|
+
|
|
125
|
+
const host = document.body.querySelector<HTMLElement>(".hk-popover-panel")!.parentElement!;
|
|
126
|
+
// Same visual target as above, written in the host's local space so
|
|
127
|
+
// the paint-time zoom multiplication lands the panel back at the
|
|
128
|
+
// anchor: 56/2 = 28, 40/2 = 20.
|
|
129
|
+
expect(host.style.top).toBe("28px");
|
|
130
|
+
expect(host.style.left).toBe("20px");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("divides a top-anchored placement's bottom offset by the zoom", async () => {
|
|
134
|
+
window.innerWidth = 1200;
|
|
135
|
+
window.innerHeight = 800;
|
|
136
|
+
patchRootZoom("3");
|
|
137
|
+
const anchor = document.createElement("button");
|
|
138
|
+
// Footer bar: 60px tall at the viewport bottom edge in VISUAL px.
|
|
139
|
+
patchRects(anchor, rect(20, 740, 120, 60), rect(0, 400, 240, 90));
|
|
140
|
+
const open = mountPopover(anchor, "top-start");
|
|
141
|
+
|
|
142
|
+
open.value = true;
|
|
143
|
+
await flushFrames();
|
|
144
|
+
|
|
145
|
+
const host = document.body.querySelector<HTMLElement>(".hk-popover-panel")!.parentElement!;
|
|
146
|
+
// bottom = vh - anchor.top + off = 800 - 740 + 4 = 64 (clamped ≤ 702)
|
|
147
|
+
// → the panel's bottom edge sits 64 visual px above the viewport
|
|
148
|
+
// bottom (4px gap over the bar); start align → left = anchor.left
|
|
149
|
+
// = 20 (inside the pad clamp). Written divided by 3 to survive the
|
|
150
|
+
// ×3 paint scale.
|
|
151
|
+
expect(parseFloat(host.style.bottom)).toBeCloseTo(64 / 3, 2);
|
|
152
|
+
expect(parseFloat(host.style.left)).toBeCloseTo(20 / 3, 2);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
16
16
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
17
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
17
18
|
import { useI18n } from "../i18n/context";
|
|
18
19
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
19
20
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
@@ -557,12 +558,27 @@ export default defineComponent({
|
|
|
557
558
|
zIndex: panelZ.value,
|
|
558
559
|
};
|
|
559
560
|
}
|
|
561
|
+
// Anchored coords are measured in the ROOT VISUAL space (gBCR under
|
|
562
|
+
// standardized CSS zoom reports ancestor zoom already applied), but
|
|
563
|
+
// this fixed host lives inside the zoomed subtree, so the browser
|
|
564
|
+
// scales its px back up at paint. Writing visual px verbatim landed
|
|
565
|
+
// every popup zoom× away from its anchor (chest's root-level DPI
|
|
566
|
+
// scale: a 300% menu opened three viewport-widths off screen; a
|
|
567
|
+
// top-anchored popover floated a zoom× gap above its footer bar).
|
|
568
|
+
// Divide once at the write layer — the measurement, flip and clamp
|
|
569
|
+
// math above stays in the visual space it was computed in. The
|
|
570
|
+
// anchored branch is edge-anchored (0 / custom property) and needs
|
|
571
|
+
// none. Known limit: nothing observes a root-zoom change while the
|
|
572
|
+
// popover is open (resize/RO/reopen all recompute) — transient
|
|
573
|
+
// surfaces reopen correct, so no zoom watcher is wired.
|
|
574
|
+
const z = ancestorZoom(panelHostRef.value ?? document.body);
|
|
560
575
|
const c = coords.value;
|
|
576
|
+
const localPx = (v: number) => `${v / z}px`;
|
|
561
577
|
return {
|
|
562
|
-
...(c.top != null ? { top:
|
|
563
|
-
...(c.left != null ? { left:
|
|
564
|
-
...(c.bottom != null ? { bottom:
|
|
565
|
-
...(c.right != null ? { right:
|
|
578
|
+
...(c.top != null ? { top: localPx(c.top) } : {}),
|
|
579
|
+
...(c.left != null ? { left: localPx(c.left) } : {}),
|
|
580
|
+
...(c.bottom != null ? { bottom: localPx(c.bottom) } : {}),
|
|
581
|
+
...(c.right != null ? { right: localPx(c.right) } : {}),
|
|
566
582
|
position: "fixed" as const,
|
|
567
583
|
pointerEvents: "auto" as const,
|
|
568
584
|
zIndex: panelZ.value,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression rig for the select-panel popout under standardized CSS zoom
|
|
3
|
+
* (chest's root-level manual DPI scale): the anchor gBCR and the clamp
|
|
4
|
+
* viewport live in the root VISUAL space, the teleported fixed host
|
|
5
|
+
* paints its local px scaled by the cumulative zoom, and the panel's
|
|
6
|
+
* offsetWidth stays local — positionPanel must multiply the panel size
|
|
7
|
+
* into visual space for the flip/clamp math and divide the written px
|
|
8
|
+
* back to local, or every dropdown lands zoom× away from its trigger.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
12
|
+
import type { ComponentPublicInstance } from "vue";
|
|
13
|
+
|
|
14
|
+
import HkSelectPanel from "./HkSelectPanel";
|
|
15
|
+
|
|
16
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
17
|
+
const containers: HTMLElement[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
21
|
+
for (const el of containers.splice(0)) el.remove();
|
|
22
|
+
document.body
|
|
23
|
+
.querySelectorAll(".hk-select-popout, .hk-select-popout-host, .hk-select-sheet-panel, .hk-select-sheet-scrim")
|
|
24
|
+
.forEach((el) => el.remove());
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
window.innerWidth = 1200;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function rect(x: number, y: number, w: number, h: number): DOMRect {
|
|
30
|
+
return { x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h, toJSON: () => ({}) } as DOMRect;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function patchRootZoom(zoom: string) {
|
|
34
|
+
const original = window.getComputedStyle.bind(window);
|
|
35
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
|
36
|
+
(el: Element, pseudo?: string | null): CSSStyleDeclaration => {
|
|
37
|
+
const decl = original(el, pseudo ?? undefined);
|
|
38
|
+
return new Proxy(decl, {
|
|
39
|
+
get(target, prop, recv) {
|
|
40
|
+
if (prop === "zoom") return el === document.documentElement ? zoom : undefined;
|
|
41
|
+
const v = Reflect.get(target, prop, recv);
|
|
42
|
+
return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mountPanel() {
|
|
50
|
+
const container = document.createElement("div");
|
|
51
|
+
document.body.appendChild(container);
|
|
52
|
+
containers.push(container);
|
|
53
|
+
|
|
54
|
+
const open = ref(false);
|
|
55
|
+
const anchorEl = ref<HTMLElement | null>(null);
|
|
56
|
+
const Wrapper = defineComponent({
|
|
57
|
+
setup() {
|
|
58
|
+
return () =>
|
|
59
|
+
h("div", [
|
|
60
|
+
h("button", {
|
|
61
|
+
ref: (el: Element | ComponentPublicInstance | null) => {
|
|
62
|
+
anchorEl.value = el as HTMLElement | null;
|
|
63
|
+
},
|
|
64
|
+
type: "button",
|
|
65
|
+
onClick: () => { open.value = !open.value; },
|
|
66
|
+
}, "filter"),
|
|
67
|
+
h(HkSelectPanel, {
|
|
68
|
+
open: open.value,
|
|
69
|
+
"onUpdate:open": (v: boolean) => { open.value = v; },
|
|
70
|
+
anchorRef: anchorEl.value,
|
|
71
|
+
title: "Filters",
|
|
72
|
+
placement: "top-start",
|
|
73
|
+
}, {
|
|
74
|
+
default: () => [h("label", { class: "row" }, "a"), h("label", { class: "row" }, "b")],
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const app = createApp(Wrapper);
|
|
80
|
+
mounts.push(app);
|
|
81
|
+
app.mount(container);
|
|
82
|
+
return { open, button: container.querySelector<HTMLButtonElement>("button")! };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function flush() {
|
|
86
|
+
await nextTick();
|
|
87
|
+
await nextTick();
|
|
88
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("HkSelectPanel popout under root CSS zoom", () => {
|
|
92
|
+
// Anchor mid-viewport, panel size unmeasurable in happy-dom (offsetWidth
|
|
93
|
+
// 0) → pw falls back to max(anchorWidth,180)=180, ph to 200.
|
|
94
|
+
const ANCHOR = { x: 200, y: 300, w: 100, h: 40 };
|
|
95
|
+
|
|
96
|
+
it("writes visual px verbatim at zoom 1 (identity)", async () => {
|
|
97
|
+
window.innerWidth = 1200;
|
|
98
|
+
patchRootZoom("1");
|
|
99
|
+
const { button } = mountPanel();
|
|
100
|
+
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h));
|
|
101
|
+
|
|
102
|
+
button.click();
|
|
103
|
+
await flush();
|
|
104
|
+
|
|
105
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
106
|
+
// top = 300 - 4 - 200 = 96, left = 200 (start).
|
|
107
|
+
expect(host.style.top).toBe("96px");
|
|
108
|
+
expect(host.style.left).toBe("200px");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("divides the written px by the cumulative root zoom", async () => {
|
|
112
|
+
window.innerWidth = 1200;
|
|
113
|
+
patchRootZoom("2");
|
|
114
|
+
const { button } = mountPanel();
|
|
115
|
+
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h));
|
|
116
|
+
|
|
117
|
+
button.click();
|
|
118
|
+
await flush();
|
|
119
|
+
|
|
120
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
121
|
+
// Same visual target, written in the host's local space: 96/2, 200/2.
|
|
122
|
+
expect(host.style.top).toBe("48px");
|
|
123
|
+
expect(host.style.left).toBe("100px");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -13,6 +13,7 @@ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
|
13
13
|
import { useOverlay } from "../runtime/useOverlay";
|
|
14
14
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
15
15
|
import { createBackGuard } from "../runtime/backStack";
|
|
16
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
16
17
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
17
18
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
18
19
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
@@ -525,9 +526,22 @@ export default defineComponent({
|
|
|
525
526
|
const anchor = props.anchorRef;
|
|
526
527
|
if (!anchor) return;
|
|
527
528
|
const r = anchor.getBoundingClientRect();
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
529
|
+
// The popout host is teleported INSIDE the root zoom subtree: its
|
|
530
|
+
// inline fixed px are interpreted locally and scaled by the
|
|
531
|
+
// cumulative CSS zoom at paint, while gBCR rects (anchor r, and the
|
|
532
|
+
// clamp viewport below) report the ROOT VISUAL space where that
|
|
533
|
+
// zoom is already applied. Convert both ways at the boundaries —
|
|
534
|
+
// offsetWidth stays local (and transform-agnostic, unlike gBCR
|
|
535
|
+
// which would swallow the enter transition's scale) so panel sizes
|
|
536
|
+
// are multiplied into visual space for the flip/clamp math, and the
|
|
537
|
+
// final px are divided back to local for the write. Without the
|
|
538
|
+
// conversion every dropdown lands zoom× away from its trigger
|
|
539
|
+
// (chest's root-level manual DPI scale).
|
|
540
|
+
const z = ancestorZoom(popoutHostRef.value ?? document.body);
|
|
541
|
+
const pwRaw = panelRef.value?.offsetWidth || 0;
|
|
542
|
+
const phRaw = panelRef.value?.offsetHeight || 0;
|
|
543
|
+
const pw = pwRaw > 0 ? pwRaw * z : Math.max(r.width, 180);
|
|
544
|
+
const ph = phRaw > 0 ? phRaw * z : 200;
|
|
531
545
|
let side: "top" | "bottom" = props.placement.startsWith("top-") ? "top" : "bottom";
|
|
532
546
|
let top =
|
|
533
547
|
side === "top"
|
|
@@ -566,9 +580,9 @@ export default defineComponent({
|
|
|
566
580
|
const maxLeft = Math.max(VIEWPORT_PAD, window.innerWidth - VIEWPORT_PAD - pw);
|
|
567
581
|
left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft);
|
|
568
582
|
coords.value = {
|
|
569
|
-
top: `${Math.round(top)}px`,
|
|
570
|
-
left: `${Math.round(left)}px`,
|
|
571
|
-
...(props.matchAnchorWidth ? { minWidth: `${Math.round(r.width)}px` } : {}),
|
|
583
|
+
top: `${Math.round(top / z)}px`,
|
|
584
|
+
left: `${Math.round(left / z)}px`,
|
|
585
|
+
...(props.matchAnchorWidth ? { minWidth: `${Math.round(r.width / z)}px` } : {}),
|
|
572
586
|
};
|
|
573
587
|
}
|
|
574
588
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
|
|
2
2
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
3
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
3
4
|
import "./HkTooltip.scss";
|
|
4
5
|
|
|
5
6
|
export default defineComponent({
|
|
@@ -33,6 +34,15 @@ export default defineComponent({
|
|
|
33
34
|
if (!wrapperRef.value) return;
|
|
34
35
|
const rect = wrapperRef.value.getBoundingClientRect();
|
|
35
36
|
const gap = 8;
|
|
37
|
+
// The tooltip teleports to <body> — inside any root CSS zoom
|
|
38
|
+
// subtree — while `rect` is already in the root visual space
|
|
39
|
+
// (standardized zoom reports ancestor zoom applied). The browser
|
|
40
|
+
// scales the tooltip's local px back up at paint, so the visual
|
|
41
|
+
// rect values must be written divided by the cumulative zoom or
|
|
42
|
+
// the tooltip drifts zoom× off its trigger (chest's root-level
|
|
43
|
+
// manual DPI scale).
|
|
44
|
+
const z = ancestorZoom(document.body);
|
|
45
|
+
const local = (v: number) => `${v / z}px`;
|
|
36
46
|
const style: CSSProperties = {};
|
|
37
47
|
|
|
38
48
|
if (props.maxWidth) {
|
|
@@ -41,23 +51,23 @@ export default defineComponent({
|
|
|
41
51
|
|
|
42
52
|
switch (props.placement) {
|
|
43
53
|
case "top":
|
|
44
|
-
style.top =
|
|
45
|
-
style.left =
|
|
54
|
+
style.top = local(rect.top - gap);
|
|
55
|
+
style.left = local(rect.left + rect.width / 2);
|
|
46
56
|
style.transform = "translate(-50%, -100%)";
|
|
47
57
|
break;
|
|
48
58
|
case "bottom":
|
|
49
|
-
style.top =
|
|
50
|
-
style.left =
|
|
59
|
+
style.top = local(rect.bottom + gap);
|
|
60
|
+
style.left = local(rect.left + rect.width / 2);
|
|
51
61
|
style.transform = "translate(-50%, 0)";
|
|
52
62
|
break;
|
|
53
63
|
case "left":
|
|
54
|
-
style.top =
|
|
55
|
-
style.left =
|
|
64
|
+
style.top = local(rect.top + rect.height / 2);
|
|
65
|
+
style.left = local(rect.left - gap);
|
|
56
66
|
style.transform = "translate(-100%, -50%)";
|
|
57
67
|
break;
|
|
58
68
|
case "right":
|
|
59
|
-
style.top =
|
|
60
|
-
style.left =
|
|
69
|
+
style.top = local(rect.top + rect.height / 2);
|
|
70
|
+
style.left = local(rect.right + gap);
|
|
61
71
|
style.transform = "translate(0, -50%)";
|
|
62
72
|
break;
|
|
63
73
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract for the cumulative-zoom helper that keeps teleported overlays
|
|
3
|
+
* anchored under standardized CSS zoom (chest's root-level manual DPI
|
|
4
|
+
* scale): gBCR reports root VISUAL space while fixed px written inside
|
|
5
|
+
* the zoomed subtree are local — the helper is the one conversion factor
|
|
6
|
+
* between the two. It must multiply every explicit zoom along the
|
|
7
|
+
* element → documentElement chain, ignore links without DOM zoom support
|
|
8
|
+
* (old engines, happy-dom) or with `normal`/garbage values, and default
|
|
9
|
+
* to the identity 1 everywhere else.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { ancestorZoom } from "./cssZoom";
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Patch getComputedStyle so the given elements report a `zoom` value. */
|
|
20
|
+
function patchZoom(map: Map<Element, string>) {
|
|
21
|
+
const original = window.getComputedStyle.bind(window);
|
|
22
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
|
23
|
+
(el: Element, pseudo?: string | null): CSSStyleDeclaration => {
|
|
24
|
+
const decl = original(el, pseudo ?? undefined);
|
|
25
|
+
const zoom = map.get(el);
|
|
26
|
+
return new Proxy(decl, {
|
|
27
|
+
get(target, prop, recv) {
|
|
28
|
+
if (prop === "zoom") return zoom;
|
|
29
|
+
const v = Reflect.get(target, prop, recv);
|
|
30
|
+
return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("ancestorZoom", () => {
|
|
38
|
+
it("returns 1 when no element reports a zoom", () => {
|
|
39
|
+
patchZoom(new Map());
|
|
40
|
+
const el = document.createElement("div");
|
|
41
|
+
document.body.appendChild(el);
|
|
42
|
+
expect(ancestorZoom(el)).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("multiplies the explicit zoom values along the ancestor chain", () => {
|
|
46
|
+
const el = document.createElement("div");
|
|
47
|
+
document.body.appendChild(el);
|
|
48
|
+
patchZoom(
|
|
49
|
+
new Map([
|
|
50
|
+
[document.documentElement, "2"],
|
|
51
|
+
[document.body, "1.5"],
|
|
52
|
+
]),
|
|
53
|
+
);
|
|
54
|
+
expect(ancestorZoom(el)).toBeCloseTo(3);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("counts only the links between the element and the root", () => {
|
|
58
|
+
const outer = document.createElement("div");
|
|
59
|
+
const inner = document.createElement("div");
|
|
60
|
+
outer.appendChild(inner);
|
|
61
|
+
document.body.appendChild(outer);
|
|
62
|
+
patchZoom(new Map([[outer, "2"]]));
|
|
63
|
+
// inner's chain: outer(2) → body → html — the unrelated sibling-zoom
|
|
64
|
+
// absence below outer stays out.
|
|
65
|
+
expect(ancestorZoom(inner)).toBe(2);
|
|
66
|
+
expect(ancestorZoom(outer)).toBe(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ignores normal and unparseable values", () => {
|
|
70
|
+
const el = document.createElement("div");
|
|
71
|
+
document.body.appendChild(el);
|
|
72
|
+
patchZoom(
|
|
73
|
+
new Map([
|
|
74
|
+
[document.documentElement, "normal"],
|
|
75
|
+
[document.body, "zoomed"],
|
|
76
|
+
]),
|
|
77
|
+
);
|
|
78
|
+
expect(ancestorZoom(el)).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("guards non-positive zoom values", () => {
|
|
82
|
+
const el = document.createElement("div");
|
|
83
|
+
document.body.appendChild(el);
|
|
84
|
+
patchZoom(
|
|
85
|
+
new Map([
|
|
86
|
+
[document.documentElement, "0"],
|
|
87
|
+
[document.body, "-2"],
|
|
88
|
+
]),
|
|
89
|
+
);
|
|
90
|
+
expect(ancestorZoom(el)).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns 1 for a null element", () => {
|
|
94
|
+
expect(ancestorZoom(null)).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective CSS `zoom` between a teleported overlay element and the
|
|
3
|
+
* viewport root.
|
|
4
|
+
*
|
|
5
|
+
* Standardized CSS zoom (Chrome 128+, Firefox 126+, Safari 18.2+) made
|
|
6
|
+
* `getBoundingClientRect()` report the ROOT VISUAL space — ancestor zoom
|
|
7
|
+
* is already multiplied into every rect — while `position: fixed` px
|
|
8
|
+
* assigned on an element that LIVES inside the zoomed subtree are
|
|
9
|
+
* interpreted in that element's own local space and scaled back up at
|
|
10
|
+
* paint. Overlay components that measure anchors with gBCR and position
|
|
11
|
+
* themselves with fixed px therefore land zoom× away from their anchor
|
|
12
|
+
* unless the written px are divided by the cumulative zoom (chest's
|
|
13
|
+
* root-level manual DPI scale is the producer of such subtrees; at a
|
|
14
|
+
* 300% scale an anchored menu flew three viewport-widths off screen).
|
|
15
|
+
*
|
|
16
|
+
* `zoom` is not inherited, so the factor is the product of the explicit
|
|
17
|
+
* zoom values along the element → documentElement chain. Links without
|
|
18
|
+
* DOM zoom support (old engines, happy-dom/jsdom), `normal`, garbage or
|
|
19
|
+
* non-positive numbers contribute nothing; the default stays 1 — the
|
|
20
|
+
* identity the fixed-positioning math has always assumed. No DOM at all
|
|
21
|
+
* (SSR guards) also collapses to 1.
|
|
22
|
+
*/
|
|
23
|
+
export function ancestorZoom(el: Element | null | undefined): number {
|
|
24
|
+
if (typeof window === "undefined" || !el) return 1;
|
|
25
|
+
let zoom = 1;
|
|
26
|
+
let cur: Element | null = el;
|
|
27
|
+
while (cur) {
|
|
28
|
+
// `zoom` is missing from older DOM style declarations — read it off a
|
|
29
|
+
// widened record instead of assuming the lib carries the property.
|
|
30
|
+
const raw = (window.getComputedStyle(cur) as CSSStyleDeclaration & { zoom?: string | number }).zoom;
|
|
31
|
+
const v = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseFloat(raw) : Number.NaN;
|
|
32
|
+
if (Number.isFinite(v) && v > 0) zoom *= v;
|
|
33
|
+
cur = cur.parentElement;
|
|
34
|
+
}
|
|
35
|
+
return zoom > 0 ? zoom : 1;
|
|
36
|
+
}
|