@celestia-island/hikari 0.55.57 → 0.55.59
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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkCard from "./HkCard";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mountCard(props: Record<string, unknown> = {}, slotText = "content") {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({
|
|
12
|
+
render: () =>
|
|
13
|
+
h(HkCard, props, { default: () => h("span", { class: "slot-marker" }, slotText) }),
|
|
14
|
+
});
|
|
15
|
+
app.mount(container);
|
|
16
|
+
mounts.push({ app, container });
|
|
17
|
+
return container;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const m of mounts) m.app.unmount();
|
|
22
|
+
mounts.length = 0;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("HkCard — scrollable body", () => {
|
|
26
|
+
it("default card renders no scroll machinery", () => {
|
|
27
|
+
const c = mountCard();
|
|
28
|
+
expect(c.querySelector(".hk-card-scrollable")).toBeNull();
|
|
29
|
+
expect(c.querySelector(".hk-scroll-container")).toBeNull();
|
|
30
|
+
// The slot lands directly in the body, no pad wrapper in between.
|
|
31
|
+
expect(c.querySelector(".hk-card-body .slot-marker")).not.toBeNull();
|
|
32
|
+
expect(c.querySelector(".hk-card-body-pad")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("scrollable embeds the standard scroll container and flex classes", () => {
|
|
36
|
+
const c = mountCard({ scrollable: true });
|
|
37
|
+
expect(c.querySelector(".hk-card-scrollable")).not.toBeNull();
|
|
38
|
+
const scroll = c.querySelector(".hk-card-body .hk-scroll-container");
|
|
39
|
+
expect(scroll).not.toBeNull();
|
|
40
|
+
// The embedded scroller IS the library scroll container (overlay
|
|
41
|
+
// scrollbar machinery), not a bare overflow div.
|
|
42
|
+
expect(scroll?.querySelector(".hk-scroll-container-viewport")).not.toBeNull();
|
|
43
|
+
// Padded card: content padding lives inside the scroll region.
|
|
44
|
+
expect(c.querySelector(".hk-scroll-container .hk-card-body-pad .slot-marker")).not.toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("padded false drops the inner pad wrapper too", () => {
|
|
48
|
+
const c = mountCard({ scrollable: true, padded: false });
|
|
49
|
+
expect(c.querySelector(".hk-card-body-pad")).toBeNull();
|
|
50
|
+
expect(c.querySelector(".hk-scroll-container .slot-marker")).not.toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("header and footer stay outside the scroll region", () => {
|
|
54
|
+
const c = mountCard(
|
|
55
|
+
{ scrollable: true, title: "T" },
|
|
56
|
+
undefined,
|
|
57
|
+
);
|
|
58
|
+
// Re-mount with a footer slot via raw slots is not available through
|
|
59
|
+
// the props-only helper; assert the header ordering here and cover
|
|
60
|
+
// the footer through the props-less variant below.
|
|
61
|
+
const header = c.querySelector(".hk-card-header");
|
|
62
|
+
const scroll = c.querySelector(".hk-card-body-scroll");
|
|
63
|
+
expect(header).not.toBeNull();
|
|
64
|
+
expect(scroll).not.toBeNull();
|
|
65
|
+
expect(header!.contains(scroll as Node)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("footer renders after the scroll body, not inside it", () => {
|
|
69
|
+
const container = document.createElement("div");
|
|
70
|
+
document.body.appendChild(container);
|
|
71
|
+
const app = createApp({
|
|
72
|
+
render: () =>
|
|
73
|
+
h(HkCard, { scrollable: true }, {
|
|
74
|
+
default: () => h("span", { class: "slot-marker" }, "content"),
|
|
75
|
+
footer: () => h("div", { class: "footer-marker" }, "foot"),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
app.mount(container);
|
|
79
|
+
mounts.push({ app, container });
|
|
80
|
+
|
|
81
|
+
const footer = container.querySelector(".hk-card-footer");
|
|
82
|
+
const scroll = container.querySelector(".hk-card-body-scroll");
|
|
83
|
+
expect(footer).not.toBeNull();
|
|
84
|
+
expect(footer!.contains(scroll as Node)).toBe(false);
|
|
85
|
+
expect(scroll!.contains(footer as Node)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("scroll content survives remounts of the pad wrapper class", () => {
|
|
89
|
+
// Padding flag flips at runtime (a consumer toggling density): the
|
|
90
|
+
// scroll container must persist (keyed on nothing but its slot).
|
|
91
|
+
const c = mountCard({ scrollable: true });
|
|
92
|
+
expect(c.querySelector(".hk-card-body-pad")).not.toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -53,3 +53,38 @@
|
|
|
53
53
|
padding: var(--space-12) var(--space-16);
|
|
54
54
|
border-top: 1px solid var(--border-faint);
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
/* Scrollable card: the body becomes the flex host of an embedded
|
|
58
|
+
HkScrollContainer, so the card carries the ONE library scrollbar
|
|
59
|
+
system instead of every consumer nesting its own scroll layer.
|
|
60
|
+
Header and footer stay natural-height; the body fills the remainder.
|
|
61
|
+
Content padding lives INSIDE the scroll region (the pad wrapper the
|
|
62
|
+
component renders when `padded` is set) so the overlay scrollbar
|
|
63
|
+
hugs the card edge while content keeps its inset while scrolling. */
|
|
64
|
+
.hk-card-scrollable {
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
|
|
68
|
+
> .hk-card-header,
|
|
69
|
+
> .hk-card-footer {
|
|
70
|
+
flex: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
> .hk-card-body {
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
flex: 1 1 auto;
|
|
77
|
+
min-height: 0;
|
|
78
|
+
padding: 0;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
> .hk-card-body > .hk-card-body-scroll {
|
|
83
|
+
flex: 1 1 auto;
|
|
84
|
+
min-height: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.hk-card-body-pad {
|
|
88
|
+
padding: var(--space-16);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineComponent } from "vue";
|
|
2
2
|
|
|
3
|
+
import HkScrollContainer from "./HkScrollContainer";
|
|
3
4
|
import "./HkCard.scss";
|
|
4
5
|
|
|
5
6
|
export default defineComponent({
|
|
@@ -8,6 +9,17 @@ export default defineComponent({
|
|
|
8
9
|
title: { type: String, default: undefined },
|
|
9
10
|
hoverable: { type: Boolean, default: false },
|
|
10
11
|
padded: { type: Boolean, default: true },
|
|
12
|
+
/** Turn the card body into the library's standard scroll region
|
|
13
|
+
* (HkScrollContainer: overlay scrollbar, overflow sensing) instead
|
|
14
|
+
* of a plain static block. The card becomes a column flex box —
|
|
15
|
+
* header and footer keep their natural height while the body
|
|
16
|
+
* fills the rest and scrolls. The card itself needs a height from
|
|
17
|
+
* its context (grid stretch, an explicit h-* / max-h-* utility, or a flex
|
|
18
|
+
* parent), exactly like any scroll container: without one there is
|
|
19
|
+
* nothing to scroll and the body simply fits. Content padding
|
|
20
|
+
* moves inside the scroll region so the scrollbar hugs the card
|
|
21
|
+
* edge; `padded: false` drops the inner padding as usual. */
|
|
22
|
+
scrollable: { type: Boolean, default: false },
|
|
11
23
|
class: { type: String, default: "" },
|
|
12
24
|
},
|
|
13
25
|
emits: {
|
|
@@ -18,6 +30,7 @@ export default defineComponent({
|
|
|
18
30
|
<div
|
|
19
31
|
class={[
|
|
20
32
|
"hk-card",
|
|
33
|
+
props.scrollable ? "hk-card-scrollable" : "",
|
|
21
34
|
props.hoverable ? "hk-card-hoverable" : "",
|
|
22
35
|
props.class,
|
|
23
36
|
]}
|
|
@@ -29,7 +42,13 @@ export default defineComponent({
|
|
|
29
42
|
</div>
|
|
30
43
|
) : null}
|
|
31
44
|
<div class={["hk-card-body", !props.padded ? "hk-card-body-unpadded" : ""]}>
|
|
32
|
-
{
|
|
45
|
+
{props.scrollable ? (
|
|
46
|
+
<HkScrollContainer axis="vertical" class="hk-card-body-scroll">
|
|
47
|
+
<div class={props.padded ? "hk-card-body-pad" : undefined}>{slots.default?.()}</div>
|
|
48
|
+
</HkScrollContainer>
|
|
49
|
+
) : (
|
|
50
|
+
slots.default?.()
|
|
51
|
+
)}
|
|
33
52
|
</div>
|
|
34
53
|
{slots.footer ? <div class="hk-card-footer">{slots.footer()}</div> : null}
|
|
35
54
|
</div>
|
|
@@ -1184,12 +1184,19 @@ describe("useSizeMorph heightMorph (round 18)", () => {
|
|
|
1184
1184
|
expect(h.frame.style.transition).toContain("300ms");
|
|
1185
1185
|
// No clip staging, no riders — this is a plain height animation.
|
|
1186
1186
|
expect(h.frame.style.clipPath).toBe("");
|
|
1187
|
+
// The resident layer promotion is LIFTED for the animation's
|
|
1188
|
+
// duration (round 19: the per-frame texture re-allocation of a
|
|
1189
|
+
// composited layer during height animation composites as
|
|
1190
|
+
// transparent on phone GPUs — the background flash-through).
|
|
1191
|
+
expect(h.frame.style.willChange).toBe("");
|
|
1187
1192
|
|
|
1188
|
-
// The transitionend cleans the inline override
|
|
1193
|
+
// The transitionend cleans the inline override AND restores the
|
|
1194
|
+
// resident promotion (the frame is at rest).
|
|
1189
1195
|
const ev = new Event("transitionend");
|
|
1190
1196
|
Object.defineProperty(ev, "propertyName", { value: "height" });
|
|
1191
1197
|
h.frame.dispatchEvent(ev);
|
|
1192
1198
|
expect(h.frame.style.transition).toBe("");
|
|
1199
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
1193
1200
|
h.stop();
|
|
1194
1201
|
});
|
|
1195
1202
|
|
|
@@ -936,25 +936,47 @@ export function useSizeMorph(
|
|
|
936
936
|
// 2. Re-establish the OLD pin under no transition, flush it.
|
|
937
937
|
f.style.height = `${pinned}px`;
|
|
938
938
|
void f.offsetHeight;
|
|
939
|
-
// 3.
|
|
940
|
-
//
|
|
941
|
-
//
|
|
939
|
+
// 3. DEMOTE the frame from its resident composited layer for the
|
|
940
|
+
// animation's duration. A `will-change: clip-path` layer whose
|
|
941
|
+
// element changes layout size per-frame re-allocates its GPU
|
|
942
|
+
// texture every frame — on a phone GPU the gap between the old
|
|
943
|
+
// texture being discarded and the new one rastering composites
|
|
944
|
+
// as TRANSPARENT, flashing the page behind the sheet through
|
|
945
|
+
// (round-19 report: "background content keeps flashing"). With
|
|
946
|
+
// the promotion lifted, the height animation's per-frame paint
|
|
947
|
+
// happens on the main thread, which is slower but never leaves
|
|
948
|
+
// a frame where the sheet's surface is missing.
|
|
949
|
+
const residentWill_ = f.style.willChange;
|
|
950
|
+
if (residentWill) {
|
|
951
|
+
f.style.willChange = "";
|
|
952
|
+
residentWill = false;
|
|
953
|
+
}
|
|
942
954
|
f.style.transition =
|
|
943
955
|
`height ${durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
|
|
944
956
|
f.style.height = `${next}px`;
|
|
945
957
|
pinned = next;
|
|
946
|
-
// 4. Clean
|
|
958
|
+
// 4. Clean up on the animation's own clock: restore the transition
|
|
959
|
+
// list and the resident layer promotion together — the frame is
|
|
960
|
+
// at its final geometry and at rest, so the re-promotion's
|
|
961
|
+
// raster has all the time it needs.
|
|
962
|
+
const restore = (): void => {
|
|
963
|
+
f.removeEventListener("transitionend", onEnd);
|
|
964
|
+
f.style.transition = "";
|
|
965
|
+
if (residentWill_ === "clip-path") {
|
|
966
|
+
f.style.willChange = "clip-path";
|
|
967
|
+
residentWill = true;
|
|
968
|
+
}
|
|
969
|
+
};
|
|
947
970
|
const onEnd = (ev: TransitionEvent): void => {
|
|
948
971
|
if (ev.target === f && ev.propertyName === "height") {
|
|
949
|
-
|
|
950
|
-
f.style.transition = "";
|
|
972
|
+
restore();
|
|
951
973
|
}
|
|
952
974
|
};
|
|
953
975
|
f.addEventListener("transitionend", onEnd);
|
|
954
|
-
setTimeout(
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
976
|
+
const watchdog = setTimeout(restore, durationMs + 350);
|
|
977
|
+
// If the element goes away mid-flight, don't leak the timer.
|
|
978
|
+
const origClear = watchdog;
|
|
979
|
+
void origClear; // timer fires restore() which is idempotent
|
|
958
980
|
}
|
|
959
981
|
|
|
960
982
|
return { start, stop, hold, remeasure, heightMorph };
|