@celestia-island/hikari 0.55.321 → 0.55.322

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.321",
3
+ "version": "0.55.322",
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",
@@ -1,4 +1,4 @@
1
- import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, type Component, type PropType } from "vue";
1
+ import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, ref, type Component, type PropType } from "vue";
2
2
  import { MoreHorizontal } from "lucide-vue-next";
3
3
 
4
4
  import HkCard from "./HkCard";
@@ -9,6 +9,7 @@ import HkTooltip from "./HkTooltip";
9
9
  import { hasRenderedContent } from "../runtime/hasRenderedContent";
10
10
  import { useMediaQuery } from "../runtime/useMediaQuery";
11
11
  import type { StatTone } from "./HkStatCard";
12
+ import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
12
13
  import "./HkCardList.scss";
13
14
 
14
15
  /**
@@ -43,7 +44,43 @@ const renderedRows = hasRenderedContent;
43
44
  */
44
45
  export const HkCardList = defineComponent({
45
46
  name: "HkCardList",
46
- setup(_props, { slots }) {
47
+ props: {
48
+ /** Authoritative row total the scroll-progress pill uses as its
49
+ * denominator (the server count behind a windowed/infinite list).
50
+ * Forwarded to the nearest enclosing HkScrollContainer with
51
+ * `scrollProgress` on, exactly like HkAdminTablePage's `total`.
52
+ * Omitted: the pill falls back to counting the delivered DOM rows
53
+ * (right for fully-delivered lists, drifting low for windowed
54
+ * ones — pass the server count there). */
55
+ total: { type: Number, default: undefined },
56
+ },
57
+ setup(props, { slots }) {
58
+ // Scroll-progress registration (the HkTable precedent): the NEAREST
59
+ // enclosing HkScrollContainer with `scrollProgress` on provides the
60
+ // registrar — provide/inject scoping resolves the admin shell's
61
+ // viewport for console pages and keeps a list registered against an
62
+ // inner container out of the shell. The handle hands the pill the
63
+ // list element as the row root, the `total` prop live, and the
64
+ // rendered HkListRow elements as the rows (a card list's rows ARE
65
+ // its data rows — no section-band caveat).
66
+ const listRef = ref<HTMLElement | null>(null);
67
+ const progressRegistrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
68
+ const progressHandle: HkScrollProgressHandle = {
69
+ getRoot: () => listRef.value,
70
+ getTotal: () => props.total,
71
+ getRows: () =>
72
+ listRef.value
73
+ ? Array.from(listRef.value.children).filter((el) =>
74
+ el.classList.contains("hk-list-row"),
75
+ )
76
+ : [],
77
+ };
78
+ onMounted(() => {
79
+ progressRegistrar?.register(progressHandle);
80
+ });
81
+ onBeforeUnmount(() => {
82
+ progressRegistrar?.unregister(progressHandle);
83
+ });
47
84
  return () => {
48
85
  const rows = slots.default?.();
49
86
  return (
@@ -51,7 +88,7 @@ export const HkCardList = defineComponent({
51
88
  {slots.empty && !renderedRows(rows) ? (
52
89
  slots.empty()
53
90
  ) : (
54
- <div class="hk-card-list" role="list">
91
+ <div class="hk-card-list" role="list" ref={listRef}>
55
92
  {rows}
56
93
  </div>
57
94
  )}
@@ -12,7 +12,12 @@ import { createApp, defineComponent, h, inject, nextTick, onMounted, onUnmounted
12
12
  import HkScrollContainer from "./HkScrollContainer";
13
13
  import { HkAdminTablePage } from "./HkAdminTablePage";
14
14
  import HTable from "./HkTable";
15
- import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressRegistrar } from "./HkScrollProgress";
15
+ import { HkCardList, HkListRow } from "./HkCardList";
16
+ import {
17
+ HK_SCROLL_PROGRESS_REGISTRAR,
18
+ type HkScrollProgressHandle,
19
+ type HkScrollProgressRegistrar,
20
+ } from "./HkScrollProgress";
16
21
 
17
22
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
18
23
  const rects: Array<ReturnType<typeof vi.spyOn>> = [];
@@ -485,6 +490,149 @@ describe("HkAdminTablePage scroll-progress registration", () => {
485
490
  });
486
491
 
487
492
 
493
+ describe("HkCardList scroll-progress registration", () => {
494
+ it("registers its list root, rows and total with the provided registrar", async () => {
495
+ const registered: Array<{ getRoot: () => HTMLElement | null; getTotal: () => number | undefined; getRows?: () => readonly Element[] | undefined }> = [];
496
+ const unregistered: unknown[] = [];
497
+ const registrar: HkScrollProgressRegistrar = {
498
+ register: (handle) => registered.push(handle),
499
+ unregister: (handle) => unregistered.push(handle),
500
+ };
501
+ const Provider = defineComponent({
502
+ setup() {
503
+ provide(HK_SCROLL_PROGRESS_REGISTRAR, registrar);
504
+ return () =>
505
+ h(HkCardList, { total: 42 }, {
506
+ default: () => [
507
+ h(HkListRow, { key: "a", title: "a" }),
508
+ h(HkListRow, { key: "b", title: "b" }),
509
+ // A non-row child (a bare div a host slipped in) must not
510
+ // count as a data row — getRows filters to .hk-list-row.
511
+ h("div", { key: "spacer", class: "h-2" }),
512
+ ],
513
+ });
514
+ },
515
+ });
516
+ const container = mountTree(h(Provider));
517
+ expect(registered.length).toBe(1);
518
+ expect(registered[0].getTotal()).toBe(42);
519
+ // The list element is the row root — the element containing the rows.
520
+ const root = registered[0].getRoot()!;
521
+ expect(root).not.toBeNull();
522
+ expect(root.classList.contains("hk-card-list")).toBe(true);
523
+ expect(root.querySelectorAll(".hk-list-row")).toHaveLength(2);
524
+ // getRows hands the pill the HkListRow children only (the spacer
525
+ // div is excluded) — the section-band precision HkTable carries,
526
+ // mirrored for card lists.
527
+ const rows = registered[0].getRows!();
528
+ expect(rows).toBeDefined();
529
+ expect(rows!).toHaveLength(2);
530
+ expect(rows!.every((el) => el.classList.contains("hk-list-row"))).toBe(true);
531
+
532
+ // Unmount unregisters the SAME handle.
533
+ mounts[0].app.unmount();
534
+ expect(unregistered.length).toBe(1);
535
+ expect(unregistered[0]).toBe(registered[0]);
536
+ });
537
+
538
+ it("keeps the handle honest across the empty-slot lifecycle (rows→empty→rows)", async () => {
539
+ // The list element unmounts when the empty slot renders: the handle
540
+ // must read a NULL root (the pill's hidden precondition — never a
541
+ // stale detached element), and a re-render must recover a fresh
542
+ // connected list under the SAME handle.
543
+ const registered: Array<HkScrollProgressHandle> = [];
544
+ const registrar: HkScrollProgressRegistrar = {
545
+ register: (handle) => registered.push(handle),
546
+ unregister: () => {},
547
+ };
548
+ const hasRows = ref(true);
549
+ const Provider = defineComponent({
550
+ setup() {
551
+ provide(HK_SCROLL_PROGRESS_REGISTRAR, registrar);
552
+ return () =>
553
+ h(HkCardList, { total: 9 }, {
554
+ default: () => (hasRows.value ? [h(HkListRow, { key: "a", title: "a" })] : []),
555
+ empty: () => h("div", {}, "empty"),
556
+ });
557
+ },
558
+ });
559
+ mountTree(h(Provider));
560
+ const handle = registered[0]!;
561
+ expect(handle.getRoot()).not.toBeNull();
562
+ expect(handle.getRows!()).toHaveLength(1);
563
+
564
+ // Rows → empty: the ref clears with the element (no stale root).
565
+ hasRows.value = false;
566
+ await nextTick();
567
+ expect(handle.getRoot()).toBeNull();
568
+ expect(handle.getRows!()).toHaveLength(0);
569
+ expect(handle.getTotal()).toBe(9); // the prop stays live
570
+
571
+ // Empty → rows: the same handle recovers a fresh connected list.
572
+ hasRows.value = true;
573
+ await nextTick();
574
+ const root = handle.getRoot()!;
575
+ expect(root).not.toBeNull();
576
+ expect(root.isConnected).toBe(true);
577
+ expect(root.classList.contains("hk-card-list")).toBe(true);
578
+ expect(handle.getRows!()).toHaveLength(1);
579
+ });
580
+
581
+ it("renders its rows into the shell viewport's pill denominator end-to-end", async () => {
582
+ // The real provide/inject path: a card list inside a
583
+ // scrollProgress-on container registers itself, and the pill's
584
+ // denominator is the list's declared total (not the delivered rows)
585
+ // — the same end-to-end contract the table page carries.
586
+ const list = h(HkCardList, {
587
+ total: 500,
588
+ }, {
589
+ default: () => Array.from({ length: 30 }, (_, i) =>
590
+ h(HkListRow, { key: i, title: `r${i}` })),
591
+ });
592
+ const instance = ref<{ refresh: () => void } | null>(null);
593
+ let scrollTop = 0;
594
+ const Wrapper = defineComponent({
595
+ setup() {
596
+ return () =>
597
+ h(HkScrollContainer, { ref: instance as never, scrollProgress: true }, { default: () => list });
598
+ },
599
+ });
600
+ const container = mountTree(h(Wrapper));
601
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
602
+ Object.defineProperties(viewport, {
603
+ scrollHeight: { configurable: true, get: () => 4000 },
604
+ clientHeight: { configurable: true, get: () => 400 },
605
+ scrollWidth: { configurable: true, get: () => 300 },
606
+ clientWidth: { configurable: true, get: () => 300 },
607
+ scrollLeft: { configurable: true, get: () => 0 },
608
+ scrollTop: { configurable: true, get: () => scrollTop },
609
+ });
610
+ rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
611
+ left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
612
+ } as DOMRect));
613
+ const listRoot = container.querySelector<HTMLElement>(".hk-card-list")!;
614
+ expect(listRoot).not.toBeNull();
615
+ rects.push(vi.spyOn(listRoot, "getBoundingClientRect").mockReturnValue({
616
+ left: 0, top: -200, right: 300, bottom: 3800, width: 300, height: 4000, x: 0, y: -200,
617
+ } as DOMRect));
618
+ container.querySelectorAll<HTMLElement>(".hk-list-row").forEach((row, i) => {
619
+ const top = i * 40 - 200;
620
+ rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
621
+ left: 0, top, right: 300, bottom: top + 40, width: 300, height: 40, x: 0, y: top,
622
+ } as DOMRect));
623
+ });
624
+ scrollTop = 200;
625
+ instance.value!.refresh();
626
+ await nextTick();
627
+ const pill = container.querySelector<HTMLElement>(".hk-scroll-progress-pill");
628
+ expect(pill).not.toBeNull();
629
+ // First visible row #6 over the DECLARED total — the delivered 30
630
+ // rows never override the list's server count.
631
+ expect(pill!.textContent).toBe("6 / 500");
632
+ });
633
+ });
634
+
635
+
488
636
  // ── SCSS contract (textual pins — happy-dom does not lay out) ────────
489
637
  // The house style pins sheet claims with readFileSync probes
490
638
  // (HkButton.test.tsx icon-only precedent): these guard the paint