@celestia-island/hikari 0.55.33 → 0.55.35

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.55.33",
3
+ "version": "0.55.35",
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",
@@ -215,11 +215,19 @@ describe("HkBlockingToast", () => {
215
215
  expect(slot).not.toBeNull();
216
216
  const wrapper = slot!.parentElement as HTMLElement;
217
217
  Object.defineProperty(wrapper, "clientWidth", { value: 384, configurable: true });
218
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
219
+ configurable: true,
220
+ value: () => ({ width: 384, height: 0, top: 0, left: 0 }),
221
+ });
218
222
  Object.defineProperty(slot!, "offsetParent", { value: wrapper, configurable: true });
219
223
  Object.defineProperty(slot!, "offsetTop", { value: 0, configurable: true });
220
224
  Object.defineProperty(slot!, "offsetLeft", { value: 0, configurable: true });
221
225
  Object.defineProperty(slot!, "offsetWidth", { value: 384, configurable: true });
222
226
  Object.defineProperty(slot!, "offsetHeight", { value: 120, configurable: true });
227
+ Object.defineProperty(slot!, "getBoundingClientRect", {
228
+ configurable: true,
229
+ value: () => ({ width: 384, height: 120, top: 0, left: 0 }),
230
+ });
223
231
 
224
232
  // Dismiss (not confirm): this case only cares about the leave animation.
225
233
  cancelButton(cards()[0]).click();
@@ -256,6 +264,10 @@ describe("HkBlockingToast", () => {
256
264
  configurable: true,
257
265
  get: () => (reflowed() ? 138 : 384),
258
266
  });
267
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
268
+ configurable: true,
269
+ value: () => ({ width: reflowed() ? 138 : 384, height: 0, top: 0, left: 0 }),
270
+ });
259
271
  const stub = (
260
272
  el: HTMLElement,
261
273
  box: { top: number; left: number; width: number; height: number },
@@ -265,6 +277,15 @@ describe("HkBlockingToast", () => {
265
277
  Object.defineProperty(el, "offsetLeft", { configurable: true, get: () => (reflowed() ? 0 : box.left) });
266
278
  Object.defineProperty(el, "offsetWidth", { configurable: true, get: () => (reflowed() ? 138 : box.width) });
267
279
  Object.defineProperty(el, "offsetHeight", { configurable: true, get: () => box.height });
280
+ Object.defineProperty(el, "getBoundingClientRect", {
281
+ configurable: true,
282
+ value: () => ({
283
+ width: reflowed() ? 138 : box.width,
284
+ height: box.height,
285
+ top: 0,
286
+ left: 0,
287
+ }),
288
+ });
268
289
  };
269
290
  stub(first, { top: 0, left: 0, width: 384, height: 120 });
270
291
  stub(second, { top: 132, left: 0, width: 384, height: 120 });
@@ -118,26 +118,27 @@ export default defineComponent({
118
118
  const handles = new Map<number, PopupHandle>();
119
119
 
120
120
  // Pre-patch geometry of every card, refreshed on each update — same
121
- // multi-removal guard as HkToast (see the full rationale there):
122
- // within one patch pass an earlier leaving card's position:absolute
123
- // re-fits the shrink-to-fit column before a later card's beforeLeave
124
- // reads its box, so live offset reads would teleport and squash the
125
- // later cards. Snap the right anchor at snapshot time too — the
126
- // wrapper's live clientWidth is already re-fit when the pin runs.
121
+ // multi-removal guard as HkToast (see the full rationale there,
122
+ // including the getBoundingClientRect choice for sizes: offset*
123
+ // integer rounding would shrink the pinned card by up to half a
124
+ // pixel and rewrap its last character). Snap the right anchor at
125
+ // snapshot time too — the wrapper is already re-fit when the pin
126
+ // runs.
127
127
  const hostRef = ref<{ $el?: Element } | null>(null);
128
128
  const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
129
129
  onBeforeUpdate(() => {
130
130
  const host = hostRef.value?.$el;
131
131
  if (host == null || host.nodeType !== 1) return;
132
- const parentWidth = (host as HTMLElement).clientWidth;
132
+ const hostWidth = (host as HTMLElement).getBoundingClientRect().width;
133
133
  for (const child of Array.from(host.children)) {
134
134
  const e = child as HTMLElement;
135
+ const rect = e.getBoundingClientRect();
135
136
  preLeaveBoxes.set(e, {
136
137
  top: e.offsetTop,
137
138
  left: e.offsetLeft,
138
- width: e.offsetWidth,
139
- height: e.offsetHeight,
140
- right: parentWidth - (e.offsetLeft + e.offsetWidth),
139
+ width: rect.width,
140
+ height: rect.height,
141
+ right: hostWidth - (e.offsetLeft + rect.width),
141
142
  });
142
143
  }
143
144
  });
@@ -252,7 +252,10 @@ describe("HkCrossfade — stylesheet contract", () => {
252
252
 
253
253
  describe("HkCrossfade — wiring contract", () => {
254
254
  const here = dirname(fileURLToPath(import.meta.url));
255
- const src = readFileSync(join(here, "HkCrossfade.tsx"), "utf-8");
255
+ // Windows checkouts (core.autocrlf) hand the working tree CRLF sources;
256
+ // normalize before text-pinning so the contract is EOL-agnostic instead
257
+ // of green-on-CI / red-on-Windows.
258
+ const src = readFileSync(join(here, "HkCrossfade.tsx"), "utf-8").replace(/\r\n?/g, "\n");
256
259
 
257
260
  it("runs the Transition in default (simultaneous) mode and never overrides its duration", () => {
258
261
  expect(src).toContain('<Transition\n name="hk-crossfade"');
@@ -877,6 +877,145 @@ describe("HkMenu sidebar variant", () => {
877
877
  });
878
878
  });
879
879
 
880
+ describe("HkMenu sidebar defaultExpandGroups", () => {
881
+ /** Two INDEPENDENT groups, no active row anywhere — the shape where
882
+ * accordion mode keeps everything closed and table-of-contents mode
883
+ * must open everything. */
884
+ const groupedItems: HkMenuItem[] = [
885
+ { key: "g1", label: "Group One", children: [{ key: "g1-a", label: "One A" }] },
886
+ { key: "g2", label: "Group Two", children: [{ key: "g2-a", label: "Two A" }] },
887
+ ];
888
+
889
+ function mountSidebar(
890
+ items: HkMenuItem[],
891
+ props: Record<string, unknown> = {},
892
+ ): HTMLElement {
893
+ const container = document.createElement("div");
894
+ document.body.appendChild(container);
895
+ containers.push(container);
896
+ const app = createApp({
897
+ render: () => h(HkMenu, { variant: "sidebar", open: true, items, ...props }),
898
+ });
899
+ mounts.push(app);
900
+ app.mount(container);
901
+ return container;
902
+ }
903
+
904
+ const toggleOf = (root: HTMLElement, label: string): HTMLButtonElement =>
905
+ Array.from(root.querySelectorAll(".hk-menu-sidebar-group-toggle")).find(
906
+ (r) => r.textContent?.includes(label),
907
+ ) as HTMLButtonElement;
908
+
909
+ it("keeps the accordion default when the prop is absent (additive no-op)", () => {
910
+ // Pinned at the DOM level: with no prop the group carries no open
911
+ // marker and its children stay unrendered — every existing consumer
912
+ // (drawer account menus, nav sidebars) is untouched.
913
+ const root = mountSidebar(groupedItems);
914
+ expect(root.textContent).not.toContain("One A");
915
+ expect(root.textContent).not.toContain("Two A");
916
+ expect(root.querySelector(".hk-menu-sidebar-group[data-open]")).toBeNull();
917
+ });
918
+
919
+ it("renders every group expanded at mount, active row or not", () => {
920
+ const root = mountSidebar(groupedItems, { defaultExpandGroups: true });
921
+ expect(root.textContent).toContain("One A");
922
+ expect(root.textContent).toContain("Two A");
923
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(2);
924
+ // The toggle rows' a11y state agrees with the rendered children.
925
+ for (const toggle of root.querySelectorAll(".hk-menu-sidebar-group-toggle")) {
926
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
927
+ }
928
+ });
929
+
930
+ it("a user collapse sticks across navigation — no auto-resurrection", async () => {
931
+ const container = document.createElement("div");
932
+ document.body.appendChild(container);
933
+ containers.push(container);
934
+ const activeKey = ref("none");
935
+ const app = createApp({
936
+ render: () =>
937
+ h(HkMenu, {
938
+ variant: "sidebar",
939
+ open: true,
940
+ items: groupedItems,
941
+ activeKey: activeKey.value,
942
+ defaultExpandGroups: true,
943
+ }),
944
+ });
945
+ mounts.push(app);
946
+ app.mount(container);
947
+ await settle();
948
+
949
+ // First toggle COLLAPSES (open is the default state here).
950
+ toggleOf(container, "Group One").click();
951
+ await settle();
952
+ expect(container.textContent).not.toContain("One A");
953
+ // The sibling keeps its default-open state.
954
+ expect(container.textContent).toContain("Two A");
955
+
956
+ // Navigating INTO the collapsed group must not resurrect it: the
957
+ // user's collapse is the user's word (the auto-expand watch fires on
958
+ // activeKey changes and must stay inert in this mode).
959
+ activeKey.value = "g1-a";
960
+ await settle();
961
+ expect(container.textContent).not.toContain("One A");
962
+
963
+ // Re-expanding works, lands the active row, and stays open.
964
+ toggleOf(container, "Group One").click();
965
+ await settle();
966
+ expect(container.textContent).toContain("One A");
967
+ const active = container.querySelector(".hk-menu-sidebar-row[data-active]");
968
+ expect(active?.textContent).toContain("One A");
969
+ });
970
+
971
+ it("collapsing one group never touches its siblings' default-open state", async () => {
972
+ const root = mountSidebar(groupedItems, { defaultExpandGroups: true });
973
+ await settle();
974
+ toggleOf(root, "Group Two").click();
975
+ await settle();
976
+ expect(root.textContent).toContain("One A");
977
+ expect(root.textContent).not.toContain("Two A");
978
+ expect(toggleOf(root, "Group One").getAttribute("aria-expanded")).toBe("true");
979
+ });
980
+
981
+ it("expands nested groups too, and a parent round-trip restores the still-open inner group", async () => {
982
+ const root = mountSidebar(
983
+ [
984
+ {
985
+ key: "outer",
986
+ label: "Outer",
987
+ children: [
988
+ {
989
+ key: "inner",
990
+ label: "Inner",
991
+ children: [{ key: "leaf", label: "Deep Leaf" }],
992
+ },
993
+ ],
994
+ },
995
+ ],
996
+ { defaultExpandGroups: true },
997
+ );
998
+ await settle();
999
+
1000
+ // Both levels render expanded at mount (depth > 1 included).
1001
+ expect(root.textContent).toContain("Deep Leaf");
1002
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(2);
1003
+
1004
+ // Collapse the parent: the whole subtree unmounts…
1005
+ toggleOf(root, "Outer").click();
1006
+ await settle();
1007
+ expect(root.textContent).not.toContain("Deep Leaf");
1008
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(0);
1009
+
1010
+ // …and re-expanding restores the inner group still open — its state
1011
+ // lived in the key sets, so no re-toggle of "Inner" is needed.
1012
+ toggleOf(root, "Outer").click();
1013
+ await settle();
1014
+ expect(root.textContent).toContain("Deep Leaf");
1015
+ expect(root.querySelectorAll(".hk-menu-sidebar-group[data-open]")).toHaveLength(2);
1016
+ });
1017
+ });
1018
+
880
1019
  describe("HkMenu close linger (leave-transition window)", () => {
881
1020
  it("keeps the panel tree mounted briefly after close, then unmounts", async () => {
882
1021
  const openRef = ref(true);
@@ -156,6 +156,16 @@ export default defineComponent({
156
156
  * length, applied on the `dvh`-capable branch, never sanitized.
157
157
  */
158
158
  maxHeight: { type: String, default: undefined },
159
+ /**
160
+ * Sidebar variant only: every collapsible group renders EXPANDED
161
+ * until the user collapses it — nav sidebars that read as a table of
162
+ * contents instead of an accordion. Default false keeps the accordion
163
+ * behavior (only the group holding the active row auto-opens). The
164
+ * user's first toggle on a group still takes over and sticks:
165
+ * expansion is the default, never a resurrection. No-op in the
166
+ * popup variant (sub-levels there are cascades, not inline groups).
167
+ */
168
+ defaultExpandGroups: { type: Boolean, default: false },
159
169
  },
160
170
  emits: ["update:open", "select"],
161
171
  setup(props, { emit, slots }) {
@@ -696,6 +706,10 @@ export default defineComponent({
696
706
  const userCollapsed = ref<Set<string>>(new Set());
697
707
 
698
708
  function isGroupExpanded(key: string): boolean {
709
+ // Table-of-contents mode (defaultExpandGroups): open IS the default
710
+ // state — only an explicit user collapse closes a group, and it
711
+ // stays closed (no auto-resurrection on activeKey/items changes).
712
+ if (props.defaultExpandGroups) return !userCollapsed.value.has(key);
699
713
  if (userGroups.value.has(key)) return true;
700
714
  if (autoGroups.value.has(key)) return !userCollapsed.value.has(key);
701
715
  return false;
@@ -779,8 +793,10 @@ export default defineComponent({
779
793
 
780
794
  function renderSidebarItem(item: HkMenuItem, depth: number) {
781
795
  if (!isGroup(item)) return renderSidebarRow(item, depth);
782
- // Auto-expanded when containing the active row (fresh mount shows
783
- // where you are); the user's first toggle on a group takes over.
796
+ // Accordion mode: auto-expanded when containing the active row
797
+ // (fresh mount shows where you are). defaultExpandGroups mode:
798
+ // everything starts open instead. Either way the user's first
799
+ // toggle on a group takes over and sticks.
784
800
  const expanded = isGroupExpanded(item.key);
785
801
  return (
786
802
  <div key={item.key} class="hk-menu-sidebar-group" data-open={expanded || undefined}>
@@ -452,6 +452,22 @@
452
452
  }
453
453
 
454
454
  .hk-modal-content {
455
+ // IME cap-chase guard (2026-09-21, Xiaomi WebView chest report): on
456
+ // adjustResize engines the keyboard animation steps the layout
457
+ // viewport — and with it the 100dvh term of the max-height cap —
458
+ // every frame. With max-height in the base transition list each step
459
+ // RETARGETS a 150ms transition, so a sheet that binds the cap (tall
460
+ // content: multi-step wizards, model pickers) stays permanently
461
+ // mid-transition: the box chases the viewport, layout stays dirty,
462
+ // and the fixed layer re-rasters per step (black-block flicker).
463
+ // Short sheets never bind the cap and never noticed. The cap now
464
+ // follows the viewport INSTANTLY on phones — which is exactly how
465
+ // native iOS/Android sheets track the keyboard — while the height
466
+ // pin (content-driven, one-shot per morph) and the clip reveal
467
+ // keep their transition. Desktop keeps the full list.
468
+ transition:
469
+ height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
470
+ clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
455
471
  // !important beats the inline maxWidth (width prop); base width: 100%
456
472
  // already applies at every viewport, so only the cap is re-asserted.
457
473
  max-width: 100% !important;
@@ -74,6 +74,39 @@ describe("HkModal mobile sheet spacing contract", () => {
74
74
  expect(content).not.toMatch(/max-height:\s*none/);
75
75
  });
76
76
 
77
+ // 2026-09-21 IME cap-chase guard (Xiaomi WebView chest report): the
78
+ // keyboard animation steps the layout viewport — and the 100dvh term
79
+ // of the cap — every frame; with max-height in the transition list
80
+ // each step RETARGETS a 150ms transition and a cap-bound (tall)
81
+ // sheet chases its own viewport, re-rastering per step (black-block
82
+ // flicker). The phone sheet's transition list must therefore carry
83
+ // ONLY the content-driven channels: the height pin (one-shot per
84
+ // morph) and the clip reveal. The cap itself follows the viewport
85
+ // instantly — native sheet behavior.
86
+ it("drops max-height from the phone sheet transition (IME cap-chase guard)", () => {
87
+ // Extraction self-check: the mobile rule must exist and MUST carry
88
+ // a transition declaration at all (its absence is the regression,
89
+ // not a vacuous pass).
90
+ expect(content.length, "mobile .hk-modal-content rule extracted").toBeGreaterThan(0);
91
+ const tr = content.match(/transition:\s*([^;]+);/)?.[1] ?? "";
92
+ expect(tr.length, "mobile sheet transition declaration present").toBeGreaterThan(0);
93
+ expect(tr).toMatch(/\bheight\b/);
94
+ expect(tr).toMatch(/\bclip-path\b/);
95
+ expect(tr).not.toMatch(/\bmax-height\b/);
96
+ });
97
+
98
+ it("keeps the desktop frame transition list intact (base rule)", () => {
99
+ // The guard is phone-only by design: the base rule outside the
100
+ // media block keeps height+max-height+clip-path animated.
101
+ const base = src.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
102
+ expect(base.length, "base .hk-modal-content rule extracted").toBeGreaterThan(0);
103
+ const tr = base.match(/transition:\s*([^;]+);/)?.[1] ?? "";
104
+ expect(tr.length, "base transition declaration present").toBeGreaterThan(0);
105
+ expect(tr).toMatch(/\bheight\b/);
106
+ expect(tr).toMatch(/\bmax-height\b/);
107
+ expect(tr).toMatch(/\bclip-path\b/);
108
+ });
109
+
77
110
  it("lifts the 70vh body cap on phones so the footer sits on the bottom edge", () => {
78
111
  expect(body).toContain("max-height: none");
79
112
  });
@@ -16,6 +16,10 @@ import { useToast } from "../runtime/useToast";
16
16
  * same patch pass, so live offset reads in a later sibling's
17
17
  * before-leave hook would teleport it onto the reflowed slot and
18
18
  * squash its bar (HkListTransition's pre-patch snapshot pattern)
19
+ * - the pin reproduces the box EXACTLY (getBoundingClientRect sizes):
20
+ * offsetWidth/Height round to integers, and pinning a round-down
21
+ * integer shrank the box by the sub-pixel gap — enough to push a
22
+ * last character onto a second line the instant the leave started
19
23
  * - the toast is gone after the leave settles
20
24
  *
21
25
  * (Repo test convention: raw createApp + document queries, no
@@ -48,16 +52,30 @@ function stubLayout(el: HTMLElement, over: Partial<{
48
52
  offsetLeft: number;
49
53
  offsetWidth: number;
50
54
  offsetHeight: number;
55
+ /** getBoundingClientRect width — the exact fractional box the pin
56
+ * must reproduce (offsetWidth is its integer rounding). */
57
+ rectWidth: number;
58
+ rectHeight: number;
51
59
  }>): void {
52
60
  // happy-dom has no layout engine; feed the leave hook the geometry a
53
61
  // real browser would read from the laid-out stack.
54
62
  const wrapper = el.parentElement as HTMLElement;
63
+ const rectWidth = over.rectWidth ?? over.offsetWidth ?? 384;
64
+ const rectHeight = over.rectHeight ?? over.offsetHeight ?? 83;
55
65
  Object.defineProperty(wrapper, "clientWidth", { value: 384, configurable: true });
66
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
67
+ configurable: true,
68
+ value: () => ({ width: rectWidth, height: 0, top: 0, left: 0 }),
69
+ });
56
70
  Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
57
71
  Object.defineProperty(el, "offsetTop", { value: over.offsetTop ?? 0, configurable: true });
58
72
  Object.defineProperty(el, "offsetLeft", { value: over.offsetLeft ?? 0, configurable: true });
59
73
  Object.defineProperty(el, "offsetWidth", { value: over.offsetWidth ?? 384, configurable: true });
60
74
  Object.defineProperty(el, "offsetHeight", { value: over.offsetHeight ?? 83, configurable: true });
75
+ Object.defineProperty(el, "getBoundingClientRect", {
76
+ configurable: true,
77
+ value: () => ({ width: rectWidth, height: rectHeight, top: 0, left: 0 }),
78
+ });
61
79
  }
62
80
 
63
81
  afterEach(async () => {
@@ -132,6 +150,10 @@ describe("HkToast", () => {
132
150
  configurable: true,
133
151
  get: () => (reflowed() ? 138 : 384),
134
152
  });
153
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
154
+ configurable: true,
155
+ value: () => ({ width: reflowed() ? 138 : 384, height: 0, top: 0, left: 0 }),
156
+ });
135
157
  const stub = (
136
158
  el: HTMLElement,
137
159
  pre: { top: number; left: number; width: number; height: number },
@@ -145,6 +167,15 @@ describe("HkToast", () => {
145
167
  get: () => (reflowed() ? post[short] : pre[short]),
146
168
  });
147
169
  }
170
+ Object.defineProperty(el, "getBoundingClientRect", {
171
+ configurable: true,
172
+ value: () => ({
173
+ width: reflowed() ? post.width : pre.width,
174
+ height: reflowed() ? post.height : pre.height,
175
+ top: 0,
176
+ left: 0,
177
+ }),
178
+ });
148
179
  };
149
180
  stub(first, { top: 0, left: 0, width: 384, height: 83 }, { top: 0, left: 0, width: 384, height: 83 });
150
181
  stub(second, { top: 95, left: 0, width: 384, height: 48 }, { top: 0, left: 0, width: 138, height: 48 });
@@ -170,4 +201,37 @@ describe("HkToast", () => {
170
201
  await settle();
171
202
  expect(items().length).toBe(0);
172
203
  });
204
+
205
+ it("pins the exact fractional box so the rounding gap cannot rewrap the text", async () => {
206
+ mountHost();
207
+ const toast = useToast();
208
+ toast.error("message whose single line sits within a half pixel of the boundary");
209
+ await settle();
210
+
211
+ const item = items()[0];
212
+ // offsetWidth rounds the real box DOWN (369.40625 → 369): pinning
213
+ // that integer shrank the box by the rounding gap, which pushed the
214
+ // last character to a second line the moment the leave started. The
215
+ // pin must reproduce the exact fractional box instead.
216
+ stubLayout(item, {
217
+ offsetTop: 0,
218
+ offsetLeft: 0,
219
+ offsetWidth: 369,
220
+ offsetHeight: 83,
221
+ rectWidth: 369.40625,
222
+ rectHeight: 83,
223
+ });
224
+
225
+ item.querySelector<HTMLButtonElement>(".hk-toast-close")!.click();
226
+ await nextTick();
227
+
228
+ const leaving = document.querySelector<HTMLElement>(".hk-toast-leave-active");
229
+ expect(leaving).not.toBeNull();
230
+ expect(leaving!.style.width).toBe("369.40625px");
231
+ expect(leaving!.style.right).toBe("0px");
232
+ expect(leaving!.style.height).toBe("83px");
233
+
234
+ await settle();
235
+ expect(items().length).toBe(0);
236
+ });
173
237
  });
@@ -185,20 +185,33 @@ export default defineComponent({
185
185
  // texts read as one squashed multi-line blob. The wrapper itself may
186
186
  // also have re-fit when the pin runs, so the right anchor is snapped
187
187
  // at snapshot time too (see LeaveBoxSnapshot.right).
188
+ //
189
+ // Sizes come from getBoundingClientRect, NOT the offset* family:
190
+ // offsetWidth/Height round to integers, and the pinned box shrinks
191
+ // whenever the real box rounds down — a toast whose text sits within
192
+ // that sub-pixel distance of the wrap boundary loses its last
193
+ // character to the next line the instant the leave pins it. Rect
194
+ // width/height are exact and translation-invariant (enter/move only
195
+ // ever translate these surfaces, and translation does not change a
196
+ // box's size), so the pin reproduces the visible box to the pixel.
197
+ // Positions stay on offsetTop/offsetLeft — the transform-immune
198
+ // layout slot, where the ±0.5px integer error is invisible.
188
199
  const hostRef = ref<{ $el?: Element } | null>(null);
189
200
  const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
190
201
  onBeforeUpdate(() => {
191
202
  const host = hostRef.value?.$el;
192
203
  if (host == null || host.nodeType !== 1) return;
193
- const parentWidth = (host as HTMLElement).clientWidth;
204
+ const hostEl = host as HTMLElement;
205
+ const hostWidth = hostEl.getBoundingClientRect().width;
194
206
  for (const child of Array.from(host.children)) {
195
207
  const e = child as HTMLElement;
208
+ const rect = e.getBoundingClientRect();
196
209
  preLeaveBoxes.set(e, {
197
210
  top: e.offsetTop,
198
211
  left: e.offsetLeft,
199
- width: e.offsetWidth,
200
- height: e.offsetHeight,
201
- right: parentWidth - (e.offsetLeft + e.offsetWidth),
212
+ width: rect.width,
213
+ height: rect.height,
214
+ right: hostWidth - (e.offsetLeft + rect.width),
202
215
  });
203
216
  }
204
217
  });