@celestia-island/hikari 0.55.195 → 0.55.197
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/HkTabs.scss +7 -0
- package/src/components/HkTabs.test.ts +109 -1
- package/src/components/HkTabs.tsx +28 -10
- package/src/components/HkTransitionContainer.scss +13 -0
- package/src/components/HkTransitionContainer.test.tsx +127 -0
- package/src/components/HkTransitionContainer.tsx +154 -0
- package/src/composables/useAnimationLoop.ts +95 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -166,6 +166,13 @@
|
|
|
166
166
|
|
|
167
167
|
/* === Panel (pill / tablist mode) === */
|
|
168
168
|
|
|
169
|
+
/* The panel area is ONE HkTransitionContainer (`.hk-tabs-panels`,
|
|
170
|
+
aliasing `.hk-tc`) keyed on the active tab: HkCrossfade dissolves the
|
|
171
|
+
outgoing panel in a shared grid cell while the container's inline
|
|
172
|
+
height transition morphs to the incoming panel's natural size. Both
|
|
173
|
+
generations carry data-active through the dissolve, so the :not rule
|
|
174
|
+
below only ever hides a genuinely inactive render, never a
|
|
175
|
+
dissolving one. */
|
|
169
176
|
.hk-tabs-panel {
|
|
170
177
|
/* No outline suppression: the panel div carries no tabindex and can
|
|
171
178
|
never take focus, so there is nothing to suppress. */
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
6
6
|
|
|
7
7
|
import HkTabs from "./HkTabs";
|
|
8
|
+
import { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
|
|
9
|
+
import { DEFAULT_ANIMATION_DURATION_MS } from "../composables/useAnimationLoop";
|
|
8
10
|
|
|
9
11
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
12
|
|
|
@@ -515,3 +517,109 @@ describe("HkTabs segmented variant", () => {
|
|
|
515
517
|
expect(itemRule![1]).not.toMatch(/\bflex:/);
|
|
516
518
|
});
|
|
517
519
|
});
|
|
520
|
+
|
|
521
|
+
describe("HkTabs panel morph (HkTransitionContainer consumption)", () => {
|
|
522
|
+
/** Pill mode with panels ON (renderPanels defaults true) and a slot
|
|
523
|
+
* per tab — the surface the morph container plugs into. */
|
|
524
|
+
function mountWithPanels(
|
|
525
|
+
extra: Record<string, unknown> = {},
|
|
526
|
+
slots?: Record<string, () => unknown>,
|
|
527
|
+
): { container: HTMLElement; active: { value: string } } {
|
|
528
|
+
const container = document.createElement("div");
|
|
529
|
+
document.body.appendChild(container);
|
|
530
|
+
containers.push(container);
|
|
531
|
+
const active = ref("a");
|
|
532
|
+
const panelSlots = slots ?? {
|
|
533
|
+
a: () => h("p", { class: "body" }, "panel-alpha"),
|
|
534
|
+
b: () => h("p", { class: "body" }, "panel-beta"),
|
|
535
|
+
c: () => h("p", { class: "body" }, "panel-gamma"),
|
|
536
|
+
};
|
|
537
|
+
const Wrapper = defineComponent({
|
|
538
|
+
setup() {
|
|
539
|
+
return () =>
|
|
540
|
+
h(HkTabs, {
|
|
541
|
+
tabs: [
|
|
542
|
+
{ key: "a", label: "Alpha" },
|
|
543
|
+
{ key: "b", label: "Beta" },
|
|
544
|
+
{ key: "c", label: "Gamma" },
|
|
545
|
+
],
|
|
546
|
+
...extra,
|
|
547
|
+
modelValue: active.value,
|
|
548
|
+
"onUpdate:modelValue": (v: string) => { active.value = v; },
|
|
549
|
+
}, panelSlots);
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
const app = createApp(Wrapper);
|
|
553
|
+
mounts.push(app);
|
|
554
|
+
app.mount(container);
|
|
555
|
+
return { container, active };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function freezeRaf(): void {
|
|
559
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
560
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
afterEach(() => {
|
|
564
|
+
vi.useRealTimers();
|
|
565
|
+
vi.unstubAllGlobals();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("renders the active panel inside the height-morph container and swaps it on tab change", async () => {
|
|
569
|
+
vi.useFakeTimers();
|
|
570
|
+
const { container, active } = mountWithPanels();
|
|
571
|
+
// ONE morph host (`.hk-tabs-panels` aliasing `.hk-tc`) below the
|
|
572
|
+
// strip, carrying the family height-morph transition contract.
|
|
573
|
+
const host = container.querySelector<HTMLElement>(".hk-tabs-panels.hk-tc");
|
|
574
|
+
expect(host).not.toBeNull();
|
|
575
|
+
expect(host?.style.transitionProperty).toBe("height");
|
|
576
|
+
expect(host?.style.transitionDuration).toBe(`${DEFAULT_ANIMATION_DURATION_MS}ms`);
|
|
577
|
+
// It owns exactly ONE panel — the active tab's.
|
|
578
|
+
const panels = container.querySelectorAll<HTMLElement>(".hk-tabs-panel");
|
|
579
|
+
expect(panels).toHaveLength(1);
|
|
580
|
+
expect(panels[0].hasAttribute("data-active")).toBe(true);
|
|
581
|
+
expect(panels[0].getAttribute("role")).toBe("tabpanel");
|
|
582
|
+
expect(container.textContent).toContain("panel-alpha");
|
|
583
|
+
|
|
584
|
+
active.value = "b";
|
|
585
|
+
await nextTick();
|
|
586
|
+
// Past the watchdog budget: the dissolve settled on Beta's panel —
|
|
587
|
+
// content, crossfade key and single item all moved with the tab.
|
|
588
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100);
|
|
589
|
+
const settled = container.querySelectorAll<HTMLElement>(".hk-tabs-panel");
|
|
590
|
+
expect(settled).toHaveLength(1);
|
|
591
|
+
expect(settled[0].textContent).toContain("panel-beta");
|
|
592
|
+
const item = container.querySelector<HTMLElement>(".hk-crossfade-item");
|
|
593
|
+
expect(item?.dataset.crossfadeKey).toBe("b");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("keeps both panel generations data-active mid-dissolve (no display:none pop)", async () => {
|
|
597
|
+
vi.useFakeTimers();
|
|
598
|
+
freezeRaf();
|
|
599
|
+
const { container, active } = mountWithPanels();
|
|
600
|
+
active.value = "b";
|
|
601
|
+
await nextTick();
|
|
602
|
+
await vi.advanceTimersByTimeAsync(100); // inside the dissolve window
|
|
603
|
+
// Both generations on stage and BOTH marked active: the leaving
|
|
604
|
+
// panel keeps its last-rendered data-active, so the stylesheet's
|
|
605
|
+
// `:not([data-active])` hide never fires mid-dissolve — the swap
|
|
606
|
+
// is HkCrossfade's stacked-grid dissolve, not an instant toggle.
|
|
607
|
+
const mid = container.querySelectorAll<HTMLElement>(".hk-tabs-panel");
|
|
608
|
+
expect(mid).toHaveLength(2);
|
|
609
|
+
expect(mid[0].hasAttribute("data-active")).toBe(true);
|
|
610
|
+
expect(mid[1].hasAttribute("data-active")).toBe(true);
|
|
611
|
+
expect(mid[0].textContent).toContain("panel-alpha");
|
|
612
|
+
expect(mid[1].textContent).toContain("panel-beta");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("drops the panel area entirely while the active option is merged away", () => {
|
|
616
|
+
const { container } = mountWithPanels(
|
|
617
|
+
{ mergeKeys: ["a"] },
|
|
618
|
+
{ a: () => h("p", "panel-alpha"), merged: () => h("span", "m") },
|
|
619
|
+
);
|
|
620
|
+
// Pill-mode merged contract: merged options' panels are not
|
|
621
|
+
// rendered while merged — no host, no panel.
|
|
622
|
+
expect(container.querySelector(".hk-tabs-panels")).toBeNull();
|
|
623
|
+
expect(container.querySelector(".hk-tabs-panel")).toBeNull();
|
|
624
|
+
});
|
|
625
|
+
});
|
|
@@ -4,6 +4,7 @@ import "./HkTabs.scss";
|
|
|
4
4
|
import HkScrollContainer from "./HkScrollContainer";
|
|
5
5
|
import HkHoverRevealAction from "./HkHoverRevealAction";
|
|
6
6
|
import HkIconButton from "./HkIconButton";
|
|
7
|
+
import HkTransitionContainer from "./HkTransitionContainer";
|
|
7
8
|
import { iconByName } from "../composables/iconRegistry";
|
|
8
9
|
import { useI18n } from "../i18n/context";
|
|
9
10
|
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
@@ -88,6 +89,12 @@ interface ScrollerExpose {
|
|
|
88
89
|
* the row is then divided into one equal `1fr` grid track per unit,
|
|
89
90
|
* so merging Light+Dark into the altitude strip swaps TWO one-unit
|
|
90
91
|
* slots for ONE two-unit cell and nothing else on the strip moves;
|
|
92
|
+
* - morphing tab panels (pill mode, `renderPanels`): the panel area is
|
|
93
|
+
* ONE HkTransitionContainer keyed on the active tab — switching tabs
|
|
94
|
+
* dissolves the outgoing panel through HkCrossfade while the
|
|
95
|
+
* container height-morphs to the incoming panel's natural size
|
|
96
|
+
* (panels never had switch CSS before — inactive ones were simply
|
|
97
|
+
* display:none'd, so nothing visual is layered away);
|
|
91
98
|
* - full arrow-key navigation (arrows/Home/End, wrapping, disabled tabs
|
|
92
99
|
* skipped, selection follows focus) and roving tabindex.
|
|
93
100
|
*/
|
|
@@ -435,6 +442,13 @@ export default defineComponent({
|
|
|
435
442
|
|
|
436
443
|
return () => {
|
|
437
444
|
const mergedHead = mergedRun.value[0];
|
|
445
|
+
/** The ONE panel on stage: the active tab's, when panels render
|
|
446
|
+
* (pill mode only) and the tab is not collapsed into the merged
|
|
447
|
+
* cell — merged options' panels are not rendered while merged
|
|
448
|
+
* (unchanged contract; the whole container drops out instead). */
|
|
449
|
+
const panelTab = props.renderPanels && !isSegmented.value
|
|
450
|
+
? props.tabs.find((tb) => tb.key === props.modelValue && !isMergedTab(tb))
|
|
451
|
+
: undefined;
|
|
438
452
|
const listChildren = [
|
|
439
453
|
<div
|
|
440
454
|
key="$indicator"
|
|
@@ -553,16 +567,20 @@ export default defineComponent({
|
|
|
553
567
|
</HkScrollContainer>
|
|
554
568
|
) : list}
|
|
555
569
|
|
|
556
|
-
{
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
570
|
+
{/* Panel area (pill mode, renderPanels): ONE morph container
|
|
571
|
+
keyed on the active tab (2026-10-22 animation direction) —
|
|
572
|
+
HkCrossfade dissolves the outgoing panel in the shared grid
|
|
573
|
+
cell while the container's inline-height transition rides the
|
|
574
|
+
incoming panel's natural size. The strip's own hk-tabs-swap
|
|
575
|
+
transitions are untouched; a merged-active strip renders no
|
|
576
|
+
panel at all, exactly as before. */}
|
|
577
|
+
{panelTab && (
|
|
578
|
+
<HkTransitionContainer swapKey={panelTab.key} class="hk-tabs-panels">
|
|
579
|
+
<div role="tabpanel" class="hk-tabs-panel" data-active>
|
|
580
|
+
{slots[panelTab.key]?.()}
|
|
581
|
+
</div>
|
|
582
|
+
</HkTransitionContainer>
|
|
583
|
+
)}
|
|
566
584
|
</div>
|
|
567
585
|
);
|
|
568
586
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// HkTransitionContainer — the state-keyed morph container. The host
|
|
2
|
+
// carries the inline size (transitioned by the component); the stage
|
|
3
|
+
// is the measured content. Overflow hides DURING the morph only (the
|
|
4
|
+
// host is `overflow: clip` while a pinned size is active — applied via
|
|
5
|
+
// the inline width/height, the clip rides along) so shortened content
|
|
6
|
+
// never spills past the animating box.
|
|
7
|
+
.hk-tc {
|
|
8
|
+
overflow: clip;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.hk-tc-stage {
|
|
12
|
+
width: 100%;
|
|
13
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, ref, type App } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkTransitionContainer from "./HkTransitionContainer";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_ANIMATION_DURATION_MS,
|
|
7
|
+
animationLoopArmedCount,
|
|
8
|
+
onAnimationFrame,
|
|
9
|
+
} from "../composables/useAnimationLoop";
|
|
10
|
+
|
|
11
|
+
/** Shared-container discipline (HkCrossfade.test precedent): every
|
|
12
|
+
* case appends its own container; afterEach tears them all down. */
|
|
13
|
+
const containers: HTMLElement[] = [];
|
|
14
|
+
|
|
15
|
+
interface Harness {
|
|
16
|
+
app: App;
|
|
17
|
+
container: HTMLElement;
|
|
18
|
+
key: { value: string };
|
|
19
|
+
swaps: Array<{ key: string | number; previous: string | number; durationMs: number }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mountContainer(initial = "a"): Harness {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
containers.push(container);
|
|
26
|
+
const key = ref(initial);
|
|
27
|
+
const swaps: Harness["swaps"] = [];
|
|
28
|
+
const Wrapper = defineComponent({
|
|
29
|
+
setup() {
|
|
30
|
+
return () => (
|
|
31
|
+
<HkTransitionContainer
|
|
32
|
+
swapKey={key.value}
|
|
33
|
+
onSwap={(e: { key: string | number; previous: string | number; durationMs: number }) => {
|
|
34
|
+
swaps.push(e);
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<p>{`panel-${key.value}`}</p>
|
|
38
|
+
</HkTransitionContainer>
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const app = createApp(Wrapper);
|
|
43
|
+
app.mount(container);
|
|
44
|
+
return { app, container, key, swaps };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
for (const c of containers.splice(0)) c.remove();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("HkTransitionContainer", () => {
|
|
52
|
+
it("renders the slot inside the morph host", () => {
|
|
53
|
+
const t = mountContainer("a");
|
|
54
|
+
const host = t.container.querySelector<HTMLElement>(".hk-tc");
|
|
55
|
+
const stage = t.container.querySelector<HTMLElement>(".hk-tc-stage");
|
|
56
|
+
expect(host).not.toBeNull();
|
|
57
|
+
expect(stage).not.toBeNull();
|
|
58
|
+
expect(t.container.textContent).toContain("panel-a");
|
|
59
|
+
t.app.unmount();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("defaults to the 300ms family duration on the host transition", () => {
|
|
63
|
+
const t = mountContainer("a");
|
|
64
|
+
const host = t.container.querySelector<HTMLElement>(".hk-tc");
|
|
65
|
+
expect(host?.style.transitionDuration).toBe(`${DEFAULT_ANIMATION_DURATION_MS}ms`);
|
|
66
|
+
expect(DEFAULT_ANIMATION_DURATION_MS).toBe(300);
|
|
67
|
+
t.app.unmount();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("axis=width transitions only the width property", () => {
|
|
71
|
+
const container = document.createElement("div");
|
|
72
|
+
document.body.appendChild(container);
|
|
73
|
+
containers.push(container);
|
|
74
|
+
const app = createApp({
|
|
75
|
+
render: () => (
|
|
76
|
+
<HkTransitionContainer swapKey="a" axis="width">
|
|
77
|
+
<p>x</p>
|
|
78
|
+
</HkTransitionContainer>
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
app.mount(container);
|
|
82
|
+
const host = container.querySelector<HTMLElement>(".hk-tc");
|
|
83
|
+
expect(host?.style.transitionProperty).toBe("width");
|
|
84
|
+
app.unmount();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("survives a swapKey change without ResizeObserver (degraded path, no NaN)", async () => {
|
|
88
|
+
const t = mountContainer("login");
|
|
89
|
+
t.key.value = "org";
|
|
90
|
+
await new Promise((r) => setTimeout(r, DEFAULT_ANIMATION_DURATION_MS + 120));
|
|
91
|
+
// jsdom has no ResizeObserver and zero offsets: no morph, no crash,
|
|
92
|
+
// and any pinned inline size is well-formed (never NaNpx).
|
|
93
|
+
const host = t.container.querySelector<HTMLElement>(".hk-tc");
|
|
94
|
+
expect(host?.style.width).not.toContain("NaN");
|
|
95
|
+
expect(host?.style.height).not.toContain("NaN");
|
|
96
|
+
t.app.unmount();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("useAnimationLoop (shared rAF context)", () => {
|
|
101
|
+
it("registers and unregisters; the loop idles at zero", () => {
|
|
102
|
+
const cb = vi.fn();
|
|
103
|
+
const off = onAnimationFrame(cb);
|
|
104
|
+
expect(animationLoopArmedCount()).toBe(1);
|
|
105
|
+
off();
|
|
106
|
+
expect(animationLoopArmedCount()).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("dedupes the same callback registration", () => {
|
|
110
|
+
const cb = vi.fn();
|
|
111
|
+
const off1 = onAnimationFrame(cb);
|
|
112
|
+
const off2 = onAnimationFrame(cb);
|
|
113
|
+
expect(animationLoopArmedCount()).toBe(1);
|
|
114
|
+
off1();
|
|
115
|
+
expect(animationLoopArmedCount()).toBe(0);
|
|
116
|
+
off2();
|
|
117
|
+
expect(animationLoopArmedCount()).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("unregister is idempotent", () => {
|
|
121
|
+
const cb = vi.fn();
|
|
122
|
+
const off = onAnimationFrame(cb);
|
|
123
|
+
off();
|
|
124
|
+
off();
|
|
125
|
+
expect(animationLoopArmedCount()).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { defineComponent, nextTick, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_ANIMATION_DURATION_MS } from "../composables/useAnimationLoop";
|
|
4
|
+
import HkCrossfade from "./HkCrossfade";
|
|
5
|
+
|
|
6
|
+
import "./HkTransitionContainer.scss";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HkTransitionContainer — the state-keyed morph container.
|
|
10
|
+
*
|
|
11
|
+
* HkCrossfade's height-safe dissolve, PLUS the size choreography the
|
|
12
|
+
* user direction (2026-10-22) asks of every swap surface: when the
|
|
13
|
+
* bound state key changes, the OLD content fades out, the container's
|
|
14
|
+
* width/height smoothly animate to the incoming content's natural
|
|
15
|
+
* size, and the NEW content fades in — one 300ms default, CSS-driven
|
|
16
|
+
* (transition on inline size), with the animation-loop registration
|
|
17
|
+
* kept for the choreography CSS cannot express.
|
|
18
|
+
*
|
|
19
|
+
* Binding: `swapKey` is the identity of the content on stage — the same
|
|
20
|
+
* contract HkCrossfade owns (same value ⇒ no swap). Tabs, segmented
|
|
21
|
+
* controls, editor mode switches, chat-node state labels all reduce to
|
|
22
|
+
* "a key changed; morph to the new panel".
|
|
23
|
+
*
|
|
24
|
+
* Scroll handoff (loose coupling, per the user's spec): the container
|
|
25
|
+
* never touches any scroller itself. Each swap emits a `swap` event
|
|
26
|
+
* carrying `{ key, previous, durationMs }`; the HOST forwards that to
|
|
27
|
+
* its scroll container's PUBLIC interface — the same scrollTo API used
|
|
28
|
+
* for back-to-top and jump-to-line — so the scrollbar adapts to the
|
|
29
|
+
* size change and the caret/anchor coordinate glides instead of
|
|
30
|
+
* teleporting. The scroll container needs zero knowledge of this
|
|
31
|
+
* component.
|
|
32
|
+
*
|
|
33
|
+
* Sizing: a ResizeObserver on the inner stage feeds the container's
|
|
34
|
+
* inline width/height; the CSS transition on those properties does the
|
|
35
|
+
* actual morph (off-thread, cancellable, respects the global animation
|
|
36
|
+
* switch via the duration override to 0s). When the observer is
|
|
37
|
+
* unavailable (older webview, SSR, jsdom) the container degrades to
|
|
38
|
+
* plain auto sizing — a dissolve without the morph, never a broken
|
|
39
|
+
* layout.
|
|
40
|
+
*/
|
|
41
|
+
export default defineComponent({
|
|
42
|
+
name: "HkTransitionContainer",
|
|
43
|
+
props: {
|
|
44
|
+
/** Identity of the content on stage; changing it starts the morph. */
|
|
45
|
+
swapKey: { type: [String, Number] as PropType<string | number>, required: true },
|
|
46
|
+
/** Which axes morph. `height` is the default (most swap surfaces);
|
|
47
|
+
* `width` for horizontal morphs (segmented panels); `both` for
|
|
48
|
+
* free-floating cards. */
|
|
49
|
+
axis: {
|
|
50
|
+
type: String as PropType<"height" | "width" | "both">,
|
|
51
|
+
default: "height",
|
|
52
|
+
},
|
|
53
|
+
/** Morph + dissolve duration, ms. Family default 300. */
|
|
54
|
+
duration: { type: Number, default: DEFAULT_ANIMATION_DURATION_MS },
|
|
55
|
+
tag: { type: String, default: "div" },
|
|
56
|
+
},
|
|
57
|
+
emits: {
|
|
58
|
+
/** Fired when a swap settles: the host forwards this to its scroll
|
|
59
|
+
* container's public scrollTo interface. */
|
|
60
|
+
swap: (payload: { key: string | number; previous: string | number; durationMs: number }) => true,
|
|
61
|
+
},
|
|
62
|
+
setup(props, { slots, emit }) {
|
|
63
|
+
const hostRef = ref<HTMLElement | null>(null);
|
|
64
|
+
const stageRef = ref<HTMLElement | null>(null);
|
|
65
|
+
// Pinned inline size: `{ w, h }` while morphing, `null` = auto.
|
|
66
|
+
const pinned = ref<{ w: number; h: number } | null>(null);
|
|
67
|
+
|
|
68
|
+
let observer: ResizeObserver | null = null;
|
|
69
|
+
let pendingKey: string | number | null = null;
|
|
70
|
+
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
+
|
|
72
|
+
function readStage(): { w: number; h: number } {
|
|
73
|
+
const el = stageRef.value;
|
|
74
|
+
if (!el) return { w: 0, h: 0 };
|
|
75
|
+
return { w: el.offsetWidth, h: el.offsetHeight };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function applyPinned(): void {
|
|
79
|
+
const el = hostRef.value;
|
|
80
|
+
if (!el) return;
|
|
81
|
+
const p = pinned.value;
|
|
82
|
+
el.style.width = p && props.axis !== "height" ? `${p.w}px` : "";
|
|
83
|
+
el.style.height = p && props.axis !== "width" ? `${p.h}px` : "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function observe(): void {
|
|
87
|
+
if (typeof ResizeObserver === "undefined" || !stageRef.value) return;
|
|
88
|
+
observer?.disconnect();
|
|
89
|
+
observer = new ResizeObserver((entries) => {
|
|
90
|
+
const rect = entries[0]?.contentRect;
|
|
91
|
+
if (!rect || pendingKey === null) return;
|
|
92
|
+
// Stage settled at the incoming content's natural size: pin to
|
|
93
|
+
// it (from the outgoing size) → next frame the CSS transition
|
|
94
|
+
// morphs the container; after the duration, release to auto.
|
|
95
|
+
pinned.value = { w: Math.ceil(rect.width), h: Math.ceil(rect.height) };
|
|
96
|
+
applyPinned();
|
|
97
|
+
if (settleTimer !== null) clearTimeout(settleTimer);
|
|
98
|
+
settleTimer = setTimeout(() => {
|
|
99
|
+
pinned.value = null;
|
|
100
|
+
applyPinned();
|
|
101
|
+
const key = pendingKey;
|
|
102
|
+
pendingKey = null;
|
|
103
|
+
if (key !== null) {
|
|
104
|
+
emit("swap", { key, previous: previousKey, durationMs: props.duration });
|
|
105
|
+
}
|
|
106
|
+
}, props.duration + 40);
|
|
107
|
+
});
|
|
108
|
+
observer.observe(stageRef.value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let previousKey: string | number = props.swapKey;
|
|
112
|
+
|
|
113
|
+
watch(() => props.swapKey, (next) => {
|
|
114
|
+
// Pin the OUTGOING size first so the container never collapses
|
|
115
|
+
// through zero while the content swaps underneath.
|
|
116
|
+
pendingKey = next;
|
|
117
|
+
pinned.value = readStage();
|
|
118
|
+
applyPinned();
|
|
119
|
+
void nextTick(() => observe());
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
onBeforeUnmount(() => {
|
|
123
|
+
observer?.disconnect();
|
|
124
|
+
observer = null;
|
|
125
|
+
if (settleTimer !== null) clearTimeout(settleTimer);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
const Tag = props.tag as "div" | "section" | "main" | "aside";
|
|
130
|
+
const d = `${props.duration}ms`;
|
|
131
|
+
return (
|
|
132
|
+
<Tag
|
|
133
|
+
ref={hostRef}
|
|
134
|
+
class="hk-tc"
|
|
135
|
+
style={{
|
|
136
|
+
transitionProperty:
|
|
137
|
+
props.axis === "both" ? "width, height" : props.axis === "width" ? "width" : "height",
|
|
138
|
+
transitionDuration: d,
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{/* The stage is HkCrossfade: it owns the opacity dissolve
|
|
142
|
+
(both generations stacked in one grid cell — no exit blank,
|
|
143
|
+
no collapse flash) and the swap-stall watchdog; THIS
|
|
144
|
+
component owns the measured size morph around it. */}
|
|
145
|
+
<div ref={stageRef} class="hk-tc-stage">
|
|
146
|
+
<HkCrossfade swapKey={props.swapKey}>
|
|
147
|
+
{slots.default?.()}
|
|
148
|
+
</HkCrossfade>
|
|
149
|
+
</div>
|
|
150
|
+
</Tag>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAnimationLoop — the family's ONE shared requestAnimationFrame loop.
|
|
3
|
+
*
|
|
4
|
+
* Every JS-driven animation in the library registers a callback here
|
|
5
|
+
* instead of opening its own rAF chain: the context owns a single loop
|
|
6
|
+
* that runs ONLY while at least one callback is registered, and each
|
|
7
|
+
* frame dispatches `(now, deltaMs)` to every callback that was armed at
|
|
8
|
+
* frame start (a callback that registers/unregisters mid-frame takes
|
|
9
|
+
* effect next frame — the iteration walks a snapshot). CSS transitions
|
|
10
|
+
* remain the preferred vehicle (the browser composites them
|
|
11
|
+
* off-thread); this loop is for the choreography CSS cannot express —
|
|
12
|
+
* measured size morphs, staged state sequences, scroll handshakes
|
|
13
|
+
* (user direction 2026-10-22).
|
|
14
|
+
*
|
|
15
|
+
* The family-wide default duration for BOTH CSS and loop-driven
|
|
16
|
+
* animation is 300ms (`DEFAULT_ANIMATION_DURATION_MS`), matching the
|
|
17
|
+
* design system's `--duration-normal` token — one number, everywhere.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The family-wide animation default: 300ms (== `--duration-normal`). */
|
|
21
|
+
export const DEFAULT_ANIMATION_DURATION_MS = 300;
|
|
22
|
+
|
|
23
|
+
/** First-frame delta stand-in: one 60Hz frame. */
|
|
24
|
+
const DEFAULT_FRAME_DELTA_MS = 1000 / 60;
|
|
25
|
+
|
|
26
|
+
/** A per-frame callback: absolute rAF timestamp + ms since last frame. */
|
|
27
|
+
export type AnimationFrameCallback = (nowMs: number, deltaMs: number) => void;
|
|
28
|
+
|
|
29
|
+
interface LoopState {
|
|
30
|
+
callbacks: Set<AnimationFrameCallback>;
|
|
31
|
+
handle: number | null;
|
|
32
|
+
last: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Module-level singleton: one loop per document, however many consumers.
|
|
36
|
+
const state: LoopState = { callbacks: new Set(), handle: null, last: null };
|
|
37
|
+
|
|
38
|
+
function engine(nowMs: number): void {
|
|
39
|
+
// Snapshot BEFORE mutating state: mid-frame (un)registrations land
|
|
40
|
+
// next frame, and a callback never runs twice inside one frame.
|
|
41
|
+
const armed = Array.from(state.callbacks);
|
|
42
|
+
const prev = state.last;
|
|
43
|
+
if (armed.length > 0) {
|
|
44
|
+
state.handle = requestAnimationFrame(engine);
|
|
45
|
+
state.last = nowMs;
|
|
46
|
+
} else {
|
|
47
|
+
state.handle = null;
|
|
48
|
+
state.last = null;
|
|
49
|
+
}
|
|
50
|
+
const delta = prev === null ? DEFAULT_FRAME_DELTA_MS : Math.max(0, nowMs - prev);
|
|
51
|
+
for (const cb of armed) cb(nowMs, delta);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register a per-frame callback. Returns the unregister function; call
|
|
56
|
+
* it when the animation settles (or the component unmounts). Registering
|
|
57
|
+
* the SAME function twice is idempotent (Set semantics). The loop is
|
|
58
|
+
* lazily started by the first registration and stops itself the moment
|
|
59
|
+
* the last callback unregisters — idle pages pay zero rAF churn.
|
|
60
|
+
*/
|
|
61
|
+
export function onAnimationFrame(cb: AnimationFrameCallback): () => void {
|
|
62
|
+
state.callbacks.add(cb);
|
|
63
|
+
if (state.handle === null) {
|
|
64
|
+
state.last = null;
|
|
65
|
+
state.handle = requestAnimationFrame(engine);
|
|
66
|
+
}
|
|
67
|
+
return () => {
|
|
68
|
+
state.callbacks.delete(cb);
|
|
69
|
+
if (state.callbacks.size === 0 && state.handle !== null) {
|
|
70
|
+
cancelAnimationFrame(state.handle);
|
|
71
|
+
state.handle = null;
|
|
72
|
+
state.last = null;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test/introspection hook: how many callbacks are armed right now. */
|
|
78
|
+
export function animationLoopArmedCount(): number {
|
|
79
|
+
return state.callbacks.size;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Component-facing wrapper: one registration bound to one callback
|
|
84
|
+
* (register on creation, `dispose()` when the owner unmounts). The
|
|
85
|
+
* callback receives `(nowMs, deltaMs)` every frame while armed.
|
|
86
|
+
*/
|
|
87
|
+
export function useAnimationLoop(cb: AnimationFrameCallback): { dispose: () => void } {
|
|
88
|
+
let off: (() => void) | null = onAnimationFrame(cb);
|
|
89
|
+
return {
|
|
90
|
+
dispose(): void {
|
|
91
|
+
off?.();
|
|
92
|
+
off = null;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -613,6 +613,7 @@ export { default as HkColorPicker } from "./components/HkColorPicker";
|
|
|
613
613
|
export { default as HkCopyBadge } from "./components/HkCopyBadge";
|
|
614
614
|
export { default as HkConfirmDialog } from "./components/HkConfirmDialog";
|
|
615
615
|
export { default as HkCrossfade } from "./components/HkCrossfade";
|
|
616
|
+
export { default as HkTransitionContainer } from "./components/HkTransitionContainer";
|
|
616
617
|
export { default as HkDivider } from "./components/HkDivider";
|
|
617
618
|
export { default as HkDiffViewer } from "./components/HkDiffViewer";
|
|
618
619
|
export { parseUnifiedDiff, computeGaps } from "./components/diffParse";
|