@celestia-island/hikari 0.55.30 → 0.55.32
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 +53 -0
- package/src/components/HkBlockingToast.tsx +30 -2
- package/src/components/HkIconButtonGroup.scss +11 -2
- package/src/components/HkToast.test.tsx +64 -0
- package/src/components/HkToast.tsx +34 -3
- package/src/i18n/locales/ar/components.json +3 -1
- package/src/i18n/locales/de/components.json +3 -1
- package/src/i18n/locales/en/components.json +3 -1
- package/src/i18n/locales/es/components.json +3 -1
- package/src/i18n/locales/fr/components.json +3 -1
- package/src/i18n/locales/ja/components.json +3 -1
- package/src/i18n/locales/ko/components.json +3 -1
- package/src/i18n/locales/pt/components.json +3 -1
- package/src/i18n/locales/ru/components.json +3 -1
- package/src/i18n/locales/zh-Hans/components.json +3 -1
- package/src/i18n/locales/zh-Hant/components.json +3 -1
- package/src/i18n/usageSurface.test.ts +177 -0
- package/src/utils/dom.test.ts +18 -0
- package/src/utils/dom.ts +11 -1
package/package.json
CHANGED
|
@@ -237,4 +237,57 @@ describe("HkBlockingToast", () => {
|
|
|
237
237
|
await settle();
|
|
238
238
|
expect(cards().length).toBe(0);
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
it("pins simultaneously leaving card slots to their pre-patch boxes", async () => {
|
|
242
|
+
mountHost();
|
|
243
|
+
const gateA = showBlockingToast("First prompt", { title: "One" });
|
|
244
|
+
const gateB = showBlockingToast("Second prompt", { title: "Two" });
|
|
245
|
+
await settle();
|
|
246
|
+
|
|
247
|
+
// Same multi-removal stub as HkToast.test: once the first slot goes
|
|
248
|
+
// position:absolute mid-patch, the shrink-to-fit column re-fits, so
|
|
249
|
+
// a live read in the second slot's before-leave would freeze the
|
|
250
|
+
// reflowed box. The pin must carry the pre-patch geometry instead.
|
|
251
|
+
const slots = document.querySelectorAll<HTMLElement>(".hk-blocking-toast-slot");
|
|
252
|
+
const [first, second] = slots;
|
|
253
|
+
const wrapper = first.parentElement as HTMLElement;
|
|
254
|
+
const reflowed = () => first.classList.contains("hk-blocking-toast-leave-active");
|
|
255
|
+
Object.defineProperty(wrapper, "clientWidth", {
|
|
256
|
+
configurable: true,
|
|
257
|
+
get: () => (reflowed() ? 138 : 384),
|
|
258
|
+
});
|
|
259
|
+
const stub = (
|
|
260
|
+
el: HTMLElement,
|
|
261
|
+
box: { top: number; left: number; width: number; height: number },
|
|
262
|
+
) => {
|
|
263
|
+
Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
|
|
264
|
+
Object.defineProperty(el, "offsetTop", { configurable: true, get: () => (reflowed() ? 0 : box.top) });
|
|
265
|
+
Object.defineProperty(el, "offsetLeft", { configurable: true, get: () => (reflowed() ? 0 : box.left) });
|
|
266
|
+
Object.defineProperty(el, "offsetWidth", { configurable: true, get: () => (reflowed() ? 138 : box.width) });
|
|
267
|
+
Object.defineProperty(el, "offsetHeight", { configurable: true, get: () => box.height });
|
|
268
|
+
};
|
|
269
|
+
stub(first, { top: 0, left: 0, width: 384, height: 120 });
|
|
270
|
+
stub(second, { top: 132, left: 0, width: 384, height: 120 });
|
|
271
|
+
|
|
272
|
+
cancelButton(cards()[0]).click();
|
|
273
|
+
cancelButton(cards()[1]).click();
|
|
274
|
+
await nextTick();
|
|
275
|
+
|
|
276
|
+
const [pinFirst, pinSecond] =
|
|
277
|
+
document.querySelectorAll<HTMLElement>(".hk-blocking-toast-leave-active");
|
|
278
|
+
// The second slot's pin mirrors its PRE-patch box — full-width bar at
|
|
279
|
+
// its own row — not the reflowed sliver at the vacated top slot.
|
|
280
|
+
expect(pinFirst!.style.right).toBe("0px");
|
|
281
|
+
expect(pinFirst!.style.top).toBe("0px");
|
|
282
|
+
expect(pinFirst!.style.width).toBe("384px");
|
|
283
|
+
expect(pinFirst!.style.height).toBe("120px");
|
|
284
|
+
expect(pinSecond!.style.right).toBe("0px");
|
|
285
|
+
expect(pinSecond!.style.top).toBe("132px");
|
|
286
|
+
expect(pinSecond!.style.width).toBe("384px");
|
|
287
|
+
expect(pinSecond!.style.height).toBe("120px");
|
|
288
|
+
|
|
289
|
+
await Promise.allSettled([gateA, gateB]);
|
|
290
|
+
await settle();
|
|
291
|
+
expect(cards().length).toBe(0);
|
|
292
|
+
});
|
|
240
293
|
});
|
|
@@ -26,6 +26,8 @@ import { AlertTriangle, CircleX as XCircle, Info } from "lucide-vue-next";
|
|
|
26
26
|
import {
|
|
27
27
|
defineComponent,
|
|
28
28
|
onBeforeUnmount,
|
|
29
|
+
onBeforeUpdate,
|
|
30
|
+
ref,
|
|
29
31
|
Teleport,
|
|
30
32
|
TransitionGroup,
|
|
31
33
|
watchEffect,
|
|
@@ -33,7 +35,7 @@ import {
|
|
|
33
35
|
|
|
34
36
|
import { useI18n } from "../i18n/context";
|
|
35
37
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
36
|
-
import { clearLeaveGeometry, pinLeaveGeometry } from "../utils/dom";
|
|
38
|
+
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
37
39
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
38
40
|
import {
|
|
39
41
|
resolveBlockingToast,
|
|
@@ -115,6 +117,31 @@ export default defineComponent({
|
|
|
115
117
|
const itemHooks = useSurfaceTransition(320).hooks();
|
|
116
118
|
const handles = new Map<number, PopupHandle>();
|
|
117
119
|
|
|
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.
|
|
127
|
+
const hostRef = ref<{ $el?: Element } | null>(null);
|
|
128
|
+
const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
|
|
129
|
+
onBeforeUpdate(() => {
|
|
130
|
+
const host = hostRef.value?.$el;
|
|
131
|
+
if (host == null || host.nodeType !== 1) return;
|
|
132
|
+
const parentWidth = (host as HTMLElement).clientWidth;
|
|
133
|
+
for (const child of Array.from(host.children)) {
|
|
134
|
+
const e = child as HTMLElement;
|
|
135
|
+
preLeaveBoxes.set(e, {
|
|
136
|
+
top: e.offsetTop,
|
|
137
|
+
left: e.offsetLeft,
|
|
138
|
+
width: e.offsetWidth,
|
|
139
|
+
height: e.offsetHeight,
|
|
140
|
+
right: parentWidth - (e.offsetLeft + e.offsetWidth),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
118
145
|
// Keep one popup-manager entry (kind "toast") per visible card so
|
|
119
146
|
// registry introspection and stacking stay coherent; unregister on
|
|
120
147
|
// removal. Cards register in queue order, so newer prompts get the
|
|
@@ -150,13 +177,14 @@ export default defineComponent({
|
|
|
150
177
|
<Teleport to="body">
|
|
151
178
|
<div class="hk-blocking-toast-container">
|
|
152
179
|
<TransitionGroup
|
|
180
|
+
ref={hostRef}
|
|
153
181
|
tag="div"
|
|
154
182
|
name="hk-blocking-toast"
|
|
155
183
|
onBeforeEnter={itemHooks.onBeforeEnter}
|
|
156
184
|
onAfterEnter={itemHooks.onAfterEnter}
|
|
157
185
|
onBeforeLeave={(el: Element) => {
|
|
158
186
|
itemHooks.onBeforeLeave();
|
|
159
|
-
pinLeaveGeometry(el);
|
|
187
|
+
pinLeaveGeometry(el, { box: preLeaveBoxes.get(el) });
|
|
160
188
|
}}
|
|
161
189
|
onAfterLeave={itemHooks.onAfterLeave}
|
|
162
190
|
onLeaveCancelled={(el: Element) => {
|
|
@@ -60,7 +60,13 @@
|
|
|
60
60
|
padding: var(--hk-icon-group-inset);
|
|
61
61
|
border: none;
|
|
62
62
|
background: transparent;
|
|
63
|
-
gap
|
|
63
|
+
/* A visible gap between items (so the sliding thumb never visually
|
|
64
|
+
* merges neighbours) must ALSO feed the thumb's translate arithmetic —
|
|
65
|
+
* one custom property drives both, keeping the position pure index
|
|
66
|
+
* math with no JS measurement. Do not replace with a literal `gap: 0`;
|
|
67
|
+
* the thumb formula below consumes the same variable. */
|
|
68
|
+
--hk-icon-group-slider-gap: 0.375rem;
|
|
69
|
+
gap: var(--hk-icon-group-slider-gap);
|
|
64
70
|
flex-wrap: nowrap;
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -93,7 +99,10 @@
|
|
|
93
99
|
border-radius: var(--radius-sm, 4px);
|
|
94
100
|
background: rgb(var(--color-primary, 90 140 255) / 14%);
|
|
95
101
|
transform: translateX(
|
|
96
|
-
calc(
|
|
102
|
+
calc(
|
|
103
|
+
var(--hk-icon-group-active-index, 0) *
|
|
104
|
+
(var(--hk-icon-group-item-size) + var(--hk-icon-group-slider-gap))
|
|
105
|
+
)
|
|
97
106
|
);
|
|
98
107
|
transition: transform var(--duration, 0.18s) ease;
|
|
99
108
|
pointer-events: none;
|
|
@@ -11,6 +11,11 @@ import { useToast } from "../runtime/useToast";
|
|
|
11
11
|
* stack column is shrink-to-fit and collapses once the toast leaves
|
|
12
12
|
* the flow — a percentage width there resolves against a dead
|
|
13
13
|
* containing block and the toast "flashes" into a sliver
|
|
14
|
+
* - the pin measures the PRE-PATCH box: during a multi-toast removal an
|
|
15
|
+
* earlier sibling's position:absolute re-fits the column inside the
|
|
16
|
+
* same patch pass, so live offset reads in a later sibling's
|
|
17
|
+
* before-leave hook would teleport it onto the reflowed slot and
|
|
18
|
+
* squash its bar (HkListTransition's pre-patch snapshot pattern)
|
|
14
19
|
* - the toast is gone after the leave settles
|
|
15
20
|
*
|
|
16
21
|
* (Repo test convention: raw createApp + document queries, no
|
|
@@ -106,4 +111,63 @@ describe("HkToast", () => {
|
|
|
106
111
|
await settle();
|
|
107
112
|
expect(items().length).toBe(0);
|
|
108
113
|
});
|
|
114
|
+
|
|
115
|
+
it("pins a later sibling to its pre-patch box when several toasts leave in one pass", async () => {
|
|
116
|
+
mountHost();
|
|
117
|
+
const toast = useToast();
|
|
118
|
+
toast.error("long enough error body that wrapped to a tall box");
|
|
119
|
+
toast.warning("medium body on the second row");
|
|
120
|
+
await settle();
|
|
121
|
+
|
|
122
|
+
const [first, second] = items();
|
|
123
|
+
// Model the browser's synchronous re-layout: once the FIRST toast
|
|
124
|
+
// goes position:absolute mid-patch, the shrink-to-fit column re-fits
|
|
125
|
+
// to the remaining in-flow toast — its box re-stretches to its own
|
|
126
|
+
// max-content (138) and it shifts up to the vacated top slot.
|
|
127
|
+
// happy-dom has no layout engine, so the stubs branch on the first
|
|
128
|
+
// toast's leave-active class to emulate the before/after readings.
|
|
129
|
+
const wrapper = first.parentElement as HTMLElement;
|
|
130
|
+
const reflowed = () => first.classList.contains("hk-toast-leave-active");
|
|
131
|
+
Object.defineProperty(wrapper, "clientWidth", {
|
|
132
|
+
configurable: true,
|
|
133
|
+
get: () => (reflowed() ? 138 : 384),
|
|
134
|
+
});
|
|
135
|
+
const stub = (
|
|
136
|
+
el: HTMLElement,
|
|
137
|
+
pre: { top: number; left: number; width: number; height: number },
|
|
138
|
+
post: { top: number; left: number; width: number; height: number },
|
|
139
|
+
) => {
|
|
140
|
+
Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
|
|
141
|
+
for (const key of ["offsetTop", "offsetLeft", "offsetWidth", "offsetHeight"] as const) {
|
|
142
|
+
const short = key.slice(6).toLowerCase() as "top" | "left" | "width" | "height";
|
|
143
|
+
Object.defineProperty(el, key, {
|
|
144
|
+
configurable: true,
|
|
145
|
+
get: () => (reflowed() ? post[short] : pre[short]),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
stub(first, { top: 0, left: 0, width: 384, height: 83 }, { top: 0, left: 0, width: 384, height: 83 });
|
|
150
|
+
stub(second, { top: 95, left: 0, width: 384, height: 48 }, { top: 0, left: 0, width: 138, height: 48 });
|
|
151
|
+
|
|
152
|
+
const [idA, idB] = toast.toasts.map((t) => t.id);
|
|
153
|
+
toast.remove(idA);
|
|
154
|
+
toast.remove(idB);
|
|
155
|
+
await nextTick();
|
|
156
|
+
|
|
157
|
+
const [pinFirst, pinSecond] =
|
|
158
|
+
document.querySelectorAll<HTMLElement>(".hk-toast-leave-active");
|
|
159
|
+
// The second toast's pin mirrors its PRE-patch box — full-width bar
|
|
160
|
+
// at its own row — not the reflowed sliver at the vacated top slot.
|
|
161
|
+
expect(pinFirst!.style.right).toBe("0px");
|
|
162
|
+
expect(pinFirst!.style.top).toBe("0px");
|
|
163
|
+
expect(pinFirst!.style.width).toBe("384px");
|
|
164
|
+
expect(pinFirst!.style.height).toBe("83px");
|
|
165
|
+
expect(pinSecond!.style.right).toBe("0px");
|
|
166
|
+
expect(pinSecond!.style.top).toBe("95px");
|
|
167
|
+
expect(pinSecond!.style.width).toBe("384px");
|
|
168
|
+
expect(pinSecond!.style.height).toBe("48px");
|
|
169
|
+
|
|
170
|
+
await settle();
|
|
171
|
+
expect(items().length).toBe(0);
|
|
172
|
+
});
|
|
109
173
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CircleX as XCircle, X } from "lucide-vue-next";
|
|
2
|
-
import { computed, defineComponent, onMounted, onUnmounted, ref, Teleport, Transition, TransitionGroup, watch } from "vue";
|
|
2
|
+
import { computed, defineComponent, onBeforeUpdate, onMounted, onUnmounted, ref, Teleport, Transition, TransitionGroup, watch } from "vue";
|
|
3
3
|
import { AlertTriangle, CheckCircle, Copy, Info } from "lucide-vue-next";
|
|
4
4
|
|
|
5
5
|
|
|
@@ -7,7 +7,7 @@ import { useToast, type ToastItem, type ToastMessage, type ToastType } from "../
|
|
|
7
7
|
import { useClipboard } from "../runtime/useClipboard";
|
|
8
8
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
9
9
|
import { useI18n } from "../i18n/context";
|
|
10
|
-
import { clearLeaveGeometry, pinLeaveGeometry } from "../utils/dom";
|
|
10
|
+
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
11
11
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
12
12
|
import "./HkToast.scss";
|
|
13
13
|
|
|
@@ -173,6 +173,36 @@ export default defineComponent({
|
|
|
173
173
|
let popupHandle: PopupHandle | null = null;
|
|
174
174
|
const containerZ = ref<number | null>(null);
|
|
175
175
|
|
|
176
|
+
// Pre-patch geometry of every toast, refreshed on each update (the
|
|
177
|
+
// DOM is still the pre-patch tree at onBeforeUpdate — same pattern
|
|
178
|
+
// as HkListTransition / HkTabs). During a multi-toast removal the
|
|
179
|
+
// first leaving sibling gets its leave-active class (position:
|
|
180
|
+
// absolute) synchronously inside the patch pass, so by the time the
|
|
181
|
+
// NEXT sibling's beforeLeave runs, the shrink-to-fit stack column
|
|
182
|
+
// has already re-fit: a live offset read there would freeze the
|
|
183
|
+
// reflowed box — toasts teleport onto the top slot, a narrow toast's
|
|
184
|
+
// bar collapses to its own max-content, and the overlapped fading
|
|
185
|
+
// texts read as one squashed multi-line blob. The wrapper itself may
|
|
186
|
+
// also have re-fit when the pin runs, so the right anchor is snapped
|
|
187
|
+
// at snapshot time too (see LeaveBoxSnapshot.right).
|
|
188
|
+
const hostRef = ref<{ $el?: Element } | null>(null);
|
|
189
|
+
const preLeaveBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
|
|
190
|
+
onBeforeUpdate(() => {
|
|
191
|
+
const host = hostRef.value?.$el;
|
|
192
|
+
if (host == null || host.nodeType !== 1) return;
|
|
193
|
+
const parentWidth = (host as HTMLElement).clientWidth;
|
|
194
|
+
for (const child of Array.from(host.children)) {
|
|
195
|
+
const e = child as HTMLElement;
|
|
196
|
+
preLeaveBoxes.set(e, {
|
|
197
|
+
top: e.offsetTop,
|
|
198
|
+
left: e.offsetLeft,
|
|
199
|
+
width: e.offsetWidth,
|
|
200
|
+
height: e.offsetHeight,
|
|
201
|
+
right: parentWidth - (e.offsetLeft + e.offsetWidth),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
176
206
|
onMounted(() => {
|
|
177
207
|
popupHandle = manager.register("toast", false);
|
|
178
208
|
containerZ.value = popupHandle.zIndex;
|
|
@@ -195,13 +225,14 @@ export default defineComponent({
|
|
|
195
225
|
<Teleport to="body">
|
|
196
226
|
<div class="hk-toast-container" style={containerStyle.value}>
|
|
197
227
|
<TransitionGroup
|
|
228
|
+
ref={hostRef}
|
|
198
229
|
tag="div"
|
|
199
230
|
name="hk-toast"
|
|
200
231
|
onBeforeEnter={itemHooks.onBeforeEnter}
|
|
201
232
|
onAfterEnter={itemHooks.onAfterEnter}
|
|
202
233
|
onBeforeLeave={(el: Element) => {
|
|
203
234
|
itemHooks.onBeforeLeave();
|
|
204
|
-
pinLeaveGeometry(el);
|
|
235
|
+
pinLeaveGeometry(el, { box: preLeaveBoxes.get(el) });
|
|
205
236
|
}}
|
|
206
237
|
onAfterLeave={itemHooks.onAfterLeave}
|
|
207
238
|
onLeaveCancelled={(el: Element) => {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "اختيار",
|
|
52
52
|
"hikari::filePicker.copy": "نسخ",
|
|
53
53
|
"hikari::filePicker.copyPath": "نسخ المسار",
|
|
54
|
+
"hikari::filePicker.currentPath": "المسار الحالي",
|
|
54
55
|
"hikari::filePicker.cut": "قص",
|
|
55
56
|
"hikari::filePicker.empty": "هذا المجلد فارغ",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "اختر ملفات…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "استخدام \"{q}\"",
|
|
219
220
|
"hikari::tagInput.reorder": "إعادة الترتيب — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "الطبقات المخفية",
|
|
221
|
-
"hikari::theme.modeTabHint": "هذه التبويبات تختار لوحة الألوان التي تحرّرها؛ لتبديل الوضع الفاتح/الداكن للتطبيق كله استخدم شريط الأوضاع أعلى نافذة السمة."
|
|
222
|
+
"hikari::theme.modeTabHint": "هذه التبويبات تختار لوحة الألوان التي تحرّرها؛ لتبديل الوضع الفاتح/الداكن للتطبيق كله استخدم شريط الأوضاع أعلى نافذة السمة.",
|
|
223
|
+
"hikari::emptyState.loading": "جارٍ التحميل"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "Auswählen",
|
|
52
52
|
"hikari::filePicker.copy": "Kopieren",
|
|
53
53
|
"hikari::filePicker.copyPath": "Pfad kopieren",
|
|
54
|
+
"hikari::filePicker.currentPath": "Aktueller Pfad",
|
|
54
55
|
"hikari::filePicker.cut": "Ausschneiden",
|
|
55
56
|
"hikari::filePicker.empty": "Dieser Ordner ist leer",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "Dateien auswählen…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "„{q}“ verwenden",
|
|
219
220
|
"hikari::tagInput.reorder": "Neu anordnen — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "Verdeckte Ebenen",
|
|
221
|
-
"hikari::theme.modeTabHint": "Diese Reiter wählen die zu bearbeitende Palette; die appweite Hell/Dunkel-Umschaltung liegt im Modusstreifen des Themen-Popups."
|
|
222
|
+
"hikari::theme.modeTabHint": "Diese Reiter wählen die zu bearbeitende Palette; die appweite Hell/Dunkel-Umschaltung liegt im Modusstreifen des Themen-Popups.",
|
|
223
|
+
"hikari::emptyState.loading": "Wird geladen"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "Choose",
|
|
52
52
|
"hikari::filePicker.copy": "Copy",
|
|
53
53
|
"hikari::filePicker.copyPath": "Copy path",
|
|
54
|
+
"hikari::filePicker.currentPath": "Current path",
|
|
54
55
|
"hikari::filePicker.cut": "Cut",
|
|
55
56
|
"hikari::filePicker.empty": "This folder is empty",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "Choose files…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "Use “{q}”",
|
|
219
220
|
"hikari::tagInput.reorder": "Reorder {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "Hidden layers",
|
|
221
|
-
"hikari::theme.modeTabHint": "These tabs pick which palette you are EDITING; to switch the app-wide light/dark, use the mode strip at the top of the theme popup."
|
|
222
|
+
"hikari::theme.modeTabHint": "These tabs pick which palette you are EDITING; to switch the app-wide light/dark, use the mode strip at the top of the theme popup.",
|
|
223
|
+
"hikari::emptyState.loading": "Loading"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "Elegir",
|
|
52
52
|
"hikari::filePicker.copy": "Copiar",
|
|
53
53
|
"hikari::filePicker.copyPath": "Copiar ruta",
|
|
54
|
+
"hikari::filePicker.currentPath": "Ruta actual",
|
|
54
55
|
"hikari::filePicker.cut": "Cortar",
|
|
55
56
|
"hikari::filePicker.empty": "Esta carpeta está vacía",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "Elegir archivos…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "Usar «{q}»",
|
|
219
220
|
"hikari::tagInput.reorder": "Reordenar — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "Capas ocultas",
|
|
221
|
-
"hikari::theme.modeTabHint": "Estas pestañas eligen la paleta que estás EDITANDO; para cambiar el modo claro/oscuro de toda la app, usa la franja de modos de la parte superior del panel de temas."
|
|
222
|
+
"hikari::theme.modeTabHint": "Estas pestañas eligen la paleta que estás EDITANDO; para cambiar el modo claro/oscuro de toda la app, usa la franja de modos de la parte superior del panel de temas.",
|
|
223
|
+
"hikari::emptyState.loading": "Cargando"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "Choisir",
|
|
52
52
|
"hikari::filePicker.copy": "Copier",
|
|
53
53
|
"hikari::filePicker.copyPath": "Copier le chemin",
|
|
54
|
+
"hikari::filePicker.currentPath": "Chemin actuel",
|
|
54
55
|
"hikari::filePicker.cut": "Couper",
|
|
55
56
|
"hikari::filePicker.empty": "Ce dossier est vide",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "Choisir des fichiers…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "Utiliser « {q} »",
|
|
219
220
|
"hikari::tagInput.reorder": "Réorganiser — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "Couches masquées",
|
|
221
|
-
"hikari::theme.modeTabHint": "Ces onglets choisissent la palette éditée ; pour basculer clair/sombre dans toute l’app, utilisez la barre de mode du menu de thème."
|
|
222
|
+
"hikari::theme.modeTabHint": "Ces onglets choisissent la palette éditée ; pour basculer clair/sombre dans toute l’app, utilisez la barre de mode du menu de thème.",
|
|
223
|
+
"hikari::emptyState.loading": "Chargement"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "選択",
|
|
52
52
|
"hikari::filePicker.copy": "コピー",
|
|
53
53
|
"hikari::filePicker.copyPath": "パスをコピー",
|
|
54
|
+
"hikari::filePicker.currentPath": "現在のパス",
|
|
54
55
|
"hikari::filePicker.cut": "切り取り",
|
|
55
56
|
"hikari::filePicker.empty": "このフォルダーは空です",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "複数のファイルを選択…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "「{q}」を使用",
|
|
219
220
|
"hikari::tagInput.reorder": "並べ替え — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "非表示の階層",
|
|
221
|
-
"hikari::theme.modeTabHint": "このタブは編集対象のパレットを選ぶだけです。アプリ全体の明/暗はテーマポップアップ上部のモードストリップで切り替えてください。"
|
|
222
|
+
"hikari::theme.modeTabHint": "このタブは編集対象のパレットを選ぶだけです。アプリ全体の明/暗はテーマポップアップ上部のモードストリップで切り替えてください。",
|
|
223
|
+
"hikari::emptyState.loading": "読み込み中"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "선택",
|
|
52
52
|
"hikari::filePicker.copy": "복사",
|
|
53
53
|
"hikari::filePicker.copyPath": "경로 복사",
|
|
54
|
+
"hikari::filePicker.currentPath": "현재 경로",
|
|
54
55
|
"hikari::filePicker.cut": "잘라내기",
|
|
55
56
|
"hikari::filePicker.empty": "이 폴더는 비어 있습니다",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "여러 파일 선택…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "\"{q}\" 사용",
|
|
219
220
|
"hikari::tagInput.reorder": "순서 변경 — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "숨겨진 레이어",
|
|
221
|
-
"hikari::theme.modeTabHint": "이 탭은 편집할 팔레트를 고를 뿐입니다. 앱 전체 밝기/어둠은 테마 팝업 상단의 모드 스트립에서 바꾸세요."
|
|
222
|
+
"hikari::theme.modeTabHint": "이 탭은 편집할 팔레트를 고를 뿐입니다. 앱 전체 밝기/어둠은 테마 팝업 상단의 모드 스트립에서 바꾸세요.",
|
|
223
|
+
"hikari::emptyState.loading": "불러오는 중"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "Escolher",
|
|
52
52
|
"hikari::filePicker.copy": "Copiar",
|
|
53
53
|
"hikari::filePicker.copyPath": "Copiar caminho",
|
|
54
|
+
"hikari::filePicker.currentPath": "Caminho atual",
|
|
54
55
|
"hikari::filePicker.cut": "Recortar",
|
|
55
56
|
"hikari::filePicker.empty": "Esta pasta está vazia",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "Escolher arquivos…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "Usar \"{q}\"",
|
|
219
220
|
"hikari::tagInput.reorder": "Reordenar — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "Camadas ocultas",
|
|
221
|
-
"hikari::theme.modeTabHint": "Estas abas escolhem a paleta que você está EDITANDO; para alternar o claro/escuro do app inteiro, use a faixa de modos no topo do painel de temas."
|
|
222
|
+
"hikari::theme.modeTabHint": "Estas abas escolhem a paleta que você está EDITANDO; para alternar o claro/escuro do app inteiro, use a faixa de modos no topo do painel de temas.",
|
|
223
|
+
"hikari::emptyState.loading": "Carregando"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "Выбрать",
|
|
52
52
|
"hikari::filePicker.copy": "Копировать",
|
|
53
53
|
"hikari::filePicker.copyPath": "Копировать путь",
|
|
54
|
+
"hikari::filePicker.currentPath": "Текущий путь",
|
|
54
55
|
"hikari::filePicker.cut": "Вырезать",
|
|
55
56
|
"hikari::filePicker.empty": "Эта папка пуста",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "Выбрать файлы…",
|
|
@@ -218,5 +219,6 @@
|
|
|
218
219
|
"hikari::tagInput.addCustom": "Использовать «{q}»",
|
|
219
220
|
"hikari::tagInput.reorder": "Изменить порядок — {label}",
|
|
220
221
|
"hikari::modal.hiddenLayers": "Скрытые слои",
|
|
221
|
-
"hikari::theme.modeTabHint": "Вкладки выбирают редактируемую палитру; общесистемное переключение светлой/тёмной — в полосе режимов вверху меню темы."
|
|
222
|
+
"hikari::theme.modeTabHint": "Вкладки выбирают редактируемую палитру; общесистемное переключение светлой/тёмной — в полосе режимов вверху меню темы.",
|
|
223
|
+
"hikari::emptyState.loading": "Загрузка"
|
|
222
224
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "选择",
|
|
52
52
|
"hikari::filePicker.copy": "复制",
|
|
53
53
|
"hikari::filePicker.copyPath": "复制路径",
|
|
54
|
+
"hikari::filePicker.currentPath": "当前路径",
|
|
54
55
|
"hikari::filePicker.cut": "剪切",
|
|
55
56
|
"hikari::filePicker.empty": "此文件夹为空",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "选择多个文件…",
|
|
@@ -222,5 +223,6 @@
|
|
|
222
223
|
"hikari::tagInput.addCustom": "使用「{q}」",
|
|
223
224
|
"hikari::tagInput.reorder": "拖动排序 — {label}",
|
|
224
225
|
"hikari::modal.hiddenLayers": "隐藏层级",
|
|
225
|
-
"hikari::theme.modeTabHint": "切换这里编辑的是对应模式的调色板;要切换应用整体深/浅,请用主题弹窗顶部的模式条。"
|
|
226
|
+
"hikari::theme.modeTabHint": "切换这里编辑的是对应模式的调色板;要切换应用整体深/浅,请用主题弹窗顶部的模式条。",
|
|
227
|
+
"hikari::emptyState.loading": "加载中"
|
|
226
228
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"hikari::filePicker.confirm": "選擇",
|
|
52
52
|
"hikari::filePicker.copy": "複製",
|
|
53
53
|
"hikari::filePicker.copyPath": "複製路徑",
|
|
54
|
+
"hikari::filePicker.currentPath": "目前路徑",
|
|
54
55
|
"hikari::filePicker.cut": "剪下",
|
|
55
56
|
"hikari::filePicker.empty": "此資料夾為空",
|
|
56
57
|
"hikari::filePicker.fieldChooseMany": "選擇多個檔案…",
|
|
@@ -222,5 +223,6 @@
|
|
|
222
223
|
"hikari::tagInput.addCustom": "使用「{q}」",
|
|
223
224
|
"hikari::tagInput.reorder": "拖曳排序 — {label}",
|
|
224
225
|
"hikari::modal.hiddenLayers": "隱藏層級",
|
|
225
|
-
"hikari::theme.modeTabHint": "切換這裡編輯的是對應模式的調色盤;要切換應用整體深/淺,請用主題彈窗頂部的模式條。"
|
|
226
|
+
"hikari::theme.modeTabHint": "切換這裡編輯的是對應模式的調色盤;要切換應用整體深/淺,請用主題彈窗頂部的模式條。",
|
|
227
|
+
"hikari::emptyState.loading": "載入中"
|
|
226
228
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* t() usage-surface audit (2026-09-19, follow-up to the #599 incident):
|
|
7
|
+
* flatKeyParity.test.ts proves the locale bundles AGREE with en, but both
|
|
8
|
+
* sides of that check live in the JSON — nothing pinned the source side.
|
|
9
|
+
* Renaming a key in the bundles (or deleting it from en only) left every
|
|
10
|
+
* test green while the components fell back to their hard-coded English
|
|
11
|
+
* literals in every language (exactly how the theme.modeTabHint gap sat
|
|
12
|
+
* unnoticed until #599, alongside two older strays this suite landed:
|
|
13
|
+
* emptyState.loading and filePicker.currentPath).
|
|
14
|
+
*
|
|
15
|
+
* This file closes that loop: it statically scans every consumer source
|
|
16
|
+
* file under packages/vue/src for `t("hikari::…")` literals — in ALL
|
|
17
|
+
* THREE quote forms (double, single, backtick) and across line breaks
|
|
18
|
+
* after the opening paren (HkPhoneInput.tsx:194 already uses that split
|
|
19
|
+
* form) — and asserts each referenced key exists, non-empty, in the
|
|
20
|
+
* loader-merged bundle of EVERY locale.
|
|
21
|
+
*
|
|
22
|
+
* The `hikari::` prefix is exactly hikari's own component vocabulary.
|
|
23
|
+
* Unprefixed literals ("common.time.*", "${i18nKey}.*", "locale.title")
|
|
24
|
+
* belong to the HOST-app namespace (registered at runtime via
|
|
25
|
+
* mergeMessages, or passed in as props.t) and are pinned by their own
|
|
26
|
+
* suites (timeKeys.test.ts & co.) — out of scope here by construction.
|
|
27
|
+
*/
|
|
28
|
+
const modules = import.meta.glob<{ default: Record<string, unknown> }>(
|
|
29
|
+
"./locales/*/*.json",
|
|
30
|
+
{ eager: true },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const EXPECTED_LOCALES = [
|
|
34
|
+
"ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** The loader's own merge (i18n/context.ts buildLocaleMessages): per locale
|
|
38
|
+
* directory, the union over every *.json of top-level flat string keys plus
|
|
39
|
+
* one level of legacy nested sections. */
|
|
40
|
+
function buildLocaleUniverse(locale: string): Map<string, string> {
|
|
41
|
+
const merged = new Map<string, string>();
|
|
42
|
+
for (const [path, mod] of Object.entries(modules)) {
|
|
43
|
+
if (!path.includes(`/locales/${locale}/`)) continue;
|
|
44
|
+
for (const [key, value] of Object.entries(mod.default)) {
|
|
45
|
+
if (typeof value === "string") merged.set(key, value);
|
|
46
|
+
else if (value !== null && typeof value === "object") {
|
|
47
|
+
for (const [leaf, text] of Object.entries(value as Record<string, unknown>)) {
|
|
48
|
+
if (typeof text === "string") merged.set(leaf, text);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return merged;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SRC_ROOT = join(import.meta.dirname, "..");
|
|
57
|
+
|
|
58
|
+
/** Consumer sources: everything under src/ EXCEPT the i18n suite itself
|
|
59
|
+
* (its tests exercise the loader with their own literals — bundle-side
|
|
60
|
+
* guards already cover those keys; scanning them here would just couple
|
|
61
|
+
* this audit to test churn, not to the real usage surface). */
|
|
62
|
+
function walkSources(dir: string, acc: string[] = []): string[] {
|
|
63
|
+
for (const name of readdirSync(dir)) {
|
|
64
|
+
const path = join(dir, name);
|
|
65
|
+
const info = statSync(path);
|
|
66
|
+
if (info.isDirectory()) {
|
|
67
|
+
if (dir === SRC_ROOT && name === "i18n") continue;
|
|
68
|
+
walkSources(path, acc);
|
|
69
|
+
} else if (/\.(ts|tsx|vue)$/.test(name)) acc.push(path);
|
|
70
|
+
}
|
|
71
|
+
return acc;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Static literal call sites. The `\s*` tolerates a line break between
|
|
75
|
+
* `t(` and the key; the character class covers ", ', and ` so a template
|
|
76
|
+
* literal WITHOUT interpolation is still a pinned literal. */
|
|
77
|
+
const STATIC_USE_RE = /\bt\(\s*(["'`])hikari::[A-Za-z0-9_.]+\1/g;
|
|
78
|
+
/** Dynamic families: a template key with `${…}` interpolation can never be
|
|
79
|
+
* resolved statically — they are enumerated in DYNAMIC_KEY_FAMILIES. */
|
|
80
|
+
const DYNAMIC_USE_RE = /\bt\(\s*`hikari::[A-Za-z0-9_.]*\$\{/g;
|
|
81
|
+
|
|
82
|
+
interface Site {
|
|
83
|
+
key: string;
|
|
84
|
+
where: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function scanSources(): { statics: Site[]; dynamics: string[] } {
|
|
88
|
+
const statics: Site[] = [];
|
|
89
|
+
const dynamics: string[] = [];
|
|
90
|
+
for (const path of walkSources(SRC_ROOT)) {
|
|
91
|
+
const text = readFileSync(path, "utf8");
|
|
92
|
+
const rel = path.slice(SRC_ROOT.length + 1);
|
|
93
|
+
for (const match of text.matchAll(STATIC_USE_RE)) {
|
|
94
|
+
const key = match[0].slice(match[0].indexOf("hikari::"), -1);
|
|
95
|
+
statics.push({ key, where: `${rel}:${text.slice(0, match.index).split("\n").length}` });
|
|
96
|
+
}
|
|
97
|
+
for (const match of text.matchAll(DYNAMIC_USE_RE)) {
|
|
98
|
+
// The literal portion before "${" — e.g. "hikari::context." — which
|
|
99
|
+
// is the family prefix the whitelist is keyed by.
|
|
100
|
+
dynamics.push(match[0].slice(match[0].indexOf("hikari::"), -2));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { statics, dynamics };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Interpolated key families that intentionally escape the static scan,
|
|
107
|
+
* each with the reason it cannot be pinned by literal. Both directions
|
|
108
|
+
* are enforced below: every family found in source must be listed, and
|
|
109
|
+
* every listed family must still exist in source, so this whitelist
|
|
110
|
+
* cannot rot into silence. */
|
|
111
|
+
const DYNAMIC_KEY_FAMILIES: Record<string, string> = {
|
|
112
|
+
"hikari::context.": "HkContextRing section keys — closed enum, pinned by name in contextKeys.test.ts.",
|
|
113
|
+
"hikari::statusBar.backend.": "HkConnectionStatus — enumerated connection-state suffixes.",
|
|
114
|
+
"hikari::statusBar.region.": "HkStatusBar region names — OPTIONAL per-locale overrides over Intl.DisplayNames (see LOCALE_SPECIFIC_NESTED in flatKeyParity.test.ts).",
|
|
115
|
+
"hikari::theme.groups.": "Registry type: group/slot ids are registered by downstream apps; t() resolves via a resolveLocalizedText fallback, never a hikari bundle.",
|
|
116
|
+
"hikari::theme.tokens.": "HkColorSchemeEditor token enum — closed set defined in platform.json locales.",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
describe("t() usage surface vs locale bundles", () => {
|
|
120
|
+
const universes = new Map(EXPECTED_LOCALES.map((l) => [l, buildLocaleUniverse(l)]));
|
|
121
|
+
const { statics, dynamics } = scanSources();
|
|
122
|
+
|
|
123
|
+
it("covers all 11 locales in the merged bundle universe", () => {
|
|
124
|
+
const found = new Set(
|
|
125
|
+
Object.keys(modules)
|
|
126
|
+
.map((p) => p.match(/locales\/([^/]+)\//)?.[1])
|
|
127
|
+
.filter((l): l is string => Boolean(l)),
|
|
128
|
+
);
|
|
129
|
+
expect([...found].sort()).toEqual(EXPECTED_LOCALES);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("scanner self-proof: the extraction actually extracts (no vacuous pass)", () => {
|
|
133
|
+
// Zero-hit discipline: a broken regex/walk yields zeros that LOOK green.
|
|
134
|
+
// Pin the floor near today's real volume (446 files, 325 sites, 270
|
|
135
|
+
// distinct keys) so a hollowed scanner goes loudly red, and pin known
|
|
136
|
+
// positives — including one multi-line call form — by name and file.
|
|
137
|
+
expect(walkSources(SRC_ROOT).length).toBeGreaterThan(400);
|
|
138
|
+
expect(statics.length).toBeGreaterThan(320);
|
|
139
|
+
expect(new Set(statics.map((s) => s.key)).size).toBeGreaterThan(260);
|
|
140
|
+
const firstSiteOf = (key: string) =>
|
|
141
|
+
statics.find((s) => s.key === key)?.where ?? "NOT FOUND";
|
|
142
|
+
expect(firstSiteOf("hikari::theme.modeTabHint")).toMatch(/HkColorSchemeEditor\.tsx/);
|
|
143
|
+
expect(firstSiteOf("hikari::emptyState.loading")).toMatch(/HkEmptyState\.tsx/);
|
|
144
|
+
expect(firstSiteOf("hikari::filePicker.currentPath")).toMatch(/HkFileBrowserDialog\.tsx/);
|
|
145
|
+
// Split-across-lines call form (t( and the key on different lines):
|
|
146
|
+
expect(firstSiteOf("hikari::phoneInput.searchCountries")).toMatch(/HkPhoneInput\.tsx/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("resolves every statically referenced hikari:: key in every locale, non-empty", () => {
|
|
150
|
+
expect(statics.length, "source scan found call sites").toBeGreaterThan(0);
|
|
151
|
+
const violations: string[] = [];
|
|
152
|
+
const reported = new Set<string>();
|
|
153
|
+
for (const { key, where } of statics) {
|
|
154
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
155
|
+
const value = universes.get(locale)!.get(key);
|
|
156
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
157
|
+
if (reported.has(key)) continue;
|
|
158
|
+
reported.add(key);
|
|
159
|
+
violations.push(
|
|
160
|
+
`${key} (first referenced at ${where}) is missing or empty in ${locale}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
expect(violations, "every referenced key must resolve in all 11 locales").toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("enumerates exactly the whitelisted dynamic key families, both directions", () => {
|
|
169
|
+
const found = [...new Set(dynamics)].sort();
|
|
170
|
+
expect(found, "families found in source but missing from the whitelist").toEqual(
|
|
171
|
+
Object.keys(DYNAMIC_KEY_FAMILIES).sort(),
|
|
172
|
+
);
|
|
173
|
+
for (const family of Object.keys(DYNAMIC_KEY_FAMILIES)) {
|
|
174
|
+
expect(found, `${family} is whitelisted but no longer used — prune it`).toContain(family);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/utils/dom.test.ts
CHANGED
|
@@ -110,6 +110,24 @@ describe("pinLeaveGeometry", () => {
|
|
|
110
110
|
expect(el.style.boxSizing).toBe("border-box");
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it("prefers the snapshot's right pin over a live clientWidth on the right anchor", () => {
|
|
114
|
+
// Right-anchored hosts re-fit mid-removal: by the time a later
|
|
115
|
+
// sibling pins, the live clientWidth has already collapsed, so the
|
|
116
|
+
// snapshot-time right offset must win over the live computation.
|
|
117
|
+
const parent = { clientWidth: 138 };
|
|
118
|
+
const el = stubEl(parent, { offsetTop: 95, offsetLeft: 0, offsetWidth: 384, offsetHeight: 48 });
|
|
119
|
+
|
|
120
|
+
pinLeaveGeometry(el as unknown as Element, {
|
|
121
|
+
box: { top: 95, left: 0, width: 384, height: 48, right: 0 },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(el.style.right).toBe("0px");
|
|
125
|
+
expect(el.style.top).toBe("95px");
|
|
126
|
+
expect(el.style.width).toBe("384px");
|
|
127
|
+
expect(el.style.height).toBe("48px");
|
|
128
|
+
expect(el.style.boxSizing).toBe("border-box");
|
|
129
|
+
});
|
|
130
|
+
|
|
113
131
|
it("clearLeaveGeometry strips exactly the pins pinLeaveGeometry wrote", () => {
|
|
114
132
|
const parent = { clientWidth: 384 };
|
|
115
133
|
const el = stubEl(parent, { offsetWidth: 384, offsetHeight: 83 });
|
package/src/utils/dom.ts
CHANGED
|
@@ -49,6 +49,13 @@ export interface LeaveBoxSnapshot {
|
|
|
49
49
|
left: number;
|
|
50
50
|
width: number;
|
|
51
51
|
height: number;
|
|
52
|
+
/** Right-pin offset (`containing-block width − (left + width)`)
|
|
53
|
+
* computed at SNAPSHOT time. Right-anchored hosts need this because
|
|
54
|
+
* the containing block itself re-fits mid-removal: by the time a
|
|
55
|
+
* later sibling pins, a live `clientWidth` read would measure the
|
|
56
|
+
* already-collapsed wrapper and shove the pinned box past the edge
|
|
57
|
+
* the anchor is supposed to hold. */
|
|
58
|
+
right?: number;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -107,7 +114,10 @@ export function pinLeaveGeometry(
|
|
|
107
114
|
// CSS `right` for an absolutely positioned box is measured from the
|
|
108
115
|
// containing block's right padding-box edge; offsetLeft/offsetWidth
|
|
109
116
|
// place the item's right border edge within that same padding box.
|
|
110
|
-
|
|
117
|
+
// Prefer the snapshot-time value: a live clientWidth here would
|
|
118
|
+
// measure the wrapper AFTER earlier siblings already left the flow
|
|
119
|
+
// (multi-element removal re-fits it synchronously).
|
|
120
|
+
const right = opts?.box?.right ?? parent.clientWidth - (left + width);
|
|
111
121
|
node.style.right = `${right}px`;
|
|
112
122
|
}
|
|
113
123
|
node.style.top = `${top}px`;
|