@celestia-island/hikari 0.55.196 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.196",
3
+ "version": "0.55.197",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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
- {props.renderPanels && !isSegmented.value && props.tabs.filter((tab) => !isMergedTab(tab)).map((tab) => (
557
- <div
558
- key={tab.key}
559
- role="tabpanel"
560
- class="hk-tabs-panel"
561
- data-active={(tab.key === props.modelValue) || undefined}
562
- >
563
- {tab.key === props.modelValue && slots[tab.key]?.()}
564
- </div>
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