@celestia-island/hikari 0.55.244 → 0.55.245

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.244",
3
+ "version": "0.55.245",
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",
@@ -542,4 +542,36 @@ describe("HkSidebar standalone rail: the menu group fold survives its own swap",
542
542
  "the expanded sidebar inherits the closed group",
543
543
  ).toBe("false");
544
544
  });
545
+
546
+ it("keeps the scroll position across density flips (the #44 P3 audit finding)", async () => {
547
+ vi.useFakeTimers();
548
+ const c = mountSidebar({ width: "240px", collapsible: true, collapsedWidth: "64px" });
549
+
550
+ // Collapse to the rail and settle the dissolve.
551
+ c.toggle().click();
552
+ await vi.advanceTimersByTimeAsync(500);
553
+ const bodies = () => c.container.querySelectorAll<HTMLElement>(".hk-sidebar-body");
554
+ const active = () => bodies()[bodies().length - 1]!;
555
+ expect(bodies().length, "one settled generation").toBe(1);
556
+
557
+ // The user scrolls the rail partway down…
558
+ active().scrollTop = 120;
559
+
560
+ // …then a peek flips the density (the shell keys the swap on
561
+ // `railActive && !peeking`, so every peek open/close is a flip).
562
+ c.aside().dispatchEvent(new Event("pointerenter"));
563
+ await vi.advanceTimersByTimeAsync(900);
564
+ expect(active().scrollTop, "the peek's full generation resumes the scroll").toBe(120);
565
+
566
+ // Leaving the peek flips back to the rail dress; the position must
567
+ // survive that remount too.
568
+ c.aside().dispatchEvent(new Event("pointerleave"));
569
+ await vi.advanceTimersByTimeAsync(900);
570
+ expect(active().scrollTop, "the rail generation resumes the scroll").toBe(120);
571
+
572
+ // And a full expand is the same crossing.
573
+ c.toggle().click();
574
+ await vi.advanceTimersByTimeAsync(900);
575
+ expect(active().scrollTop, "the expanded body resumes the scroll").toBe(120);
576
+ });
545
577
  });
@@ -1,4 +1,4 @@
1
- import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from "vue";
1
+ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, nextTick } from "vue";
2
2
 
3
3
  import "./HkSidebar.scss";
4
4
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
@@ -143,6 +143,34 @@ export default defineComponent({
143
143
  onMounted(attachBodyScrollbar);
144
144
  watch([bodyRef, bodyWrapRef], attachBodyScrollbar);
145
145
 
146
+ // Density flips remount the body band (the rail swap renders each
147
+ // generation's body FRESH), which used to snap the scroll back to the
148
+ // top on every flip — including each hover-peek open/close, because
149
+ // the shell keys the swap on `railActive && !peeking` (the #44 P3
150
+ // audit finding; probed: scrollTop 120 -> peek -> 0 -> back -> 0).
151
+ // The sidebar's own instance SURVIVES the flip (the swap lives inside
152
+ // it), so the position only needs to cross the remount. A flip patches
153
+ // the shared ref old -> null -> new: the null step still sees the
154
+ // LEAVING element as the old value (its DOM is alive for the
155
+ // dissolve), which is where the position is read from — an event-only
156
+ // save would miss programmatic scrolls (no `scroll` event fires for
157
+ // them), so the read happens on the ref hand-off itself and the
158
+ // onScroll below merely keeps the value fresh between flips. Raw
159
+ // scrollTop is an approximation across dresses (rail rows and full
160
+ // rows have different heights) — an exact restore would need an
161
+ // item-keyed anchor, not worth it for a nav list; the browser clamps
162
+ // any overshoot.
163
+ const savedScrollTop = ref(0);
164
+ const onBodyScroll = (e: Event): void => {
165
+ savedScrollTop.value = (e.currentTarget as HTMLElement).scrollTop;
166
+ };
167
+ watch(bodyRef, async (body, leaving) => {
168
+ if (leaving) savedScrollTop.value = leaving.scrollTop;
169
+ if (!body) return;
170
+ await nextTick();
171
+ body.scrollTop = savedScrollTop.value;
172
+ });
173
+
146
174
  onBeforeUnmount(() => {
147
175
  bodyScrollbar?.detach();
148
176
  bodyScrollbar = null;
@@ -199,7 +227,7 @@ export default defineComponent({
199
227
  <header class="hk-sidebar-header">{slots.header()}</header>
200
228
  ) : null}
201
229
  <div ref={bodyWrapRef} class="hk-sidebar-body-wrap">
202
- <div ref={bodyRef} class="hk-sidebar-body">
230
+ <div ref={bodyRef} class="hk-sidebar-body" onScroll={onBodyScroll}>
203
231
  {/* Scoped params (ignored by plain slots): the density that
204
232
  owns THIS generation so a host renders icon-only rows
205
233
  while collapsed and full rows during a peek, plus the