@celestia-island/hikari 0.55.290 → 0.55.292
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 +1 -1
- package/src/components/HkAdminTablePage.tsx +54 -16
- package/src/components/HkFaceShell.tsx +9 -1
- package/src/components/HkScrollContainer.tsx +187 -0
- package/src/components/HkScrollProgress.scss +58 -0
- package/src/components/HkScrollProgress.test.tsx +502 -0
- package/src/components/HkScrollProgress.tsx +71 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
inject,
|
|
4
|
+
onMounted,
|
|
5
|
+
onUnmounted,
|
|
6
|
+
ref,
|
|
7
|
+
type Component,
|
|
8
|
+
type PropType,
|
|
9
|
+
} from "vue";
|
|
2
10
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
3
11
|
import HkCrossfade from "./HkCrossfade";
|
|
4
12
|
import { HkPageHeader } from "./HkPageHeader";
|
|
13
|
+
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
|
|
5
14
|
import type { HkRowManageGate } from "./HkTable";
|
|
6
15
|
|
|
7
16
|
|
|
@@ -65,12 +74,39 @@ export const HkAdminTablePage = defineComponent({
|
|
|
65
74
|
type: [Boolean, Function] as PropType<HkRowManageGate>,
|
|
66
75
|
default: false,
|
|
67
76
|
},
|
|
77
|
+
/** Authoritative row total the scroll-progress pill uses as its
|
|
78
|
+
* denominator (the server count behind a paged list — chest's
|
|
79
|
+
* usePagedTableFetch total). Omitted: the pill falls back to
|
|
80
|
+
* counting the delivered DOM rows. Registration into the
|
|
81
|
+
* enclosing shell viewport is automatic (see setup). */
|
|
82
|
+
total: { type: Number, default: undefined },
|
|
68
83
|
},
|
|
69
84
|
emits: {
|
|
70
85
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
71
86
|
manage: (_row: Record<string, unknown>, _opener: HTMLElement) => true,
|
|
72
87
|
},
|
|
73
88
|
setup(props, { emit, slots }) {
|
|
89
|
+
// Scroll-progress registration: the nearest enclosing HkScrollContainer
|
|
90
|
+
// with `scrollProgress` on provides the registrar (provide/inject
|
|
91
|
+
// scoping resolves the shell viewport for admin pages and keeps an
|
|
92
|
+
// inner container's lists out of the shell). The handle exposes the
|
|
93
|
+
// table card as the row root plus the `total` prop live — the pill's
|
|
94
|
+
// denominator follows it as pages load.
|
|
95
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
96
|
+
const tableCardRef = ref<HTMLElement | null>(null);
|
|
97
|
+
let handle: HkScrollProgressHandle | null = null;
|
|
98
|
+
onMounted(() => {
|
|
99
|
+
if (!registrar) return;
|
|
100
|
+
handle = {
|
|
101
|
+
getRoot: () => tableCardRef.value,
|
|
102
|
+
getTotal: () => props.total,
|
|
103
|
+
};
|
|
104
|
+
registrar.register(handle);
|
|
105
|
+
});
|
|
106
|
+
onUnmounted(() => {
|
|
107
|
+
if (registrar && handle) registrar.unregister(handle);
|
|
108
|
+
handle = null;
|
|
109
|
+
});
|
|
74
110
|
|
|
75
111
|
return () => {
|
|
76
112
|
const { t } = useI18n();
|
|
@@ -129,21 +165,23 @@ export const HkAdminTablePage = defineComponent({
|
|
|
129
165
|
) : slots.body ? (
|
|
130
166
|
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
131
167
|
) : (
|
|
132
|
-
<
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
168
|
+
<div ref={tableCardRef} class="hk-admin-table-page-tablecard">
|
|
169
|
+
<HCard padded={false}>
|
|
170
|
+
<HTable
|
|
171
|
+
columns={props.columns}
|
|
172
|
+
rows={props.rows}
|
|
173
|
+
rowKey={props.rowKey}
|
|
174
|
+
draggable={props.draggable}
|
|
175
|
+
manageable={props.manageable}
|
|
176
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
177
|
+
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
178
|
+
emit("manage", row, opener)
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
{slots}
|
|
182
|
+
</HTable>
|
|
183
|
+
</HCard>
|
|
184
|
+
</div>
|
|
147
185
|
)}
|
|
148
186
|
</HkCrossfade>
|
|
149
187
|
{slots["create-modal"]?.()}
|
|
@@ -236,6 +236,14 @@ export const HkFaceShell = defineComponent({
|
|
|
236
236
|
* `stage` renders the content bare — the face owns scrolling,
|
|
237
237
|
* canvases and floating layers. */
|
|
238
238
|
contentMode: { type: String as PropType<"document" | "stage">, default: "document" },
|
|
239
|
+
/** Scroll-progress pill on the document viewport (2026-11): while a
|
|
240
|
+
* registered list scrolls, the shell floats an "xx / xx" counter
|
|
241
|
+
* near the viewport's bottom-center (HkScrollContainer
|
|
242
|
+
* `scrollProgress`; lists register themselves — HkAdminTablePage
|
|
243
|
+
* does it for every admin list — so hosts inherit the affordance
|
|
244
|
+
* with zero per-page work). Default ON: the shell is the
|
|
245
|
+
* system-wide default-on point. Pass `false` to run the face bare. */
|
|
246
|
+
scrollProgress: { type: Boolean, default: true },
|
|
239
247
|
/** Content-area padding (inside the scroll viewport). Document mode
|
|
240
248
|
* only — a stage face owns its own geometry. Padding is applied to
|
|
241
249
|
* an inner wrapper rather than the scroll container so card
|
|
@@ -537,7 +545,7 @@ export const HkFaceShell = defineComponent({
|
|
|
537
545
|
{props.contentMode === "stage" ? (
|
|
538
546
|
renderContentStage()
|
|
539
547
|
) : (
|
|
540
|
-
<HScrollContainer class="s-admin-shell-scroll">
|
|
548
|
+
<HScrollContainer class="s-admin-shell-scroll" scrollProgress={props.scrollProgress}>
|
|
541
549
|
{/* Padding lives INSIDE the scroll viewport (an inner
|
|
542
550
|
wrapper) so card box-shadows are not clipped at the
|
|
543
551
|
viewport edges. */}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
defineComponent,
|
|
4
4
|
onBeforeUnmount,
|
|
5
5
|
onMounted,
|
|
6
|
+
provide,
|
|
6
7
|
ref,
|
|
7
8
|
shallowRef,
|
|
8
9
|
watch,
|
|
@@ -16,6 +17,12 @@ import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrol
|
|
|
16
17
|
import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
|
|
17
18
|
import { provideScrollWindow } from "../composables/useScrollWindow";
|
|
18
19
|
import { SCROLL_HOST_CLASS } from "./HkScrollPin";
|
|
20
|
+
import {
|
|
21
|
+
HK_SCROLL_PROGRESS_REGISTRAR,
|
|
22
|
+
default as HkScrollProgress,
|
|
23
|
+
type HkScrollProgressHandle,
|
|
24
|
+
type HkScrollProgressRegistrar,
|
|
25
|
+
} from "./HkScrollProgress";
|
|
19
26
|
import { scheduleFrame, notifyScrollStart, onceFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
20
27
|
import HFab from "./HkFab";
|
|
21
28
|
|
|
@@ -193,6 +200,40 @@ export default defineComponent({
|
|
|
193
200
|
type: String,
|
|
194
201
|
default: '[aria-current]:not([aria-current="false"]), [aria-selected="true"], .is-active',
|
|
195
202
|
},
|
|
203
|
+
/** Scroll-progress pill opt-in (2026-11): while on, the container
|
|
204
|
+
* floats a small "xx / xx" counter near its bottom-center while the
|
|
205
|
+
* scroll offset is changing — first visible row index over total
|
|
206
|
+
* rows of whichever registered list is under the viewport. Lists
|
|
207
|
+
* opt in per-list by registering against the provided registrar
|
|
208
|
+
* (see `HK_SCROLL_PROGRESS_REGISTRAR`); provide/inject scoping
|
|
209
|
+
* keeps a list registered against an inner container from leaking
|
|
210
|
+
* to this one. Sensing rides the same rAF-coalesced update pass as
|
|
211
|
+
* the overflow mirror — no second scroll listener. Off by default
|
|
212
|
+
* (per-house additive-chrome discipline; HkFaceShell turns it on
|
|
213
|
+
* for the document-mode shell).
|
|
214
|
+
*
|
|
215
|
+
* Known edges (both accepted, documented for the next reader):
|
|
216
|
+
* the registrar is provided at SETUP time only — a container
|
|
217
|
+
* mounted OFF that flips the prop on later renders the pill with
|
|
218
|
+
* nothing registered (hosts pass the prop statically; FaceShell
|
|
219
|
+
* never toggles it). And sensing keys on THIS container's viewport
|
|
220
|
+
* scroll — a nested INNER scroller moving on its own triggers no
|
|
221
|
+
* pass here, so rows clipped inside the inner scroller can be
|
|
222
|
+
* counted by the outer pill while invisible. The admin grammar
|
|
223
|
+
* (one shell viewport) never nests. */
|
|
224
|
+
scrollProgress: { type: Boolean, default: false },
|
|
225
|
+
/** Idle window (ms) after the last scroll-offset change before the
|
|
226
|
+
* pill fades out. Read live; clamped to 300..3000. Only meaningful
|
|
227
|
+
* with `scrollProgress`. */
|
|
228
|
+
scrollProgressIdle: { type: Number, default: 900 },
|
|
229
|
+
/** Which descendants count as "rows" for the numerator/denominator.
|
|
230
|
+
* The default matches the table grammar every admin list ships —
|
|
231
|
+
* BODY rows only: a `tr` default would count the header row as row
|
|
232
|
+
* one and skew every index. A non-table list (a card roster)
|
|
233
|
+
* overrides with its own row spelling. Only read while
|
|
234
|
+
* `scrollProgress` is on; a runtime change re-senses on the next
|
|
235
|
+
* pass. */
|
|
236
|
+
scrollProgressSelector: { type: String, default: "tbody tr" },
|
|
196
237
|
},
|
|
197
238
|
emits: { approachEnd: () => true, "update:backTopVisible": (_v: boolean) => true },
|
|
198
239
|
setup(props, { slots, expose, emit }) {
|
|
@@ -275,6 +316,142 @@ export default defineComponent({
|
|
|
275
316
|
provideScrollWindow(viewportRef, props.overscanScreens);
|
|
276
317
|
}
|
|
277
318
|
|
|
319
|
+
// ── Scroll progress pill (scrollProgress opt-in) ──────────────────
|
|
320
|
+
// Registration store + pill state. The registrar is PROVIDED here —
|
|
321
|
+
// once, at setup, only when the prop is on (the windowed-mode
|
|
322
|
+
// precedent above) — so provide/inject scoping resolves a list's
|
|
323
|
+
// inject to the NEAREST enclosing container with the feature on: a
|
|
324
|
+
// list registered against an inner container never leaks to an
|
|
325
|
+
// outer one, and with the prop off there is no registrar at all.
|
|
326
|
+
const progressRegistrars = new Set<HkScrollProgressHandle>();
|
|
327
|
+
const progressShown = ref(false);
|
|
328
|
+
const progressActive = ref(false);
|
|
329
|
+
const progressNumerator = ref(0);
|
|
330
|
+
const progressDenominator = ref(0);
|
|
331
|
+
let progressIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
332
|
+
// The offset the last sensing pass saw; null = not baselined yet, so
|
|
333
|
+
// the mount-time pass never counts as "scrolling" (the pill appears
|
|
334
|
+
// on the first real offset change, not on mount).
|
|
335
|
+
let progressLastTop: number | null = null;
|
|
336
|
+
|
|
337
|
+
function clampProgressIdle(): number {
|
|
338
|
+
return Math.min(3000, Math.max(300, props.scrollProgressIdle));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function disarmProgressIdle() {
|
|
342
|
+
if (progressIdleTimer !== null) {
|
|
343
|
+
clearTimeout(progressIdleTimer);
|
|
344
|
+
progressIdleTimer = null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function armProgressIdle() {
|
|
349
|
+
disarmProgressIdle();
|
|
350
|
+
progressIdleTimer = setTimeout(() => {
|
|
351
|
+
progressIdleTimer = null;
|
|
352
|
+
progressActive.value = false;
|
|
353
|
+
}, clampProgressIdle());
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function registerProgress(handle: HkScrollProgressHandle) {
|
|
357
|
+
progressRegistrars.add(handle);
|
|
358
|
+
scheduleUpdate();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function unregisterProgress(handle: HkScrollProgressHandle) {
|
|
362
|
+
progressRegistrars.delete(handle);
|
|
363
|
+
scheduleUpdate();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (props.scrollProgress) {
|
|
367
|
+
provide(HK_SCROLL_PROGRESS_REGISTRAR, {
|
|
368
|
+
register: registerProgress,
|
|
369
|
+
unregister: unregisterProgress,
|
|
370
|
+
} satisfies HkScrollProgressRegistrar);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Drop every pill precondition at once. `progressLastTop` resets so
|
|
374
|
+
* the next appearance re-baselines — a pass that only re-armed the
|
|
375
|
+
* preconditions (content grew back) must not read as a scroll. */
|
|
376
|
+
function setProgressHidden() {
|
|
377
|
+
progressShown.value = false;
|
|
378
|
+
progressActive.value = false;
|
|
379
|
+
disarmProgressIdle();
|
|
380
|
+
progressLastTop = null;
|
|
381
|
+
progressNumerator.value = 0;
|
|
382
|
+
progressDenominator.value = 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The ACTIVE registration: the first registered list whose root
|
|
386
|
+
* intersects the container viewport (registration order — the Set
|
|
387
|
+
* preserves insertion); when none intersects, the one whose root
|
|
388
|
+
* top is nearest ABOVE the viewport top (the list the user just
|
|
389
|
+
* scrolled out of). Deterministic in both tiers. */
|
|
390
|
+
function pickActiveProgressRoot(band: DOMRect): { handle: HkScrollProgressHandle; root: HTMLElement } | null {
|
|
391
|
+
let firstIntersecting: { handle: HkScrollProgressHandle; root: HTMLElement } | null = null;
|
|
392
|
+
let nearestAbove: { handle: HkScrollProgressHandle; root: HTMLElement; top: number } | null = null;
|
|
393
|
+
for (const handle of progressRegistrars) {
|
|
394
|
+
const root = handle.getRoot();
|
|
395
|
+
if (!root || !root.isConnected) continue;
|
|
396
|
+
const rect = root.getBoundingClientRect();
|
|
397
|
+
if (rect.bottom > band.top && rect.top < band.bottom) {
|
|
398
|
+
if (!firstIntersecting) firstIntersecting = { handle, root };
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (rect.top <= band.top && (!nearestAbove || rect.top > nearestAbove.top)) {
|
|
402
|
+
nearestAbove = { handle, root, top: rect.top };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return firstIntersecting ?? (nearestAbove ? { handle: nearestAbove.handle, root: nearestAbove.root } : null);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Pill sensing, riding the same throttled pass as the overflow
|
|
409
|
+
* mirror (scroll / resize / content resize / refresh — no second
|
|
410
|
+
* listener). Preconditions: a registration, vertical overflow, and
|
|
411
|
+
* an active list with at least one row; failing any hides the pill
|
|
412
|
+
* outright. While they hold, the numerator is the first row whose
|
|
413
|
+
* bottom edge is still below the viewport top (1-based), and the
|
|
414
|
+
* denominator is the registered total — unless it is missing,
|
|
415
|
+
* non-finite, sub-1, or STALE (smaller than the live row count, a
|
|
416
|
+
* page that loaded more rows than it declared), in which case the
|
|
417
|
+
* DOM count wins so numerator can never exceed denominator. */
|
|
418
|
+
function senseScrollProgress(vp: HTMLElement) {
|
|
419
|
+
if (!props.scrollProgress || progressRegistrars.size === 0) {
|
|
420
|
+
setProgressHidden();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const band = vp.getBoundingClientRect();
|
|
424
|
+
const active = pickActiveProgressRoot(band);
|
|
425
|
+
const rows = active ? active.root.querySelectorAll(props.scrollProgressSelector) : [];
|
|
426
|
+
if (!active || rows.length === 0 || vp.scrollHeight <= vp.clientHeight + 1) {
|
|
427
|
+
setProgressHidden();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
let numerator = rows.length;
|
|
431
|
+
for (let i = 0; i < rows.length; i++) {
|
|
432
|
+
if ((rows[i] as HTMLElement).getBoundingClientRect().bottom > band.top) {
|
|
433
|
+
numerator = i + 1;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const declared = active.handle.getTotal();
|
|
438
|
+
const denominator =
|
|
439
|
+
typeof declared === "number" && Number.isFinite(declared) && declared >= 1
|
|
440
|
+
? Math.max(declared, rows.length)
|
|
441
|
+
: rows.length;
|
|
442
|
+
progressNumerator.value = Math.min(numerator, denominator);
|
|
443
|
+
progressDenominator.value = denominator;
|
|
444
|
+
progressShown.value = true;
|
|
445
|
+
// Scroll-active: only a changed offset counts (the pass also runs
|
|
446
|
+
// for resizes and content mutations). The idle fade re-arms on
|
|
447
|
+
// every moving pass, so continuous scrolling never snaps off.
|
|
448
|
+
if (progressLastTop !== null && vp.scrollTop !== progressLastTop) {
|
|
449
|
+
progressActive.value = true;
|
|
450
|
+
armProgressIdle();
|
|
451
|
+
}
|
|
452
|
+
progressLastTop = vp.scrollTop;
|
|
453
|
+
}
|
|
454
|
+
|
|
278
455
|
const hasV = () => props.axis !== "horizontal";
|
|
279
456
|
const hasH = () => props.axis !== "vertical";
|
|
280
457
|
// Safe centering applies to horizontal-axis containers only; the
|
|
@@ -414,6 +591,7 @@ export default defineComponent({
|
|
|
414
591
|
senseOverflow(vp);
|
|
415
592
|
senseBackTop(vp);
|
|
416
593
|
senseSelectionBeyond(vp);
|
|
594
|
+
senseScrollProgress(vp);
|
|
417
595
|
}
|
|
418
596
|
|
|
419
597
|
/** Back-to-top hysteresis, derived on the same throttled pass as the
|
|
@@ -622,6 +800,7 @@ export default defineComponent({
|
|
|
622
800
|
overlay?.detach();
|
|
623
801
|
overlay = null;
|
|
624
802
|
stopApproach();
|
|
803
|
+
disarmProgressIdle();
|
|
625
804
|
selectionMO?.disconnect();
|
|
626
805
|
selectionMO = null;
|
|
627
806
|
scheduled?.disconnect();
|
|
@@ -774,6 +953,14 @@ export default defineComponent({
|
|
|
774
953
|
{showAutoTag.value && (
|
|
775
954
|
<span class="hk-scroll-container-autotag" aria-hidden="true">{t("hikari::scrollContainer.auto", "Auto")}</span>
|
|
776
955
|
)}
|
|
956
|
+
{props.scrollProgress && (
|
|
957
|
+
<HkScrollProgress
|
|
958
|
+
shown={progressShown.value}
|
|
959
|
+
active={progressActive.value}
|
|
960
|
+
numerator={progressNumerator.value}
|
|
961
|
+
denominator={progressDenominator.value}
|
|
962
|
+
/>
|
|
963
|
+
)}
|
|
777
964
|
{props.autoFollow && props.followAffordance && !pinned.value && (
|
|
778
965
|
<HFab
|
|
779
966
|
class="hk-scroll-container-jump"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
3
|
+
/* ── Scroll-progress pill (HkScrollContainer scrollProgress) ──────
|
|
4
|
+
The "xx / xx" counter that floats near the bottom-center of a
|
|
5
|
+
scrolling list: first visible row index over total rows. It hangs
|
|
6
|
+
off the container HOST (position: relative), never the viewport —
|
|
7
|
+
viewport-absolute chrome scrolls away with the content, the same
|
|
8
|
+
reason the autotag band and the selection-hint gradients paint on
|
|
9
|
+
the root.
|
|
10
|
+
|
|
11
|
+
Visual vocabulary is the autotag band's (the container's other
|
|
12
|
+
bottom chrome): muted mono digits on the mixed surface pill, same
|
|
13
|
+
z-index tier (6). The bottom inset is safe-area aware the way
|
|
14
|
+
HkFab's corners are. Bare digits only — counters are locale-neutral
|
|
15
|
+
(the HkLoadMore counter precedent). */
|
|
16
|
+
.hk-scroll-progress-pill {
|
|
17
|
+
position: absolute;
|
|
18
|
+
left: 50%;
|
|
19
|
+
bottom: max(var(--space-10), env(safe-area-inset-bottom, 0px));
|
|
20
|
+
transform: translateX(-50%);
|
|
21
|
+
z-index: 6;
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
@include user-select(none);
|
|
24
|
+
padding: var(--space-2) var(--space-8);
|
|
25
|
+
font-family: var(--font-mono);
|
|
26
|
+
font-size: var(--text-2xs);
|
|
27
|
+
font-variant-numeric: tabular-nums;
|
|
28
|
+
letter-spacing: 0.04em;
|
|
29
|
+
color: var(--hi-color-muted, #6c6c6c);
|
|
30
|
+
background: color-mix(in srgb, var(--hi-color-surface, rgba(255, 255, 255, 0.95)) 78%, transparent);
|
|
31
|
+
border: 1px solid color-mix(in srgb, var(--hi-color-border, rgba(100, 100, 100, 0.08)) 12%, transparent);
|
|
32
|
+
border-radius: var(--radius-full, 9999px);
|
|
33
|
+
@include backdrop-filter(blur(4px));
|
|
34
|
+
|
|
35
|
+
/* Resting = transparent; only an in-flight scroll lights it. The
|
|
36
|
+
* ~200ms ease is the show AND hide fade (data-state flips on the
|
|
37
|
+
* container's throttled sensing pass). */
|
|
38
|
+
opacity: 0;
|
|
39
|
+
transition: opacity 200ms ease;
|
|
40
|
+
|
|
41
|
+
&[data-state="active"] {
|
|
42
|
+
opacity: 0.85;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* A failed precondition (no registered list, no vertical overflow,
|
|
46
|
+
* a list whose rows are empty) removes the box outright — the pill
|
|
47
|
+
* must never linger as dead chrome. */
|
|
48
|
+
&[data-state="hidden"] {
|
|
49
|
+
display: none;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Reduced motion: no fade at all — the state flips instantly. */
|
|
54
|
+
@media (prefers-reduced-motion: reduce) {
|
|
55
|
+
.hk-scroll-progress-pill {
|
|
56
|
+
transition: none;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Scroll-progress pill: registration contract, sensing behavior, and the
|
|
2
|
+
// HkAdminTablePage auto-registration. Geometry is stubbed the same way
|
|
3
|
+
// the overlay-scrollbar tests do it (happy-dom performs no layout) —
|
|
4
|
+
// Object.defineProperties for the viewport's scroll numbers, spyOn for
|
|
5
|
+
// the getBoundingClientRect boxes the numerator reads.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { createApp, defineComponent, h, inject, nextTick, onMounted, onUnmounted, provide, ref } from "vue";
|
|
11
|
+
|
|
12
|
+
import HkScrollContainer from "./HkScrollContainer";
|
|
13
|
+
import { HkAdminTablePage } from "./HkAdminTablePage";
|
|
14
|
+
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressRegistrar } from "./HkScrollProgress";
|
|
15
|
+
|
|
16
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
17
|
+
const rects: Array<ReturnType<typeof vi.spyOn>> = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
22
|
+
app.unmount();
|
|
23
|
+
container.remove();
|
|
24
|
+
}
|
|
25
|
+
for (const spy of rects.splice(0)) spy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function mountTree(root: ReturnType<typeof h>) {
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
const app = createApp({ render: () => root });
|
|
32
|
+
app.mount(container);
|
|
33
|
+
mounts.push({ app, container });
|
|
34
|
+
return container;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Scroller {
|
|
38
|
+
container: HTMLElement;
|
|
39
|
+
viewport: HTMLElement;
|
|
40
|
+
pill: () => HTMLElement | null;
|
|
41
|
+
refresh: () => void;
|
|
42
|
+
setScrollTop: (top: number) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Mount a HkScrollContainer with `scrollProgress` on and the given slot
|
|
46
|
+
* content. scrollTop rides a mutable cell so successive refresh() passes
|
|
47
|
+
* can move it (the sensing pass reads the live getter). */
|
|
48
|
+
function mountScroller(slot: () => ReturnType<typeof h>, props: Record<string, unknown> = {}): Scroller {
|
|
49
|
+
let scrollTop = 0;
|
|
50
|
+
const instance = ref<{ refresh: () => void } | null>(null);
|
|
51
|
+
const Wrapper = defineComponent({
|
|
52
|
+
setup() {
|
|
53
|
+
return () =>
|
|
54
|
+
h(HkScrollContainer, {
|
|
55
|
+
ref: instance as never,
|
|
56
|
+
scrollProgress: true,
|
|
57
|
+
...props,
|
|
58
|
+
}, { default: slot });
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const container = mountTree(h(Wrapper));
|
|
62
|
+
const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport");
|
|
63
|
+
if (!viewport) throw new Error("container did not render its viewport");
|
|
64
|
+
Object.defineProperties(viewport, {
|
|
65
|
+
scrollHeight: { configurable: true, get: () => 2000 },
|
|
66
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
67
|
+
scrollWidth: { configurable: true, get: () => 300 },
|
|
68
|
+
clientWidth: { configurable: true, get: () => 300 },
|
|
69
|
+
scrollLeft: { configurable: true, get: () => 0 },
|
|
70
|
+
scrollTop: { configurable: true, get: () => scrollTop },
|
|
71
|
+
});
|
|
72
|
+
rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
|
|
73
|
+
left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
|
|
74
|
+
} as DOMRect));
|
|
75
|
+
return {
|
|
76
|
+
container,
|
|
77
|
+
viewport,
|
|
78
|
+
pill: () => container.querySelector<HTMLElement>(".hk-scroll-progress-pill"),
|
|
79
|
+
refresh: () => instance.value?.refresh(),
|
|
80
|
+
setScrollTop: (top: number) => {
|
|
81
|
+
scrollTop = top;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A minimal registered list: `rows` tr elements inside a table (the
|
|
87
|
+
* default `tr` row grammar), the registrar contacted on mounted. */
|
|
88
|
+
function makeList(options: { rows: number; total?: number }) {
|
|
89
|
+
return defineComponent({
|
|
90
|
+
setup() {
|
|
91
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
92
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
93
|
+
onMounted(() => {
|
|
94
|
+
registrar?.register({ getRoot: () => rootRef.value, getTotal: () => options.total });
|
|
95
|
+
});
|
|
96
|
+
return () =>
|
|
97
|
+
h("div", { ref: rootRef }, [
|
|
98
|
+
h("table", { class: "probe-table" }, [
|
|
99
|
+
h("tbody", {}, Array.from({ length: options.rows }, (_, i) =>
|
|
100
|
+
h("tr", { "data-row": String(i), key: i }, `row ${i}`),
|
|
101
|
+
)),
|
|
102
|
+
]),
|
|
103
|
+
]);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Stub every row's rect: 40px tall, stacked from the content top, so a
|
|
109
|
+
* given scrollTop produces a deterministic first-visible row. */
|
|
110
|
+
function stubRows(container: HTMLElement, rows: number, scrollTop = 0) {
|
|
111
|
+
container.querySelectorAll<HTMLElement>("tr").forEach((row, i) => {
|
|
112
|
+
const top = i * 40 - scrollTop;
|
|
113
|
+
rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
|
|
114
|
+
left: 0, top, right: 100, bottom: top + 40, width: 100, height: 40, x: 0, y: top,
|
|
115
|
+
} as DOMRect));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stubRootRect(container: HTMLElement, selector: string, top: number, bottom: number, wrapper = false) {
|
|
120
|
+
// `wrapper`: stub the registered ROOT (the list's wrapper div) instead
|
|
121
|
+
// of the table inside it — pickActiveProgressRoot reads the root.
|
|
122
|
+
const anchor = container.querySelector<HTMLElement>(selector);
|
|
123
|
+
if (!anchor) throw new Error("list root missing");
|
|
124
|
+
const root = wrapper ? anchor.parentElement! : anchor;
|
|
125
|
+
rects.push(vi.spyOn(root, "getBoundingClientRect").mockReturnValue({
|
|
126
|
+
left: 0, top, right: 100, bottom, width: 100, height: bottom - top, x: 0, y: top,
|
|
127
|
+
} as DOMRect));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ROWS = 40;
|
|
131
|
+
|
|
132
|
+
describe("HkScrollProgress", () => {
|
|
133
|
+
it("renders no pill element when scrollProgress is off", async () => {
|
|
134
|
+
const scroller = mountScroller(() => h(makeList({ rows: ROWS, total: 500 })), { scrollProgress: false });
|
|
135
|
+
scroller.refresh();
|
|
136
|
+
await nextTick();
|
|
137
|
+
expect(scroller.pill()).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("answers hidden while nothing is registered", async () => {
|
|
141
|
+
const scroller = mountScroller(() => h("div", { class: "bare-content" }, "content"));
|
|
142
|
+
scroller.refresh();
|
|
143
|
+
await nextTick();
|
|
144
|
+
const pill = scroller.pill();
|
|
145
|
+
expect(pill).not.toBeNull();
|
|
146
|
+
expect(pill!.dataset.state).toBe("hidden");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("provides NO registrar when scrollProgress is off", () => {
|
|
150
|
+
// The provide is guarded by the prop — a list inside an OFF container
|
|
151
|
+
// must resolve null (so it falls through to an OUTER on-container, or
|
|
152
|
+
// registers nowhere). Pins the guard: deleting the `if (props.scrollProgress)`
|
|
153
|
+
// around the provide turns this red.
|
|
154
|
+
let saw: unknown = "unset";
|
|
155
|
+
const Probe = defineComponent({
|
|
156
|
+
setup() {
|
|
157
|
+
saw = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
158
|
+
return () => h("div", "probe");
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
mountScroller(() => h(Probe), { scrollProgress: false });
|
|
162
|
+
expect(saw).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("resolves inner-OFF inside outer-ON to the OUTER registrar identity", async () => {
|
|
166
|
+
const seen: unknown[] = [];
|
|
167
|
+
const Probe = defineComponent({
|
|
168
|
+
setup() {
|
|
169
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
170
|
+
onMounted(() => {
|
|
171
|
+
registrar?.register({ getRoot: () => null, getTotal: () => 7 });
|
|
172
|
+
seen.push(registrar);
|
|
173
|
+
});
|
|
174
|
+
return () => h("div", "probe");
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
// Three probes: (a) directly under the outer ON container, (b) inside
|
|
178
|
+
// an inner OFF container, (c) inside an inner ON container. Registrar
|
|
179
|
+
// IDENTITY pins the scoping matrix: (a)==(b) — the OFF inner provides
|
|
180
|
+
// nothing, so (b) falls through to the OUTER registrar — while (c)
|
|
181
|
+
// receives the INNER container's own registrar (shadowing). Deleting
|
|
182
|
+
// the `if (props.scrollProgress)` provide guard turns (b) into a
|
|
183
|
+
// third distinct registrar and this test red.
|
|
184
|
+
const scroller = mountScroller(() =>
|
|
185
|
+
h("div", [
|
|
186
|
+
h(Probe),
|
|
187
|
+
h(HkScrollContainer, { scrollProgress: false }, { default: () => h(Probe) }),
|
|
188
|
+
h(HkScrollContainer, { scrollProgress: true }, { default: () => h(Probe) }),
|
|
189
|
+
]),
|
|
190
|
+
);
|
|
191
|
+
scroller.refresh();
|
|
192
|
+
await nextTick();
|
|
193
|
+
expect(seen.length).toBe(3);
|
|
194
|
+
expect(seen[0]).not.toBeNull();
|
|
195
|
+
expect(seen[1]).toBe(seen[0]);
|
|
196
|
+
expect(seen[2]).not.toBe(seen[0]);
|
|
197
|
+
// Two pills exist (outer ON + inner ON; the OFF inner renders none).
|
|
198
|
+
expect(scroller.container.querySelectorAll(".hk-scroll-progress-pill").length).toBe(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("shows the active count over the registered total on scroll", async () => {
|
|
202
|
+
const List = makeList({ rows: ROWS, total: 340 });
|
|
203
|
+
const scroller = mountScroller(() => h(List));
|
|
204
|
+
// Baseline pass: preconditions hold, no offset change yet.
|
|
205
|
+
stubRows(scroller.container, ROWS, 0);
|
|
206
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
207
|
+
scroller.refresh();
|
|
208
|
+
await nextTick();
|
|
209
|
+
expect(scroller.pill()!.dataset.state).toBe("idle");
|
|
210
|
+
|
|
211
|
+
// A real offset change lights the pill: row 9's bottom edge lands at
|
|
212
|
+
// the viewport top, so the first VISIBLE row is #11; total 340.
|
|
213
|
+
scroller.setScrollTop(400);
|
|
214
|
+
stubRows(scroller.container, ROWS, 400);
|
|
215
|
+
scroller.refresh();
|
|
216
|
+
await nextTick();
|
|
217
|
+
const pill = scroller.pill()!;
|
|
218
|
+
expect(pill.dataset.state).toBe("active");
|
|
219
|
+
expect(pill.textContent).toBe("11 / 340");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("falls back to the DOM row count when the total is absent", async () => {
|
|
223
|
+
const List = makeList({ rows: ROWS });
|
|
224
|
+
const scroller = mountScroller(() => h(List));
|
|
225
|
+
scroller.setScrollTop(400);
|
|
226
|
+
stubRows(scroller.container, ROWS, 400);
|
|
227
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
228
|
+
scroller.refresh();
|
|
229
|
+
await nextTick();
|
|
230
|
+
expect(scroller.pill()!.textContent).toBe("11 / 40");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("falls back to the DOM row count when the declared total went stale", async () => {
|
|
234
|
+
// A total smaller than the delivered rows (a page loaded more than it
|
|
235
|
+
// declared) must never let the numerator exceed the denominator.
|
|
236
|
+
const List = makeList({ rows: ROWS, total: 5 });
|
|
237
|
+
const scroller = mountScroller(() => h(List));
|
|
238
|
+
scroller.setScrollTop(400);
|
|
239
|
+
stubRows(scroller.container, ROWS, 400);
|
|
240
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
241
|
+
scroller.refresh();
|
|
242
|
+
await nextTick();
|
|
243
|
+
expect(scroller.pill()!.textContent).toBe("11 / 40");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("answers hidden when the content fits without overflow", async () => {
|
|
247
|
+
const List = makeList({ rows: ROWS, total: 340 });
|
|
248
|
+
const scroller = mountScroller(() => h(List));
|
|
249
|
+
Object.defineProperties(scroller.viewport, {
|
|
250
|
+
scrollHeight: { configurable: true, get: () => 400 },
|
|
251
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
252
|
+
});
|
|
253
|
+
stubRows(scroller.container, ROWS, 0);
|
|
254
|
+
stubRootRect(scroller.container, ".probe-table", 0, 400);
|
|
255
|
+
scroller.refresh();
|
|
256
|
+
await nextTick();
|
|
257
|
+
expect(scroller.pill()!.dataset.state).toBe("hidden");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("fades to idle after the idle window and re-arms on the next move", async () => {
|
|
261
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
262
|
+
const List = makeList({ rows: ROWS, total: 340 });
|
|
263
|
+
const scroller = mountScroller(() => h(List));
|
|
264
|
+
stubRows(scroller.container, ROWS, 0);
|
|
265
|
+
stubRootRect(scroller.container, ".probe-table", -100, 1900);
|
|
266
|
+
scroller.refresh();
|
|
267
|
+
await nextTick();
|
|
268
|
+
scroller.setScrollTop(400);
|
|
269
|
+
stubRows(scroller.container, ROWS, 400);
|
|
270
|
+
scroller.refresh();
|
|
271
|
+
await nextTick();
|
|
272
|
+
expect(scroller.pill()!.dataset.state).toBe("active");
|
|
273
|
+
// Idle window (default 900ms): opacity fades via data-state="idle".
|
|
274
|
+
vi.advanceTimersByTime(900);
|
|
275
|
+
await nextTick();
|
|
276
|
+
expect(scroller.pill()!.dataset.state).toBe("idle");
|
|
277
|
+
// The next offset change re-arms the active state.
|
|
278
|
+
scroller.setScrollTop(500);
|
|
279
|
+
stubRows(scroller.container, ROWS, 500);
|
|
280
|
+
scroller.refresh();
|
|
281
|
+
await nextTick();
|
|
282
|
+
expect(scroller.pill()!.dataset.state).toBe("active");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("tracks the intersecting list among several registrations", async () => {
|
|
286
|
+
// List A sits fully above the viewport; list B intersects. The pill
|
|
287
|
+
// describes B; once B unregisters, the nearest-above tier takes A.
|
|
288
|
+
const bHandle: { fn?: () => void } = {};
|
|
289
|
+
const listA = defineComponent({
|
|
290
|
+
setup() {
|
|
291
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
292
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
293
|
+
onMounted(() => registrar?.register({ getRoot: () => rootRef.value, getTotal: () => 10 }));
|
|
294
|
+
return () =>
|
|
295
|
+
h("div", { ref: rootRef }, [
|
|
296
|
+
h("table", { class: "probe-table-a" }, [h("tbody", {}, Array.from({ length: 10 }, (_, i) => h("tr", { "data-list": "a", key: i }, `a${i}`)))]),
|
|
297
|
+
]);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const listB = defineComponent({
|
|
301
|
+
setup() {
|
|
302
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
303
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
304
|
+
onMounted(() => {
|
|
305
|
+
const handle = { getRoot: () => rootRef.value, getTotal: () => 20 };
|
|
306
|
+
registrar?.register(handle);
|
|
307
|
+
bHandle.fn = () => registrar?.unregister(handle);
|
|
308
|
+
});
|
|
309
|
+
return () =>
|
|
310
|
+
h("div", { ref: rootRef }, [
|
|
311
|
+
h("table", { class: "probe-table-b" }, [h("tbody", {}, Array.from({ length: 20 }, (_, i) => h("tr", { "data-list": "b", key: i }, `b${i}`)))]),
|
|
312
|
+
]);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const scroller = mountScroller(() => h("div", [h(listA), h(listB)]));
|
|
316
|
+
// A's rows live above the band; B's rows intersect it.
|
|
317
|
+
scroller.container.querySelectorAll<HTMLTableRowElement>("tr").forEach((row) => {
|
|
318
|
+
const above = row.dataset.list === "a";
|
|
319
|
+
const top = above ? -2000 : 100;
|
|
320
|
+
rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
|
|
321
|
+
left: 0, top, right: 100, bottom: top + 40, width: 100, height: 40, x: 0, y: top,
|
|
322
|
+
} as DOMRect));
|
|
323
|
+
});
|
|
324
|
+
// Roots: A above (bottom above the band's top), B intersecting.
|
|
325
|
+
stubRootRect(scroller.container, ".probe-table-a", -2000, -1600, true);
|
|
326
|
+
stubRootRect(scroller.container, ".probe-table-b", 100, 900, true);
|
|
327
|
+
scroller.setScrollTop(100);
|
|
328
|
+
scroller.refresh();
|
|
329
|
+
await nextTick();
|
|
330
|
+
// B's rows all intersect the band, so the first visible row is #1.
|
|
331
|
+
expect(scroller.pill()!.textContent).toBe("1 / 20");
|
|
332
|
+
|
|
333
|
+
// B leaves the registry: the nearest-above tier describes A — every
|
|
334
|
+
// A row sits above the band, so the numerator reads as "past the
|
|
335
|
+
// end" (rows.length) over A's total.
|
|
336
|
+
bHandle.fn?.();
|
|
337
|
+
scroller.refresh();
|
|
338
|
+
await nextTick();
|
|
339
|
+
expect(scroller.pill()!.textContent).toBe("10 / 10");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("hides once the registration goes away", async () => {
|
|
343
|
+
const handleBox: { fn?: () => void } = {};
|
|
344
|
+
const List = defineComponent({
|
|
345
|
+
setup() {
|
|
346
|
+
const registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
347
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
348
|
+
onMounted(() => {
|
|
349
|
+
const handle = { getRoot: () => rootRef.value, getTotal: () => 10 };
|
|
350
|
+
registrar?.register(handle);
|
|
351
|
+
handleBox.fn = () => registrar?.unregister(handle);
|
|
352
|
+
});
|
|
353
|
+
return () =>
|
|
354
|
+
h("div", { ref: rootRef }, [
|
|
355
|
+
h("table", {}, [h("tbody", {}, Array.from({ length: 10 }, (_, i) => h("tr", { key: i }, `r${i}`)))]),
|
|
356
|
+
]);
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
const scroller = mountScroller(() => h(List));
|
|
360
|
+
stubRows(scroller.container, 10, 0);
|
|
361
|
+
stubRootRect(scroller.container, "table", -100, 1900);
|
|
362
|
+
// First pass baselines the offset (no active yet); the second move
|
|
363
|
+
// lights the pill.
|
|
364
|
+
scroller.setScrollTop(100);
|
|
365
|
+
scroller.refresh();
|
|
366
|
+
await nextTick();
|
|
367
|
+
scroller.setScrollTop(200);
|
|
368
|
+
scroller.refresh();
|
|
369
|
+
await nextTick();
|
|
370
|
+
expect(scroller.pill()!.dataset.state).toBe("active");
|
|
371
|
+
handleBox.fn?.();
|
|
372
|
+
scroller.refresh();
|
|
373
|
+
await nextTick();
|
|
374
|
+
expect(scroller.pill()!.dataset.state).toBe("hidden");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("HkAdminTablePage scroll-progress registration", () => {
|
|
379
|
+
it("registers its table root and total with the provided registrar", async () => {
|
|
380
|
+
const registered: Array<{ getRoot: () => HTMLElement | null; getTotal: () => number | undefined }> = [];
|
|
381
|
+
const unregistered: unknown[] = [];
|
|
382
|
+
const registrar: HkScrollProgressRegistrar = {
|
|
383
|
+
register: (handle) => registered.push(handle),
|
|
384
|
+
unregister: (handle) => unregistered.push(handle),
|
|
385
|
+
};
|
|
386
|
+
const Provider = defineComponent({
|
|
387
|
+
setup() {
|
|
388
|
+
provide(HK_SCROLL_PROGRESS_REGISTRAR, registrar);
|
|
389
|
+
return () =>
|
|
390
|
+
h(HkAdminTablePage, {
|
|
391
|
+
title: "T",
|
|
392
|
+
rows: [{ name: "a" }, { name: "b" }],
|
|
393
|
+
columns: [{ key: "name", title: "Name" }],
|
|
394
|
+
total: 99,
|
|
395
|
+
});
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const container = mountTree(h(Provider));
|
|
399
|
+
expect(registered.length).toBe(1);
|
|
400
|
+
expect(registered[0].getTotal()).toBe(99);
|
|
401
|
+
// The table card is the row root — the element containing the rows.
|
|
402
|
+
expect(registered[0].getRoot()).not.toBeNull();
|
|
403
|
+
expect(registered[0].getRoot()!.contains(container.querySelector("table"))).toBe(true);
|
|
404
|
+
|
|
405
|
+
// Unmount unregisters the SAME handle.
|
|
406
|
+
mounts[0].app.unmount();
|
|
407
|
+
expect(unregistered.length).toBe(1);
|
|
408
|
+
expect(unregistered[0]).toBe(registered[0]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("renders its rows into the shell viewport's pill denominator end-to-end", async () => {
|
|
412
|
+
// The real provide/inject path: AdminTablePage inside a
|
|
413
|
+
// scrollProgress-on container registers itself, and the pill's
|
|
414
|
+
// denominator is the page's declared total (not the delivered rows).
|
|
415
|
+
const page = h(HkAdminTablePage, {
|
|
416
|
+
title: "T",
|
|
417
|
+
rows: Array.from({ length: 30 }, (_, i) => ({ name: `r${i}` })),
|
|
418
|
+
columns: [{ key: "name", title: "Name" }],
|
|
419
|
+
total: 500,
|
|
420
|
+
});
|
|
421
|
+
const instance = ref<{ refresh: () => void } | null>(null);
|
|
422
|
+
let scrollTop = 0;
|
|
423
|
+
const Wrapper = defineComponent({
|
|
424
|
+
setup() {
|
|
425
|
+
return () =>
|
|
426
|
+
h(HkScrollContainer, { ref: instance as never, scrollProgress: true }, { default: () => page });
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
const container = mountTree(h(Wrapper));
|
|
430
|
+
const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
431
|
+
Object.defineProperties(viewport, {
|
|
432
|
+
scrollHeight: { configurable: true, get: () => 4000 },
|
|
433
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
434
|
+
scrollWidth: { configurable: true, get: () => 300 },
|
|
435
|
+
clientWidth: { configurable: true, get: () => 300 },
|
|
436
|
+
scrollLeft: { configurable: true, get: () => 0 },
|
|
437
|
+
scrollTop: { configurable: true, get: () => scrollTop },
|
|
438
|
+
});
|
|
439
|
+
rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
|
|
440
|
+
left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
|
|
441
|
+
} as DOMRect));
|
|
442
|
+
// The list root is AdminTablePage's table-card wrapper (the anchor
|
|
443
|
+
// class the page ships for exactly this geometry contract).
|
|
444
|
+
const card = container.querySelector("table");
|
|
445
|
+
expect(card).not.toBeNull();
|
|
446
|
+
const cardHost = container.querySelector<HTMLElement>(".hk-admin-table-page-tablecard")!;
|
|
447
|
+
expect(cardHost.contains(card!)).toBe(true);
|
|
448
|
+
rects.push(vi.spyOn(cardHost, "getBoundingClientRect").mockReturnValue({
|
|
449
|
+
left: 0, top: -200, right: 300, bottom: 3800, width: 300, height: 4000, x: 0, y: -200,
|
|
450
|
+
} as DOMRect));
|
|
451
|
+
container.querySelectorAll<HTMLElement>("tbody tr").forEach((row, i) => {
|
|
452
|
+
const top = i * 40 - 200;
|
|
453
|
+
rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
|
|
454
|
+
left: 0, top, right: 300, bottom: top + 40, width: 300, height: 40, x: 0, y: top,
|
|
455
|
+
} as DOMRect));
|
|
456
|
+
});
|
|
457
|
+
scrollTop = 200;
|
|
458
|
+
instance.value!.refresh();
|
|
459
|
+
await nextTick();
|
|
460
|
+
const pill = container.querySelector<HTMLElement>(".hk-scroll-progress-pill");
|
|
461
|
+
expect(pill).not.toBeNull();
|
|
462
|
+
// First visible row #6 over the DECLARED total — the delivered rows
|
|
463
|
+
// (30) never override the page's server count.
|
|
464
|
+
expect(pill!.textContent).toBe("6 / 500");
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
// ── SCSS contract (textual pins — happy-dom does not lay out) ────────
|
|
470
|
+
// The house style pins sheet claims with readFileSync probes
|
|
471
|
+
// (HkButton.test.tsx icon-only precedent): these guard the paint
|
|
472
|
+
// decisions the DOM tests cannot see.
|
|
473
|
+
const scss = readFileSync(join(__dirname, "HkScrollProgress.scss"), "utf-8").replace(/\r\n/g, "\n");
|
|
474
|
+
|
|
475
|
+
describe("HkScrollProgress SCSS contract", () => {
|
|
476
|
+
it("hides by display:none, never just transparency", () => {
|
|
477
|
+
const hidden = scss.match(/&\[data-state="hidden"\]\s*{[^}]*}/)?.[0] ?? "";
|
|
478
|
+
expect(hidden, "hidden-state rule must exist").toBeTruthy();
|
|
479
|
+
expect(hidden).toContain("display: none");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("fades via a 200ms opacity transition on the pill itself", () => {
|
|
483
|
+
const rule = scss.match(/\.hk-scroll-progress-pill\s*{[^}]*}/)?.[0] ?? "";
|
|
484
|
+
expect(rule, "pill rule must exist").toBeTruthy();
|
|
485
|
+
expect(rule).toContain("transition: opacity 200ms ease");
|
|
486
|
+
expect(rule).toContain("pointer-events: none");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("hangs bottom-center with the safe-area inset and the autotag z-tier", () => {
|
|
490
|
+
const rule = scss.match(/\.hk-scroll-progress-pill\s*{[^}]*}/)?.[0] ?? "";
|
|
491
|
+
expect(rule).toContain("position: absolute");
|
|
492
|
+
expect(rule).toContain("left: 50%");
|
|
493
|
+
expect(rule).toContain("max(var(--space-10), env(safe-area-inset-bottom, 0px))");
|
|
494
|
+
expect(rule).toContain("z-index: 6");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("drops the fade entirely under prefers-reduced-motion", () => {
|
|
498
|
+
const reduced = scss.match(/@media \(prefers-reduced-motion: reduce\)\s*{[^}@]*{[^}]*}\s*}/)?.[0] ?? "";
|
|
499
|
+
expect(reduced, "reduced-motion block must exist").toBeTruthy();
|
|
500
|
+
expect(reduced).toContain("transition: none");
|
|
501
|
+
});
|
|
502
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Scroll-progress pill: the shared registration vocabulary and the pill
|
|
2
|
+
// renderer for HkScrollContainer's `scrollProgress` opt-in.
|
|
3
|
+
//
|
|
4
|
+
// This module owns three things:
|
|
5
|
+
// 1. the provide/inject key (`HK_SCROLL_PROGRESS_REGISTRAR`) a list uses to
|
|
6
|
+
// find the registrar of the NEAREST enclosing HkScrollContainer that has
|
|
7
|
+
// the feature on — provide/inject scoping is what makes a list registered
|
|
8
|
+
// against an inner container never leak to an outer one;
|
|
9
|
+
// 2. the registration types (`HkScrollProgressHandle`,
|
|
10
|
+
// `HkScrollProgressRegistrar`);
|
|
11
|
+
// 3. the pill renderer itself — HkScrollContainer imports and mounts it
|
|
12
|
+
// inside its host, so the component stays internal to the feature.
|
|
13
|
+
//
|
|
14
|
+
// The pill shows bare digits "12 / 340" (first visible row / total rows) —
|
|
15
|
+
// deliberately locale-neutral, the same contract as HkLoadMore's counter.
|
|
16
|
+
import { defineComponent, type InjectionKey } from "vue";
|
|
17
|
+
|
|
18
|
+
import "./HkScrollProgress.scss";
|
|
19
|
+
|
|
20
|
+
/** What a list hands to the registrar so the pill can describe it:
|
|
21
|
+
* the list's root element (the box that scrolls inside the container's
|
|
22
|
+
* viewport) and the authoritative row total when the list knows one
|
|
23
|
+
* (undefined = the pill falls back to counting DOM rows). */
|
|
24
|
+
export interface HkScrollProgressHandle {
|
|
25
|
+
getRoot(): HTMLElement | null;
|
|
26
|
+
getTotal(): number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Provided by every HkScrollContainer instance whose `scrollProgress`
|
|
30
|
+
* prop is on, and never when the prop is off. Lists inject it (null
|
|
31
|
+
* fallback) and register on mounted / unregister on unmounted. */
|
|
32
|
+
export interface HkScrollProgressRegistrar {
|
|
33
|
+
register(handle: HkScrollProgressHandle): void;
|
|
34
|
+
unregister(handle: HkScrollProgressHandle): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const HK_SCROLL_PROGRESS_REGISTRAR: InjectionKey<HkScrollProgressRegistrar> =
|
|
38
|
+
Symbol("hk-scroll-progress-registrar");
|
|
39
|
+
|
|
40
|
+
/** The pill renderer. Three states, mirrored on `data-state` so the SCSS
|
|
41
|
+
* owns the paint:
|
|
42
|
+
* - "hidden" — a precondition fails (no registration, no overflow, no
|
|
43
|
+
* rows): `display: none`, never just transparent;
|
|
44
|
+
* - "active" — the scroll offset is changing: opaque;
|
|
45
|
+
* - "idle" — scrolling stopped: opacity 0 over the ~200ms fade.
|
|
46
|
+
* A11y honesty: `aria-hidden` throughout — the pill is DECORATIVE chrome
|
|
47
|
+
* and nothing announces the row position (HkInfiniteTail's role=status
|
|
48
|
+
* announces load / end-of-list events only, never a scroll position);
|
|
49
|
+
* this component adds no announce channel of its own. */
|
|
50
|
+
const HkScrollProgress = defineComponent({
|
|
51
|
+
name: "HkScrollProgress",
|
|
52
|
+
props: {
|
|
53
|
+
shown: { type: Boolean, default: false },
|
|
54
|
+
active: { type: Boolean, default: false },
|
|
55
|
+
numerator: { type: Number, default: 0 },
|
|
56
|
+
denominator: { type: Number, default: 0 },
|
|
57
|
+
},
|
|
58
|
+
setup(props) {
|
|
59
|
+
return () => (
|
|
60
|
+
<span
|
|
61
|
+
class="hk-scroll-progress-pill"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
data-state={!props.shown ? "hidden" : props.active ? "active" : "idle"}
|
|
64
|
+
>
|
|
65
|
+
{props.numerator} / {props.denominator}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export default HkScrollProgress;
|
package/src/index.ts
CHANGED
|
@@ -507,6 +507,12 @@ export type { PasswordValidationResult, PasswordLevel, PasswordStrengthEvaluator
|
|
|
507
507
|
export { FOCUSABLE_SELECTOR, getFocusableElements, focusFirst, trapFocus, scrollToElement } from "./utils/dom";
|
|
508
508
|
export { useApproachEnd } from "./composables/useApproachEnd";
|
|
509
509
|
export type { ApproachEndOptions, ApproachEndHandle } from "./composables/useApproachEnd";
|
|
510
|
+
// The scroll-progress pill's public vocabulary: HkScrollContainer's
|
|
511
|
+
// `scrollProgress` registrar — lists (HkAdminTablePage does it built-in)
|
|
512
|
+
// inject this key to register their row root + total with the nearest
|
|
513
|
+
// enclosing container that has the feature on.
|
|
514
|
+
export { HK_SCROLL_PROGRESS_REGISTRAR } from "./components/HkScrollProgress";
|
|
515
|
+
export type { HkScrollProgressHandle, HkScrollProgressRegistrar } from "./components/HkScrollProgress";
|
|
510
516
|
// The change-mark rail's public vocabulary: HkScrollContainer's `markers`
|
|
511
517
|
// prop is typed with it, so consumers can name what they pass.
|
|
512
518
|
export type { OverlayScrollMapSegment, OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
|