@celestia-island/hikari 0.55.33 → 0.55.34
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/HkBlockingToast.test.tsx +21 -0
- package/src/components/HkBlockingToast.tsx +11 -10
- package/src/components/HkCrossfade.test.tsx +4 -1
- package/src/components/HkMenu.test.ts +139 -0
- package/src/components/HkMenu.tsx +18 -2
- package/src/components/HkToast.test.tsx +64 -0
- package/src/components/HkToast.tsx +17 -4
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
|
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:
|
|
139
|
-
height:
|
|
140
|
-
right:
|
|
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
|
-
|
|
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
|
-
//
|
|
783
|
-
// where you are)
|
|
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}>
|
|
@@ -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
|
|
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:
|
|
200
|
-
height:
|
|
201
|
-
right:
|
|
212
|
+
width: rect.width,
|
|
213
|
+
height: rect.height,
|
|
214
|
+
right: hostWidth - (e.offsetLeft + rect.width),
|
|
202
215
|
});
|
|
203
216
|
}
|
|
204
217
|
});
|