@celestia-island/hikari 0.41.9 → 0.42.1

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.41.9",
3
+ "version": "0.42.1",
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",
package/src/DemoApp.tsx CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  HSkeleton, HSkeletonList, HAvatar, HKbd, HDivider,
17
17
  HAlert, HEmptyState, HExpansionPanel,
18
18
  HTabs, HCard, HTable, HTimeline,
19
+ HScrollContainer, HLoadMore,
19
20
  HMediaSlider, HMediaPlayer, HImageViewer,
20
21
  HImagePreview,
21
22
  HZoomToolbar, HMinimap, HTrendChart,
@@ -197,6 +198,24 @@ export default defineComponent({
197
198
  syncZoom();
198
199
  }
199
200
 
201
+ // HLoadMore + HScrollContainer approachEnd — the canonical dynamic
202
+ // loading pair: the container emits `approachEnd` when the content
203
+ // end nears (including the initial under-filled pass), the demo
204
+ // "fetches" a page, and the indicator morphs button → spinner → end.
205
+ const pagedRows = ref(Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`));
206
+ const pagedState = ref<"idle" | "loading" | "end">("idle");
207
+ function loadPagedRows() {
208
+ if (pagedState.value !== "idle") return;
209
+ pagedState.value = "loading";
210
+ setTimeout(() => {
211
+ pagedRows.value = [
212
+ ...pagedRows.value,
213
+ ...Array.from({ length: 10 }, (_, i) => `Row ${pagedRows.value.length + i + 1}`),
214
+ ];
215
+ pagedState.value = pagedRows.value.length >= 60 ? "end" : "idle";
216
+ }, 500);
217
+ }
218
+
200
219
  return () => (
201
220
  <div class="demo">
202
221
  <header class="demo-header">
@@ -225,6 +244,22 @@ export default defineComponent({
225
244
  </div>
226
245
  </section>
227
246
 
247
+ <section>
248
+ <h2>HScrollContainer + HLoadMore</h2>
249
+ <HScrollContainer
250
+ style={{ height: "320px" }}
251
+ approachEnd
252
+ onApproachEnd={loadPagedRows}
253
+ >
254
+ <div style={{ display: "flex", flexDirection: "column" }}>
255
+ {pagedRows.value.map((r) => (
256
+ <div key={r} style={{ padding: "6px 12px", borderBottom: "1px solid rgb(var(--color-muted, 108 108 108) / 18%)" }}>{r}</div>
257
+ ))}
258
+ <HLoadMore state={pagedState.value} shown={pagedRows.value.length} total={60} onLoadMore={loadPagedRows} />
259
+ </div>
260
+ </HScrollContainer>
261
+ </section>
262
+
228
263
  <section>
229
264
  <h2>HIconButton</h2>
230
265
  <div class="row">
@@ -0,0 +1,53 @@
1
+ /*
2
+ * HkLoadMore — the unified bottom indicator for dynamically loaded
3
+ * lists. Hairline rules flank the control so the indicator reads as
4
+ * "the list pauses here" rather than a stray button; everything is
5
+ * token-driven (no hardcoded colors) so themes restyle it for free.
6
+ */
7
+
8
+ .hk-load-more {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: var(--space-12);
12
+ padding: var(--space-12) 0;
13
+ color: rgb(var(--color-text-tertiary));
14
+ }
15
+
16
+ /* Hairline rules: transparent at the outer edge, muted towards the
17
+ * control; the end rule mirrors the gradient. */
18
+ .hk-load-more-rule {
19
+ flex: 1;
20
+ height: 1px;
21
+ background: linear-gradient(to right, transparent, rgb(var(--color-muted) / 35%));
22
+ }
23
+
24
+ .hk-load-more-rule-end {
25
+ background: linear-gradient(to left, transparent, rgb(var(--color-muted) / 35%));
26
+ }
27
+
28
+ .hk-load-more-core {
29
+ flex: none;
30
+ display: inline-flex;
31
+ align-items: center;
32
+ gap: var(--space-10);
33
+ }
34
+
35
+ /* Delivery progress ("3 / 19"): mono, tabular so the digits don't
36
+ * shimmer while counting up. */
37
+ .hk-load-more-count {
38
+ font-family: var(--font-mono);
39
+ font-size: var(--text-2xs);
40
+ font-variant-numeric: tabular-nums;
41
+ letter-spacing: 0.04em;
42
+ color: rgb(var(--color-text-tertiary));
43
+ }
44
+
45
+ .hk-load-more[data-state="loading"] .hk-load-more-count {
46
+ opacity: 0.6;
47
+ }
48
+
49
+ .hk-load-more-end {
50
+ font-size: var(--text-2xs);
51
+ letter-spacing: 0.08em;
52
+ color: rgb(var(--color-text-tertiary));
53
+ }
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkLoadMore from "./HkLoadMore";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mount(node: ReturnType<typeof h>) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({ render: () => node });
12
+ app.mount(container);
13
+ mounts.push({ app, container });
14
+ return container;
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const { app, container } of mounts.splice(0)) {
19
+ app.unmount();
20
+ container.remove();
21
+ }
22
+ });
23
+
24
+ /** The en bundle loads eagerly, so the two-arg t() fallbacks double as
25
+ * the assertions' expected strings — no i18n test setup needed. */
26
+ describe("HkLoadMore", () => {
27
+ it("idle renders the load-more button and the shown/total counter", () => {
28
+ const c = mount(h(HkLoadMore, { shown: 3, total: 19 }));
29
+ const button = c.querySelector("button.hk-btn");
30
+ expect(button).not.toBeNull();
31
+ expect(button!.textContent).toContain("Load more");
32
+ expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("3 / 19");
33
+ expect(c.querySelector(".hk-load-more")!.getAttribute("data-state")).toBe("idle");
34
+ });
35
+
36
+ it("clicking the button emits load-more", () => {
37
+ const fired: string[] = [];
38
+ const c = mount(h(HkLoadMore, { onLoadMore: () => fired.push("more") }));
39
+ (c.querySelector("button.hk-btn") as HTMLButtonElement).click();
40
+ expect(fired).toEqual(["more"]);
41
+ });
42
+
43
+ it("loading state disables the button with aria-busy and the loading label", () => {
44
+ const c = mount(h(HkLoadMore, { state: "loading", shown: 3, total: 19 }));
45
+ const button = c.querySelector("button.hk-btn") as HTMLButtonElement;
46
+ expect(button.disabled).toBe(true);
47
+ expect(button.getAttribute("aria-busy")).toBe("true");
48
+ expect(button.textContent).toContain("Loading…");
49
+ expect(c.querySelector(".hk-spinner")).not.toBeNull();
50
+ });
51
+
52
+ it("end state swaps the button for the reached-the-end status note", () => {
53
+ const c = mount(h(HkLoadMore, { state: "end", shown: 19, total: 19 }));
54
+ expect(c.querySelector("button.hk-btn")).toBeNull();
55
+ const note = c.querySelector('[role="status"]');
56
+ expect(note).not.toBeNull();
57
+ expect(note!.textContent).toContain("You've reached the end");
58
+ expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("19 / 19");
59
+ });
60
+
61
+ it("omits the counter entirely when shown/total are absent", () => {
62
+ const c = mount(h(HkLoadMore));
63
+ expect(c.querySelector(".hk-load-more-count")).toBeNull();
64
+ });
65
+ });
@@ -0,0 +1,72 @@
1
+ import { defineComponent, type PropType } from "vue";
2
+
3
+ import "./HkLoadMore.scss";
4
+ import { useI18n } from "../i18n/context";
5
+ import HButton from "./HkButton";
6
+
7
+ /**
8
+ * The unified bottom indicator for dynamically loaded lists — one look
9
+ * for every consumer (cruise panels, tables, feeds), three states:
10
+ *
11
+ * - `idle`: a ghost load-more button; clicking emits `loadMore`.
12
+ * - `loading`: the SAME button with its built-in spinner (HkButton
13
+ * swaps in an HSpinner while loading) — no second spinner here, the
14
+ * affordance morphs in place at a stable height.
15
+ * - `end`: a muted "reached the end" note replacing the button.
16
+ *
17
+ * Optional `shown`/`total` render a tabular mono counter next to the
18
+ * control in every state ("3 / 19"), giving users the delivery
19
+ * progress without each app inventing its own wording.
20
+ */
21
+ export default defineComponent({
22
+ name: "HkLoadMore",
23
+ props: {
24
+ state: { type: String as PropType<"idle" | "loading" | "end">, default: "idle" },
25
+ /** Delivered item count (with `total`, renders the progress counter). */
26
+ shown: { type: Number, default: undefined },
27
+ /** Full item count behind the windowed list. */
28
+ total: { type: Number, default: undefined },
29
+ },
30
+ emits: ["loadMore"],
31
+ setup(props, { emit }) {
32
+ const { t } = useI18n();
33
+ return () => {
34
+ const hasCount =
35
+ typeof props.shown === "number" && typeof props.total === "number";
36
+ const loading = props.state === "loading";
37
+ return (
38
+ <div class="hk-load-more" data-state={props.state}>
39
+ <span class="hk-load-more-rule" aria-hidden="true" />
40
+ <span class="hk-load-more-core">
41
+ {props.state === "end" ? (
42
+ <span class="hk-load-more-end" role="status">
43
+ {t("hikari::loadMore.end", "You've reached the end")}
44
+ </span>
45
+ ) : (
46
+ <HButton
47
+ variant="ghost"
48
+ size="sm"
49
+ icon="ChevronDown"
50
+ loading={loading}
51
+ aria-label={
52
+ loading
53
+ ? t("hikari::loadMore.loading", "Loading…")
54
+ : t("hikari::loadMore.more", "Load more")
55
+ }
56
+ onClick={() => emit("loadMore")}
57
+ >
58
+ {loading
59
+ ? t("hikari::loadMore.loading", "Loading…")
60
+ : t("hikari::loadMore.more", "Load more")}
61
+ </HButton>
62
+ )}
63
+ {hasCount ? (
64
+ <span class="hk-load-more-count">{props.shown} / {props.total}</span>
65
+ ) : null}
66
+ </span>
67
+ <span class="hk-load-more-rule hk-load-more-rule-end" aria-hidden="true" />
68
+ </div>
69
+ );
70
+ };
71
+ },
72
+ });
@@ -11,6 +11,8 @@ interface Mounted {
11
11
  instance: {
12
12
  refresh: () => void;
13
13
  getOverflow: () => { horizontal: string; vertical: string };
14
+ isNearEnd?: () => boolean;
15
+ recheck?: () => void;
14
16
  } | null;
15
17
  }
16
18
 
@@ -47,7 +49,7 @@ function mountScroller(props: Record<string, unknown> = {}, slotText = "content"
47
49
  /** happy-dom performs no layout, so scroll geometry is stubbed on the
48
50
  * viewport instance (shadowing the prototype getters) before a
49
51
  * refresh() pass re-senses the overflow. */
50
- function stubGeometry(el: HTMLElement, geom: { scrollWidth?: number; clientWidth?: number; scrollLeft?: number }) {
52
+ function stubGeometry(el: HTMLElement, geom: { scrollWidth?: number; clientHeight?: number; clientWidth?: number; scrollLeft?: number; scrollHeight?: number; scrollTop?: number }) {
51
53
  const desc: PropertyDescriptorMap = {};
52
54
  for (const [key, value] of Object.entries(geom)) {
53
55
  desc[key] = { configurable: true, get: () => value };
@@ -158,3 +160,44 @@ describe("HkScrollContainer scrollbar reactivity", () => {
158
160
  expect(root.querySelectorAll(".hk-scrollbar-track").length).toBe(0);
159
161
  });
160
162
  });
163
+
164
+ describe("HkScrollContainer approachEnd", () => {
165
+ /** Two awaited frames: scheduleFrame's scheduling rAF + the composable's
166
+ * onceFrame initial sensing pass. */
167
+ async function flushFrames(): Promise<void> {
168
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
169
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
170
+ }
171
+
172
+ it("emits approachEnd for under-filled content and exposes isNearEnd", async () => {
173
+ const emissions: number[] = [];
174
+ const m = mountScroller({ approachEnd: true, onApproachEnd: () => emissions.push(1) });
175
+ // happy-dom has no layout; a real under-filled DOM reports the
176
+ // CSSOM clamp (scrollHeight == clientHeight), so stub THAT.
177
+ stubGeometry(m.viewport, { scrollHeight: 300, clientHeight: 300, scrollTop: 0 });
178
+ await flushFrames();
179
+ expect(emissions.length).toBe(1);
180
+ expect(m.instance?.isNearEnd?.()).toBe(true);
181
+ });
182
+
183
+ it("does not emit when the prop is off", async () => {
184
+ const emissions: number[] = [];
185
+ mountScroller({ onApproachEnd: () => emissions.push(1) });
186
+ await flushFrames();
187
+ expect(emissions.length).toBe(0);
188
+ });
189
+
190
+ it("exposes recheck and honours it at the container level", async () => {
191
+ const emissions: number[] = [];
192
+ const m = mountScroller({ approachEnd: true, onApproachEnd: () => emissions.push(1) });
193
+ stubGeometry(m.viewport, { scrollHeight: 300, clientHeight: 300, scrollTop: 0 });
194
+ await flushFrames();
195
+ expect(emissions.length).toBe(1);
196
+ // Pin recheck on the exposed surface: dropping it from expose() must
197
+ // fail here, not just at the composable level.
198
+ expect(typeof m.instance?.recheck).toBe("function");
199
+ m.instance?.recheck?.();
200
+ await flushFrames();
201
+ expect(emissions.length).toBe(2);
202
+ });
203
+ });
@@ -12,6 +12,7 @@ import {
12
12
  import { useI18n } from "../i18n/context";
13
13
  import "./HkScrollContainer.scss";
14
14
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
15
+ import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
15
16
  import { provideScrollWindow } from "../composables/useScrollWindow";
16
17
  import { scheduleFrame, notifyScrollStart, onceFrame, type AnimationHandle } from "../runtime/animationBus";
17
18
  import HFab from "./HkFab";
@@ -61,8 +62,25 @@ export default defineComponent({
61
62
  * scrolled away from the bottom, float a small jump-back FAB that
62
63
  * snaps to the latest content and re-arms follow. */
63
64
  followAffordance: { type: Boolean, default: false },
65
+ /** Additive opt-in: sense "the viewport is at/near the end of its
66
+ * content" and emit `approachEnd` so the consumer can load the
67
+ * next page of data (infinite/dynamic loading). The event fires on
68
+ * zone entry and whenever the content geometry grows while still
69
+ * in the zone — including every pass while the content does not
70
+ * fill the viewport (CSSOM clamps scrollHeight there, so the
71
+ * under-filled state always reports; that is what lets a consumer
72
+ * auto-fill a first screen). Fire-and-dedup contract: guard the
73
+ * handler with your own loading flag; when a load renders nothing
74
+ * new (all filtered out downstream), call the exposed `recheck()`
75
+ * to force the next emission. */
76
+ approachEnd: { type: Boolean, default: false },
77
+ /** Distance in px from the vertical end that still counts as
78
+ * approaching (`approachEnd`). Read live, so runtime changes need
79
+ * no remount. */
80
+ approachDistance: { type: Number, default: 160 },
64
81
  },
65
- setup(props, { slots, expose }) {
82
+ emits: { approachEnd: () => true },
83
+ setup(props, { slots, expose, emit }) {
66
84
  const { t } = useI18n();
67
85
  const viewportRef = ref<HTMLElement>();
68
86
  let ro: ResizeObserver | null = null;
@@ -87,6 +105,31 @@ export default defineComponent({
87
105
  let followRO: ResizeObserver | null = null;
88
106
  const showAutoTag = computed(() => props.autoFollow && props.scrollbar && pinned.value);
89
107
 
108
+ // End-approach sensing (infinite loading): the composable owns the
109
+ // scroll/resize/mutation sensors; this component only owns the
110
+ // lifecycle and forwards the emission. `distance` is passed as a
111
+ // getter so runtime approachDistance changes need no restart.
112
+ const approachHandle = shallowRef<ApproachEndHandle | null>(null);
113
+
114
+ function startApproach() {
115
+ if (approachHandle.value) return;
116
+ approachHandle.value = useApproachEnd(
117
+ viewportRef as unknown as import("vue").Ref<HTMLElement | null>,
118
+ () => emit("approachEnd"),
119
+ { distance: () => props.approachDistance },
120
+ );
121
+ }
122
+
123
+ function stopApproach() {
124
+ approachHandle.value?.stop();
125
+ approachHandle.value = null;
126
+ }
127
+
128
+ watch(() => props.approachEnd, (on) => {
129
+ if (on) startApproach();
130
+ else stopApproach();
131
+ }, { flush: "post" });
132
+
90
133
  if (props.mode === "windowed") {
91
134
  provideScrollWindow(viewportRef as unknown as import("vue").Ref<HTMLElement | null>, props.overscanScreens);
92
135
  }
@@ -255,6 +298,8 @@ export default defineComponent({
255
298
  mountScrollbars();
256
299
  }
257
300
 
301
+ if (props.approachEnd) startApproach();
302
+
258
303
  vp.addEventListener("scroll", onScroll, { passive: true });
259
304
  vp.addEventListener("wheel", onWheel, { passive: false });
260
305
 
@@ -287,6 +332,7 @@ export default defineComponent({
287
332
  }
288
333
  overlay?.detach();
289
334
  overlay = null;
335
+ stopApproach();
290
336
  scheduled?.disconnect();
291
337
  settleHandle?.disconnect();
292
338
  settleHandle = null;
@@ -341,7 +387,22 @@ export default defineComponent({
341
387
  };
342
388
  }
343
389
 
344
- expose({ scrollTo, scrollToElement, getScrollElement, getScrollTop, refresh, getOverflow });
390
+ /** True while the viewport sits within `approachDistance` of its
391
+ * vertical end (only meaningful with `approachEnd`). */
392
+ function isNearEnd(): boolean {
393
+ return approachHandle.value?.isNearEnd() ?? false;
394
+ }
395
+
396
+ /** Force one end-approach sensing pass that ignores the
397
+ * same-geometry dedup. Call after a load that may still leave the
398
+ * content under-filled — while under-filled the sensor fires on
399
+ * its own, but a consumer whose load rendered nothing new (all
400
+ * filtered out downstream) needs this to keep the walk going. */
401
+ function recheck(): void {
402
+ approachHandle.value?.recheck();
403
+ }
404
+
405
+ expose({ scrollTo, scrollToElement, getScrollElement, getScrollTop, refresh, getOverflow, isNearEnd, recheck });
345
406
 
346
407
  return () => {
347
408
  const Tag = props.as as "div" | "section" | "nav" | "main" | "aside";
@@ -0,0 +1,76 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, type VNode } from "vue";
3
+
4
+ import HkTag from "./HkTag";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mountTag(props: Record<string, unknown> = {}, children?: () => VNode) {
9
+ const events = { close: 0 };
10
+ const container = document.createElement("div");
11
+ document.body.appendChild(container);
12
+ const app = createApp({
13
+ render: () =>
14
+ h(
15
+ HkTag,
16
+ {
17
+ ...props,
18
+ onClose: () => {
19
+ events.close++;
20
+ },
21
+ },
22
+ children ? { default: children } : undefined,
23
+ ),
24
+ });
25
+ app.mount(container);
26
+ mounts.push({ app, container });
27
+ return { events, container };
28
+ }
29
+
30
+ afterEach(() => {
31
+ for (const { app, container } of mounts.splice(0)) {
32
+ app.unmount();
33
+ container.remove();
34
+ }
35
+ document.body.innerHTML = "";
36
+ });
37
+
38
+ describe("HkTag", () => {
39
+ it("renders slot content without a close button by default", () => {
40
+ const { container } = mountTag({}, () => h("span", "Default"));
41
+ const tag = container.querySelector(".hk-tag")!;
42
+ expect(tag.textContent).toBe("Default");
43
+ expect(tag.querySelector(".hk-tag-close")).toBeNull();
44
+ });
45
+
46
+ it("closable renders the × which emits close", () => {
47
+ const { events, container } = mountTag({ closable: true }, () => h("span", "Closable"));
48
+ const close = container.querySelector<HTMLButtonElement>(".hk-tag-close")!;
49
+ expect(close).toBeTruthy();
50
+ // The pre-prop markup: an unlabeled button, exactly as before.
51
+ expect(close.hasAttribute("aria-label")).toBe(false);
52
+ close.click();
53
+ expect(events.close).toBe(1);
54
+ });
55
+
56
+ it("closeLabel names the close button for screen readers", () => {
57
+ const { events, container } = mountTag(
58
+ { closable: true, closeLabel: "Remove — News" },
59
+ () => h("span", "News"),
60
+ );
61
+ const close = container.querySelector<HTMLButtonElement>(".hk-tag-close")!;
62
+ expect(close.getAttribute("aria-label")).toBe("Remove — News");
63
+ close.click();
64
+ expect(events.close).toBe(1);
65
+ });
66
+
67
+ it("keeps the variant and size classes untouched", () => {
68
+ const { container } = mountTag(
69
+ { variant: "success", size: "sm", closable: true, closeLabel: "Remove" },
70
+ () => h("span", "Done"),
71
+ );
72
+ const tag = container.querySelector(".hk-tag")!;
73
+ expect(tag.classList.contains("hk-tag-success")).toBe(true);
74
+ expect(tag.classList.contains("hk-tag-sm")).toBe(true);
75
+ });
76
+ });
@@ -11,6 +11,13 @@ export default defineComponent({
11
11
  variant: { type: String as PropType<TagVariant>, default: "default" },
12
12
  size: { type: String as PropType<"sm" | "md">, default: "md" },
13
13
  closable: { type: Boolean, default: false },
14
+ /**
15
+ * Accessible name of the close button, rendered as its `aria-label`.
16
+ * Undefined (the default) renders exactly the pre-prop markup, so
17
+ * existing consumers are untouched; tag fields that can carry several
18
+ * chips side by side set it to name the entry each × removes.
19
+ */
20
+ closeLabel: { type: String, default: undefined },
14
21
  },
15
22
  emits: {
16
23
  close: () => true,
@@ -34,6 +41,7 @@ export default defineComponent({
34
41
  <button
35
42
  type="button"
36
43
  class="hk-tag-close"
44
+ aria-label={props.closeLabel}
37
45
  onClick={() => emit("close")}
38
46
  >
39
47
  <X size={12} />