@celestia-island/hikari 0.55.243 → 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
|
@@ -33,6 +33,7 @@ vi.mock("@celestia-island/hikari", async (importOriginal) => {
|
|
|
33
33
|
|
|
34
34
|
import { HkFaceShell } from "./HkFaceShell";
|
|
35
35
|
import { HkAdminShell } from "./HkAdminShell";
|
|
36
|
+
import { provideMenuGroupState } from "../composables/useMenuGroupState";
|
|
36
37
|
import HkMenu, { type HkMenuItem } from "./HkMenu";
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -722,6 +723,73 @@ async function settle(container: HTMLElement, key: "rail" | "full"): Promise<HTM
|
|
|
722
723
|
return generation(container, key);
|
|
723
724
|
}
|
|
724
725
|
|
|
726
|
+
describe("rail group fold — a host that OWNS the slot provides the store", () => {
|
|
727
|
+
beforeEach(() => {
|
|
728
|
+
vi.useFakeTimers();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("carries the fold through a rail -> peek flip (chest's AdminLayout shape)", async () => {
|
|
732
|
+
// chest defines the shell's sidebar slot from AdminLayout, so the slot
|
|
733
|
+
// content's component PARENT is AdminLayout — not the shell. A provide
|
|
734
|
+
// inside HkFaceShell therefore cannot reach it (measured on chest:
|
|
735
|
+
// 0.55.240 lost the fold there while the shell's own slot kept it).
|
|
736
|
+
// The fix for that shape is the host starting the chain itself:
|
|
737
|
+
// provideMenuGroupState() in ITS setup, above the swap. This test is
|
|
738
|
+
// that shape, and it goes red if the export stops reaching slot
|
|
739
|
+
// content or if HkMenu stops injecting.
|
|
740
|
+
const container = document.createElement("div");
|
|
741
|
+
document.body.appendChild(container);
|
|
742
|
+
const Wrapper = defineComponent({
|
|
743
|
+
name: "SlotOwnerHarness",
|
|
744
|
+
setup() {
|
|
745
|
+
provideMenuGroupState();
|
|
746
|
+
const railStyle = ref<"expanded" | "rail">("rail");
|
|
747
|
+
return () =>
|
|
748
|
+
h(HkFaceShell, {
|
|
749
|
+
navTitle: "Navigation",
|
|
750
|
+
sidebarCollapsible: true,
|
|
751
|
+
sidebarRailStyle: railStyle.value,
|
|
752
|
+
"onUpdate:sidebarRailStyle": (v: string) => {
|
|
753
|
+
railStyle.value = v as "expanded" | "rail";
|
|
754
|
+
},
|
|
755
|
+
}, {
|
|
756
|
+
header: () => null,
|
|
757
|
+
sidebar: (params: { collapsed: boolean }) =>
|
|
758
|
+
h(HkMenu, {
|
|
759
|
+
variant: "sidebar",
|
|
760
|
+
items: GROUP_ITEMS,
|
|
761
|
+
open: false,
|
|
762
|
+
defaultExpandGroups: true,
|
|
763
|
+
collapsed: params.collapsed,
|
|
764
|
+
}),
|
|
765
|
+
content: () => h("div", { class: "content-mock" }, "CONTENT"),
|
|
766
|
+
});
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
const app = createApp(Wrapper);
|
|
770
|
+
app.mount(container);
|
|
771
|
+
mounts.push({ app, container });
|
|
772
|
+
|
|
773
|
+
const railGen = await settle(container, "rail");
|
|
774
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("true");
|
|
775
|
+
groupToggle(railGen).click();
|
|
776
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
777
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
|
|
778
|
+
|
|
779
|
+
// Peek: the slot is handed full rows — a brand-new HkMenu instance.
|
|
780
|
+
container
|
|
781
|
+
.querySelector<HTMLElement>(".hk-sidebar-rail-host")!
|
|
782
|
+
.dispatchEvent(POINTER_ENTER());
|
|
783
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
784
|
+
const peekGen = await settle(container, "full");
|
|
785
|
+
expect(
|
|
786
|
+
groupToggle(peekGen).getAttribute("aria-expanded"),
|
|
787
|
+
"the host-provided store crosses into the peek's fresh generation",
|
|
788
|
+
).toBe("false");
|
|
789
|
+
expect(peekGen.textContent, "and the peek lists no children").not.toContain("Audit log");
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
725
793
|
describe("rail group fold survives the crossfade generations (#43)", () => {
|
|
726
794
|
beforeEach(() => {
|
|
727
795
|
vi.useFakeTimers();
|
|
@@ -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
|
|
@@ -41,6 +41,17 @@ import { inject, provide, shallowRef, type InjectionKey } from "vue";
|
|
|
41
41
|
* menus mounted in the same shell share one key space — name their group
|
|
42
42
|
* keys distinctly (they already name destinations, so this is the
|
|
43
43
|
* natural convention).
|
|
44
|
+
*
|
|
45
|
+
* HOSTS THAT OWN THE SLOT THEMSELVES (chest's console, 2026-10-31): a
|
|
46
|
+
* slot's component parent is the component that DEFINED it, so a
|
|
47
|
+
* provide inside HkFaceShell does NOT reach content the host writes for
|
|
48
|
+
* the shell's sidebar slot — the host is that content's parent and the
|
|
49
|
+
* shell is only its DOM/CSS host (measured: chest's grouped nav lost the
|
|
50
|
+
* fold on 0.55.240 while the shell's own slot content kept it). Such a
|
|
51
|
+
* host starts the chain at its own setup level with
|
|
52
|
+
* provideMenuGroupState (one call, ABOVE the swap — the page component
|
|
53
|
+
* survives a density flip). HkMenu's inject then finds it through the
|
|
54
|
+
* host, and every generation the swap mounts shares it.
|
|
44
55
|
*/
|
|
45
56
|
|
|
46
57
|
/** The fold store a shell provides above its sidebar swap. */
|
package/src/index.ts
CHANGED
|
@@ -481,8 +481,25 @@ export {
|
|
|
481
481
|
SIDEBAR_RAIL_PEEK_DELAY,
|
|
482
482
|
SIDEBAR_RAIL_PEEK_GRACE,
|
|
483
483
|
SIDEBAR_RAIL_PIN_FALLBACK,
|
|
484
|
+
SIDEBAR_RAIL_CLIP_SELECTOR,
|
|
485
|
+
SIDEBAR_RAIL_TOGGLE_BUTTON_SELECTOR,
|
|
484
486
|
} from "./composables/useSidebarRail";
|
|
485
487
|
export type { SidebarRailOptions, SidebarRailHandle } from "./composables/useSidebarRail";
|
|
488
|
+
// The sidebar GROUP FOLD store (known-live-issues #43). HkFaceShell and
|
|
489
|
+
// HkSidebar provide one above their rail swap, so slot content THEY own
|
|
490
|
+
// shares the fold across density generations. A host that defines the
|
|
491
|
+
// shell's sidebar slot ITSELF (chest's AdminLayout) owns the slot's
|
|
492
|
+
// component parent — a provide from inside the shell cannot reach it —
|
|
493
|
+
// and must start the chain at its own setup level:
|
|
494
|
+
//
|
|
495
|
+
// setup() { provideMenuGroupState(); return () => h(HkFaceShell, …) }
|
|
496
|
+
//
|
|
497
|
+
// Call it ABOVE the swap (i.e. in the component that survives a flip).
|
|
498
|
+
export {
|
|
499
|
+
provideMenuGroupState,
|
|
500
|
+
useMenuGroupState,
|
|
501
|
+
} from "./composables/useMenuGroupState";
|
|
502
|
+
export type { MenuGroupState } from "./composables/useMenuGroupState";
|
|
486
503
|
export {
|
|
487
504
|
drawnScale,
|
|
488
505
|
frameMetrics,
|