@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
|
@@ -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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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: () =>
|