@celestia-island/hikari 0.43.3 → 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/HkAffixPicker.test.tsx +29 -0
- package/src/components/HkAffixPicker.tsx +9 -0
- package/src/components/HkDraggableGrid.tsx +15 -4
- package/src/components/HkDraggableList.tsx +15 -4
- package/src/components/HkMenu.test.ts +69 -0
- package/src/components/HkMenu.tsx +31 -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/HkTagInput.scss +53 -7
- package/src/components/HkTagInput.test.tsx +183 -0
- package/src/components/HkTagInput.tsx +29 -4
- 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
|
@@ -444,4 +444,33 @@ describe("HkAffixPicker", () => {
|
|
|
444
444
|
// CSS/text half of the contract is pinned by the contract test.
|
|
445
445
|
expect(document.querySelector(".hk-affix-list .hk-scrollbar-track")).toBeNull();
|
|
446
446
|
});
|
|
447
|
+
|
|
448
|
+
it("caps its popup surface with the tag editor's own measure", async () => {
|
|
449
|
+
const { container } = mountPicker({ mode: "multi", selected: ["cn"] });
|
|
450
|
+
await openPopup(container);
|
|
451
|
+
|
|
452
|
+
// The picker renders through HkMenu, which forwards a cap to the shared
|
|
453
|
+
// panel surface: the popup scrolls inside min(18rem, 45dvh) instead of
|
|
454
|
+
// stretching to the panel's 36rem ceiling — the same measure HkTagInput
|
|
455
|
+
// uses, so the two sibling chip pickers read consistently.
|
|
456
|
+
const popout = document.querySelector<HTMLElement>(".hk-select-popout");
|
|
457
|
+
expect(popout, "the popup renders as the shared popout").toBeTruthy();
|
|
458
|
+
expect(popout!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(
|
|
459
|
+
"min(18rem, 45dvh)",
|
|
460
|
+
);
|
|
461
|
+
// The cap is the SURFACE's: the popout carries the hook and no inline
|
|
462
|
+
// max-height of its own…
|
|
463
|
+
expect(popout!.style.maxHeight).toBe("");
|
|
464
|
+
// …and the picker still opts out of matching the chip's width.
|
|
465
|
+
expect(document.querySelector<HTMLElement>(".hk-select-popout-host")!.style.minWidth).toBe(
|
|
466
|
+
"",
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// The popup's designed measure is untouched: the content wrapper stays a
|
|
470
|
+
// plain width container with no cap or scroll region of its own.
|
|
471
|
+
const content = document.querySelector<HTMLElement>(".hk-affix-scroll");
|
|
472
|
+
expect(content, "the content wrapper renders").toBeTruthy();
|
|
473
|
+
expect(content!.hasAttribute("style"), "no inline style on the content").toBe(false);
|
|
474
|
+
expect(document.querySelector(".hk-affix-list .hk-scrollbar-track")).toBeNull();
|
|
475
|
+
});
|
|
447
476
|
});
|
|
@@ -72,6 +72,11 @@ function isSubsequence(query: string, text: string): boolean {
|
|
|
72
72
|
* as one. The row list deliberately carries no max-height/overflow; a
|
|
73
73
|
* nested second scrollbar inside the same window is a contract
|
|
74
74
|
* violation (2026-09-08 user report: the language sheet scrolled twice).
|
|
75
|
+
* The surface itself is capped at `min(18rem, 45dvh)` through HkMenu's
|
|
76
|
+
* `maxHeight` forwarding (the HkTagInput measure, so the family reads
|
|
77
|
+
* consistently), which bounds the one scroll region instead of letting it
|
|
78
|
+
* stretch to the shared panel's 36rem ceiling. The popup's own designed
|
|
79
|
+
* measure (13–19rem wide) is unchanged: the cap bounds HEIGHT only.
|
|
75
80
|
*
|
|
76
81
|
* With `tagValues`, the multi picker's TAG LIST switches to
|
|
77
82
|
* value-driven primary text: a selected key renders its mapped value
|
|
@@ -368,6 +373,10 @@ export const HkAffixPicker = defineComponent({
|
|
|
368
373
|
anchorRef={chipRef.value}
|
|
369
374
|
placement={placement}
|
|
370
375
|
matchAnchorWidth={false}
|
|
376
|
+
// The same cap the tag editor's catalog uses: the popup hugs its
|
|
377
|
+
// own designed measure instead of stretching to the shared
|
|
378
|
+
// panel's 36rem ceiling, and a long catalog scrolls inside it.
|
|
379
|
+
maxHeight="min(18rem, 45dvh)"
|
|
371
380
|
title={title}
|
|
372
381
|
>
|
|
373
382
|
{{
|
|
@@ -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
|
|
|
@@ -345,6 +345,75 @@ describe("HkMenu on the HkSelectPanel surface", () => {
|
|
|
345
345
|
});
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
describe("HkMenu surface cap (maxHeight)", () => {
|
|
349
|
+
const CAP = "min(18rem, 45dvh)";
|
|
350
|
+
|
|
351
|
+
it("renders no cap at all when the prop is absent (the additive no-op)", async () => {
|
|
352
|
+
// Pinned at the DOM level rather than in source text: with no
|
|
353
|
+
// `maxHeight` the shared panel forwards nothing, so the popout
|
|
354
|
+
// publishes no hook and carries no inline max-height — every existing
|
|
355
|
+
// consumer (theme switchers, cascades, the menubar) is untouched.
|
|
356
|
+
mountMenu(ref(true));
|
|
357
|
+
await settle();
|
|
358
|
+
const popout = popouts()[0];
|
|
359
|
+
expect(popout.style.getPropertyValue("--hk-select-panel-max-height")).toBe("");
|
|
360
|
+
expect(popout.style.maxHeight).toBe("");
|
|
361
|
+
expect(popout.hasAttribute("style"), "no inline style at all").toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("publishes the cap on the root popout and on every cascade level", async () => {
|
|
365
|
+
mountMenu(ref(true), siblingItems, {}, { props: { maxHeight: CAP } });
|
|
366
|
+
await settle();
|
|
367
|
+
expect(popouts()[0].style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
|
|
368
|
+
|
|
369
|
+
// A cascade level is its own scroll surface, so it is capped too.
|
|
370
|
+
rowByLabel("Branch One")!.click();
|
|
371
|
+
await settle();
|
|
372
|
+
expect(popouts().length).toBe(2);
|
|
373
|
+
expect(popouts()[1].style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
|
|
374
|
+
|
|
375
|
+
// …and the cap follows the cascade to whichever level is open.
|
|
376
|
+
rowByLabel("Branch Two")!.click();
|
|
377
|
+
await settle();
|
|
378
|
+
expect(popouts().length).toBe(2);
|
|
379
|
+
expect(popouts()[1].textContent).toContain("Two A");
|
|
380
|
+
expect(popouts()[1].style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
|
|
381
|
+
|
|
382
|
+
// Height only: the cap never touches the popout's width contract.
|
|
383
|
+
expect(popoutHosts()[0].style.minWidth).toBe("");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("caps the mobile sheet path — on the band that actually scrolls", async () => {
|
|
387
|
+
window.innerWidth = 390;
|
|
388
|
+
mountMenu(ref(true), siblingItems, {}, { props: { maxHeight: CAP } });
|
|
389
|
+
await settle();
|
|
390
|
+
expect(sheets().length).toBe(1);
|
|
391
|
+
|
|
392
|
+
// In sheet mode the element the panel caps (and the one that scrolls) is
|
|
393
|
+
// the list band inside the sheet, not the sheet panel itself.
|
|
394
|
+
const band = sheets()[0].querySelector<HTMLElement>(".hk-select-sheet-list");
|
|
395
|
+
expect(band, "the sheet list band renders").toBeTruthy();
|
|
396
|
+
expect(band!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
|
|
397
|
+
|
|
398
|
+
rowByLabel("Branch One")!.click();
|
|
399
|
+
await settle();
|
|
400
|
+
expect(sheets().length).toBe(2);
|
|
401
|
+
const subBand = sheets()[1].querySelector<HTMLElement>(".hk-select-sheet-list");
|
|
402
|
+
expect(subBand, "the pushed sheet's band renders").toBeTruthy();
|
|
403
|
+
expect(subBand!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("leaves the mobile sheet band uncapped when the prop is absent", async () => {
|
|
407
|
+
window.innerWidth = 390;
|
|
408
|
+
mountMenu(ref(true), siblingItems);
|
|
409
|
+
await settle();
|
|
410
|
+
const band = sheets()[0].querySelector<HTMLElement>(".hk-select-sheet-list");
|
|
411
|
+
expect(band, "the sheet list band renders").toBeTruthy();
|
|
412
|
+
expect(band!.style.getPropertyValue("--hk-select-panel-max-height")).toBe("");
|
|
413
|
+
expect(band!.hasAttribute("style"), "no inline style at all").toBe(false);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
348
417
|
describe("HkMenu check column (selectMode)", () => {
|
|
349
418
|
const checkedItems: HkMenuItem[] = [
|
|
350
419
|
{ key: "a", label: "Alpha", checked: true },
|
|
@@ -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
|
|
|
@@ -142,6 +143,20 @@ export default defineComponent({
|
|
|
142
143
|
* shrink to fit.
|
|
143
144
|
*/
|
|
144
145
|
matchAnchorWidth: { type: Boolean, default: false },
|
|
146
|
+
/**
|
|
147
|
+
* Cap for the panel surface of EVERY level, as any CSS length
|
|
148
|
+
* (`min(18rem, 45dvh)`, `24rem`, …): it is forwarded to each
|
|
149
|
+
* HkSelectPanel this menu renders — the root desktop popout, every
|
|
150
|
+
* cascade level and each mobile sheet layer (every level is its own
|
|
151
|
+
* scroll surface, so each one is capped).
|
|
152
|
+
*
|
|
153
|
+
* Undefined (the default) forwards nothing at all, so the shared panel
|
|
154
|
+
* keeps its own ceilings and every existing consumer — theme switchers,
|
|
155
|
+
* cascades, the menubar, popups — renders exactly as before. See
|
|
156
|
+
* HkSelectPanel's own `maxHeight` for the value contract: any CSS
|
|
157
|
+
* length, applied on the `dvh`-capable branch, never sanitized.
|
|
158
|
+
*/
|
|
159
|
+
maxHeight: { type: String, default: undefined },
|
|
145
160
|
},
|
|
146
161
|
emits: ["update:open", "select"],
|
|
147
162
|
setup(props, { emit, slots }) {
|
|
@@ -384,7 +399,11 @@ export default defineComponent({
|
|
|
384
399
|
/** Synthetic anchor placing a sub-panel beside its parent row:
|
|
385
400
|
* popout to the right, flipping to the left when the viewport runs
|
|
386
401
|
* out (classic menubar cascade). The rect stays LIVE — HkSelectPanel
|
|
387
|
-
* 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. */
|
|
388
407
|
function rowAnchor(level: number, key: string): PanelAnchor {
|
|
389
408
|
const id = `${level}:${key}`;
|
|
390
409
|
let anchor = anchorCache.get(id);
|
|
@@ -395,8 +414,9 @@ export default defineComponent({
|
|
|
395
414
|
if (!row) return pointRect(0, 0);
|
|
396
415
|
const r = row.getBoundingClientRect();
|
|
397
416
|
if (!r.width && !r.height) return pointRect(0, 0); // detached
|
|
398
|
-
const
|
|
399
|
-
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);
|
|
400
420
|
return pointRect(left, r.top);
|
|
401
421
|
},
|
|
402
422
|
contains: (node) => !!rowRefs.value[id]?.contains(node),
|
|
@@ -416,12 +436,13 @@ export default defineComponent({
|
|
|
416
436
|
if (!real) return pointRect(0, 0);
|
|
417
437
|
const r = real.getBoundingClientRect();
|
|
418
438
|
if (!r.width && !r.height) return pointRect(0, 0);
|
|
419
|
-
const gap = props.offset;
|
|
420
|
-
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;
|
|
421
442
|
const left =
|
|
422
443
|
side === "right" && openRight
|
|
423
444
|
? r.right + gap
|
|
424
|
-
: Math.max(VIEWPORT_PAD, r.left -
|
|
445
|
+
: Math.max(VIEWPORT_PAD, r.left - cascadeW - gap);
|
|
425
446
|
return pointRect(left, r.top);
|
|
426
447
|
},
|
|
427
448
|
contains: (node) => !!props.anchorRef?.contains(node),
|
|
@@ -549,6 +570,7 @@ export default defineComponent({
|
|
|
549
570
|
placement={cfg.placement}
|
|
550
571
|
offset={cfg.offset}
|
|
551
572
|
matchAnchorWidth={cfg.match}
|
|
573
|
+
maxHeight={props.maxHeight}
|
|
552
574
|
onUpdate:open={(v: boolean) => {
|
|
553
575
|
if (!v) onRootCloseRequest();
|
|
554
576
|
}}
|
|
@@ -576,6 +598,7 @@ export default defineComponent({
|
|
|
576
598
|
title={branch?.label ?? props.title}
|
|
577
599
|
placement="bottom-start"
|
|
578
600
|
offset={0}
|
|
601
|
+
maxHeight={props.maxHeight}
|
|
579
602
|
onUpdate:open={(v: boolean) => {
|
|
580
603
|
if (!v) onSubCloseRequest(level);
|
|
581
604
|
}}
|
|
@@ -598,6 +621,7 @@ export default defineComponent({
|
|
|
598
621
|
open={props.open}
|
|
599
622
|
anchorRef={props.anchorRef}
|
|
600
623
|
title={props.title}
|
|
624
|
+
maxHeight={props.maxHeight}
|
|
601
625
|
onUpdate:open={(v: boolean) => {
|
|
602
626
|
if (!v) onRootCloseRequest();
|
|
603
627
|
}}
|
|
@@ -616,6 +640,7 @@ export default defineComponent({
|
|
|
616
640
|
// Sub-levels carry no title of their own — the parent item's
|
|
617
641
|
// label names the sheet.
|
|
618
642
|
title={entry.item.label}
|
|
643
|
+
maxHeight={props.maxHeight}
|
|
619
644
|
onUpdate:open={(v: boolean) => {
|
|
620
645
|
if (!v) onStackCloseRequest(i);
|
|
621
646
|
}}
|
|
@@ -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
|
|
|
@@ -101,10 +101,14 @@
|
|
|
101
101
|
|
|
102
102
|
/* A disabled field reorders nothing: the chips drop the grab affordance
|
|
103
103
|
* (and the gesture reservation with it) — the box's own not-allowed
|
|
104
|
-
* cursor takes over
|
|
104
|
+
* cursor takes over — and they become plain TEXT: with no drag left to
|
|
105
|
+
* lose, the press is free to select, so the chips are selectable here
|
|
106
|
+
* (the one state where selection and reordering cannot compete). */
|
|
105
107
|
.hk-tag-input-box[data-disabled] .hk-tag-input-tag {
|
|
106
108
|
cursor: default;
|
|
107
109
|
touch-action: auto;
|
|
110
|
+
user-select: text;
|
|
111
|
+
-webkit-user-select: text;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
/* ── inline typing input / placeholder ────────────────────────────── */
|
|
@@ -318,14 +322,50 @@
|
|
|
318
322
|
}
|
|
319
323
|
|
|
320
324
|
/* ── reordering ───────────────────────────────────────────────────── */
|
|
321
|
-
/* Live drag (both lists): the item being moved
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
+
/* Live drag (both lists): the item being moved LIFTS — a touch larger and
|
|
326
|
+
* raised off the surface — while the slot it would land in is ringed. The
|
|
327
|
+
* lift is what makes the drag read on touch, where the finger covers the
|
|
328
|
+
* chip, the cursor is invisible and dimming alone looks like nothing
|
|
329
|
+
* happened. The dimming stays LIGHT on purpose (0.85, the value of the
|
|
330
|
+
* HkDraggableList/HkDraggableGrid drag ghost): the item must still read as
|
|
331
|
+
* the one being moved, not fade out. `transform` reflows nothing — no
|
|
332
|
+
* neighbour ever moves for it — and the axis rules below keep it out of the
|
|
333
|
+
* drag's own geometry too. The raised shadow replaces the row's own
|
|
334
|
+
* box-shadow faces for as long as the drag lasts (the keyboard cursor's
|
|
335
|
+
* inset ring included: the lift is the live state).
|
|
336
|
+
*
|
|
337
|
+
* Chips carry the state as classes (HkTag takes no arbitrary attributes),
|
|
338
|
+
* panel rows as data attributes — both are assertable hooks that never
|
|
339
|
+
* collide with the pointer/hover faces. */
|
|
325
340
|
.hk-tag-input-tag-dragging,
|
|
326
341
|
.hk-tag-input-row[data-dragging] {
|
|
327
|
-
opacity: 0.
|
|
342
|
+
opacity: 0.85;
|
|
328
343
|
cursor: grabbing;
|
|
344
|
+
box-shadow:
|
|
345
|
+
0 6px 18px rgb(0 0 0 / 24%),
|
|
346
|
+
0 2px 6px rgb(0 0 0 / 16%);
|
|
347
|
+
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* The scale runs ALONG each list's own drag axis, never across it. A drag
|
|
351
|
+
* resolves its landing slot from the items' LIVE rects (usePointerReorder
|
|
352
|
+
* .indexAt): the item's MIDPOINT on the drag axis picks the slot, and on
|
|
353
|
+
* the axis ACROSS it every item's span decides which line the pointer is
|
|
354
|
+
* on — a filter that started out a no-op precisely because every row spans
|
|
355
|
+
* the list's full width. A scale is symmetric about the item's centre, so
|
|
356
|
+
* scaling along the drag axis leaves both readings exactly as they were;
|
|
357
|
+
* growing ACROSS it widens one item's band, and a pointer a few pixels
|
|
358
|
+
* outside the strip (the row list's own padding is 6px) would then match
|
|
359
|
+
* that item ALONE — every sibling falls out of the line filter, the slot
|
|
360
|
+
* collapses back onto the item's own index, and the drag ends as a silent
|
|
361
|
+
* no-op with no drop ring anywhere. Chips therefore scale on X (a
|
|
362
|
+
* horizontal strip), rows on Y (a vertical list). */
|
|
363
|
+
.hk-tag-input-tag-dragging {
|
|
364
|
+
transform: scaleX(1.03);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.hk-tag-input-row[data-dragging] {
|
|
368
|
+
transform: scaleY(1.03);
|
|
329
369
|
}
|
|
330
370
|
|
|
331
371
|
.hk-tag-input-tag-drop {
|
|
@@ -384,7 +424,13 @@
|
|
|
384
424
|
.hk-tag-input-box,
|
|
385
425
|
.hk-tag-input-chevron,
|
|
386
426
|
.hk-tag-input-grip,
|
|
387
|
-
.hk-tag-input-row
|
|
427
|
+
.hk-tag-input-row,
|
|
428
|
+
/* The lift itself is KEPT without its ramp: the scale and the raised
|
|
429
|
+
* shadow are a static state marker, not motion, and dropping them would
|
|
430
|
+
* take the drag feedback away from exactly the users who cannot rely on
|
|
431
|
+
* the cursor following the pointer — only the 0.12s transition goes. */
|
|
432
|
+
.hk-tag-input-tag-dragging,
|
|
433
|
+
.hk-tag-input-row[data-dragging] {
|
|
388
434
|
transition: none;
|
|
389
435
|
}
|
|
390
436
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
2
5
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
6
|
|
|
4
7
|
import HkModal from "./HkModal";
|
|
@@ -1494,6 +1497,92 @@ describe("HkTagInput panel geometry and drag reordering", () => {
|
|
|
1494
1497
|
expect(tagTexts(container)).toEqual(["gitee.com", "Technology", "News"]);
|
|
1495
1498
|
});
|
|
1496
1499
|
|
|
1500
|
+
it("lifts the dragged chip and drops the lift on release", async () => {
|
|
1501
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
|
|
1502
|
+
const strip = tags(container);
|
|
1503
|
+
pinStrip(strip, 60, "x");
|
|
1504
|
+
|
|
1505
|
+
// Mid-gesture (still held): exactly the item being moved carries the
|
|
1506
|
+
// drag state — the hook the stylesheet lifts — and the slot it would
|
|
1507
|
+
// land in keeps its own ring. Nothing else is marked.
|
|
1508
|
+
holdPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, strip[0]);
|
|
1509
|
+
await nextTick();
|
|
1510
|
+
const dragging = tags(container).filter((t) =>
|
|
1511
|
+
t.classList.contains("hk-tag-input-tag-dragging"),
|
|
1512
|
+
);
|
|
1513
|
+
expect(dragging).toHaveLength(1);
|
|
1514
|
+
expect(dragging[0]).toBe(strip[0]);
|
|
1515
|
+
expect(tags(container).filter((t) => t.classList.contains("hk-tag-input-tag-drop"))).toHaveLength(
|
|
1516
|
+
1,
|
|
1517
|
+
);
|
|
1518
|
+
|
|
1519
|
+
releasePointer({ x: 200, y: 10 });
|
|
1520
|
+
await settle();
|
|
1521
|
+
// The lift is a LIVE-drag state: the release clears it in both lists.
|
|
1522
|
+
expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(0);
|
|
1523
|
+
expect(container.querySelectorAll(".hk-tag-input-tag-drop")).toHaveLength(0);
|
|
1524
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
it("drops the chip's lift when the gesture is cancelled without a drop", async () => {
|
|
1528
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1529
|
+
pinStrip(tags(container), 60, "x");
|
|
1530
|
+
|
|
1531
|
+
holdPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, tags(container)[0]);
|
|
1532
|
+
await nextTick();
|
|
1533
|
+
expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(1);
|
|
1534
|
+
|
|
1535
|
+
// The browser takes the gesture back (a vertical scroll on touch): the
|
|
1536
|
+
// drag is abandoned — no reorder, and no lift left behind.
|
|
1537
|
+
window.dispatchEvent(
|
|
1538
|
+
new PointerEvent("pointercancel", { bubbles: true, pointerId: 1, pointerType: "touch" }),
|
|
1539
|
+
);
|
|
1540
|
+
await settle();
|
|
1541
|
+
expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(0);
|
|
1542
|
+
expect(container.querySelectorAll(".hk-tag-input-tag-drop")).toHaveLength(0);
|
|
1543
|
+
expect(events.modelValue).toEqual([]);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
it("lifts the dragged panel row too, and drops the lift on release", async () => {
|
|
1547
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
|
|
1548
|
+
await openPanel(container);
|
|
1549
|
+
const selected = reorderableRows();
|
|
1550
|
+
pinStrip(selected, 40, "y");
|
|
1551
|
+
|
|
1552
|
+
// The row face of the same lift is the data attribute the stylesheet
|
|
1553
|
+
// keys off (rows carry no classes of their own).
|
|
1554
|
+
holdPointer({ x: 10, y: 10 }, { x: 10, y: 130 }, grip(selected[0]));
|
|
1555
|
+
await nextTick();
|
|
1556
|
+
const draggingRows = rows().filter((row) => row.hasAttribute("data-dragging"));
|
|
1557
|
+
expect(draggingRows).toHaveLength(1);
|
|
1558
|
+
expect(draggingRows[0]).toBe(selected[0]);
|
|
1559
|
+
expect(rows().filter((row) => row.hasAttribute("data-drop"))).toHaveLength(1);
|
|
1560
|
+
|
|
1561
|
+
releasePointer({ x: 10, y: 130 });
|
|
1562
|
+
await settle();
|
|
1563
|
+
expect(rows().filter((row) => row.hasAttribute("data-dragging"))).toHaveLength(0);
|
|
1564
|
+
expect(rows().filter((row) => row.hasAttribute("data-drop"))).toHaveLength(0);
|
|
1565
|
+
expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
it("hands a disabled field's chips to text selection instead of a drag", async () => {
|
|
1569
|
+
const { events, container } = mountTagInput({ modelValue: ["news", "tech"], disabled: true });
|
|
1570
|
+
|
|
1571
|
+
// The stylesheet restores selection (and drops the grab cursor) through
|
|
1572
|
+
// the box's OWN disabled hook — happy-dom has no layout, so the pin is
|
|
1573
|
+
// on that attribute, which is exactly what the rule keys off.
|
|
1574
|
+
expect(field(container).hasAttribute("data-disabled")).toBe(true);
|
|
1575
|
+
expect(tags(container)).toHaveLength(2);
|
|
1576
|
+
|
|
1577
|
+
// Nothing can be reordered here, so a press that travels the whole strip
|
|
1578
|
+
// must not be spent on a drag: no state, no emission, no lifted chip.
|
|
1579
|
+
pinStrip(tags(container), 60, "x");
|
|
1580
|
+
dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, tags(container)[0]);
|
|
1581
|
+
await settle();
|
|
1582
|
+
expect(events.modelValue).toEqual([]);
|
|
1583
|
+
expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(0);
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1497
1586
|
it("keeps a sub-threshold press a click — and never drags from the ×", async () => {
|
|
1498
1587
|
const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
|
|
1499
1588
|
pinStrip(tags(container), 60, "x");
|
|
@@ -1924,3 +2013,97 @@ describe("HkTagInput panel geometry and drag reordering", () => {
|
|
|
1924
2013
|
expect(tagTexts(container)).toEqual(["Technology"]);
|
|
1925
2014
|
});
|
|
1926
2015
|
});
|
|
2016
|
+
|
|
2017
|
+
/* The stylesheet half of the states asserted above: happy-dom has no layout
|
|
2018
|
+
* engine, so the DOM tests can only pin the HOOKS — these assertions pin
|
|
2019
|
+
* what those hooks actually buy (the lift, the selection restore, and the
|
|
2020
|
+
* reduced-motion handling), the way the sibling contract tests in this
|
|
2021
|
+
* directory pin the scroll and panel-geometry contracts. */
|
|
2022
|
+
const scss = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "HkTagInput.scss"), "utf-8");
|
|
2023
|
+
|
|
2024
|
+
/** The declaration block of the rule whose selector list starts at the
|
|
2025
|
+
* FIRST `selector` occurrence — braces balanced, so a nested block cannot
|
|
2026
|
+
* let properties appended after it evade the pin. */
|
|
2027
|
+
function styleBlock(selector: string): string {
|
|
2028
|
+
const at = scss.indexOf(selector);
|
|
2029
|
+
expect(at, `${selector} is styled`).toBeGreaterThanOrEqual(0);
|
|
2030
|
+
const open = scss.indexOf("{", at);
|
|
2031
|
+
let depth = 0;
|
|
2032
|
+
for (let i = open; i < scss.length; i += 1) {
|
|
2033
|
+
if (scss[i] === "{") depth += 1;
|
|
2034
|
+
else if (scss[i] === "}") {
|
|
2035
|
+
depth -= 1;
|
|
2036
|
+
if (depth === 0) return scss.slice(open, i + 1);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
return "";
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
describe("HkTagInput drag feedback and text selection", () => {
|
|
2043
|
+
it("lifts the dragged item in both lists instead of only dimming it", () => {
|
|
2044
|
+
const lift = styleBlock(".hk-tag-input-tag-dragging,");
|
|
2045
|
+
// ONE rule body serves both lists: the chip's class and the row's data
|
|
2046
|
+
// attribute — the very hooks the DOM tests above assert.
|
|
2047
|
+
expect(scss).toMatch(
|
|
2048
|
+
/\.hk-tag-input-tag-dragging,\s*\n\.hk-tag-input-row\[data-dragging\]\s*\{/,
|
|
2049
|
+
);
|
|
2050
|
+
expect(lift).toMatch(/box-shadow:/);
|
|
2051
|
+
// Subtle dimming on purpose: the lifted item must stay readable as the
|
|
2052
|
+
// one being moved, not fade away (the pre-wave 0.55 did).
|
|
2053
|
+
expect(lift).toMatch(/opacity:\s*0\.85/);
|
|
2054
|
+
// The lift is a state, never layout: no width/height/margin edits, and
|
|
2055
|
+
// no isotropic scale smuggled into the shared body.
|
|
2056
|
+
expect(lift).not.toMatch(/(^|[^-])(width|height|margin|padding)\s*:/);
|
|
2057
|
+
expect(lift).not.toMatch(/transform\s*:/);
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
it("scales each list ALONG its drag axis and never across it", () => {
|
|
2061
|
+
// The drop slot is resolved from the items' live rects
|
|
2062
|
+
// (usePointerReorder.indexAt): the item's midpoint on the drag axis
|
|
2063
|
+
// picks the slot, and every item's span on the axis ACROSS it decides
|
|
2064
|
+
// which line the pointer is on. A scale is symmetric about the centre,
|
|
2065
|
+
// so scaling along the drag axis changes neither — while growing across
|
|
2066
|
+
// it widens the dragged item's band, and a pointer a few pixels outside
|
|
2067
|
+
// the strip would then match that item alone: every sibling falls out of
|
|
2068
|
+
// the line filter and the drag silently collapses to a no-op. CSS is the
|
|
2069
|
+
// only place this can regress, so each hook's own transform is pinned.
|
|
2070
|
+
const axisTransform = (hook: string): string | undefined =>
|
|
2071
|
+
scss.match(
|
|
2072
|
+
new RegExp(`${hook.replace(/[[\]]/g, "\\$&")}\\s*\\{\\s*transform:\\s*([^;]+);`),
|
|
2073
|
+
)?.[1];
|
|
2074
|
+
expect(axisTransform(".hk-tag-input-tag-dragging"), "the chip strip is horizontal").toBe(
|
|
2075
|
+
"scaleX(1.03)",
|
|
2076
|
+
);
|
|
2077
|
+
expect(axisTransform(".hk-tag-input-row[data-dragging]"), "the row list is vertical").toBe(
|
|
2078
|
+
"scaleY(1.03)",
|
|
2079
|
+
);
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
it("keeps the lift's state marker under reduced motion and drops only its ramp", () => {
|
|
2083
|
+
const reduce = scss.slice(scss.indexOf("@media (prefers-reduced-motion: reduce)"));
|
|
2084
|
+
expect(reduce, "the reduced-motion block exists").toContain(".hk-tag-input-tag-dragging");
|
|
2085
|
+
expect(reduce).toContain(".hk-tag-input-row[data-dragging]");
|
|
2086
|
+
expect(reduce).toMatch(/transition:\s*none/);
|
|
2087
|
+
// The scale and the shadow survive — a reduced-motion user gets the
|
|
2088
|
+
// same affordance, just without the 0.12s motion.
|
|
2089
|
+
expect(reduce).not.toMatch(/transform:\s*none/);
|
|
2090
|
+
expect(reduce).not.toMatch(/box-shadow:\s*none/);
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it("restores text selection on a disabled field's chips only", () => {
|
|
2094
|
+
const disabled = styleBlock(".hk-tag-input-box[data-disabled] .hk-tag-input-tag");
|
|
2095
|
+
expect(disabled).toContain("user-select: text");
|
|
2096
|
+
expect(disabled).toContain("-webkit-user-select: text");
|
|
2097
|
+
expect(disabled).toContain("cursor: default");
|
|
2098
|
+
// The editable chip keeps the gesture reservation: its press is the
|
|
2099
|
+
// reorder gesture, so it stays non-selectable (the documented trade-off).
|
|
2100
|
+
const chip = styleBlock(".hk-tag-input-tag {");
|
|
2101
|
+
expect(chip).toContain("user-select: none");
|
|
2102
|
+
expect(chip).toContain("cursor: grab");
|
|
2103
|
+
// The panel rows' LABELS are never selection-blocked — only the grip,
|
|
2104
|
+
// which is the drag handle, reserves the gesture.
|
|
2105
|
+
expect(styleBlock(".hk-tag-input-grip {")).toContain("user-select: none");
|
|
2106
|
+
const row = styleBlock(".hk-tag-input-row {");
|
|
2107
|
+
expect(row).not.toMatch(/user-select/);
|
|
2108
|
+
});
|
|
2109
|
+
});
|
|
@@ -130,8 +130,11 @@ type HkTagStop =
|
|
|
130
130
|
* a plain click/tap still clicks:
|
|
131
131
|
* * a CHIP is dragged by its BODY (its × never starts a drag, and
|
|
132
132
|
* pressing any control inside a chip is not a drag at all); the
|
|
133
|
-
* chip follows no ghost —
|
|
134
|
-
*
|
|
133
|
+
* chip follows no ghost — it LIFTS in place (a touch larger,
|
|
134
|
+
* raised off the surface, only lightly dimmed so it stays legible
|
|
135
|
+
* as the item being moved) while the drop slot is ringed and the
|
|
136
|
+
* release emits the reordered array. The lift is what makes the
|
|
137
|
+
* gesture readable under a finger, where the cursor is invisible;
|
|
135
138
|
* * in the PANEL only the SELECTED rows move, and only through the
|
|
136
139
|
* explicit GRIP HANDLE at the row's trailing edge (the handle is
|
|
137
140
|
* the one element that claims touch, so the sheet keeps scrolling
|
|
@@ -142,10 +145,17 @@ type HkTagStop =
|
|
|
142
145
|
* of the (capped) surface in one gesture; nothing is scrolled
|
|
143
146
|
* without a live drag. A row drag is CLAMPED to the selected
|
|
144
147
|
* group: unselected rows are not drop targets and never move, and
|
|
145
|
-
* a release past the group's end lands on its last position
|
|
148
|
+
* a release past the group's end lands on its last position. A
|
|
149
|
+
* dragged row lifts exactly like a dragged chip;
|
|
146
150
|
* both lists are keyed HkListTransition groups (FLIP `move`), so the
|
|
147
151
|
* moved chip/row and its neighbours animate into their new places,
|
|
148
|
-
* with the library's usual reduced-motion opt-outs
|
|
152
|
+
* with the library's usual reduced-motion opt-outs (the lift's scale
|
|
153
|
+
* and shadow stay — only their 0.12s ramp is dropped, so a
|
|
154
|
+
* reduced-motion user keeps the state marker without the motion). The
|
|
155
|
+
* lift scales ALONG each list's drag axis and never across it: the drop
|
|
156
|
+
* slot is resolved from the items' live rects, so a span grown across
|
|
157
|
+
* that axis would hide every sibling from the pointer and silently
|
|
158
|
+
* collapse the gesture into a no-op;
|
|
149
159
|
* - a key in `modelValue` that the catalog does not carry (a custom
|
|
150
160
|
* tag, or an option deleted since) degrades to its raw key as the
|
|
151
161
|
* tag label — the same rule as HkAffixPicker.tagEntries. Such a key
|
|
@@ -188,6 +198,21 @@ type HkTagStop =
|
|
|
188
198
|
* row). The grip handle is a pointer affordance; the field and the panel
|
|
189
199
|
* are otherwise pointer-only for reordering.
|
|
190
200
|
*
|
|
201
|
+
* CHIP TEXT IS NOT SELECTABLE WHILE THE FIELD IS EDITABLE — the chip is an
|
|
202
|
+
* interactive control, and its press is the reorder gesture, exactly as a
|
|
203
|
+
* `<button>`'s label is not selectable because its press is the activation.
|
|
204
|
+
* The two cannot be had at once: a press allowed to start a text selection
|
|
205
|
+
* is a press the browser has already claimed, so it can no longer become a
|
|
206
|
+
* ~6px drag (on touch the long press raises the selection callout and the
|
|
207
|
+
* drag dies on `pointercancel`; on a mouse the two gestures run together
|
|
208
|
+
* and neither reads). The threshold therefore wins by design, and the
|
|
209
|
+
* trade-off is paid where it costs nothing instead: a DISABLED field
|
|
210
|
+
* reorders nothing, so its chips ARE selectable text — the stylesheet
|
|
211
|
+
* flips their `user-select` back to `text` (and drops the grab cursor)
|
|
212
|
+
* under the box's own disabled hook. The panel rows keep their LABELS
|
|
213
|
+
* selectable even while editable: only the grip handle reserves the
|
|
214
|
+
* gesture there, and it — not the label — is the drag target.
|
|
215
|
+
*
|
|
191
216
|
* `maxTags` freezes ADDS only. Reordering stays available at the cap:
|
|
192
217
|
* the order is host state, and a reorder adds nothing (nor removes
|
|
193
218
|
* anything).
|
|
@@ -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
|
+
}
|