@celestia-island/hikari 0.45.1 → 0.45.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -94,13 +94,18 @@
|
|
|
94
94
|
opacity: 0;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/* The leave ghost keeps its in-flow geometry via the pinLeaveGeometry
|
|
98
|
+
* hook wired in HkListTransition.tsx (explicit width/top/left inline
|
|
99
|
+
* pins). Do NOT add `width: 100%` here — without a position:relative
|
|
100
|
+
* host it resolves against the nearest positioned ancestor's collapsed
|
|
101
|
+
* box and the row flashes full-width while fading (the toast #306
|
|
102
|
+
* defect class). */
|
|
97
103
|
.hk-list-grow-leave-active {
|
|
98
104
|
transition:
|
|
99
105
|
transform var(--duration-fast, 0.15s) cubic-bezier(0.5, 0, 0.75, 0),
|
|
100
106
|
opacity var(--duration-fast, 0.15s) ease-in;
|
|
101
107
|
pointer-events: none;
|
|
102
108
|
position: absolute;
|
|
103
|
-
width: 100%;
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
.hk-list-grow-leave-to {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkListTransition from "./HkListTransition";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkListTransition leave-geometry contract:
|
|
8
|
+
* - a leaving row in an absolute-leave variant (pop/slide/fade/grow) is
|
|
9
|
+
* pinned to its measured in-flow box by the before-leave hook
|
|
10
|
+
* (pinLeaveGeometry), so it cannot flash to `width: 100%` of a
|
|
11
|
+
* collapsed containing block when the host is not position:relative
|
|
12
|
+
* - the `reveal` variant animates its own height and must NOT be pinned
|
|
13
|
+
*
|
|
14
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
15
|
+
* @vue/test-utils dependency; happy-dom has no layout engine, so
|
|
16
|
+
* offset* metrics are stubbed.)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
20
|
+
|
|
21
|
+
type Variant = "pop" | "slide" | "fade" | "grow" | "reveal";
|
|
22
|
+
|
|
23
|
+
function mountRows(variant: Variant, keys: () => string[]) {
|
|
24
|
+
const container = document.createElement("div");
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
const app = createApp({
|
|
27
|
+
render: () =>
|
|
28
|
+
h(
|
|
29
|
+
HkListTransition,
|
|
30
|
+
{ tag: "ul", variant },
|
|
31
|
+
{ default: () => keys().map((k) => h("li", { key: k }, k)) },
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
mounts.push({ app, container });
|
|
35
|
+
app.mount(container);
|
|
36
|
+
return container;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stubLayout(el: HTMLElement, over: Partial<{
|
|
40
|
+
offsetTop: number;
|
|
41
|
+
offsetLeft: number;
|
|
42
|
+
offsetWidth: number;
|
|
43
|
+
offsetHeight: number;
|
|
44
|
+
}> = {}): void {
|
|
45
|
+
const wrapper = el.parentElement as HTMLElement;
|
|
46
|
+
Object.defineProperty(wrapper, "clientWidth", { value: 300, configurable: true });
|
|
47
|
+
Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
|
|
48
|
+
Object.defineProperty(el, "offsetTop", { value: over.offsetTop ?? 0, configurable: true });
|
|
49
|
+
Object.defineProperty(el, "offsetLeft", { value: over.offsetLeft ?? 0, configurable: true });
|
|
50
|
+
Object.defineProperty(el, "offsetWidth", { value: over.offsetWidth ?? 300, configurable: true });
|
|
51
|
+
Object.defineProperty(el, "offsetHeight", { value: over.offsetHeight ?? 40, configurable: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rows(): HTMLElement[] {
|
|
55
|
+
return Array.from(document.querySelectorAll<HTMLElement>("ul > li"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
60
|
+
app.unmount();
|
|
61
|
+
container.remove();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("HkListTransition leave geometry", () => {
|
|
66
|
+
it("pins the leaving grow row to its in-flow box instead of width:100%", async () => {
|
|
67
|
+
const keys = ref(["a", "b", "c"]);
|
|
68
|
+
mountRows("grow", () => keys.value);
|
|
69
|
+
await nextTick();
|
|
70
|
+
|
|
71
|
+
stubLayout(rows()[1]!, { offsetTop: 40, offsetLeft: 0, offsetWidth: 300, offsetHeight: 40 });
|
|
72
|
+
|
|
73
|
+
keys.value = ["a", "c"];
|
|
74
|
+
await nextTick();
|
|
75
|
+
await nextTick();
|
|
76
|
+
|
|
77
|
+
const leaving = document.querySelector<HTMLElement>(".hk-list-grow-leave-active");
|
|
78
|
+
expect(leaving).not.toBeNull();
|
|
79
|
+
// The pin mirrors the measured in-flow box exactly; a percentage
|
|
80
|
+
// width would resolve against a possibly-collapsed containing block.
|
|
81
|
+
expect(leaving!.style.left).toBe("0px");
|
|
82
|
+
expect(leaving!.style.top).toBe("40px");
|
|
83
|
+
expect(leaving!.style.width).toBe("300px");
|
|
84
|
+
expect(leaving!.style.height).toBe("40px");
|
|
85
|
+
expect(leaving!.style.boxSizing).toBe("border-box");
|
|
86
|
+
expect(leaving!.style.width).not.toBe("100%");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not pin the reveal variant (its own height drives the squeeze)", async () => {
|
|
90
|
+
const keys = ref(["a", "b"]);
|
|
91
|
+
mountRows("reveal", () => keys.value);
|
|
92
|
+
await nextTick();
|
|
93
|
+
|
|
94
|
+
stubLayout(rows()[0]!);
|
|
95
|
+
|
|
96
|
+
keys.value = ["b"];
|
|
97
|
+
await nextTick();
|
|
98
|
+
await nextTick();
|
|
99
|
+
|
|
100
|
+
const leaving = document.querySelector<HTMLElement>(".hk-list-reveal-leave-active");
|
|
101
|
+
expect(leaving).not.toBeNull();
|
|
102
|
+
expect(leaving!.style.height).toBe("");
|
|
103
|
+
expect(leaving!.style.width).toBe("");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { defineComponent, TransitionGroup, type PropType } from "vue";
|
|
1
|
+
import { defineComponent, onBeforeUpdate, ref, TransitionGroup, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
+
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
3
4
|
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
4
5
|
|
|
5
6
|
import "./HkListTransition.scss";
|
|
@@ -63,10 +64,58 @@ export default defineComponent({
|
|
|
63
64
|
if (pending === 0) report.cancel();
|
|
64
65
|
};
|
|
65
66
|
|
|
67
|
+
// Variants whose leave-active rule lifts the row out of flow with
|
|
68
|
+
// `position: absolute` (see HkListTransition.scss). Only these need
|
|
69
|
+
// the geometry pin; `reveal` animates the row's own height in place
|
|
70
|
+
// and `none` never leaves the flow, so a pin would freeze the very
|
|
71
|
+
// property they animate.
|
|
72
|
+
const ABSOLUTE_VARIANTS: ReadonlySet<string> = new Set(["pop", "slide", "fade", "grow"]);
|
|
73
|
+
|
|
74
|
+
// Pre-patch geometry of every row, refreshed on each update (the DOM
|
|
75
|
+
// is still the pre-patch tree at onBeforeUpdate — same pattern as
|
|
76
|
+
// HkTabs). During a multi-row removal the first leaving sibling gets
|
|
77
|
+
// its leave-active class (position:absolute) synchronously inside the
|
|
78
|
+
// patch pass, so a live offset read in a later sibling's beforeLeave
|
|
79
|
+
// hook would freeze an already-reflowed position. The pin also
|
|
80
|
+
// replaces `grow`'s old `width: 100%`, which resolved against a
|
|
81
|
+
// possibly-collapsed containing block when the host is not
|
|
82
|
+
// position:relative (the full-width leave flash).
|
|
83
|
+
const hostRef = ref<{ $el?: Element } | null>(null);
|
|
84
|
+
const prePatchBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
|
|
85
|
+
onBeforeUpdate(() => {
|
|
86
|
+
// Fragment hosts (tag="") have a comment anchor as $el — no
|
|
87
|
+
// element children to measure there.
|
|
88
|
+
const host = hostRef.value?.$el;
|
|
89
|
+
if (host == null || host.nodeType !== 1) return;
|
|
90
|
+
for (const child of Array.from(host.children)) {
|
|
91
|
+
const e = child as HTMLElement;
|
|
92
|
+
prePatchBoxes.set(e, {
|
|
93
|
+
top: e.offsetTop,
|
|
94
|
+
left: e.offsetLeft,
|
|
95
|
+
width: e.offsetWidth,
|
|
96
|
+
height: e.offsetHeight,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const usesAbsoluteLeave = () =>
|
|
102
|
+
!props.disabled && ABSOLUTE_VARIANTS.has(props.variant);
|
|
103
|
+
|
|
104
|
+
function pinLeaving(el: Element) {
|
|
105
|
+
if (!usesAbsoluteLeave()) return;
|
|
106
|
+
pinLeaveGeometry(el, { anchorX: "left", box: prePatchBoxes.get(el) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unpinLeaving(el: Element) {
|
|
110
|
+
if (!usesAbsoluteLeave()) return;
|
|
111
|
+
clearLeaveGeometry(el);
|
|
112
|
+
}
|
|
113
|
+
|
|
66
114
|
return () => {
|
|
67
115
|
const name = props.disabled ? "hk-list-none" : `hk-list-${props.variant}`;
|
|
68
116
|
return (
|
|
69
117
|
<TransitionGroup
|
|
118
|
+
ref={hostRef}
|
|
70
119
|
tag={props.tag}
|
|
71
120
|
name={name}
|
|
72
121
|
appear={props.appear}
|
|
@@ -74,9 +123,15 @@ export default defineComponent({
|
|
|
74
123
|
onBeforeEnter={arm}
|
|
75
124
|
onAfterEnter={settle}
|
|
76
125
|
onEnterCancelled={settle}
|
|
77
|
-
onBeforeLeave={
|
|
126
|
+
onBeforeLeave={(el: Element) => {
|
|
127
|
+
arm();
|
|
128
|
+
pinLeaving(el);
|
|
129
|
+
}}
|
|
78
130
|
onAfterLeave={settle}
|
|
79
|
-
onLeaveCancelled={
|
|
131
|
+
onLeaveCancelled={(el: Element) => {
|
|
132
|
+
settle();
|
|
133
|
+
unpinLeaving(el);
|
|
134
|
+
}}
|
|
80
135
|
>
|
|
81
136
|
{slots.default?.()}
|
|
82
137
|
</TransitionGroup>
|
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
* assertion: the theme-row lead cell must carry an explicit size that
|
|
8
8
|
* fits the widest host lead mark, not just the mixin's generic box.
|
|
9
9
|
*
|
|
10
|
-
* The widened cell must stay scoped
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* The widened cell must stay scoped (2026-09-12 field report): shipped
|
|
11
|
+
* first as a bare `.s-theme-item-btn .hk-menu-item-icon` rule, it also
|
|
12
|
+
* matched the 自定义 row and every host row reusing s-theme-item-btn —
|
|
13
|
+
* and mi.icon normalizes those cells' svg to the cell, so their 14px
|
|
14
|
+
* glyphs rendered 28px wide. The widening therefore targets named scopes
|
|
15
|
+
* (lead slot, customize row) plus the toggle-owned mode-extra strip, the
|
|
16
|
+
* last one widening the CELL while pinning the glyph back to the
|
|
17
|
+
* standard box so host strip rows (chest's DPI entry) align their labels
|
|
18
|
+
* with the theme rows' names without rescaling their icons.
|
|
16
19
|
*/
|
|
17
20
|
import { describe, expect, it } from "vitest";
|
|
18
21
|
import { readFileSync } from "node:fs";
|
|
@@ -33,12 +36,12 @@ describe("HkThemeToggle row lead-cell contract", () => {
|
|
|
33
36
|
expect(block![0]).toContain("height: 28px");
|
|
34
37
|
});
|
|
35
38
|
|
|
36
|
-
it("does not widen icon cells
|
|
39
|
+
it("does not widen icon cells with a bare unscoped theme-row rule", () => {
|
|
37
40
|
const css = read("HkThemeToggle.scss");
|
|
38
41
|
// A bare `.s-theme-item-btn .hk-menu-item-icon {` rule also hits the
|
|
39
42
|
// customize row and host mode-extra rows; the widened cell may only
|
|
40
|
-
// target
|
|
41
|
-
expect(css).not.toMatch(
|
|
43
|
+
// target named scopes (lead slot, customize row, mode-extra strip).
|
|
44
|
+
expect(css).not.toMatch(/^\.s-theme-item-btn \.hk-menu-item-icon\s*\{/m);
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
it("aligns the customize row to the lead column without rescaling its glyph", () => {
|
|
@@ -60,4 +63,23 @@ describe("HkThemeToggle row lead-cell contract", () => {
|
|
|
60
63
|
expect(svgBlock).not.toBeNull();
|
|
61
64
|
expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
|
|
62
65
|
});
|
|
66
|
+
|
|
67
|
+
it("aligns mode-extra host rows to the lead column without rescaling their glyph", () => {
|
|
68
|
+
const css = read("HkThemeToggle.scss");
|
|
69
|
+
// Host strip rows (chest's DPI entry) join the 28px lead column so
|
|
70
|
+
// their label starts at the same x as the theme rows' names
|
|
71
|
+
// (2026-09-12 field report), but the glyph keeps the standard box.
|
|
72
|
+
const block = css.match(
|
|
73
|
+
/\.s-theme-mode-extra \.s-theme-item-btn \.hk-menu-item-icon\s*\{[^}]*\}/,
|
|
74
|
+
);
|
|
75
|
+
expect(block).not.toBeNull();
|
|
76
|
+
expect(block![0]).toContain("width: 28px");
|
|
77
|
+
expect(block![0]).toContain("height: 28px");
|
|
78
|
+
|
|
79
|
+
const svgBlock = css.match(
|
|
80
|
+
/\.s-theme-mode-extra \.s-theme-item-btn \.hk-menu-item-icon > svg\s*\{[^}]*\}/,
|
|
81
|
+
);
|
|
82
|
+
expect(svgBlock).not.toBeNull();
|
|
83
|
+
expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
|
|
84
|
+
});
|
|
63
85
|
});
|
|
@@ -243,6 +243,27 @@
|
|
|
243
243
|
height: var(--hk-menu-item-icon-box);
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/* Host rows in the mode-extra strip (chest's DPI entry row reuses
|
|
247
|
+
* s-theme-item-btn) join the same 28px lead column, so every row's name
|
|
248
|
+
* in the toggle starts at one x — desktop popover and mobile sheet both
|
|
249
|
+
* (2026-09-12 field report: with the strip row at the generic 16px cell
|
|
250
|
+
* its label sat a full cell-width left of the theme rows' names). The
|
|
251
|
+
* strip container is the toggle's own, so the scope needs no host
|
|
252
|
+
* class-name contract. As with the customize row, the cell widens but
|
|
253
|
+
* the GLYPH keeps the standard menu icon size — widening a cell must
|
|
254
|
+
* never re-scale its glyph (the 0.42.2 regression class). */
|
|
255
|
+
.s-theme-mode-extra .s-theme-item-btn .hk-menu-item-icon {
|
|
256
|
+
@include mi.icon;
|
|
257
|
+
|
|
258
|
+
width: 28px;
|
|
259
|
+
height: 28px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.s-theme-mode-extra .s-theme-item-btn .hk-menu-item-icon > svg {
|
|
263
|
+
width: var(--hk-menu-item-icon-box);
|
|
264
|
+
height: var(--hk-menu-item-icon-box);
|
|
265
|
+
}
|
|
266
|
+
|
|
246
267
|
/* Custom rows keep the name clear of the overlaid delete button. */
|
|
247
268
|
.s-theme-item-row[data-custom] .s-theme-item-name {
|
|
248
269
|
margin-inline-end: 2rem;
|