@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.3",
3
+ "version": "0.43.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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
- node.style.width = `${sourceRect.width}px`;
118
- node.style.transform = `translate3d(${sourceRect.left}px, ${sourceRect.top}px, 0)`;
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
- const gx = clientX - s.grabOffsetX;
135
- const gy = clientY - s.grabOffsetY;
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
- node.style.width = `${sourceRect.width}px`;
100
- node.style.transform = `translate3d(${sourceRect.left}px, ${sourceRect.top}px, 0)`;
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
- const gx = s.originX - s.grabOffsetX;
117
- const gy = clientY - s.grabOffsetY;
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 openRight = r.right + CASCADE_PANEL_W <= window.innerWidth - VIEWPORT_PAD;
399
- const left = openRight ? r.right : Math.max(VIEWPORT_PAD, r.left - CASCADE_PANEL_W);
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 openRight = r.right + gap + CASCADE_PANEL_W <= window.innerWidth - VIEWPORT_PAD;
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 - CASCADE_PANEL_W - gap);
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
- const headerH = header ? header.getBoundingClientRect().height : props.headerFallbackHeight;
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: `${c.top}px` } : {}),
563
- ...(c.left != null ? { left: `${c.left}px` } : {}),
564
- ...(c.bottom != null ? { bottom: `${c.bottom}px` } : {}),
565
- ...(c.right != null ? { right: `${c.right}px` } : {}),
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
- // 0 (unstyled/test environments) counts as unmeasured — fall back.
529
- const pw = panelRef.value?.offsetWidth || Math.max(r.width, 180);
530
- const ph = panelRef.value?.offsetHeight || 200;
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 dims, the slot it would
322
- * land in is ringed. Chips carry the state as classes (HkTag takes no
323
- * arbitrary attributes), panel rows as data attributes — both are
324
- * assertable hooks that never collide with the pointer/hover faces. */
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.55;
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 — the drop slot is highlighted and the
134
- * release emits the reordered array;
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 = `${rect.top - gap}px`;
45
- style.left = `${rect.left + rect.width / 2}px`;
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 = `${rect.bottom + gap}px`;
50
- style.left = `${rect.left + rect.width / 2}px`;
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 = `${rect.top + rect.height / 2}px`;
55
- style.left = `${rect.left - gap}px`;
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 = `${rect.top + rect.height / 2}px`;
60
- style.left = `${rect.right + gap}px`;
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
+ }