@celestia-island/hikari 0.55.32 → 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/src/i18n/usageSurface.test.ts +66 -13
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
|
});
|
|
@@ -24,6 +24,13 @@ import { describe, expect, it } from "vitest";
|
|
|
24
24
|
* belong to the HOST-app namespace (registered at runtime via
|
|
25
25
|
* mergeMessages, or passed in as props.t) and are pinned by their own
|
|
26
26
|
* suites (timeKeys.test.ts & co.) — out of scope here by construction.
|
|
27
|
+
*
|
|
28
|
+
* Interpolated keys cannot be pinned by literal, so their CONSTRUCTION
|
|
29
|
+
* SITES are scanned wherever the backtick template appears — adjacency to
|
|
30
|
+
* t() is NOT required (F1: HkStatusBar.tsx:233 builds the key into a
|
|
31
|
+
* variable first) — and each discovered family is whitelisted and, when
|
|
32
|
+
* its enum is closed inside hikari, pinned key-by-key (see
|
|
33
|
+
* CONSTRUCTED_FAMILY_KEYS).
|
|
27
34
|
*/
|
|
28
35
|
const modules = import.meta.glob<{ default: Record<string, unknown> }>(
|
|
29
36
|
"./locales/*/*.json",
|
|
@@ -75,18 +82,23 @@ function walkSources(dir: string, acc: string[] = []): string[] {
|
|
|
75
82
|
* `t(` and the key; the character class covers ", ', and ` so a template
|
|
76
83
|
* literal WITHOUT interpolation is still a pinned literal. */
|
|
77
84
|
const STATIC_USE_RE = /\bt\(\s*(["'`])hikari::[A-Za-z0-9_.]+\1/g;
|
|
78
|
-
/**
|
|
79
|
-
* resolved statically — they are enumerated in
|
|
80
|
-
|
|
85
|
+
/** Constructed key families: a template key with `${…}` interpolation can
|
|
86
|
+
* never be resolved statically — they are enumerated in
|
|
87
|
+
* DYNAMIC_KEY_FAMILIES. The regex matches the template construction
|
|
88
|
+
* ANYWHERE in source, NOT only adjacent to `t(`: F1 (verify-hk603,
|
|
89
|
+
* 2026-09-19) — HkStatusBar.tsx builds `hikari::statusBar.tier.${tier}`
|
|
90
|
+
* into a variable and only calls t(tierLabelKey) 135 lines later, so the
|
|
91
|
+
* old adjacency-only regex never saw the family at all. */
|
|
92
|
+
const CONSTRUCTED_KEY_RE = /`hikari::[A-Za-z0-9_.]*\$\{/g;
|
|
81
93
|
|
|
82
94
|
interface Site {
|
|
83
95
|
key: string;
|
|
84
96
|
where: string;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
function scanSources(): { statics: Site[];
|
|
99
|
+
function scanSources(): { statics: Site[]; constructed: Site[] } {
|
|
88
100
|
const statics: Site[] = [];
|
|
89
|
-
const
|
|
101
|
+
const constructed: Site[] = [];
|
|
90
102
|
for (const path of walkSources(SRC_ROOT)) {
|
|
91
103
|
const text = readFileSync(path, "utf8");
|
|
92
104
|
const rel = path.slice(SRC_ROOT.length + 1);
|
|
@@ -94,13 +106,14 @@ function scanSources(): { statics: Site[]; dynamics: string[] } {
|
|
|
94
106
|
const key = match[0].slice(match[0].indexOf("hikari::"), -1);
|
|
95
107
|
statics.push({ key, where: `${rel}:${text.slice(0, match.index).split("\n").length}` });
|
|
96
108
|
}
|
|
97
|
-
for (const match of text.matchAll(
|
|
109
|
+
for (const match of text.matchAll(CONSTRUCTED_KEY_RE)) {
|
|
98
110
|
// The literal portion before "${" — e.g. "hikari::context." — which
|
|
99
111
|
// is the family prefix the whitelist is keyed by.
|
|
100
|
-
|
|
112
|
+
const family = match[0].slice(1, -2);
|
|
113
|
+
constructed.push({ key: family, where: `${rel}:${text.slice(0, match.index).split("\n").length}` });
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
|
-
return { statics,
|
|
116
|
+
return { statics, constructed };
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
/** Interpolated key families that intentionally escape the static scan,
|
|
@@ -112,13 +125,25 @@ const DYNAMIC_KEY_FAMILIES: Record<string, string> = {
|
|
|
112
125
|
"hikari::context.": "HkContextRing section keys — closed enum, pinned by name in contextKeys.test.ts.",
|
|
113
126
|
"hikari::statusBar.backend.": "HkConnectionStatus — enumerated connection-state suffixes.",
|
|
114
127
|
"hikari::statusBar.region.": "HkStatusBar region names — OPTIONAL per-locale overrides over Intl.DisplayNames (see LOCALE_SPECIFIC_NESTED in flatKeyParity.test.ts).",
|
|
128
|
+
"hikari::statusBar.tier.": "HkStatusBar transport tiers — constructed into a variable at a distance from its t() call (F1); the four concrete ids are pinned by name in CONSTRUCTED_FAMILY_KEYS below.",
|
|
115
129
|
"hikari::theme.groups.": "Registry type: group/slot ids are registered by downstream apps; t() resolves via a resolveLocalizedText fallback, never a hikari bundle.",
|
|
116
130
|
"hikari::theme.tokens.": "HkColorSchemeEditor token enum — closed set defined in platform.json locales.",
|
|
117
131
|
};
|
|
118
132
|
|
|
133
|
+
/** Concrete suffixes each constructed family resolves to, for families whose
|
|
134
|
+
* enum is closed INSIDE hikari (the whitelist reason records which). Each
|
|
135
|
+
* listed key is asserted to exist, non-empty, in every locale — that is
|
|
136
|
+
* the tooth the adjacency regex lacked: deleting the whole family from
|
|
137
|
+
* every bundle used to leave every audit pin green (F1 blind spot).
|
|
138
|
+
* Families resolved by downstream apps (theme.groups.) or optional
|
|
139
|
+
* per-locale overrides (statusBar.region.) deliberately stay out. */
|
|
140
|
+
const CONSTRUCTED_FAMILY_KEYS: Record<string, string[]> = {
|
|
141
|
+
"hikari::statusBar.tier.": ["local", "poll", "sse", "ws"],
|
|
142
|
+
};
|
|
143
|
+
|
|
119
144
|
describe("t() usage surface vs locale bundles", () => {
|
|
120
145
|
const universes = new Map(EXPECTED_LOCALES.map((l) => [l, buildLocaleUniverse(l)]));
|
|
121
|
-
const { statics,
|
|
146
|
+
const { statics, constructed } = scanSources();
|
|
122
147
|
|
|
123
148
|
it("covers all 11 locales in the merged bundle universe", () => {
|
|
124
149
|
const found = new Set(
|
|
@@ -144,6 +169,11 @@ describe("t() usage surface vs locale bundles", () => {
|
|
|
144
169
|
expect(firstSiteOf("hikari::filePicker.currentPath")).toMatch(/HkFileBrowserDialog\.tsx/);
|
|
145
170
|
// Split-across-lines call form (t( and the key on different lines):
|
|
146
171
|
expect(firstSiteOf("hikari::phoneInput.searchCountries")).toMatch(/HkPhoneInput\.tsx/);
|
|
172
|
+
// Constructed-key scan positive (F1): the tier template is built into a
|
|
173
|
+
// variable 135 lines away from its t() call — the scan must still see it.
|
|
174
|
+
expect(
|
|
175
|
+
constructed.find((s) => s.key === "hikari::statusBar.tier.")?.where ?? "NOT FOUND",
|
|
176
|
+
).toMatch(/HkStatusBar\.tsx/);
|
|
147
177
|
});
|
|
148
178
|
|
|
149
179
|
it("resolves every statically referenced hikari:: key in every locale, non-empty", () => {
|
|
@@ -165,13 +195,36 @@ describe("t() usage surface vs locale bundles", () => {
|
|
|
165
195
|
expect(violations, "every referenced key must resolve in all 11 locales").toEqual([]);
|
|
166
196
|
});
|
|
167
197
|
|
|
168
|
-
it("enumerates exactly the whitelisted
|
|
169
|
-
const found = [...new Set(
|
|
170
|
-
expect(found, "families
|
|
198
|
+
it("enumerates exactly the whitelisted constructed key families, both directions", () => {
|
|
199
|
+
const found = [...new Set(constructed.map((s) => s.key))].sort();
|
|
200
|
+
expect(found, "families constructed in source but missing from the whitelist").toEqual(
|
|
171
201
|
Object.keys(DYNAMIC_KEY_FAMILIES).sort(),
|
|
172
202
|
);
|
|
173
203
|
for (const family of Object.keys(DYNAMIC_KEY_FAMILIES)) {
|
|
174
|
-
expect(found, `${family} is whitelisted but no longer
|
|
204
|
+
expect(found, `${family} is whitelisted but no longer constructed — prune it`).toContain(family);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("resolves every named concrete key of the constructed families in every locale", () => {
|
|
209
|
+
const violations: string[] = [];
|
|
210
|
+
for (const [family, suffixes] of Object.entries(CONSTRUCTED_FAMILY_KEYS)) {
|
|
211
|
+
expect(
|
|
212
|
+
DYNAMIC_KEY_FAMILIES[family],
|
|
213
|
+
`${family} carries named keys but has no whitelist entry`,
|
|
214
|
+
).toBeDefined();
|
|
215
|
+
for (const suffix of suffixes) {
|
|
216
|
+
const key = family + suffix;
|
|
217
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
218
|
+
const value = universes.get(locale)!.get(key);
|
|
219
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
220
|
+
violations.push(`${key} is missing or empty in ${locale}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
175
224
|
}
|
|
225
|
+
expect(
|
|
226
|
+
violations,
|
|
227
|
+
"constructed families' concrete keys must resolve in all 11 locales",
|
|
228
|
+
).toEqual([]);
|
|
176
229
|
});
|
|
177
230
|
});
|