@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.290",
3
+ "version": "0.55.292",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -1,7 +1,16 @@
1
- import { defineComponent, type Component, type PropType } from "vue";
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
- <HCard padded={false}>
133
- <HTable
134
- columns={props.columns}
135
- rows={props.rows}
136
- rowKey={props.rowKey}
137
- draggable={props.draggable}
138
- manageable={props.manageable}
139
- onReorder={(from: number, to: number) => emit("reorder", from, to)}
140
- onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
141
- emit("manage", row, opener)
142
- }
143
- >
144
- {slots}
145
- </HTable>
146
- </HCard>
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";