@celestia-island/hikari 0.55.20 → 0.55.21

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.20",
3
+ "version": "0.55.21",
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",
@@ -248,3 +248,156 @@ describe("HkScrollContainer approachEnd", () => {
248
248
  expect(emissions.length).toBe(2);
249
249
  });
250
250
  });
251
+
252
+ // ── Back-to-top signal (scroll parameter every template can hand to a
253
+ // floating quick-action pad) — sensed on the container's own throttled
254
+ // pass; HkWaterfall forwards these thresholds and mirrors the emit. ──
255
+ describe("HkScrollContainer back-to-top signal", () => {
256
+ async function flushFrames(): Promise<void> {
257
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
258
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
259
+ }
260
+
261
+ it("emits update:backTopVisible with the SHOW/HIDE hysteresis band", async () => {
262
+ const container = document.createElement("div");
263
+ document.body.appendChild(container);
264
+ containers.push(container);
265
+
266
+ const emitted: boolean[] = [];
267
+ const instance = ref<{ backTopVisible?: boolean } | null>(null);
268
+ const Wrapper = defineComponent({
269
+ setup() {
270
+ return () =>
271
+ h(HkScrollContainer, {
272
+ ref: instance as never,
273
+ backTopShow: 360,
274
+ backTopHide: 240,
275
+ "onUpdate:backTopVisible": (v: boolean) => emitted.push(v),
276
+ }, { default: () => h("span", "content") });
277
+ },
278
+ });
279
+ const app = createApp(Wrapper);
280
+ mounts.push(app);
281
+ app.mount(container);
282
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
283
+ if (!viewport) throw new Error("no viewport");
284
+
285
+ const scrollTo = async (top: number) => {
286
+ stubGeometry(viewport, { scrollTop: top, scrollHeight: 5000, clientHeight: 400 });
287
+ viewport.dispatchEvent(new Event("scroll"));
288
+ await flushFrames();
289
+ };
290
+
291
+ await scrollTo(0);
292
+ expect(emitted).toEqual([]); // below: never emits
293
+ await scrollTo(300);
294
+ expect(emitted).toEqual([]); // inside the band, was hidden: stays hidden
295
+ await scrollTo(400);
296
+ expect(emitted).toEqual([true]); // past SHOW: emits true
297
+ emitted.length = 0;
298
+ await scrollTo(300);
299
+ expect(emitted).toEqual([]); // inside the band, was visible: stays visible
300
+ await scrollTo(200);
301
+ expect(emitted).toEqual([false]); // below HIDE: emits false
302
+ app.unmount();
303
+ });
304
+
305
+ it("exposes the sensed state as backTopVisible", async () => {
306
+ const container = document.createElement("div");
307
+ document.body.appendChild(container);
308
+ containers.push(container);
309
+ const instance = ref<{ backTopVisible?: boolean } | null>(null);
310
+ const Wrapper = defineComponent({
311
+ setup() {
312
+ return () =>
313
+ h(HkScrollContainer, {
314
+ ref: instance as never,
315
+ backTopShow: 100,
316
+ }, { default: () => h("span", "content") });
317
+ },
318
+ });
319
+ const app = createApp(Wrapper);
320
+ mounts.push(app);
321
+ app.mount(container);
322
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
323
+ if (!viewport) throw new Error("no viewport");
324
+
325
+ stubGeometry(viewport, { scrollTop: 500, scrollHeight: 5000, clientHeight: 400 });
326
+ viewport.dispatchEvent(new Event("scroll"));
327
+ await flushFrames();
328
+ expect(instance.value?.backTopVisible).toBe(true);
329
+
330
+ // Show-only mode hides at the SAME threshold it shows at: 50 < 100
331
+ // must read false (the hysteresis fallback `top > show` is what
332
+ // pins this — a mutant `top > 0` would keep it true here).
333
+ stubGeometry(viewport, { scrollTop: 50, scrollHeight: 5000, clientHeight: 400 });
334
+ viewport.dispatchEvent(new Event("scroll"));
335
+ await flushFrames();
336
+ expect(instance.value?.backTopVisible).toBe(false);
337
+
338
+ stubGeometry(viewport, { scrollTop: 0, scrollHeight: 5000, clientHeight: 400 });
339
+ viewport.dispatchEvent(new Event("scroll"));
340
+ await flushFrames();
341
+ expect(instance.value?.backTopVisible).toBe(false);
342
+ app.unmount();
343
+ });
344
+
345
+ it("re-senses immediately when the thresholds change at runtime", async () => {
346
+ // Live-read contract (same as approachDistance): toggling backTopShow
347
+ // after mount must emit without waiting for the next scroll event —
348
+ // a consumer that swaps thresholds while idle gets a truthful signal.
349
+ const container = document.createElement("div");
350
+ document.body.appendChild(container);
351
+ containers.push(container);
352
+ const emitted: boolean[] = [];
353
+ const props = reactive({ backTopShow: undefined as number | undefined });
354
+ const Wrapper = defineComponent({
355
+ setup() {
356
+ return () =>
357
+ h(HkScrollContainer, {
358
+ backTopShow: props.backTopShow,
359
+ "onUpdate:backTopVisible": (v: boolean) => emitted.push(v),
360
+ }, { default: () => h("span", "content") });
361
+ },
362
+ });
363
+ const app = createApp(Wrapper);
364
+ mounts.push(app);
365
+ app.mount(container);
366
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
367
+ if (!viewport) throw new Error("no viewport");
368
+ stubGeometry(viewport, { scrollTop: 500, scrollHeight: 5000, clientHeight: 400 });
369
+ await flushFrames();
370
+ expect(emitted).toEqual([]); // inert while unset, geometry already past
371
+
372
+ props.backTopShow = 100;
373
+ await nextTick();
374
+ await flushFrames();
375
+ expect(emitted).toEqual([true]); // re-sensed without any scroll event
376
+ app.unmount();
377
+ });
378
+
379
+ it("never emits while backTopShow is unset", async () => {
380
+ const container = document.createElement("div");
381
+ document.body.appendChild(container);
382
+ containers.push(container);
383
+ const emitted: boolean[] = [];
384
+ const Wrapper = defineComponent({
385
+ setup() {
386
+ return () =>
387
+ h(HkScrollContainer, {
388
+ "onUpdate:backTopVisible": (v: boolean) => emitted.push(v),
389
+ }, { default: () => h("span", "content") });
390
+ },
391
+ });
392
+ const app = createApp(Wrapper);
393
+ mounts.push(app);
394
+ app.mount(container);
395
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
396
+ if (!viewport) throw new Error("no viewport");
397
+ stubGeometry(viewport, { scrollTop: 5000, scrollHeight: 5000, clientHeight: 400 });
398
+ viewport.dispatchEvent(new Event("scroll"));
399
+ await flushFrames();
400
+ expect(emitted).toEqual([]);
401
+ app.unmount();
402
+ });
403
+ });
@@ -85,8 +85,23 @@ export default defineComponent({
85
85
  * scroll content dissolving at the dock's edge reads as
86
86
  * intentional instead of a hard clip. */
87
87
  dockFade: { type: Boolean, default: true },
88
+ /** Back-to-top signal (scroll parameter every scrollable template
89
+ * can hand to a floating quick-action pad): while set, the
90
+ * container derives a visibility signal from its own throttled
91
+ * scroll pass — visible once scrollTop passes SHOW px, hidden
92
+ * again below HIDE px. The HIDE < SHOW hysteresis band stops the
93
+ * signal from flickering when the user idles at one threshold.
94
+ * Emits `update:backTopVisible` on change; inert (never emitted)
95
+ * while unset. */
96
+ backTopShow: { type: Number, default: undefined },
97
+ /** Hide threshold of the hysteresis; only read when `backTopShow`
98
+ * is also set. Without it the signal falls back to the single
99
+ * `top > backTopShow` test. A misconfigured `backTopHide >=
100
+ * backTopShow` degrades the same way (the show test wins first),
101
+ * so the band only widens, never inverts. */
102
+ backTopHide: { type: Number, default: undefined },
88
103
  },
89
- emits: { approachEnd: () => true },
104
+ emits: { approachEnd: () => true, "update:backTopVisible": (_v: boolean) => true },
90
105
  setup(props, { slots, expose, emit }) {
91
106
  const { t } = useI18n();
92
107
  const viewportRef = ref<HTMLElement>();
@@ -112,6 +127,12 @@ export default defineComponent({
112
127
 
113
128
  const pinned = ref(true);
114
129
  const FOLLOW_THRESHOLD = 24;
130
+
131
+ // Back-to-top visibility (see the backTopShow prop doc). Starts
132
+ // false and only ever changes through the sensed hysteresis below —
133
+ // the same semantics HkWaterfall shipped before the signal moved
134
+ // down to the shared scroll host.
135
+ const backTopVisible = ref(false);
115
136
  const autoFollowContent = shallowRef<HTMLElement | null>(null);
116
137
  let followRO: ResizeObserver | null = null;
117
138
  const showAutoTag = computed(() => props.autoFollow && props.scrollbar && pinned.value);
@@ -186,6 +207,13 @@ export default defineComponent({
186
207
  scheduleUpdate();
187
208
  }, { flush: "post" });
188
209
 
210
+ // Runtime threshold changes re-sense immediately (same live-read
211
+ // contract as approachDistance): without this, a backTopShow toggle
212
+ // after mount would emit nothing until the next scroll/resize.
213
+ watch(() => [props.backTopShow, props.backTopHide] as const, () => {
214
+ scheduleUpdate();
215
+ });
216
+
189
217
  /** Build the overlay tracks for the enabled axes (scrollbar on). */
190
218
  function mountScrollbars() {
191
219
  const vp = viewportRef.value;
@@ -261,6 +289,30 @@ export default defineComponent({
261
289
  const vp = viewportRef.value;
262
290
  if (!vp) return;
263
291
  senseOverflow(vp);
292
+ senseBackTop(vp);
293
+ }
294
+
295
+ /** Back-to-top hysteresis, derived on the same throttled pass as the
296
+ * overflow sensing (scroll / resize / content resize / refresh) so
297
+ * every scrollable template gets the signal for free — no consumer
298
+ * installs a second scroll listener. */
299
+ function senseBackTop(vp: HTMLElement) {
300
+ if (props.backTopShow === undefined) return;
301
+ const top = vp.scrollTop;
302
+ const show = props.backTopShow;
303
+ const hide = props.backTopHide;
304
+ const next =
305
+ hide !== undefined
306
+ ? top > show
307
+ ? true
308
+ : top < hide
309
+ ? false
310
+ : backTopVisible.value
311
+ : top > show;
312
+ if (next !== backTopVisible.value) {
313
+ backTopVisible.value = next;
314
+ emit("update:backTopVisible", next);
315
+ }
264
316
  }
265
317
 
266
318
  /** Mirror the live scroll geometry onto the root element as
@@ -455,7 +507,7 @@ export default defineComponent({
455
507
  approachHandle.value?.recheck();
456
508
  }
457
509
 
458
- expose({ scrollTo, scrollToElement, getScrollElement, getScrollTop, refresh, getOverflow, isNearEnd, recheck });
510
+ expose({ scrollTo, scrollToElement, getScrollElement, getScrollTop, refresh, getOverflow, isNearEnd, recheck, backTopVisible });
459
511
 
460
512
  return () => {
461
513
  const Tag = props.as as "div" | "section" | "nav" | "main" | "aside";
@@ -252,25 +252,11 @@ export default defineComponent({
252
252
  function recomputeActive() {
253
253
  scrollRaf = null;
254
254
  if (!scrollEl) return;
255
- const top = scrollEl.scrollTop;
256
-
257
- if (props.backTopShow !== undefined) {
258
- const show = props.backTopShow;
259
- const hide = props.backTopHide;
260
- const next =
261
- hide !== undefined
262
- ? top > show
263
- ? true
264
- : top < hide
265
- ? false
266
- : backTopVisible.value
267
- : top > show;
268
- if (next !== backTopVisible.value) {
269
- backTopVisible.value = next;
270
- emit("update:backTopVisible", next);
271
- }
272
- }
273
-
255
+ // Back-to-top visibility is no longer derived here: the shared
256
+ // scroll host (HkScrollContainer) senses it on its own throttled
257
+ // pass and this view mirrors the host's signal (see the render
258
+ // wiring below) — one scroll pass, one source of truth, and every
259
+ // other scrollable template gets the same signal for free.
274
260
  const viewportTop = scrollEl.getBoundingClientRect().top;
275
261
  const attr = sectionAttribute.value;
276
262
  const sections = scrollEl.querySelectorAll<HTMLElement>(`[${attr}]`);
@@ -409,6 +395,18 @@ export default defineComponent({
409
395
  class="hk-waterfall-scroll"
410
396
  mode="windowed"
411
397
  overscanScreens={props.overscanScreens}
398
+ // Back-to-top sensing lives on the scroll host now; this view
399
+ // forwards the thresholds and mirrors the emitted signal so its
400
+ // own public contract (update:backTopVisible + exposed
401
+ // backTopVisible) is unchanged for consumers.
402
+ backTopShow={props.backTopShow}
403
+ backTopHide={props.backTopHide}
404
+ onUpdate:backTopVisible={(v: boolean) => {
405
+ if (v !== backTopVisible.value) {
406
+ backTopVisible.value = v;
407
+ emit("update:backTopVisible", v);
408
+ }
409
+ }}
412
410
  >
413
411
  {{
414
412
  default: () =>