@celestia-island/hikari 0.41.9 → 0.42.2

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/DemoApp.tsx +35 -0
  3. package/src/components/HkColorSchemeDialog.test.ts +14 -8
  4. package/src/components/HkColorSchemeEditor.test.ts +35 -11
  5. package/src/components/HkColorSchemeEditor.tsx +54 -31
  6. package/src/components/HkImageLightbox.contract.test.ts +44 -0
  7. package/src/components/HkImageLightbox.scss +34 -1
  8. package/src/components/HkImageViewer.tsx +8 -1
  9. package/src/components/HkLoadMore.scss +53 -0
  10. package/src/components/HkLoadMore.test.tsx +65 -0
  11. package/src/components/HkLoadMore.tsx +72 -0
  12. package/src/components/HkMinimap.scss +10 -0
  13. package/src/components/HkMinimap.toolbarOnly.test.tsx +54 -0
  14. package/src/components/HkMinimap.tsx +44 -35
  15. package/src/components/HkScrollContainer.test.ts +44 -1
  16. package/src/components/HkScrollContainer.tsx +63 -2
  17. package/src/components/HkTag.test.tsx +76 -0
  18. package/src/components/HkTag.tsx +8 -0
  19. package/src/components/HkTagInput.scss +307 -0
  20. package/src/components/HkTagInput.singleScroll.contract.test.ts +73 -0
  21. package/src/components/HkTagInput.test.tsx +1122 -0
  22. package/src/components/HkTagInput.tsx +761 -0
  23. package/src/components/HkThemeToggle.contract.test.ts +28 -0
  24. package/src/components/HkThemeToggle.scss +11 -1
  25. package/src/composables/useApproachEnd.test.ts +261 -0
  26. package/src/composables/useApproachEnd.ts +169 -0
  27. package/src/i18n/locales/ar/components.json +9 -1
  28. package/src/i18n/locales/ar/platform.json +1 -0
  29. package/src/i18n/locales/de/components.json +9 -1
  30. package/src/i18n/locales/de/platform.json +1 -0
  31. package/src/i18n/locales/en/components.json +9 -1
  32. package/src/i18n/locales/en/platform.json +1 -0
  33. package/src/i18n/locales/es/components.json +9 -1
  34. package/src/i18n/locales/es/platform.json +1 -0
  35. package/src/i18n/locales/fr/components.json +9 -1
  36. package/src/i18n/locales/fr/platform.json +1 -0
  37. package/src/i18n/locales/ja/components.json +9 -1
  38. package/src/i18n/locales/ja/platform.json +1 -0
  39. package/src/i18n/locales/ko/components.json +9 -1
  40. package/src/i18n/locales/ko/platform.json +1 -0
  41. package/src/i18n/locales/pt/components.json +9 -1
  42. package/src/i18n/locales/pt/platform.json +1 -0
  43. package/src/i18n/locales/ru/components.json +9 -1
  44. package/src/i18n/locales/ru/platform.json +1 -0
  45. package/src/i18n/locales/zh-Hans/components.json +9 -1
  46. package/src/i18n/locales/zh-Hans/platform.json +1 -0
  47. package/src/i18n/locales/zh-Hant/components.json +9 -1
  48. package/src/i18n/locales/zh-Hant/platform.json +1 -0
  49. package/src/index.ts +4 -0
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h } from "vue";
3
+
4
+ import HkMinimap from "./HkMinimap";
5
+
6
+ const mounts: ReturnType<typeof createApp>[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ afterEach(() => {
10
+ for (const app of mounts.splice(0)) app.unmount();
11
+ for (const el of containers.splice(0)) el.remove();
12
+ });
13
+
14
+ function mountMinimap(props: Record<string, unknown>): HTMLElement {
15
+ const container = document.createElement("div");
16
+ document.body.appendChild(container);
17
+ containers.push(container);
18
+
19
+ const Host = defineComponent({
20
+ setup() {
21
+ return () => h("div", [h(HkMinimap, props)]);
22
+ },
23
+ });
24
+ const app = createApp(Host);
25
+ mounts.push(app);
26
+ app.mount(container);
27
+ return container;
28
+ }
29
+
30
+ describe("HkMinimap toolbar-only form", () => {
31
+ it("renders nothing without boxes, an image, or the toolbar-only flag", () => {
32
+ const c = mountMinimap({ zoomPercent: 100 });
33
+ expect(c.querySelector(".hk-minimap")).toBeNull();
34
+ });
35
+
36
+ it("renders only the zoom bar under toolbarOnly (no overview map)", () => {
37
+ const c = mountMinimap({ zoomPercent: 100, toolbarOnly: true });
38
+ const root = c.querySelector<HTMLElement>(".hk-minimap");
39
+ expect(root).not.toBeNull();
40
+ expect(root!.hasAttribute("data-toolbar-only")).toBe(true);
41
+ expect(root!.querySelector(".hk-minimap-svg")).toBeNull();
42
+ expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
43
+ expect(root!.querySelectorAll(".hk-mm-zoom-btn").length).toBe(2);
44
+ });
45
+
46
+ it("keeps the overview map when toolbarOnly is not set and an image is given", () => {
47
+ const c = mountMinimap({ imageSrc: "data:image/gif;base64,", toolbarOnly: false });
48
+ const root = c.querySelector<HTMLElement>(".hk-minimap");
49
+ expect(root).not.toBeNull();
50
+ expect(root!.hasAttribute("data-toolbar-only")).toBe(false);
51
+ expect(root!.querySelector(".hk-minimap-svg")).not.toBeNull();
52
+ expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
53
+ });
54
+ });
@@ -65,6 +65,12 @@ export default defineComponent({
65
65
  /** Show the reset/fit button in the zoom bar (chest only rendered it
66
66
  * when a reset handler was wired up). */
67
67
  showReset: { type: Boolean, default: false },
68
+ /** Toolbar-only form: render just the zoom bar, no overview map. For
69
+ * hosts whose viewport rect is uninformative at rest (e.g. the image
70
+ * lightbox at fit zoom) but that still want the zoom controls
71
+ * visible; the map is expected to reappear once it has something to
72
+ * say (the viewer switches this off when zoomed past fit). */
73
+ toolbarOnly: { type: Boolean, default: false },
68
74
  /** Optional prop-callback surface (alternative to the emits). */
69
75
  onZoomTo: { type: Function as PropType<(percent: number) => void>, default: undefined },
70
76
  onReset: { type: Function as PropType<() => void>, default: undefined },
@@ -180,7 +186,7 @@ export default defineComponent({
180
186
  });
181
187
 
182
188
  return () => {
183
- if (props.boxes.length === 0 && !props.imageSrc) return null;
189
+ if (props.boxes.length === 0 && !props.imageSrc && !props.toolbarOnly) return null;
184
190
 
185
191
  const s = scale.value;
186
192
  const vr = viewportRect.value;
@@ -213,42 +219,45 @@ export default defineComponent({
213
219
  ref={rootRef}
214
220
  class="hk-minimap"
215
221
  data-dragging={dragging.value ? "" : undefined}
222
+ data-toolbar-only={props.toolbarOnly ? "" : undefined}
216
223
  >
217
- <svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
218
- {imgPos && (
219
- <image
220
- href={props.imageSrc}
221
- x={imgPos[0]}
222
- y={imgPos[1]}
223
- width={ib.w * s}
224
- height={ib.h * s}
225
- preserveAspectRatio="none"
226
- class="hk-mm-image"
224
+ {!props.toolbarOnly && (
225
+ <svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
226
+ {imgPos && (
227
+ <image
228
+ href={props.imageSrc}
229
+ x={imgPos[0]}
230
+ y={imgPos[1]}
231
+ width={ib.w * s}
232
+ height={ib.h * s}
233
+ preserveAspectRatio="none"
234
+ class="hk-mm-image"
235
+ />
236
+ )}
237
+ {boxRects}
238
+ {hubP && (
239
+ <circle
240
+ cx={hubP[0]}
241
+ cy={hubP[1]}
242
+ r={3}
243
+ fill="rgb(var(--color-primary))"
244
+ filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
245
+ />
246
+ )}
247
+ <rect
248
+ x={vr.x}
249
+ y={vr.y}
250
+ width={Math.max(1, vr.w)}
251
+ height={Math.max(1, vr.h)}
252
+ fill="none"
253
+ stroke="rgb(var(--color-primary))"
254
+ stroke-width="1"
255
+ stroke-dasharray="3 2"
256
+ rx="2"
257
+ opacity="0.85"
227
258
  />
228
- )}
229
- {boxRects}
230
- {hubP && (
231
- <circle
232
- cx={hubP[0]}
233
- cy={hubP[1]}
234
- r={3}
235
- fill="rgb(var(--color-primary))"
236
- filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
237
- />
238
- )}
239
- <rect
240
- x={vr.x}
241
- y={vr.y}
242
- width={Math.max(1, vr.w)}
243
- height={Math.max(1, vr.h)}
244
- fill="none"
245
- stroke="rgb(var(--color-primary))"
246
- stroke-width="1"
247
- stroke-dasharray="3 2"
248
- rx="2"
249
- opacity="0.85"
250
- />
251
- </svg>
259
+ </svg>
260
+ )}
252
261
  <div class="hk-mm-zoom-bar">
253
262
  <button
254
263
  class="hk-mm-zoom-btn"
@@ -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} />