@celestia-island/hikari 0.45.7 → 0.45.9
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/HkAboutModal.scss +25 -27
- package/src/components/HkAboutModal.test.tsx +36 -18
- package/src/components/HkAboutModal.tsx +66 -54
- package/src/components/HkCrossfade.scss +61 -0
- package/src/components/HkCrossfade.test.tsx +270 -0
- package/src/components/HkCrossfade.tsx +197 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
color: rgb(var(--color-muted));
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/* ── Credits:
|
|
129
|
+
/* ── Credits: credits sentence + organization blurb ────────────────────── */
|
|
130
130
|
|
|
131
131
|
.s-about-modal-credits {
|
|
132
132
|
display: flex;
|
|
@@ -136,24 +136,19 @@
|
|
|
136
136
|
margin-top: calc(-1 * var(--space-4, 0.25rem));
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
// Inline flow (not flex): the sentence is assembled from text runs and
|
|
140
|
+
// chips, and the runs carry their own spaces around the chips.
|
|
141
|
+
.s-about-modal-credits-line {
|
|
140
142
|
margin: 0;
|
|
141
143
|
font-size: var(--text-sm, 0.8125rem);
|
|
144
|
+
line-height: 2;
|
|
142
145
|
text-align: center;
|
|
146
|
+
text-wrap: pretty;
|
|
143
147
|
color: rgb(var(--color-text));
|
|
144
148
|
}
|
|
145
149
|
|
|
146
|
-
.s-about-modal-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
.s-about-modal-row-link {
|
|
151
|
-
color: rgb(var(--color-primary));
|
|
152
|
-
text-decoration: none;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
.s-about-modal-row-link:hover {
|
|
156
|
-
text-decoration: underline;
|
|
150
|
+
.s-about-modal-credit-name {
|
|
151
|
+
color: rgb(var(--color-text));
|
|
157
152
|
}
|
|
158
153
|
|
|
159
154
|
.s-about-modal-description {
|
|
@@ -257,8 +252,8 @@
|
|
|
257
252
|
gap: var(--space-6, 0.375rem);
|
|
258
253
|
}
|
|
259
254
|
|
|
260
|
-
// Ghost pills:
|
|
261
|
-
//
|
|
255
|
+
// Ghost pills: every link in this dialog is a tag, so they share one face —
|
|
256
|
+
// credits names, licenses, external links and the legal filings.
|
|
262
257
|
.s-about-modal-link {
|
|
263
258
|
padding: var(--space-2, 0.125rem) var(--space-10, 0.625rem);
|
|
264
259
|
border: 1px solid var(--border-subtle, rgb(100 100 100 / 12%));
|
|
@@ -273,6 +268,14 @@
|
|
|
273
268
|
background var(--duration-short, 0.15s) ease;
|
|
274
269
|
}
|
|
275
270
|
|
|
271
|
+
// Chips inside the credits sentence sit in a text run: keep them on the
|
|
272
|
+
// baseline and let the surrounding spaces do the spacing.
|
|
273
|
+
.s-about-modal-credit-link {
|
|
274
|
+
display: inline-block;
|
|
275
|
+
vertical-align: middle;
|
|
276
|
+
margin: 0 0.1em;
|
|
277
|
+
}
|
|
278
|
+
|
|
276
279
|
.s-about-modal-link:hover {
|
|
277
280
|
border-color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
|
|
278
281
|
background: var(--c-primary-faint, rgb(var(--color-primary) / 5%));
|
|
@@ -292,25 +295,20 @@
|
|
|
292
295
|
|
|
293
296
|
/* ── Legal footer: filing links + copyright ───────────────────────────── */
|
|
294
297
|
|
|
298
|
+
// Filing numbers are long, so the legal chips render a notch smaller than
|
|
299
|
+
// the license / link rows.
|
|
300
|
+
.s-about-modal-footer-link {
|
|
301
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
302
|
+
}
|
|
303
|
+
|
|
295
304
|
.s-about-modal-footer-links {
|
|
296
305
|
display: flex;
|
|
297
306
|
flex-wrap: wrap;
|
|
298
307
|
justify-content: center;
|
|
299
|
-
gap: var(--space-
|
|
308
|
+
gap: var(--space-6, 0.375rem);
|
|
300
309
|
margin-top: calc(-1 * var(--space-4, 0.25rem));
|
|
301
310
|
}
|
|
302
311
|
|
|
303
|
-
.s-about-modal-footer-link {
|
|
304
|
-
font-size: var(--text-xs, 0.75rem);
|
|
305
|
-
color: rgb(var(--color-muted));
|
|
306
|
-
text-decoration: none;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
.s-about-modal-footer-link:hover {
|
|
310
|
-
color: rgb(var(--color-primary));
|
|
311
|
-
text-decoration: underline;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
312
|
.s-about-modal-footer {
|
|
315
313
|
display: flex;
|
|
316
314
|
justify-content: center;
|
|
@@ -9,8 +9,9 @@ import { setLocale } from "../i18n/context";
|
|
|
9
9
|
* - the first-letter tile is the default identity and `logoSrc` swaps it
|
|
10
10
|
* for an image inside the identity frame
|
|
11
11
|
* - version and tagline share one subtitle line
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
12
|
+
* - the credits sentence assembles text runs and linked name chips, and a
|
|
13
|
+
* name without an href stays plain text
|
|
14
|
+
* - description / license / copyright render the given strings
|
|
14
15
|
* - component rows split the machine value from an optional meta pill
|
|
15
16
|
* - the backdrop factory renders inside the clipped backdrop layer
|
|
16
17
|
* - licenses and links render as chips under one centered block
|
|
@@ -96,27 +97,44 @@ describe("HkAboutModal branding", () => {
|
|
|
96
97
|
expect(query<HTMLElement>(".s-about-modal-description").textContent).toBe("Save the world.");
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
it("renders the
|
|
100
|
+
it("renders the credits sentence with linked name chips", async () => {
|
|
100
101
|
mountAbout({
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
credits: [
|
|
103
|
+
{ text: "来自 " },
|
|
104
|
+
{ name: "Celestia Island", href: "https://github.com/celestia-island" },
|
|
105
|
+
{ text: ",由 " },
|
|
106
|
+
{ name: "伊欧", href: "https://github.com/langyo" },
|
|
107
|
+
{ text: " 倾力设计" },
|
|
108
|
+
],
|
|
105
109
|
});
|
|
106
110
|
await flushModal();
|
|
107
|
-
const line = query<HTMLElement>(".s-about-modal-
|
|
108
|
-
expect(line.textContent).toBe("
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
expect(
|
|
111
|
+
const line = query<HTMLElement>(".s-about-modal-credits-line");
|
|
112
|
+
expect(line.textContent).toBe("来自 Celestia Island,由 伊欧 倾力设计");
|
|
113
|
+
const chips = [
|
|
114
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-credit-link"),
|
|
115
|
+
];
|
|
116
|
+
expect(chips.map((chip) => chip.textContent)).toEqual(["Celestia Island", "伊欧"]);
|
|
117
|
+
// Every link in the dialog is a tag and opens in a new tab.
|
|
118
|
+
for (const chip of chips) {
|
|
119
|
+
expect(chip.classList.contains("s-about-modal-link")).toBe(true);
|
|
120
|
+
expect(chip.getAttribute("target")).toBe("_blank");
|
|
121
|
+
expect(chip.getAttribute("rel")).toContain("noopener");
|
|
122
|
+
}
|
|
123
|
+
expect(chips[0]!.getAttribute("href")).toBe("https://github.com/celestia-island");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("keeps a credit name as plain text when no href is given", async () => {
|
|
127
|
+
mountAbout({ credits: [{ text: "由 " }, { name: "伊欧" }, { text: " 主创" }] });
|
|
128
|
+
await flushModal();
|
|
129
|
+
expect(query<HTMLElement>(".s-about-modal-credits-line").textContent).toBe("由 伊欧 主创");
|
|
130
|
+
expect(query<HTMLElement>(".s-about-modal-credit-name").textContent).toBe("伊欧");
|
|
131
|
+
expect(document.body.querySelector(".s-about-modal-credit-link")).toBeNull();
|
|
113
132
|
});
|
|
114
133
|
|
|
115
|
-
it("
|
|
116
|
-
mountAbout({
|
|
134
|
+
it("skips empty credit parts", async () => {
|
|
135
|
+
mountAbout({ credits: [{ text: "" }, { name: "" }, { text: "仅此一句。" }] });
|
|
117
136
|
await flushModal();
|
|
118
|
-
expect(query<HTMLElement>(".s-about-modal-
|
|
119
|
-
expect(document.body.querySelector(".s-about-modal-row-link")).toBeNull();
|
|
137
|
+
expect(query<HTMLElement>(".s-about-modal-credits-line").textContent).toBe("仅此一句。");
|
|
120
138
|
});
|
|
121
139
|
|
|
122
140
|
it("renders software-component version rows with a meta pill and tone", async () => {
|
|
@@ -234,7 +252,7 @@ describe("HkAboutModal branding", () => {
|
|
|
234
252
|
mountAbout();
|
|
235
253
|
await flushModal();
|
|
236
254
|
expect(document.body.querySelectorAll(".s-about-modal-row").length).toBe(0);
|
|
237
|
-
expect(document.body.querySelector(".s-about-modal-
|
|
255
|
+
expect(document.body.querySelector(".s-about-modal-credits-line")).toBeNull();
|
|
238
256
|
expect(document.body.querySelector(".s-about-modal-links")).toBeNull();
|
|
239
257
|
expect(document.body.querySelector(".s-about-modal-chips")).toBeNull();
|
|
240
258
|
expect(document.body.querySelector(".s-about-modal-credits")).toBeNull();
|
|
@@ -10,6 +10,24 @@ export interface HAboutLink {
|
|
|
10
10
|
href: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* One piece of the credits line.
|
|
15
|
+
*
|
|
16
|
+
* The line is a sentence assembled from parts so a host can phrase (and
|
|
17
|
+
* order) it freely per locale: literal `text` runs sit between linked
|
|
18
|
+
* `name` chips (organization first, author second, …). The names render as
|
|
19
|
+
* the same chips as the license / link rows — every link in this dialog is
|
|
20
|
+
* a tag.
|
|
21
|
+
*/
|
|
22
|
+
export interface HAboutCredit {
|
|
23
|
+
/** Literal sentence fragment (mutually exclusive with `name`). */
|
|
24
|
+
text?: string;
|
|
25
|
+
/** Linked chip label (mutually exclusive with `text`). */
|
|
26
|
+
name?: string;
|
|
27
|
+
/** Target for a `name` chip; opens in a new tab. */
|
|
28
|
+
href?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
/**
|
|
14
32
|
* One software-component version row.
|
|
15
33
|
*
|
|
@@ -33,10 +51,11 @@ export interface HAboutComponentVersion {
|
|
|
33
51
|
*
|
|
34
52
|
* Layout (2026-09 redesign): a centered identity hero (haloed logo, name,
|
|
35
53
|
* then version + tagline on one compact line), an optional credits block
|
|
36
|
-
* (
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
54
|
+
* (a sentence built from text runs and linked name chips, plus the
|
|
55
|
+
* organization blurb), a
|
|
56
|
+
* bordered spec card holding the software-component versions, and the
|
|
57
|
+
* link rows — licenses, external links, legal filings and the credits
|
|
58
|
+
* names all render as the same ghost tag. Every one opens in a new tab. Every branding prop is optional — the modal degrades
|
|
40
59
|
* to the plain identity card when none are given.
|
|
41
60
|
*/
|
|
42
61
|
export const HkAboutModal = defineComponent({
|
|
@@ -53,14 +72,12 @@ export const HkAboutModal = defineComponent({
|
|
|
53
72
|
tagline: { type: String, default: undefined },
|
|
54
73
|
/** Optional centered small line under the credits (organization blurb). */
|
|
55
74
|
description: { type: String, default: undefined },
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/** Made-by sentence: text after the name (e.g. " 主创,来自 …"). */
|
|
63
|
-
madeBySuffix: { type: String, default: undefined },
|
|
75
|
+
/**
|
|
76
|
+
* Credits sentence, assembled from text runs and linked name chips
|
|
77
|
+
* (e.g. 来自 <Celestia Island>,由 <伊欧> 倾力设计). Names render as the
|
|
78
|
+
* same chips as the license / link rows.
|
|
79
|
+
*/
|
|
80
|
+
credits: { type: Array as PropType<HAboutCredit[]>, default: () => [] },
|
|
64
81
|
/** License chips (e.g. SySL-1.0 / BUSL-1.1), rendered centered. */
|
|
65
82
|
licenses: { type: Array as PropType<HAboutLink[]>, default: () => [] },
|
|
66
83
|
/** Software-component version rows (WebUI / engines), label + value. */
|
|
@@ -88,25 +105,38 @@ export const HkAboutModal = defineComponent({
|
|
|
88
105
|
setup(props, { emit }) {
|
|
89
106
|
const { t } = useI18n();
|
|
90
107
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
// One chip renderer for every link in the dialog: credits names,
|
|
109
|
+
// licenses, external links and the legal filings all render as tags.
|
|
110
|
+
const renderChip = (label: string, href: string, extraClass: string) => (
|
|
111
|
+
<a
|
|
112
|
+
key={`${extraClass}:${href}`}
|
|
113
|
+
class={`s-about-modal-link ${extraClass}`}
|
|
114
|
+
href={href}
|
|
115
|
+
target="_blank"
|
|
116
|
+
rel="noopener noreferrer"
|
|
117
|
+
>
|
|
118
|
+
{label}
|
|
119
|
+
</a>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const renderCredits = () => {
|
|
123
|
+
const parts = props.credits.filter((part) => part.text || part.name);
|
|
124
|
+
if (parts.length === 0) return null;
|
|
105
125
|
return (
|
|
106
|
-
<p class="s-about-modal-
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
<p class="s-about-modal-credits-line">
|
|
127
|
+
{parts.map((part, index) =>
|
|
128
|
+
part.name ? (
|
|
129
|
+
part.href ? (
|
|
130
|
+
renderChip(part.name, part.href, "s-about-modal-credit-link")
|
|
131
|
+
) : (
|
|
132
|
+
<span key={`name:${index}`} class="s-about-modal-credit-name">
|
|
133
|
+
{part.name}
|
|
134
|
+
</span>
|
|
135
|
+
)
|
|
136
|
+
) : (
|
|
137
|
+
<span key={`text:${index}`}>{part.text}</span>
|
|
138
|
+
),
|
|
139
|
+
)}
|
|
110
140
|
</p>
|
|
111
141
|
);
|
|
112
142
|
};
|
|
@@ -116,17 +146,7 @@ export const HkAboutModal = defineComponent({
|
|
|
116
146
|
return (
|
|
117
147
|
<div class="s-about-modal-links" data-slot={slot}>
|
|
118
148
|
<div class="s-about-modal-links-list">
|
|
119
|
-
{items.map((item) => (
|
|
120
|
-
<a
|
|
121
|
-
key={item.href}
|
|
122
|
-
href={item.href}
|
|
123
|
-
target="_blank"
|
|
124
|
-
rel="noopener noreferrer"
|
|
125
|
-
class="s-about-modal-link"
|
|
126
|
-
>
|
|
127
|
-
{item.label}
|
|
128
|
-
</a>
|
|
129
|
-
))}
|
|
149
|
+
{items.map((item) => renderChip(item.label, item.href, ""))}
|
|
130
150
|
</div>
|
|
131
151
|
</div>
|
|
132
152
|
);
|
|
@@ -177,9 +197,9 @@ export const HkAboutModal = defineComponent({
|
|
|
177
197
|
</div>
|
|
178
198
|
</header>
|
|
179
199
|
|
|
180
|
-
{(props.
|
|
200
|
+
{(props.credits.length > 0 || props.description) && (
|
|
181
201
|
<div class="s-about-modal-credits">
|
|
182
|
-
{
|
|
202
|
+
{renderCredits()}
|
|
183
203
|
{props.description && (
|
|
184
204
|
<p class="s-about-modal-description">{props.description}</p>
|
|
185
205
|
)}
|
|
@@ -216,17 +236,9 @@ export const HkAboutModal = defineComponent({
|
|
|
216
236
|
|
|
217
237
|
{props.footerLinks.length > 0 && (
|
|
218
238
|
<div class="s-about-modal-footer-links">
|
|
219
|
-
{props.footerLinks.map((link) =>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
href={link.href}
|
|
223
|
-
target="_blank"
|
|
224
|
-
rel="noopener noreferrer"
|
|
225
|
-
class="s-about-modal-footer-link"
|
|
226
|
-
>
|
|
227
|
-
{link.label}
|
|
228
|
-
</a>
|
|
229
|
-
))}
|
|
239
|
+
{props.footerLinks.map((link) =>
|
|
240
|
+
renderChip(link.label, link.href, "s-about-modal-footer-link"),
|
|
241
|
+
)}
|
|
230
242
|
</div>
|
|
231
243
|
)}
|
|
232
244
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* HkCrossfade — same-position simultaneous dissolve host.
|
|
2
|
+
*
|
|
3
|
+
* Geometry: the host is a one-cell grid and every item occupies THAT cell
|
|
4
|
+
* (grid-area: 1/1). During a swap the leaving item keeps the cell, so old
|
|
5
|
+
* and new stack in the SAME box: no exit blank phase, no side-by-side
|
|
6
|
+
* reflow, and the host stays as tall as the taller of the two items until
|
|
7
|
+
* the leave completes — variable-height content never collapses mid-
|
|
8
|
+
* dissolve. This is the height-safe generalization of HkPickerPane's fixed
|
|
9
|
+
* stage + absolute leave (no known stage size needed, so whole pages can
|
|
10
|
+
* swap through it).
|
|
11
|
+
*
|
|
12
|
+
* Consumers size the host from the outside (width/height/flex are theirs);
|
|
13
|
+
* the component only guarantees the stacking geometry and the dissolve.
|
|
14
|
+
*/
|
|
15
|
+
.hk-crossfade {
|
|
16
|
+
display: grid;
|
|
17
|
+
|
|
18
|
+
> .hk-crossfade-item {
|
|
19
|
+
grid-area: 1 / 1;
|
|
20
|
+
width: 100%;
|
|
21
|
+
min-width: 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Dissolve curves: BOTH sides run --duration-normal so the exchange is a
|
|
26
|
+
* true cross-fade (not a fade-out-then-in), and both ease OUT (fast
|
|
27
|
+
* start) — midway through the swap the incoming layer already carries most
|
|
28
|
+
* of the opacity while the outgoing one has already shed most of its, so
|
|
29
|
+
* the composite never dips toward the background (the classic
|
|
30
|
+
* double-exposure artifact of symmetric linear fades). The leaving item
|
|
31
|
+
* also drops pointer events: it is dead content and must not intercept
|
|
32
|
+
* clicks while it dissolves under the incoming one. */
|
|
33
|
+
.hk-crossfade-enter-active {
|
|
34
|
+
transition:
|
|
35
|
+
opacity
|
|
36
|
+
var(--duration-normal, 0.3s)
|
|
37
|
+
var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.hk-crossfade-leave-active {
|
|
41
|
+
transition: opacity var(--duration-normal, 0.3s) ease-out;
|
|
42
|
+
pointer-events: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-crossfade-enter-from,
|
|
46
|
+
.hk-crossfade-leave-to {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Reduced motion: keep the opacity exchange (a fade carries no movement —
|
|
51
|
+
* HkPhaseTransition precedent), just shorter. Hosts driving the global
|
|
52
|
+
* animation switch (`html[data-css-animations="0"]`) already pin every
|
|
53
|
+
* transition via theme.scss's global rule, so no extra variant is needed
|
|
54
|
+
* here (that variant exists for ANIMATIONS, which the global rule pauses
|
|
55
|
+
* per-play-state; transitions die through its transition-duration clamp). */
|
|
56
|
+
@media (prefers-reduced-motion: reduce) {
|
|
57
|
+
.hk-crossfade-enter-active,
|
|
58
|
+
.hk-crossfade-leave-active {
|
|
59
|
+
transition: opacity var(--duration-fast, 0.15s) ease;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import * as sass from "sass";
|
|
7
|
+
|
|
8
|
+
import HkCrossfade, { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HkCrossfade contract tests. House style: no @vue/test-utils dependency —
|
|
12
|
+
* raw createApp mounts on shared containers torn down after each case.
|
|
13
|
+
*
|
|
14
|
+
* Timing model (mirrors ModeMorphTabs.test): happy-dom resolves no CSS
|
|
15
|
+
* transition durations, so with a working (timer-driven) rAF the swap
|
|
16
|
+
* completes inside fake-timer advances. Freezing rAF via stubGlobal holds
|
|
17
|
+
* both transition generations in the DOM — which is exactly the state the
|
|
18
|
+
* simultaneous dissolve and the watchdog are about.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const mounts: App[] = [];
|
|
22
|
+
const containers: HTMLElement[] = [];
|
|
23
|
+
|
|
24
|
+
interface Harness {
|
|
25
|
+
container: HTMLElement;
|
|
26
|
+
set: (key: string) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mountCrossfade(opts: {
|
|
30
|
+
initial?: string;
|
|
31
|
+
appear?: boolean;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
} = {}): Harness {
|
|
34
|
+
const container = document.createElement("div");
|
|
35
|
+
document.body.appendChild(container);
|
|
36
|
+
containers.push(container);
|
|
37
|
+
|
|
38
|
+
const current = ref(opts.initial ?? "a");
|
|
39
|
+
const Wrapper = defineComponent({
|
|
40
|
+
setup() {
|
|
41
|
+
return () =>
|
|
42
|
+
h(
|
|
43
|
+
HkCrossfade,
|
|
44
|
+
{
|
|
45
|
+
swapKey: current.value,
|
|
46
|
+
appear: opts.appear ?? false,
|
|
47
|
+
disabled: opts.disabled ?? false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
default: () => h("p", { class: "body" }, `body-${current.value}`),
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const app = createApp(Wrapper);
|
|
56
|
+
mounts.push(app);
|
|
57
|
+
app.mount(container);
|
|
58
|
+
return {
|
|
59
|
+
container,
|
|
60
|
+
set: async (key: string) => {
|
|
61
|
+
current.value = key;
|
|
62
|
+
await nextTick();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function items(host: HTMLElement): HTMLElement[] {
|
|
68
|
+
// The mount container holds the .hk-crossfade host; items are the HOST's
|
|
69
|
+
// direct children (Transition renders no DOM of its own).
|
|
70
|
+
return Array.from(
|
|
71
|
+
host.querySelectorAll<HTMLElement>(":scope > .hk-crossfade > .hk-crossfade-item"),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function keys(host: HTMLElement): Array<string | undefined> {
|
|
76
|
+
return items(host).map((el) => el.dataset.crossfadeKey);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertNoFrozenClasses(el: HTMLElement): void {
|
|
80
|
+
for (const cls of Array.from(el.classList)) {
|
|
81
|
+
expect(cls.startsWith("hk-crossfade-enter") || cls.startsWith("hk-crossfade-leave")).toBe(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function freezeRaf(): void {
|
|
86
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
87
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
for (const app of mounts) app.unmount();
|
|
92
|
+
mounts.length = 0;
|
|
93
|
+
document.body.innerHTML = "";
|
|
94
|
+
containers.length = 0;
|
|
95
|
+
vi.unstubAllGlobals();
|
|
96
|
+
vi.useRealTimers();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("HkCrossfade — DOM contract", () => {
|
|
100
|
+
it("renders the active item silently on first paint (appear off)", () => {
|
|
101
|
+
vi.useFakeTimers();
|
|
102
|
+
const { container } = mountCrossfade({ initial: "a" });
|
|
103
|
+
const list = items(container);
|
|
104
|
+
expect(list).toHaveLength(1);
|
|
105
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("a");
|
|
106
|
+
expect(list[0]!.textContent).toContain("body-a");
|
|
107
|
+
assertNoFrozenClasses(list[0]!);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("healthy swap completes through the Transition and settles on the new item", async () => {
|
|
111
|
+
vi.useFakeTimers();
|
|
112
|
+
const { container, set } = mountCrossfade();
|
|
113
|
+
await set("b");
|
|
114
|
+
// Past the watchdog budget: the swap completed through the Transition
|
|
115
|
+
// long before, and the watchdog fired into a settled DOM as a no-op.
|
|
116
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100);
|
|
117
|
+
const list = items(container);
|
|
118
|
+
expect(list).toHaveLength(1);
|
|
119
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
120
|
+
expect(list[0]!.textContent).toContain("body-b");
|
|
121
|
+
assertNoFrozenClasses(list[0]!);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("dissolves SIMULTANEOUSLY: old and new share the same host mid-swap (no out-in gap)", async () => {
|
|
125
|
+
vi.useFakeTimers();
|
|
126
|
+
freezeRaf();
|
|
127
|
+
const { container, set } = mountCrossfade();
|
|
128
|
+
await set("b");
|
|
129
|
+
await vi.advanceTimersByTimeAsync(200); // inside the watchdog budget
|
|
130
|
+
const list = items(container);
|
|
131
|
+
// Both generations mounted at once — the default-mode contract. With
|
|
132
|
+
// `mode="out-in"` the old item would already be gone here (blank phase);
|
|
133
|
+
// with a bare default Transition they would sit side by side instead of
|
|
134
|
+
// stacked in one host cell.
|
|
135
|
+
expect(keys(container)).toEqual(["a", "b"]);
|
|
136
|
+
expect(list[0]!.classList.contains("hk-crossfade-leave-active")).toBe(true);
|
|
137
|
+
expect(list[1]!.classList.contains("hk-crossfade-enter-from")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("watchdog completes a stalled swap: sweeps frozen classes, drops the stale item", async () => {
|
|
141
|
+
vi.useFakeTimers();
|
|
142
|
+
freezeRaf();
|
|
143
|
+
const { container, set } = mountCrossfade();
|
|
144
|
+
await set("b");
|
|
145
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS - 100); // still wedged
|
|
146
|
+
expect(keys(container)).toEqual(["a", "b"]);
|
|
147
|
+
await vi.advanceTimersByTimeAsync(200); // past the budget
|
|
148
|
+
const list = items(container);
|
|
149
|
+
expect(list).toHaveLength(1);
|
|
150
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
151
|
+
assertNoFrozenClasses(list[0]!);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("re-arms a fresh Transition on the next swap after a wedged one (dissolves resume)", async () => {
|
|
155
|
+
vi.useFakeTimers();
|
|
156
|
+
freezeRaf();
|
|
157
|
+
const { container, set } = mountCrossfade();
|
|
158
|
+
await set("b");
|
|
159
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100); // recovered once
|
|
160
|
+
expect(keys(container)).toEqual(["b"]);
|
|
161
|
+
// The next swap restores the Transition. The swept DOM left no previous
|
|
162
|
+
// child inside the fresh instance, so that one swap lands as a
|
|
163
|
+
// synchronous hard cut (the one-time cost of a stall) — settled and
|
|
164
|
+
// correct, no wedge loop.
|
|
165
|
+
await set("c");
|
|
166
|
+
const list = items(container);
|
|
167
|
+
expect(list).toHaveLength(1);
|
|
168
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("c");
|
|
169
|
+
assertNoFrozenClasses(list[0]!);
|
|
170
|
+
// From there on, swaps run the dissolve again: with a previous child
|
|
171
|
+
// in the fresh Transition, the still-frozen rAF wedges this one, and
|
|
172
|
+
// the budget recovers it — every swap lands correct, at most one
|
|
173
|
+
// budget late.
|
|
174
|
+
await set("d");
|
|
175
|
+
await vi.advanceTimersByTimeAsync(200); // mid-swap: both generations
|
|
176
|
+
expect(keys(container)).toEqual(["c", "d"]);
|
|
177
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS); // recovered
|
|
178
|
+
const settled = items(container);
|
|
179
|
+
expect(settled).toHaveLength(1);
|
|
180
|
+
expect(settled[0]!.dataset.crossfadeKey).toBe("d");
|
|
181
|
+
assertNoFrozenClasses(settled[0]!);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("appear dissolves the first item in; the watchdog un-freezes it under rAF starvation", async () => {
|
|
185
|
+
vi.useFakeTimers();
|
|
186
|
+
freezeRaf();
|
|
187
|
+
const { container } = mountCrossfade({ initial: "a", appear: true });
|
|
188
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
189
|
+
expect(items(container)[0]!.classList.contains("hk-crossfade-enter-from")).toBe(true);
|
|
190
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100);
|
|
191
|
+
const list = items(container);
|
|
192
|
+
expect(list).toHaveLength(1);
|
|
193
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("a");
|
|
194
|
+
assertNoFrozenClasses(list[0]!);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("disabled swaps synchronously with zero transition classes", async () => {
|
|
198
|
+
vi.useFakeTimers();
|
|
199
|
+
const { container, set } = mountCrossfade({ disabled: true });
|
|
200
|
+
await set("b");
|
|
201
|
+
const list = items(container);
|
|
202
|
+
expect(list).toHaveLength(1);
|
|
203
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
204
|
+
assertNoFrozenClasses(list[0]!);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("re-rendering with the same swapKey swaps nothing", async () => {
|
|
208
|
+
vi.useFakeTimers();
|
|
209
|
+
const { container, set } = mountCrossfade({ initial: "a" });
|
|
210
|
+
await set("a");
|
|
211
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
212
|
+
const list = items(container);
|
|
213
|
+
expect(list).toHaveLength(1);
|
|
214
|
+
assertNoFrozenClasses(list[0]!);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("HkCrossfade — stylesheet contract", () => {
|
|
219
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
220
|
+
|
|
221
|
+
it("stacks both generations in one grid cell — the same-position guarantee", () => {
|
|
222
|
+
const css = sass.compile(join(here, "HkCrossfade.scss")).css;
|
|
223
|
+
expect(css).toMatch(/\.hk-crossfade\s*\{[^}]*display:\s*grid/);
|
|
224
|
+
const item = css.match(/\.hk-crossfade-item[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
225
|
+
expect(item).toContain("grid-area: 1/1");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("dissolves opacity only — no transform anywhere, dead layer drops pointer events", () => {
|
|
229
|
+
const css = sass.compile(join(here, "HkCrossfade.scss")).css;
|
|
230
|
+
expect(css).not.toContain("transform");
|
|
231
|
+
const leave = css.match(/\.hk-crossfade-leave-active[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
232
|
+
expect(leave).toContain("transition: opacity");
|
|
233
|
+
expect(leave).toContain("pointer-events: none");
|
|
234
|
+
expect(leave).toContain("var(--duration-normal, 0.3s)");
|
|
235
|
+
const enter = css.match(/\.hk-crossfade-enter-active[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
236
|
+
expect(enter).toContain("transition: opacity");
|
|
237
|
+
expect(enter).toContain("var(--duration-normal, 0.3s)");
|
|
238
|
+
for (const phase of ["enter-from", "leave-to"]) {
|
|
239
|
+
const block = css.match(new RegExp(`\\.hk-crossfade-${phase}[^{]*\\{[^}]*\\}`))?.[0] ?? "";
|
|
240
|
+
expect(block, `hk-crossfade-${phase} rule missing`).toContain("opacity: 0");
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("reduced motion keeps the opacity exchange, just shorter (fade survives, motion dies)", () => {
|
|
245
|
+
const css = sass.compile(join(here, "HkCrossfade.scss")).css;
|
|
246
|
+
const media = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*\}\s*$/)?.[0] ?? "";
|
|
247
|
+
expect(media).toContain(".hk-crossfade-enter-active");
|
|
248
|
+
expect(media).toContain(".hk-crossfade-leave-active");
|
|
249
|
+
expect(media).toContain("var(--duration-fast, 0.15s)");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("HkCrossfade — wiring contract", () => {
|
|
254
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
255
|
+
const src = readFileSync(join(here, "HkCrossfade.tsx"), "utf-8");
|
|
256
|
+
|
|
257
|
+
it("runs the Transition in default (simultaneous) mode and never overrides its duration", () => {
|
|
258
|
+
expect(src).toContain('<Transition\n name="hk-crossfade"');
|
|
259
|
+
expect(src).not.toMatch(/<Transition[^>]*\bmode=/);
|
|
260
|
+
expect(src).not.toContain("duration={{");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("pins the watchdog wiring and the animation-bus report", () => {
|
|
264
|
+
expect(src).toContain("export const CROSSFADE_WATCHDOG_MS = 450;");
|
|
265
|
+
expect(src).toContain("suppress.value = true;");
|
|
266
|
+
expect(src).toContain("el.remove();");
|
|
267
|
+
expect(src).toContain("useReportedTransition(REPORT_MS)");
|
|
268
|
+
expect(src).toContain("armReport(); armWatchdog();");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { defineComponent, onBeforeUnmount, ref, Transition, watch, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
4
|
+
|
|
5
|
+
import "./HkCrossfade.scss";
|
|
6
|
+
|
|
7
|
+
/** Swap-stall watchdog budget. Must sit above ANY healthy completion of the
|
|
8
|
+
* dissolve (--duration-normal 0.3s; 0s under reduced motion or the global
|
|
9
|
+
* animation switch) so the sweep only ever bites a genuinely stalled swap.
|
|
10
|
+
* Exported for tests and budget-pinning. */
|
|
11
|
+
export const CROSSFADE_WATCHDOG_MS = 450;
|
|
12
|
+
|
|
13
|
+
/** Animation-bus report window — covers the watchdog budget so JS
|
|
14
|
+
* choreography reacting to a swap outlives the CSS work under every
|
|
15
|
+
* timing, including a stalled one. */
|
|
16
|
+
const REPORT_MS = CROSSFADE_WATCHDOG_MS;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* HkCrossfade — same-position simultaneous dissolve for view/mode swaps.
|
|
20
|
+
*
|
|
21
|
+
* The library-level primitive for "the old content fades out WHILE the new
|
|
22
|
+
* content fades in, in place": a default-mode (NO `mode` prop) `<Transition>`
|
|
23
|
+
* whose enter and leave items share ONE grid cell, so the two generations
|
|
24
|
+
* stack at the same geometry for the duration of the dissolve — no exit
|
|
25
|
+
* blank phase (the `out-in` gap of HkPhaseTransition/HkStepFlow), no
|
|
26
|
+
* side-by-side reflow (a bare default-mode Transition), and no height
|
|
27
|
+
* collapse while both are mounted (the host stays as tall as the taller
|
|
28
|
+
* item until the leave completes). This is the height-safe generalization
|
|
29
|
+
* of HkPickerPane's fixed stage + absolute leave: it needs no known stage
|
|
30
|
+
* size, so whole pages (admin views, auth cards) can swap through it.
|
|
31
|
+
*
|
|
32
|
+
* Contract:
|
|
33
|
+
* - `swapKey` is the identity of the content on stage. The component owns
|
|
34
|
+
* the keyed item wrapper (like HkPhaseTransition owns its key) so the
|
|
35
|
+
* watchdog can tell stale items from the active one; rendering with the
|
|
36
|
+
* same value swaps nothing.
|
|
37
|
+
* - Pure opacity — no transform, ever. Same-position means same-position.
|
|
38
|
+
* Both sides run `--duration-normal` so the exchange is a true dissolve;
|
|
39
|
+
* both ease out (fast start), which keeps the incoming (top) layer's
|
|
40
|
+
* opacity ahead of the outgoing one's decay and stops the composite from
|
|
41
|
+
* dipping toward the background mid-swap.
|
|
42
|
+
* - The leaving item drops pointer events: it is dead content and must not
|
|
43
|
+
* intercept clicks while it dissolves under the incoming one.
|
|
44
|
+
* - `appear` dissolves the initially rendered item in; off by default —
|
|
45
|
+
* first paint should never animate (view shells, restored state).
|
|
46
|
+
* - `disabled` renders the active item bare (synchronous swap, no watchdog):
|
|
47
|
+
* the test escape hatch and the watchdog's degraded render path.
|
|
48
|
+
* - Reduced motion keeps the opacity exchange at `--duration-fast` (a fade
|
|
49
|
+
* carries no movement — HkPhaseTransition precedent); hosts driving the
|
|
50
|
+
* global animation switch (`html[data-css-animations="0"]`) already pin
|
|
51
|
+
* these transitions via theme.scss.
|
|
52
|
+
* - Every swap is REPORTED to the runtime animation bus
|
|
53
|
+
* (`useReportedTransition`, refcounted across the concurrent enter+leave)
|
|
54
|
+
* so JS choreography reacting to the swap is not cut short by the CSS
|
|
55
|
+
* work ending early.
|
|
56
|
+
* - The swap-stall watchdog bounds the rAF-starvation wedge (occluded or
|
|
57
|
+
* backgrounded webview): Vue's Transition engine is rAF-gated, so a
|
|
58
|
+
* starved swap freezes the leaving item on top of the new one (field
|
|
59
|
+
* report 2026-09 in chest's ModeMorphTabs — the design this watchdog
|
|
60
|
+
* inherits, alongside the surfaceMachine AXIOM A2 deadline rule). The
|
|
61
|
+
* 450ms budget sits above ANY healthy completion, so on healthy surfaces
|
|
62
|
+
* the sweep fires into an already-settled DOM and is a no-op. After a
|
|
63
|
+
* real stall it strips the frozen transition classes, removes the stale
|
|
64
|
+
* item Vue can no longer unmount, and degrades to synchronous rendering
|
|
65
|
+
* until the next `swapKey` change re-arms the Transition fresh.
|
|
66
|
+
*/
|
|
67
|
+
export default defineComponent({
|
|
68
|
+
name: "HkCrossfade",
|
|
69
|
+
props: {
|
|
70
|
+
swapKey: { type: [String, Number] as PropType<string | number>, required: true },
|
|
71
|
+
tag: { type: String, default: "div" },
|
|
72
|
+
appear: { type: Boolean, default: false },
|
|
73
|
+
disabled: { type: Boolean, default: false },
|
|
74
|
+
},
|
|
75
|
+
setup(props, { slots }) {
|
|
76
|
+
const hostRef = ref<HTMLElement | null>(null);
|
|
77
|
+
// Set after a watchdog sweep proved the Transition engine wedged; the
|
|
78
|
+
// render drops the Transition wrapper so keys apply synchronously until
|
|
79
|
+
// the next swap re-arms it (a one-tick toggle re-enters the wedged
|
|
80
|
+
// internal state otherwise — ModeMorphTabs lesson).
|
|
81
|
+
const suppress = ref(false);
|
|
82
|
+
|
|
83
|
+
// ── Animation-bus participation ──────────────────────────────────
|
|
84
|
+
// One swap runs an enter AND a leave concurrently; refcount them so
|
|
85
|
+
// the shared track stays armed until BOTH settle (HkListTransition
|
|
86
|
+
// pattern).
|
|
87
|
+
const report = useReportedTransition(REPORT_MS);
|
|
88
|
+
let pending = 0;
|
|
89
|
+
const armReport = () => {
|
|
90
|
+
pending++;
|
|
91
|
+
report.run();
|
|
92
|
+
};
|
|
93
|
+
const settleReport = () => {
|
|
94
|
+
pending = Math.max(0, pending - 1);
|
|
95
|
+
if (pending === 0) report.cancel();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ── Swap-stall watchdog ──────────────────────────────────────────
|
|
99
|
+
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
|
|
101
|
+
function armWatchdog(): void {
|
|
102
|
+
// Re-arm per swap event (enter or leave start); a healthy swap lets
|
|
103
|
+
// the timer fire into a settled DOM, where the sweep is a no-op.
|
|
104
|
+
if (watchdog !== null) clearTimeout(watchdog);
|
|
105
|
+
watchdog = setTimeout(onWatchdog, CROSSFADE_WATCHDOG_MS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sweepFrozenClasses(el: Element): boolean {
|
|
109
|
+
const frozenLeave = el.classList.contains("hk-crossfade-leave-active")
|
|
110
|
+
|| el.classList.contains("hk-crossfade-leave-from");
|
|
111
|
+
el.classList.remove(
|
|
112
|
+
"hk-crossfade-enter-active",
|
|
113
|
+
"hk-crossfade-enter-from",
|
|
114
|
+
"hk-crossfade-enter-to",
|
|
115
|
+
"hk-crossfade-leave-active",
|
|
116
|
+
"hk-crossfade-leave-from",
|
|
117
|
+
"hk-crossfade-leave-to",
|
|
118
|
+
);
|
|
119
|
+
return frozenLeave;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onWatchdog(): void {
|
|
123
|
+
watchdog = null;
|
|
124
|
+
const host = hostRef.value;
|
|
125
|
+
if (!host) return;
|
|
126
|
+
let wedged = false;
|
|
127
|
+
for (const el of Array.from(host.querySelectorAll<HTMLElement>(":scope > .hk-crossfade-item"))) {
|
|
128
|
+
const frozenLeave = sweepFrozenClasses(el);
|
|
129
|
+
// A stale item still holding a leave class is one Vue's rAF-gated
|
|
130
|
+
// engine can never unmount — remove it by hand and mark the engine
|
|
131
|
+
// wedged so this and future swaps render synchronously.
|
|
132
|
+
if (frozenLeave && el.dataset.crossfadeKey !== String(props.swapKey)) {
|
|
133
|
+
el.remove();
|
|
134
|
+
wedged = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (wedged) suppress.value = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
watch(() => props.swapKey, () => {
|
|
141
|
+
// Fresh swap: restore the Transition (the wedged one was discarded
|
|
142
|
+
// with the suppressed render). The watchdog re-arms from the
|
|
143
|
+
// beforeEnter/beforeLeave hooks of the new Transition instance.
|
|
144
|
+
suppress.value = false;
|
|
145
|
+
});
|
|
146
|
+
onBeforeUnmount(() => {
|
|
147
|
+
if (watchdog !== null) {
|
|
148
|
+
clearTimeout(watchdog);
|
|
149
|
+
watchdog = null;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
const Tag = props.tag as "div" | "section" | "main" | "aside";
|
|
155
|
+
const item = () => (
|
|
156
|
+
<div
|
|
157
|
+
key={props.swapKey}
|
|
158
|
+
class="hk-crossfade-item"
|
|
159
|
+
data-crossfade-key={String(props.swapKey)}
|
|
160
|
+
>
|
|
161
|
+
{slots.default?.()}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (props.disabled || suppress.value) {
|
|
166
|
+
// Watchdog recovery / explicit opt-out: no Transition wrapper, so
|
|
167
|
+
// the key swap applies synchronously even with rAF frozen.
|
|
168
|
+
return (
|
|
169
|
+
<Tag ref={hostRef} class="hk-crossfade">
|
|
170
|
+
{item()}
|
|
171
|
+
</Tag>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Tag ref={hostRef} class="hk-crossfade">
|
|
177
|
+
{/* Deliberately NO `mode`: enter and leave run CONCURRENTLY in
|
|
178
|
+
the same grid cell — that is the whole point of this component.
|
|
179
|
+
A `mode="out-in"` here would reintroduce the exit blank phase;
|
|
180
|
+
`mode="in-out"` would double-stack full-opacity content. */}
|
|
181
|
+
<Transition
|
|
182
|
+
name="hk-crossfade"
|
|
183
|
+
appear={props.appear}
|
|
184
|
+
onBeforeEnter={() => { armReport(); armWatchdog(); }}
|
|
185
|
+
onAfterEnter={settleReport}
|
|
186
|
+
onEnterCancelled={settleReport}
|
|
187
|
+
onBeforeLeave={() => { armReport(); armWatchdog(); }}
|
|
188
|
+
onAfterLeave={settleReport}
|
|
189
|
+
onLeaveCancelled={settleReport}
|
|
190
|
+
>
|
|
191
|
+
{item()}
|
|
192
|
+
</Transition>
|
|
193
|
+
</Tag>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { default as HColorPicker } from "./components/HkColorPicker";
|
|
|
16
16
|
export { HColorSchemeDialog, type HCustomTheme } from "./components/HkColorSchemeDialog";
|
|
17
17
|
export { HkColorSchemeEditor } from "./components/HkColorSchemeEditor";
|
|
18
18
|
export { default as HConfirmDialog } from "./components/HkConfirmDialog";
|
|
19
|
+
export { default as HCrossfade } from "./components/HkCrossfade";
|
|
19
20
|
export { default as HDivider } from "./components/HkDivider";
|
|
20
21
|
export { default as HDrawer } from "./components/HkDrawer";
|
|
21
22
|
export { default as HEmptyState } from "./components/HkEmptyState";
|
|
@@ -356,6 +357,7 @@ export { HkProtocolModal as HProtocolModal } from "./components/HkProtocolModal"
|
|
|
356
357
|
export {
|
|
357
358
|
HkAboutModal as HAboutModal,
|
|
358
359
|
type HAboutComponentVersion,
|
|
360
|
+
type HAboutCredit,
|
|
359
361
|
type HAboutLink,
|
|
360
362
|
} from "./components/HkAboutModal";
|
|
361
363
|
export { HkLogWindow as HLogWindow, type HLogTab } from "./components/HkLogWindow";
|