@celestia-island/hikari 0.55.295 → 0.55.298

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.295",
3
+ "version": "0.55.298",
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,16 +1,7 @@
1
- import {
2
- defineComponent,
3
- inject,
4
- onMounted,
5
- onUnmounted,
6
- ref,
7
- type Component,
8
- type PropType,
9
- } from "vue";
1
+ import { defineComponent, type Component, type PropType } from "vue";
10
2
  import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
11
3
  import HkCrossfade from "./HkCrossfade";
12
4
  import { HkPageHeader } from "./HkPageHeader";
13
- import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
14
5
  import type { HkRowManageGate } from "./HkTable";
15
6
 
16
7
 
@@ -76,9 +67,9 @@ export const HkAdminTablePage = defineComponent({
76
67
  },
77
68
  /** Authoritative row total the scroll-progress pill uses as its
78
69
  * 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). */
70
+ * usePagedTableFetch total). Forwarded to HTable, which
71
+ * self-registers with the enclosing shell viewport's registrar.
72
+ * Omitted: the pill falls back to counting the delivered DOM rows. */
82
73
  total: { type: Number, default: undefined },
83
74
  },
84
75
  emits: {
@@ -86,28 +77,6 @@ export const HkAdminTablePage = defineComponent({
86
77
  manage: (_row: Record<string, unknown>, _opener: HTMLElement) => true,
87
78
  },
88
79
  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
- });
110
-
111
80
  return () => {
112
81
  const { t } = useI18n();
113
82
  const emptyTitle = props.emptyTitle || t("hikari::tablePage.emptyTitle", "No data");
@@ -165,23 +134,22 @@ export const HkAdminTablePage = defineComponent({
165
134
  ) : slots.body ? (
166
135
  <div class="hk-admin-table-page-body">{slots.body()}</div>
167
136
  ) : (
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>
137
+ <HCard padded={false}>
138
+ <HTable
139
+ columns={props.columns}
140
+ rows={props.rows}
141
+ rowKey={props.rowKey}
142
+ total={props.total}
143
+ draggable={props.draggable}
144
+ manageable={props.manageable}
145
+ onReorder={(from: number, to: number) => emit("reorder", from, to)}
146
+ onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
147
+ emit("manage", row, opener)
148
+ }
149
+ >
150
+ {slots}
151
+ </HTable>
152
+ </HCard>
185
153
  )}
186
154
  </HkCrossfade>
187
155
  {slots["create-modal"]?.()}
@@ -146,3 +146,60 @@ describe("HkDrawer window-stack close channel", () => {
146
146
  }
147
147
  });
148
148
  });
149
+
150
+ describe("HkDrawer Escape precedence", () => {
151
+ async function settle(): Promise<void> {
152
+ await nextTick();
153
+ await new Promise((resolve) => setTimeout(resolve, 0));
154
+ await nextTick();
155
+ }
156
+
157
+ it("lets a nested control own Escape while a bare Escape still closes", async () => {
158
+ const open = ref(true);
159
+ const container = document.createElement("div");
160
+ document.body.appendChild(container);
161
+ containers.push(container);
162
+ const Wrapper = defineComponent({
163
+ setup() {
164
+ return () =>
165
+ h(HkDrawer, {
166
+ modelValue: open.value,
167
+ closable: true,
168
+ title: "Inspector",
169
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
170
+ }, {
171
+ default: () => h("div", {
172
+ class: "escape-owner",
173
+ onKeydown: (e: KeyboardEvent) => {
174
+ if (e.key === "Escape") e.preventDefault();
175
+ },
176
+ }, "nested control"),
177
+ });
178
+ },
179
+ });
180
+ const app = createApp(Wrapper);
181
+ mounts.push(app);
182
+ app.mount(container);
183
+ await settle();
184
+
185
+ const owner = document.body.querySelector<HTMLElement>(".hk-drawer-panel .escape-owner");
186
+ expect(owner, "the slot content renders inside the panel").not.toBeNull();
187
+
188
+ // `cancelable: true` is load-bearing: preventDefault() on a non-cancelable
189
+ // synthetic event is a no-op, and the drawer would then close as if
190
+ // nothing had consumed the key.
191
+ owner!.dispatchEvent(
192
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
193
+ );
194
+ await settle();
195
+ expect(open.value, "a consumed Escape must not close the drawer").toBe(true);
196
+
197
+ // A bare Escape (nothing below consumed it) still closes.
198
+ const panel = document.body.querySelector<HTMLElement>(".hk-drawer-panel")!;
199
+ panel.dispatchEvent(
200
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
201
+ );
202
+ await settle();
203
+ expect(open.value).toBe(false);
204
+ });
205
+ });
@@ -211,7 +211,12 @@ export default defineComponent({
211
211
  if (props.closable && props.overlay) close();
212
212
  }
213
213
 
214
- function onEscape() {
214
+ function onEscape(e: KeyboardEvent) {
215
+ // Precedence (the same class as HkModal's 0.55.295 fix): a nested
216
+ // control that already consumed Escape owns the key, so the drawer
217
+ // must not close underneath it. Precedence only — this surface has
218
+ // never preventDefault'd/stopPropagation'd Escape, and still does not.
219
+ if (e.defaultPrevented) return;
215
220
  if (props.closable) close();
216
221
  }
217
222
 
@@ -300,7 +305,7 @@ export default defineComponent({
300
305
  aria-modal={props.overlay}
301
306
  tabindex={-1}
302
307
  onKeydown={(e: KeyboardEvent) => {
303
- if (e.key === "Escape") onEscape();
308
+ if (e.key === "Escape") onEscape(e);
304
309
  else if (e.key === "Tab" && panelRef.value) trapFocus(panelRef.value, e);
305
310
  }}
306
311
  >
@@ -90,6 +90,29 @@ describe("HkImageLightbox", () => {
90
90
  expect(s.emitted).toEqual([false]);
91
91
  });
92
92
 
93
+ it("a nested consumed Escape does not close the lightbox", async () => {
94
+ const s = mountLightbox(true);
95
+ const dialog = s.dialog()!;
96
+ const owner = dialog.querySelector<HTMLElement>(".hk-image-viewer-img")!;
97
+ // Model a nested control that owns Escape. `cancelable: true` is
98
+ // load-bearing: preventDefault() on a non-cancelable event is a no-op.
99
+ owner.addEventListener("keydown", (e) => {
100
+ if (e.key === "Escape") e.preventDefault();
101
+ }, { once: true });
102
+
103
+ owner.dispatchEvent(
104
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
105
+ );
106
+ await nextTick();
107
+ expect(s.emitted, "a consumed Escape must not close the lightbox").toEqual([]);
108
+ expect(s.dialog()).not.toBeNull();
109
+
110
+ // A bare Escape (nothing below consumed it) still closes.
111
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true }));
112
+ await nextTick();
113
+ expect(s.emitted).toEqual([false]);
114
+ });
115
+
93
116
  it("the Escape listener is removed once closed (no stray closes)", async () => {
94
117
  const s = mountLightbox(true);
95
118
 
@@ -37,7 +37,12 @@ export default defineComponent({
37
37
  }
38
38
 
39
39
  function onWindowKeydown(e: KeyboardEvent) {
40
- if (e.key === "Escape") close();
40
+ // A nested control that consumed Escape owns the key. This listener
41
+ // sits on `window`, i.e. LAST in the bubble path, so
42
+ // `event.defaultPrevented` is the cross-component signal (same
43
+ // precedence rule as HkModal's 0.55.295 fix).
44
+ if (e.key !== "Escape" || e.defaultPrevented) return;
45
+ close();
41
46
  }
42
47
 
43
48
  watch(
@@ -0,0 +1,155 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref, type Ref } from "vue";
3
+
4
+ import HkPopover from "./HkPopover";
5
+
6
+ /**
7
+ * HkPopover Escape precedence (the same class as HkModal 0.55.295 and the
8
+ * HkDrawer / HkImageLightbox siblings).
9
+ *
10
+ * The popover panel hosts the consumer's DEFAULT SLOT, so it is a
11
+ * composition surface: a control inside it may consume Escape first
12
+ * (HkRangeRibbon resetting a brushed range, a date-picker trigger, a tag
13
+ * search). The popover must honour that consumption instead of closing the
14
+ * panel underneath the control. Its own consumption is unchanged — when it
15
+ * does close it still preventDefaults and still does not stopPropagation.
16
+ */
17
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
18
+
19
+ afterEach(() => {
20
+ for (const { app, container } of mounts.splice(0)) {
21
+ app.unmount();
22
+ container.remove();
23
+ }
24
+ document.body
25
+ .querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
26
+ .forEach((el) => el.remove());
27
+ });
28
+
29
+ async function settle(): Promise<void> {
30
+ await nextTick();
31
+ await new Promise((resolve) => setTimeout(resolve, 0));
32
+ await nextTick();
33
+ }
34
+
35
+ interface Setup {
36
+ open: Ref<boolean>;
37
+ emitted: boolean[];
38
+ panel: () => HTMLElement | null;
39
+ owner: () => HTMLElement | null;
40
+ }
41
+
42
+ function mountPopover(options: { closeOnEscape?: boolean } = {}): Setup {
43
+ const container = document.createElement("div");
44
+ document.body.appendChild(container);
45
+ const open = ref(true);
46
+ const emitted: boolean[] = [];
47
+ const Wrapper = defineComponent({
48
+ setup() {
49
+ return () =>
50
+ h(HkPopover as never, {
51
+ modelValue: open.value,
52
+ title: "probe",
53
+ closeOnEscape: options.closeOnEscape ?? true,
54
+ "onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
55
+ }, {
56
+ default: () => h("div", {
57
+ class: "escape-owner",
58
+ onKeydown: (e: KeyboardEvent) => {
59
+ if (e.key === "Escape") e.preventDefault();
60
+ },
61
+ }, "nested control"),
62
+ });
63
+ },
64
+ });
65
+ const app = createApp(Wrapper);
66
+ app.mount(container);
67
+ mounts.push({ app, container });
68
+ return {
69
+ open,
70
+ emitted,
71
+ panel: () => document.body.querySelector<HTMLElement>(".hk-popover-panel"),
72
+ owner: () => document.body.querySelector<HTMLElement>(".hk-popover-panel .escape-owner"),
73
+ };
74
+ }
75
+
76
+ describe("HkPopover Escape precedence", () => {
77
+ it("lets a nested slot control own Escape", async () => {
78
+ const s = mountPopover();
79
+ await settle();
80
+ const owner = s.owner();
81
+ expect(owner, "the slot control renders inside the panel").not.toBeNull();
82
+
83
+ // `cancelable: true` is load-bearing: preventDefault() on a
84
+ // non-cancelable synthetic event is a no-op, and the popover would then
85
+ // close as if nothing had consumed the key.
86
+ owner!.dispatchEvent(
87
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
88
+ );
89
+ await settle();
90
+
91
+ expect(s.emitted, "a consumed Escape must not close the popover").toEqual([]);
92
+ expect(s.open.value).toBe(true);
93
+ expect(s.panel(), "the panel stays mounted").not.toBeNull();
94
+ });
95
+
96
+ it("a bare Escape still closes, is consumed, and is not stopPropagation'd", async () => {
97
+ const s = mountPopover();
98
+ await settle();
99
+ const panel = s.panel();
100
+ expect(panel, "the panel renders").not.toBeNull();
101
+
102
+ const bare = new KeyboardEvent("keydown", {
103
+ key: "Escape",
104
+ bubbles: true,
105
+ cancelable: true,
106
+ });
107
+ let propagatedToDocument = false;
108
+ const onDocumentKeydown = () => {
109
+ propagatedToDocument = true;
110
+ };
111
+ document.addEventListener("keydown", onDocumentKeydown);
112
+ try {
113
+ panel!.dispatchEvent(bare);
114
+ } finally {
115
+ document.removeEventListener("keydown", onDocumentKeydown);
116
+ }
117
+ await settle();
118
+
119
+ expect(s.emitted).toEqual([false]);
120
+ expect(s.open.value).toBe(false);
121
+ // The popover's own consumption, unchanged by the precedence guard.
122
+ expect(bare.defaultPrevented, "the popover still consumes the key it acts on").toBe(true);
123
+ expect(propagatedToDocument, "the popover still does not stopPropagation").toBe(true);
124
+ });
125
+
126
+ it("closeOnEscape=false never closes, bare or consumed", async () => {
127
+ const s = mountPopover({ closeOnEscape: false });
128
+ await settle();
129
+ const owner = s.owner();
130
+ const panel = s.panel();
131
+ expect(owner, "the slot control renders inside the panel").not.toBeNull();
132
+ expect(panel, "the panel renders").not.toBeNull();
133
+
134
+ // A consumed Escape changes nothing either way. This also pins the
135
+ // invariant that lets a future refactor hoist the defaultPrevented
136
+ // guard above the closeOnEscape check safely: close() must stay gated
137
+ // on the prop, and this case proves that gate is load-bearing.
138
+ owner!.dispatchEvent(
139
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
140
+ );
141
+ await settle();
142
+
143
+ const bare = new KeyboardEvent("keydown", {
144
+ key: "Escape",
145
+ bubbles: true,
146
+ cancelable: true,
147
+ });
148
+ panel!.dispatchEvent(bare);
149
+ await settle();
150
+
151
+ expect(s.emitted).toEqual([]);
152
+ expect(s.open.value).toBe(true);
153
+ expect(bare.defaultPrevented, "no close => no consumption").toBe(false);
154
+ });
155
+ });
@@ -627,6 +627,14 @@ export default defineComponent({
627
627
 
628
628
  function onPanelKeydown(e: KeyboardEvent) {
629
629
  if (e.key === "Escape" && props.closeOnEscape) {
630
+ // Precedence (the same class as HkModal 0.55.295 and the
631
+ // HkDrawer / HkImageLightbox siblings): the panel hosts the
632
+ // consumer's default slot, so a control inside it that already
633
+ // consumed Escape (preventDefault) owns the key — the popover must
634
+ // not close underneath it. The popover's own consumption is
635
+ // unchanged: it still preventDefaults when it closes, and it has
636
+ // never stopped propagation.
637
+ if (e.defaultPrevented) return;
630
638
  e.preventDefault();
631
639
  close();
632
640
  }
@@ -1149,6 +1149,23 @@ describe("HkRangeRibbon a11y and i18n", () => {
1149
1149
  expect(host.windowChange).not.toHaveBeenCalled();
1150
1150
  expect(laneSegments(host.root)).toHaveLength(4);
1151
1151
  });
1152
+
1153
+ it("does not advertise the range affordance when the window is disabled", () => {
1154
+ const disabled = mountRibbon({ windowEnabled: false, window: null });
1155
+ const disabledStrip = disabled.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1156
+ expect(disabledStrip.hasAttribute("title"), "no drag hint on a disabled strip").toBe(false);
1157
+ expect(disabledStrip.hasAttribute("aria-describedby"), "nothing to describe").toBe(false);
1158
+ expect(disabled.querySelector(".hk-range-ribbon-hint"), "no sr-only hint").toBeNull();
1159
+
1160
+ // The enabled ribbon still carries all three.
1161
+ const enabled = mountRibbon({ window: null });
1162
+ const enabledStrip = enabled.querySelector<HTMLElement>(".hk-range-ribbon-strip")!;
1163
+ expect(enabledStrip.getAttribute("title")).toContain("Drag to select a range");
1164
+ const describedBy = enabledStrip.getAttribute("aria-describedby");
1165
+ expect(describedBy, "the enabled strip describes its keyboard contract").toBeTruthy();
1166
+ expect(document.getElementById(describedBy!), "the hint element exists").toBeTruthy();
1167
+ expect(enabled.querySelector(".hk-range-ribbon-hint")).toBeTruthy();
1168
+ });
1152
1169
  });
1153
1170
 
1154
1171
  // ── Controlled / uncontrolled switch ────────────────────────────────────
@@ -578,7 +578,7 @@ export default defineComponent({
578
578
  aria-orientation="horizontal"
579
579
  tabindex={props.windowEnabled ? 0 : -1}
580
580
  aria-label={t("hikari::ribbon.rangeAria", "Observation range")}
581
- aria-describedby={hintId}
581
+ aria-describedby={props.windowEnabled ? hintId : undefined}
582
582
  aria-disabled={props.windowEnabled ? undefined : "true"}
583
583
  aria-valuemin={0}
584
584
  aria-valuemax={100}
@@ -586,10 +586,14 @@ export default defineComponent({
586
586
  aria-valuetext={`${Math.round((win ? win[0] : 0) * 100)}% – ${Math.round(
587
587
  (win ? win[1] : 1) * 100,
588
588
  )}%`}
589
- title={t(
590
- "hikari::ribbon.dragHint",
591
- "Drag to select a range; double-click, Escape or Home resets",
592
- )}
589
+ title={
590
+ props.windowEnabled
591
+ ? t(
592
+ "hikari::ribbon.dragHint",
593
+ "Drag to select a range; double-click, Escape or Home resets",
594
+ )
595
+ : undefined
596
+ }
593
597
  onPointerdown={onStripPointerDown}
594
598
  onDblclick={(event: MouseEvent) => {
595
599
  // Same rule as Escape/Home: only a double-click that actually
@@ -701,18 +705,20 @@ export default defineComponent({
701
705
  </>
702
706
  )}
703
707
  </div>
704
- <span id={hintId} class="hk-range-ribbon-hint">
705
- {`${t(
706
- "hikari::ribbon.moveWindow",
707
- "Move window",
708
- )}: ←/→ · ${t(
709
- "hikari::ribbon.resizeWindow",
710
- "Resize window",
711
- )}: ⇧+←/→ · ${t(
712
- "hikari::ribbon.dragHint",
713
- "Drag to select a range; double-click, Escape or Home resets",
714
- )}`}
715
- </span>
708
+ {props.windowEnabled && (
709
+ <span id={hintId} class="hk-range-ribbon-hint">
710
+ {`${t(
711
+ "hikari::ribbon.moveWindow",
712
+ "Move window",
713
+ )}: ←/→ · ${t(
714
+ "hikari::ribbon.resizeWindow",
715
+ "Resize window",
716
+ )}: ⇧+←/→ · ${t(
717
+ "hikari::ribbon.dragHint",
718
+ "Drag to select a range; double-click, Escape or Home resets",
719
+ )}`}
720
+ </span>
721
+ )}
716
722
  </div>
717
723
  <div class="hk-range-ribbon-lanes">
718
724
  {lanes.value.map((lane) => (
@@ -439,11 +439,11 @@ describe("HkAdminTablePage scroll-progress registration", () => {
439
439
  rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
440
440
  left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
441
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).
442
+ // The list root is HkTable's own host element — the table
443
+ // self-registers (the page only forwards the total).
444
444
  const card = container.querySelector("table");
445
445
  expect(card).not.toBeNull();
446
- const cardHost = container.querySelector<HTMLElement>(".hk-admin-table-page-tablecard")!;
446
+ const cardHost = container.querySelector<HTMLElement>(".hk-table-host")!;
447
447
  expect(cardHost.contains(card!)).toBe(true);
448
448
  rects.push(vi.spyOn(cardHost, "getBoundingClientRect").mockReturnValue({
449
449
  left: 0, top: -200, right: 300, bottom: 3800, width: 300, height: 4000, x: 0, y: -200,
@@ -1,4 +1,4 @@
1
- import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
1
+ import { computed, defineComponent, inject, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
  import { usePointerReorder } from "../composables/usePointerReorder";
@@ -10,6 +10,7 @@ import { ancestorZoom } from "../runtime/cssZoom";
10
10
  import { useClipboardWithToast } from "../runtime/useClipboard";
11
11
  import { useToast } from "../runtime/useToast";
12
12
  import HkRowActions from "./HkRowActions";
13
+ import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
13
14
  import "./HkTable.scss";
14
15
 
15
16
  interface Column {
@@ -91,6 +92,14 @@ export default defineComponent({
91
92
  default: false,
92
93
  },
93
94
  emptyText: { type: String, default: "" },
95
+ /** Authoritative row total the scroll-progress pill uses as its
96
+ * denominator (the server count behind a paged list). Omitted: the
97
+ * pill falls back to counting the delivered DOM rows. Every HkTable
98
+ * self-registers with the nearest enclosing HkScrollContainer that
99
+ * has `scrollProgress` on — no wrapper needed (2026-11-06: moved
100
+ * here from HkAdminTablePage so the 17 bare-HkTable views inherit
101
+ * the pill too). */
102
+ total: { type: Number, default: undefined },
94
103
  },
95
104
  emits: {
96
105
  "update:selectedRows": (_rows: Record<string, unknown>[]) => true,
@@ -195,6 +204,24 @@ export default defineComponent({
195
204
  wrapperScrollbar = null;
196
205
  });
197
206
 
207
+ // Scroll-progress registration: the nearest enclosing HkScrollContainer
208
+ // with `scrollProgress` on provides the registrar (provide/inject
209
+ // scoping resolves the shell viewport for admin pages and keeps an
210
+ // inner container's lists out of the shell). The handle exposes the
211
+ // table host as the row root plus the `total` prop live — the pill's
212
+ // denominator follows it as pages load.
213
+ const progressRegistrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
214
+ const progressHandle: HkScrollProgressHandle = {
215
+ getRoot: () => wrapperHostRef.value ?? null,
216
+ getTotal: () => props.total,
217
+ };
218
+ onMounted(() => {
219
+ progressRegistrar?.register(progressHandle);
220
+ });
221
+ onBeforeUnmount(() => {
222
+ progressRegistrar?.unregister(progressHandle);
223
+ });
224
+
198
225
  function getRowKey(row: Record<string, unknown>, index: number): string {
199
226
  if (props.rowKey && row[props.rowKey] != null) return String(row[props.rowKey]);
200
227
  return String(index);