@celestia-island/hikari 0.51.2 → 0.51.3

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.51.2",
3
+ "version": "0.51.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,92 @@
1
+ // Dock-slot contract for HkScrollContainer.
2
+ //
3
+ // A dock (dockTop / dockBottom named slot) renders as a NON-scrolling
4
+ // flex sibling of the viewport: pinned to its edge, occupying layout
5
+ // space (the flex: 1 viewport shrinks by the dock's height — content
6
+ // can never slide under it). The container measures each mounted dock
7
+ // and publishes --hk-scroll-dock-top / --hk-scroll-dock-bottom on the
8
+ // host so consumers size their content end-padding against the dock
9
+ // without hand-rolled px. The dock-edge fade rides on data-dock-fade.
10
+ //
11
+ // House style: raw createApp mounts on shared containers (no
12
+ // @vue/test-utils).
13
+ import { afterEach, describe, expect, it } from "vitest";
14
+ import { createApp, h, defineComponent } from "vue";
15
+
16
+ import HkScrollContainer from "./HkScrollContainer";
17
+
18
+ const mounts: ReturnType<typeof createApp>[] = [];
19
+ const containers: HTMLElement[] = [];
20
+
21
+ function mountDock(slots: Record<string, () => any>, props: Record<string, unknown> = {}) {
22
+ const container = document.createElement("div");
23
+ document.body.appendChild(container);
24
+ containers.push(container);
25
+ const app = createApp(defineComponent({
26
+ setup() {
27
+ return () => h(HkScrollContainer, props, slots);
28
+ },
29
+ }));
30
+ mounts.push(app);
31
+ return app.mount(container);
32
+ }
33
+
34
+ afterEach(() => {
35
+ for (const app of mounts) app.unmount();
36
+ mounts.length = 0;
37
+ for (const el of containers) el.remove();
38
+ containers.length = 0;
39
+ });
40
+
41
+ describe("HkScrollContainer dock slots", () => {
42
+ it("renders a bottom dock as a non-scrolling sibling with the fade marker", () => {
43
+ const vm = mountDock({
44
+ default: () => h("div", { class: "content" }, "rows"),
45
+ dockBottom: () => h("div", { class: "the-dock" }, "dock"),
46
+ });
47
+ const host = vm.$el as HTMLElement;
48
+ const dock = host.querySelector(".hk-scroll-dock[data-side='bottom']");
49
+ expect(dock).toBeTruthy();
50
+ // Sibling of the viewport, not inside it — the dock never scrolls.
51
+ const viewport = host.querySelector(".hk-scroll-container-viewport")!;
52
+ expect(viewport.contains(dock as Node)).toBe(false);
53
+ // The dock-edge fade engages by default for a bottom dock.
54
+ expect(host.getAttribute("data-dock-fade")).toBe("bottom");
55
+ // The measured-height vars are published on the host (0px in
56
+ // happy-dom — no layout; the contract is the vars exist + RO wired).
57
+ expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("0px");
58
+ expect(host.style.getPropertyValue("--hk-scroll-dock-top")).toBe("0px");
59
+ });
60
+
61
+ it("renders a top dock and fades the top edge instead", () => {
62
+ const vm = mountDock({
63
+ default: () => h("div", { class: "content" }, "rows"),
64
+ dockTop: () => h("div", { class: "the-dock" }, "progress"),
65
+ });
66
+ const host = vm.$el as HTMLElement;
67
+ expect(host.querySelector(".hk-scroll-dock[data-side='top']")).toBeTruthy();
68
+ expect(host.getAttribute("data-dock-fade")).toBe("top");
69
+ expect(host.querySelector(".hk-scroll-dock[data-side='bottom']")).toBeNull();
70
+ });
71
+
72
+ it("omits the dock fade marker when dockFade is off", () => {
73
+ const vm = mountDock(
74
+ {
75
+ default: () => h("div", { class: "content" }, "rows"),
76
+ dockBottom: () => h("div", { class: "the-dock" }, "dock"),
77
+ },
78
+ { dockFade: false },
79
+ );
80
+ const host = vm.$el as HTMLElement;
81
+ expect(host.querySelector(".hk-scroll-dock[data-side='bottom']")).toBeTruthy();
82
+ expect(host.getAttribute("data-dock-fade")).toBeNull();
83
+ });
84
+
85
+ it("publishes no dock marker without dock slots", () => {
86
+ const vm = mountDock({ default: () => h("div", { class: "content" }, "rows") });
87
+ const host = vm.$el as HTMLElement;
88
+ expect(host.querySelector(".hk-scroll-dock")).toBeNull();
89
+ expect(host.getAttribute("data-dock-fade")).toBeNull();
90
+ expect(host.style.getPropertyValue("--hk-scroll-dock-bottom")).toBe("");
91
+ });
92
+ });
@@ -53,6 +53,38 @@
53
53
  mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
54
54
  }
55
55
 
56
+ /* ── Anchored docks (dockTop / dockBottom slots) ─────────────────
57
+ A dock renders as a NON-scrolling flex sibling of the viewport: it
58
+ stays pinned to its edge, never scrolls, and occupies layout space —
59
+ the flex: 1 viewport shrinks by the dock's height, so the content's
60
+ scroll end lands exactly at the dock's edge and can never slide
61
+ under it. The container measures each mounted dock (ResizeObserver)
62
+ and publishes --hk-scroll-dock-top / --hk-scroll-dock-bottom on the
63
+ host, so a consumer's content end-padding clears the dock with
64
+ breathing room (padding-bottom: calc(var(--hk-scroll-dock-bottom)
65
+ + 2rem)) instead of hand-rolled px guesses that go stale when the
66
+ dock reflows. */
67
+ .hk-scroll-dock {
68
+ flex: none;
69
+ min-height: 0;
70
+ }
71
+
72
+ /* Dock-edge fade: mid-scroll content dissolving at the dock's edge
73
+ (opacity 100 -> 0 across --hk-scroll-dock-fade) reads as intentional
74
+ instead of a hard clip against the dock's top edge. Content never
75
+ sits under the dock, so this fades the viewport's last few pixels,
76
+ not the dock itself. */
77
+ .hk-scroll-container[data-dock-fade="bottom"] > .hk-scroll-container-viewport {
78
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hk-scroll-dock-fade, 2rem)), transparent);
79
+ mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hk-scroll-dock-fade, 2rem)), transparent);
80
+ }
81
+
82
+ .hk-scroll-container[data-dock-fade="top"] > .hk-scroll-container-viewport {
83
+ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--hk-scroll-dock-fade, 2rem));
84
+ mask-image: linear-gradient(to bottom, transparent, #000 var(--hk-scroll-dock-fade, 2rem));
85
+ }
86
+
87
+
56
88
  .hk-scroll-container[data-fade="true"][data-h-overflow="start"] > .hk-scroll-container-viewport {
57
89
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
58
90
  mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
@@ -80,12 +80,21 @@ export default defineComponent({
80
80
  * approaching (`approachEnd`). Read live, so runtime changes need
81
81
  * no remount. */
82
82
  approachDistance: { type: Number, default: 160 },
83
+ /** Fade the viewport's bottom edge (CSS mask, --hk-scroll-dock-fade
84
+ * tall, opacity 100 -> 0) while a dock slot is mounted, so mid-
85
+ * scroll content dissolving at the dock's edge reads as
86
+ * intentional instead of a hard clip. */
87
+ dockFade: { type: Boolean, default: true },
83
88
  },
84
89
  emits: { approachEnd: () => true },
85
90
  setup(props, { slots, expose, emit }) {
86
91
  const { t } = useI18n();
87
92
  const viewportRef = ref<HTMLElement>();
93
+ const hostRef = ref<HTMLElement>();
94
+ const dockTopRef = ref<HTMLElement>();
95
+ const dockBottomRef = ref<HTMLElement>();
88
96
  let ro: ResizeObserver | null = null;
97
+ let dockRO: ResizeObserver | null = null;
89
98
  let scheduled: AnimationHandle | null = null;
90
99
  // Overlay track/thumb machinery lives in the shared composable;
91
100
  // this component keeps overflow sensing, autoFollow, the aligner,
@@ -292,10 +301,40 @@ export default defineComponent({
292
301
  }
293
302
  }
294
303
 
304
+ /** Publish each mounted dock's measured height as a host-level custom
305
+ * property (`--hk-scroll-dock-top` / `--hk-scroll-dock-bottom`). The
306
+ * dock is a non-scrolling flex sibling, so the viewport already
307
+ * shrinks by its height; the published var exists so a consumer's
308
+ * content end-padding can clear the dock with breathing room
309
+ * (padding-bottom: calc(var(--hk-scroll-dock-bottom, 0px) + 2rem))
310
+ * instead of hand-rolled px guesses that go stale when the dock
311
+ * reflows. ResizeObserver keeps it live across dock reflows. */
312
+ function publishDockSizes() {
313
+ const host = hostRef.value;
314
+ if (!host) return;
315
+ const top = dockTopRef.value;
316
+ const bottom = dockBottomRef.value;
317
+ host.style.setProperty(
318
+ "--hk-scroll-dock-top",
319
+ top ? `${Math.round(top.getBoundingClientRect().height)}px` : "0px",
320
+ );
321
+ host.style.setProperty(
322
+ "--hk-scroll-dock-bottom",
323
+ bottom ? `${Math.round(bottom.getBoundingClientRect().height)}px` : "0px",
324
+ );
325
+ }
326
+
295
327
  onMounted(() => {
296
328
  const vp = viewportRef.value;
297
329
  if (!vp) return;
298
330
 
331
+ if (dockTopRef.value || dockBottomRef.value) {
332
+ dockRO = new ResizeObserver(publishDockSizes);
333
+ if (dockTopRef.value) dockRO.observe(dockTopRef.value);
334
+ if (dockBottomRef.value) dockRO.observe(dockBottomRef.value);
335
+ publishDockSizes();
336
+ }
337
+
299
338
  if (props.scrollbar) {
300
339
  mountScrollbars();
301
340
  }
@@ -340,6 +379,8 @@ export default defineComponent({
340
379
  settleHandle = null;
341
380
  ro?.disconnect();
342
381
  ro = null;
382
+ dockRO?.disconnect();
383
+ dockRO = null;
343
384
  followRO?.disconnect();
344
385
  followRO = null;
345
386
  alignRO?.disconnect();
@@ -431,13 +472,34 @@ export default defineComponent({
431
472
  if (alignCenter()) {
432
473
  content = <div ref={setAligner} class="hk-scroll-container-aligner">{content}</div>;
433
474
  }
475
+ // Anchored dock slots: a dock renders as a NON-scrolling flex
476
+ // sibling of the viewport — it stays pinned to its edge, never
477
+ // scrolls, and occupies layout space (the flex: 1 viewport shrinks
478
+ // by the dock's height). The container measures each mounted dock
479
+ // and publishes --hk-scroll-dock-top/bottom so consumers can size
480
+ // their content end-padding against the dock without guessing px.
481
+ const dockTopSlot = slots.dockTop;
482
+ const dockBottomSlot = slots.dockBottom;
434
483
  return (
435
484
  <Tag
485
+ ref={hostRef}
436
486
  class="hk-scroll-container"
437
487
  data-axis={props.axis}
438
488
  data-align={alignCenter() ? "center" : undefined}
439
489
  data-fade={props.fade ? "true" : undefined}
490
+ data-dock-fade={
491
+ props.dockFade && dockBottomSlot
492
+ ? "bottom"
493
+ : props.dockFade && dockTopSlot
494
+ ? "top"
495
+ : undefined
496
+ }
440
497
  >
498
+ {dockTopSlot && (
499
+ <div ref={dockTopRef} class="hk-scroll-dock" data-side="top">
500
+ {dockTopSlot()}
501
+ </div>
502
+ )}
441
503
  {/* Scroll-pin host marker: the viewport participates in the pin
442
504
  contract with its live axis; generic containers declare no
443
505
  standard padding, so pins inside resolve their strategy from
@@ -449,6 +511,11 @@ export default defineComponent({
449
511
  >
450
512
  {content}
451
513
  </div>
514
+ {dockBottomSlot && (
515
+ <div ref={dockBottomRef} class="hk-scroll-dock" data-side="bottom">
516
+ {dockBottomSlot()}
517
+ </div>
518
+ )}
452
519
  {showAutoTag.value && (
453
520
  <span class="hk-scroll-container-autotag" aria-hidden="true">{t("hikari::scrollContainer.auto", "Auto")}</span>
454
521
  )}