@celestia-island/hikari 0.55.229 → 0.55.230
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/HkAdminShell.tsx +28 -0
- package/src/components/HkFaceShell.rail.test.tsx +370 -0
- package/src/components/HkFaceShell.tsx +137 -13
- package/src/components/HkMenu.rail.test.tsx +187 -0
- package/src/components/HkMenu.scss +55 -0
- package/src/components/HkMenu.tsx +77 -0
- package/src/components/HkSidebar.rail.test.tsx +141 -0
- package/src/components/HkSidebar.tsx +102 -7
- package/src/components/HkSidebarRail.scss +116 -0
- package/src/components/HkSidebarRail.styles.test.ts +163 -0
- package/src/components/HkSidebarRailToggle.test.tsx +94 -0
- package/src/components/HkSidebarRailToggle.tsx +67 -0
- package/src/composables/useSidebarRail.test.ts +249 -0
- package/src/composables/useSidebarRail.ts +194 -0
- package/src/i18n/locales/ar/components.json +2 -0
- package/src/i18n/locales/de/components.json +2 -0
- package/src/i18n/locales/en/components.json +2 -0
- package/src/i18n/locales/es/components.json +2 -0
- package/src/i18n/locales/fr/components.json +2 -0
- package/src/i18n/locales/ja/components.json +2 -0
- package/src/i18n/locales/ko/components.json +2 -0
- package/src/i18n/locales/pt/components.json +2 -0
- package/src/i18n/locales/ru/components.json +2 -0
- package/src/i18n/locales/zh-Hans/components.json +2 -0
- package/src/i18n/locales/zh-Hant/components.json +2 -0
- package/src/index.ts +14 -0
- package/src/styles/sidebar-rail.scss +11 -0
package/package.json
CHANGED
|
@@ -37,6 +37,25 @@ export const HkAdminShell = defineComponent({
|
|
|
37
37
|
props: {
|
|
38
38
|
sidebarCollapsed: { type: Boolean, default: false },
|
|
39
39
|
sidebarWidth: { type: String, default: "224px" },
|
|
40
|
+
/** Desktop icon-rail capability, forwarded to HkFaceShell (see its
|
|
41
|
+
* prop doc): opt in here and the admin sidebar gains the circular
|
|
42
|
+
* hover-reveal toggle, the one-icon rail and the overlay peek
|
|
43
|
+
* drawer. Declared on the wrapper on purpose — the same
|
|
44
|
+
* honest-forwarding rule as contentMode (a stray attr must not
|
|
45
|
+
* reach the inner shell's declared prop through fallthrough). */
|
|
46
|
+
sidebarCollapsible: { type: Boolean, default: false },
|
|
47
|
+
/** Layout width of the icon rail (default 56px), forwarded. */
|
|
48
|
+
sidebarCollapsedWidth: { type: String, default: "56px" },
|
|
49
|
+
/** Rail state channel, `v-model:sidebar-rail-style` ("expanded" |
|
|
50
|
+
* "rail"), forwarded — session-only unless the host persists it. */
|
|
51
|
+
sidebarRailStyle: {
|
|
52
|
+
type: String as PropType<"expanded" | "rail">,
|
|
53
|
+
default: "expanded",
|
|
54
|
+
},
|
|
55
|
+
/** Hover dwell before the peek opens, ms, forwarded. */
|
|
56
|
+
sidebarPeekDelay: { type: Number, default: 400 },
|
|
57
|
+
/** Grace after pointerleave before the peek closes, ms, forwarded. */
|
|
58
|
+
sidebarPeekGrace: { type: Number, default: 150 },
|
|
40
59
|
/** Viewport width at which the desktop layout takes over (below it
|
|
41
60
|
* the nav collapses into the mobile drawer). Defaults to the shared
|
|
42
61
|
* 1024px "lg" breakpoint; lower it (e.g. 768) for tablet-friendly
|
|
@@ -106,6 +125,15 @@ export const HkAdminShell = defineComponent({
|
|
|
106
125
|
h(HkFaceShell, {
|
|
107
126
|
sidebarCollapsed: props.sidebarCollapsed,
|
|
108
127
|
sidebarWidth: props.sidebarWidth,
|
|
128
|
+
sidebarCollapsible: props.sidebarCollapsible,
|
|
129
|
+
sidebarCollapsedWidth: props.sidebarCollapsedWidth,
|
|
130
|
+
sidebarRailStyle: props.sidebarRailStyle,
|
|
131
|
+
sidebarPeekDelay: props.sidebarPeekDelay,
|
|
132
|
+
sidebarPeekGrace: props.sidebarPeekGrace,
|
|
133
|
+
// The v-model channel rides the inner shell's event straight
|
|
134
|
+
// through this wrapper's listeners (attrs fallthrough carries
|
|
135
|
+
// onUpdate:sidebarRailStyle to HkFaceShell — inheritAttrs stays
|
|
136
|
+
// on, the default), so the host binding is one wire end-to-end.
|
|
109
137
|
mobileBreakpoint: props.mobileBreakpoint,
|
|
110
138
|
footerHeight: props.footerHeight,
|
|
111
139
|
navTitle: props.navTitle,
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
// Same stub shape as HkFaceShell.test.tsx: HDrawer teleports and
|
|
5
|
+
// animates; the desktop rail contract is testable without the overlay
|
|
6
|
+
// runtime.
|
|
7
|
+
vi.mock("@celestia-island/hikari", async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import("@celestia-island/hikari")>();
|
|
9
|
+
const { defineComponent, h } = await import("vue");
|
|
10
|
+
const HDrawerStub = defineComponent({
|
|
11
|
+
name: "HDrawer",
|
|
12
|
+
props: {
|
|
13
|
+
modelValue: { type: Boolean, default: false },
|
|
14
|
+
side: { type: String, default: "left" },
|
|
15
|
+
size: { type: String, default: "280px" },
|
|
16
|
+
title: { type: String, default: undefined },
|
|
17
|
+
panelClass: { type: String, default: undefined },
|
|
18
|
+
},
|
|
19
|
+
setup(props, { slots }) {
|
|
20
|
+
return () =>
|
|
21
|
+
props.modelValue
|
|
22
|
+
? h("div", { class: ["drawer-stub", props.panelClass] }, [
|
|
23
|
+
h("div", { class: "drawer-stub-body" }, slots.default?.()),
|
|
24
|
+
slots.footer
|
|
25
|
+
? h("div", { class: "drawer-stub-footer" }, slots.footer())
|
|
26
|
+
: null,
|
|
27
|
+
])
|
|
28
|
+
: null;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return { ...actual, HDrawer: HDrawerStub };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
import { HkFaceShell } from "./HkFaceShell";
|
|
35
|
+
import { HkAdminShell } from "./HkAdminShell";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* HkFaceShell desktop rail + peek integration (2026-10-30). What this
|
|
39
|
+
* file pins:
|
|
40
|
+
*
|
|
41
|
+
* 1. OPT-IN: without sidebarCollapsible the sidebar renders the legacy
|
|
42
|
+
* aside — no rail classes, no toggle, slot collapsed stays false and
|
|
43
|
+
* carries no peeking param.
|
|
44
|
+
* 2. The rail channel: the toggle drives v-model:sidebar-rail-style, the
|
|
45
|
+
* host width collapses to sidebarCollapsedWidth, the slot receives
|
|
46
|
+
* collapsed: true.
|
|
47
|
+
* 3. The PEEK overlay: after the dwell (pointerenter + 400ms) the panel
|
|
48
|
+
* grows to the full width while the HOST width — the only layout
|
|
49
|
+
* lever over the content — STAYS at the rail width (the "content
|
|
50
|
+
* never shifts" core contract), and the slot flips back to full rows
|
|
51
|
+
* (collapsed: false, peeking: true).
|
|
52
|
+
* 4. Grace + flicker: leave closes only after the grace; re-entry inside
|
|
53
|
+
* the grace keeps the peek open.
|
|
54
|
+
* 5. Escape closes; a nav click (the slot's onNavigate) closes.
|
|
55
|
+
* 6. HkAdminShell forwards the whole prop family (source pin, the same
|
|
56
|
+
* honest-forwarding rule as contentMode) and mounts the rail.
|
|
57
|
+
*
|
|
58
|
+
* The harness closes the v-model loop with a stateful wrapper — exactly
|
|
59
|
+
* what a real host's v-model:sidebar-rail-style does.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
63
|
+
|
|
64
|
+
interface SlotParams {
|
|
65
|
+
collapsed: boolean;
|
|
66
|
+
onNavigate: () => void;
|
|
67
|
+
peeking?: boolean;
|
|
68
|
+
[key: string]: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface RailMount {
|
|
72
|
+
container: HTMLElement;
|
|
73
|
+
aside(): HTMLElement;
|
|
74
|
+
panel(): HTMLElement;
|
|
75
|
+
main(): HTMLElement;
|
|
76
|
+
toggle(): HTMLButtonElement;
|
|
77
|
+
slotParams: SlotParams[];
|
|
78
|
+
emitted: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mountShell(props: Record<string, unknown> = {}): RailMount {
|
|
82
|
+
const slotParams: SlotParams[] = [];
|
|
83
|
+
const emitted: string[] = [];
|
|
84
|
+
const container = document.createElement("div");
|
|
85
|
+
document.body.appendChild(container);
|
|
86
|
+
|
|
87
|
+
const Wrapper = defineComponent({
|
|
88
|
+
name: "RailShellHarness",
|
|
89
|
+
setup() {
|
|
90
|
+
const railStyle = ref<"expanded" | "rail">(
|
|
91
|
+
(props.sidebarRailStyle as "expanded" | "rail") ?? "expanded",
|
|
92
|
+
);
|
|
93
|
+
return () =>
|
|
94
|
+
h(HkFaceShell, {
|
|
95
|
+
navTitle: "Navigation",
|
|
96
|
+
...props,
|
|
97
|
+
sidebarRailStyle: railStyle.value,
|
|
98
|
+
"onUpdate:sidebarRailStyle": (v: string) => {
|
|
99
|
+
emitted.push(v);
|
|
100
|
+
railStyle.value = v as "expanded" | "rail";
|
|
101
|
+
},
|
|
102
|
+
}, {
|
|
103
|
+
header: () => null,
|
|
104
|
+
sidebar: (params: SlotParams) => {
|
|
105
|
+
slotParams.push(params);
|
|
106
|
+
return h("div", { class: "sidebar-mock" }, "NAV");
|
|
107
|
+
},
|
|
108
|
+
content: () => h("div", { class: "content-mock" }, "CONTENT"),
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const app = createApp(Wrapper);
|
|
114
|
+
app.mount(container);
|
|
115
|
+
mounts.push({ app, container });
|
|
116
|
+
return {
|
|
117
|
+
container,
|
|
118
|
+
aside: () => container.querySelector(".hk-sidebar-rail-host") as HTMLElement,
|
|
119
|
+
panel: () => container.querySelector(".hk-sidebar-rail-panel") as HTMLElement,
|
|
120
|
+
main: () => container.querySelector(".s-admin-shell-main") as HTMLElement,
|
|
121
|
+
toggle: () =>
|
|
122
|
+
container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement,
|
|
123
|
+
slotParams,
|
|
124
|
+
emitted,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
130
|
+
app.unmount();
|
|
131
|
+
container.remove();
|
|
132
|
+
}
|
|
133
|
+
vi.useRealTimers();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const POINTER_ENTER = () => new Event("pointerenter");
|
|
137
|
+
const POINTER_LEAVE = () => new Event("pointerleave");
|
|
138
|
+
|
|
139
|
+
describe("HkFaceShell desktop sidebar rail (opt-in)", () => {
|
|
140
|
+
it("renders the LEGACY aside untouched when not collapsible", () => {
|
|
141
|
+
const c = mountShell();
|
|
142
|
+
expect(c.aside(), "no rail host class").toBeFalsy();
|
|
143
|
+
expect(c.toggle(), "no toggle button").toBeFalsy();
|
|
144
|
+
// The slot keeps its legacy param shape.
|
|
145
|
+
const last = c.slotParams[c.slotParams.length - 1]!;
|
|
146
|
+
expect(last.collapsed).toBe(false);
|
|
147
|
+
expect(typeof last.onNavigate).toBe("function");
|
|
148
|
+
expect("peeking" in last).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("keeps removing the sidebar entirely under the legacy sidebarCollapsed prop", () => {
|
|
152
|
+
const c = mountShell({ sidebarCollapsible: true, sidebarCollapsed: true });
|
|
153
|
+
expect(c.container.querySelector("aside")).toBeFalsy();
|
|
154
|
+
expect(c.container.querySelector(".content-mock")).toBeTruthy();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("HkFaceShell rail mode", () => {
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("collapses through the toggle and the v-model channel", async () => {
|
|
164
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
165
|
+
|
|
166
|
+
expect(c.aside().style.width).toBe("224px");
|
|
167
|
+
expect(c.aside().getAttribute("data-rail")).toBeNull();
|
|
168
|
+
expect(c.toggle().getAttribute("aria-expanded")).toBe("true");
|
|
169
|
+
|
|
170
|
+
c.toggle().click();
|
|
171
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
172
|
+
|
|
173
|
+
expect(c.emitted, "the model channel emits rail").toEqual(["rail"]);
|
|
174
|
+
expect(c.aside().style.width).toBe("56px");
|
|
175
|
+
expect(c.aside().hasAttribute("data-rail")).toBe(true);
|
|
176
|
+
expect(c.toggle().getAttribute("aria-expanded")).toBe("false");
|
|
177
|
+
expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("collapses with NO channel bound — the session-only default (R1 defect pin)", async () => {
|
|
181
|
+
// Semi-controlled state: a host that never binds
|
|
182
|
+
// v-model:sidebar-rail-style still gets a working toggle; the emit
|
|
183
|
+
// just lands unheard. (The pure-delegate version left the click
|
|
184
|
+
// inert — found by the R1 verifier.)
|
|
185
|
+
const container = document.createElement("div");
|
|
186
|
+
document.body.appendChild(container);
|
|
187
|
+
const app = createApp({
|
|
188
|
+
render: () =>
|
|
189
|
+
h(HkFaceShell, { navTitle: "Navigation", sidebarCollapsible: true }, {
|
|
190
|
+
header: () => null,
|
|
191
|
+
sidebar: () => h("div", "NAV"),
|
|
192
|
+
content: () => h("div", "CONTENT"),
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
app.mount(container);
|
|
196
|
+
mounts.push({ app, container });
|
|
197
|
+
|
|
198
|
+
const aside = container.querySelector(".hk-sidebar-rail-host") as HTMLElement;
|
|
199
|
+
const toggle = container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement;
|
|
200
|
+
expect(aside.style.width).toBe("224px");
|
|
201
|
+
|
|
202
|
+
toggle.click();
|
|
203
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
204
|
+
expect(aside.style.width, "the unbound toggle still collapses").toBe("56px");
|
|
205
|
+
|
|
206
|
+
toggle.click();
|
|
207
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
208
|
+
expect(aside.style.width, "and expands again").toBe("224px");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("the bound prop WINS on the way back in — external channel drives both directions", async () => {
|
|
212
|
+
// The other half of the semi-controlled contract (R2 pin): a host
|
|
213
|
+
// that treats sidebarRailStyle as ITS state (store-driven, no echo)
|
|
214
|
+
// must see the sidebar follow. Deleting the prop-sync watch leaves
|
|
215
|
+
// every other test green — this is the one that goes red.
|
|
216
|
+
const railStyle = ref<"expanded" | "rail">("expanded");
|
|
217
|
+
const container = document.createElement("div");
|
|
218
|
+
document.body.appendChild(container);
|
|
219
|
+
const app = createApp({
|
|
220
|
+
setup: () => () =>
|
|
221
|
+
h(HkFaceShell, {
|
|
222
|
+
navTitle: "Navigation",
|
|
223
|
+
sidebarCollapsible: true,
|
|
224
|
+
// Deliberately NO onUpdate:sidebarRailStyle echo — the prop is
|
|
225
|
+
// the external authority here.
|
|
226
|
+
sidebarRailStyle: railStyle.value,
|
|
227
|
+
}, {
|
|
228
|
+
header: () => null,
|
|
229
|
+
sidebar: () => h("div", "NAV"),
|
|
230
|
+
content: () => h("div", "CONTENT"),
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
app.mount(container);
|
|
234
|
+
mounts.push({ app, container });
|
|
235
|
+
const aside = () => container.querySelector(".hk-sidebar-rail-host") as HTMLElement;
|
|
236
|
+
|
|
237
|
+
railStyle.value = "rail";
|
|
238
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
239
|
+
expect(aside().style.width, "an external rail value collapses").toBe("56px");
|
|
240
|
+
|
|
241
|
+
railStyle.value = "expanded";
|
|
242
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
243
|
+
expect(aside().style.width, "and an external expanded value restores").toBe("224px");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("honors a configured rail width", async () => {
|
|
247
|
+
const c = mountShell({ sidebarCollapsible: true, sidebarCollapsedWidth: "72px" });
|
|
248
|
+
c.toggle().click();
|
|
249
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
250
|
+
expect(c.aside().style.width).toBe("72px");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("peeks after the dwell as an OVERLAY — the host width never moves", async () => {
|
|
254
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
255
|
+
c.toggle().click();
|
|
256
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
257
|
+
|
|
258
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
259
|
+
await vi.advanceTimersByTimeAsync(399);
|
|
260
|
+
expect(c.aside().hasAttribute("data-peeking"), "one tick before the dwell").toBe(false);
|
|
261
|
+
|
|
262
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
263
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(true);
|
|
264
|
+
// THE core contract: the layout footprint (host width) stays at the
|
|
265
|
+
// rail width while the panel overlays at the full width — the
|
|
266
|
+
// content <main> cannot shift because nothing in flow changed.
|
|
267
|
+
expect(c.aside().style.width).toBe("56px");
|
|
268
|
+
expect(c.panel().style.width).toBe("224px");
|
|
269
|
+
expect(c.main(), "the content host still renders").toBeTruthy();
|
|
270
|
+
// The slot renders FULL rows again inside the peek.
|
|
271
|
+
expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(false);
|
|
272
|
+
expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("closes on leave only after the grace; re-entry cancels the close", async () => {
|
|
276
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
277
|
+
c.toggle().click();
|
|
278
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
279
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
280
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
281
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(true);
|
|
282
|
+
|
|
283
|
+
// Diagonal move: out, back inside the grace — no flicker.
|
|
284
|
+
c.aside().dispatchEvent(POINTER_LEAVE());
|
|
285
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
286
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
287
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
288
|
+
expect(c.aside().hasAttribute("data-peeking"), "re-entry keeps the peek open").toBe(true);
|
|
289
|
+
|
|
290
|
+
// A full grace outside finally closes.
|
|
291
|
+
c.aside().dispatchEvent(POINTER_LEAVE());
|
|
292
|
+
await vi.advanceTimersByTimeAsync(149);
|
|
293
|
+
expect(c.aside().hasAttribute("data-peeking"), "inside the grace it stays open").toBe(true);
|
|
294
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
295
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(false);
|
|
296
|
+
expect(c.panel().style.width).toBe("56px");
|
|
297
|
+
expect(c.aside().style.width).toBe("56px");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("Escape closes an open peek", async () => {
|
|
301
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
302
|
+
c.toggle().click();
|
|
303
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
304
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
305
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
306
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(true);
|
|
307
|
+
|
|
308
|
+
c.aside().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
309
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
310
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("a nav row click inside the peek closes it (onNavigate)", async () => {
|
|
314
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
315
|
+
c.toggle().click();
|
|
316
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
317
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
318
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
319
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(true);
|
|
320
|
+
|
|
321
|
+
const nav = c.slotParams[c.slotParams.length - 1]!.onNavigate;
|
|
322
|
+
nav();
|
|
323
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
324
|
+
expect(c.aside().hasAttribute("data-peeking"), "navigate + close, like the drawer").toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("HkAdminShell forwarding (source pin)", () => {
|
|
329
|
+
it("declares and forwards the rail prop family", async () => {
|
|
330
|
+
const { readFileSync } = await import("node:fs");
|
|
331
|
+
const { dirname, join } = await import("node:path");
|
|
332
|
+
const { fileURLToPath } = await import("node:url");
|
|
333
|
+
const src = readFileSync(
|
|
334
|
+
join(dirname(fileURLToPath(import.meta.url)), "HkAdminShell.tsx"),
|
|
335
|
+
"utf8",
|
|
336
|
+
).replace(/\r\n/g, "\n");
|
|
337
|
+
// Declared on the wrapper (the honest-forwarding rule)…
|
|
338
|
+
for (const decl of [
|
|
339
|
+
"sidebarCollapsible: { type: Boolean, default: false }",
|
|
340
|
+
'sidebarCollapsedWidth: { type: String, default: "56px" }',
|
|
341
|
+
"sidebarPeekDelay: { type: Number, default: 400 }",
|
|
342
|
+
"sidebarPeekGrace: { type: Number, default: 150 }",
|
|
343
|
+
]) {
|
|
344
|
+
expect(src, decl).toContain(decl);
|
|
345
|
+
}
|
|
346
|
+
// …and forwarded to the inner shell.
|
|
347
|
+
expect(src).toContain("sidebarCollapsible: props.sidebarCollapsible,");
|
|
348
|
+
expect(src).toContain("sidebarCollapsedWidth: props.sidebarCollapsedWidth,");
|
|
349
|
+
expect(src).toContain("sidebarRailStyle: props.sidebarRailStyle,");
|
|
350
|
+
expect(src).toContain("sidebarPeekDelay: props.sidebarPeekDelay,");
|
|
351
|
+
expect(src).toContain("sidebarPeekGrace: props.sidebarPeekGrace,");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("mounts the rail through the wrapper (behavior spot-check)", () => {
|
|
355
|
+
const container = document.createElement("div");
|
|
356
|
+
document.body.appendChild(container);
|
|
357
|
+
const app = createApp({
|
|
358
|
+
render: () =>
|
|
359
|
+
h(HkAdminShell, { sidebarCollapsible: true }, {
|
|
360
|
+
header: () => null,
|
|
361
|
+
sidebar: () => h("div", "NAV"),
|
|
362
|
+
content: () => h("div", "CONTENT"),
|
|
363
|
+
}),
|
|
364
|
+
});
|
|
365
|
+
app.mount(container);
|
|
366
|
+
mounts.push({ app, container });
|
|
367
|
+
expect(container.querySelector(".hk-sidebar-rail-host")).toBeTruthy();
|
|
368
|
+
expect(container.querySelector(".hk-sidebar-rail-toggle button")).toBeTruthy();
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, ref, watch, type PropType } from "vue";
|
|
2
2
|
import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
|
+
import { useSidebarRail } from "../composables/useSidebarRail";
|
|
5
|
+
import HkSidebarRailToggle from "./HkSidebarRailToggle";
|
|
4
6
|
|
|
5
7
|
/** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
|
|
6
8
|
* padding (`inherit 0 inherit` is rejected by every engine, measured in
|
|
@@ -168,6 +170,35 @@ export const HkFaceShell = defineComponent({
|
|
|
168
170
|
props: {
|
|
169
171
|
sidebarCollapsed: { type: Boolean, default: false },
|
|
170
172
|
sidebarWidth: { type: String, default: "224px" },
|
|
173
|
+
/** OPT-IN icon-rail capability for the desktop sidebar (2026-10-30):
|
|
174
|
+
* mounts the hover-reveal circular chevron toggle on the sidebar's
|
|
175
|
+
* content-facing edge, lets a click collapse the sidebar to a one-
|
|
176
|
+
* icon rail, and — while collapsed — opens a hover-peek drawer that
|
|
177
|
+
* slides the full sidebar OVER the content as an overlay (the
|
|
178
|
+
* layout keeps the rail width; the content area never shifts).
|
|
179
|
+
* Timing lives in useSidebarRail (dwell, grace, Escape); geometry in
|
|
180
|
+
* HkSidebarRail.scss (.hk-sidebar-rail-host/-panel/-toggle).
|
|
181
|
+
* False (default) renders the sidebar exactly as before. Below
|
|
182
|
+
* mobileBreakpoint nothing changes — the mobile drawer stays the
|
|
183
|
+
* only nav surface. */
|
|
184
|
+
sidebarCollapsible: { type: Boolean, default: false },
|
|
185
|
+
/** Layout width of the icon rail (the one-icon column). Default 56px,
|
|
186
|
+
* the rail width HkSidebar's collapsed mode has always used. */
|
|
187
|
+
sidebarCollapsedWidth: { type: String, default: "56px" },
|
|
188
|
+
/** Rail state channel, `v-model:sidebar-rail-style`: "expanded" (the
|
|
189
|
+
* default) or "rail". The library itself NEVER persists this — the
|
|
190
|
+
* collapse is session-only by design; a host that wants persistence
|
|
191
|
+
* (or a shared store) binds the channel and stores the value
|
|
192
|
+
* itself. */
|
|
193
|
+
sidebarRailStyle: {
|
|
194
|
+
type: String as PropType<"expanded" | "rail">,
|
|
195
|
+
default: "expanded",
|
|
196
|
+
},
|
|
197
|
+
/** Hover dwell on the rail before the peek drawer opens, ms. */
|
|
198
|
+
sidebarPeekDelay: { type: Number, default: 400 },
|
|
199
|
+
/** Grace after pointerleave before the peek drawer closes, ms — a
|
|
200
|
+
* diagonal pointer move within the window does not flicker it shut. */
|
|
201
|
+
sidebarPeekGrace: { type: Number, default: 150 },
|
|
171
202
|
/** Viewport width at which the desktop layout takes over (below it
|
|
172
203
|
* the nav collapses into the mobile drawer). Defaults to the shared
|
|
173
204
|
* 1024px "lg" breakpoint; lower it (e.g. 768) for tablet-friendly
|
|
@@ -230,12 +261,72 @@ export const HkFaceShell = defineComponent({
|
|
|
230
261
|
* Default false: the padding reads the same at every width. */
|
|
231
262
|
contentBleedOnMobile: { type: Boolean, default: false },
|
|
232
263
|
},
|
|
233
|
-
|
|
264
|
+
emits: {
|
|
265
|
+
"update:sidebarRailStyle": (v: string) => true,
|
|
266
|
+
},
|
|
267
|
+
setup(props, { slots, emit }) {
|
|
234
268
|
const { t } = useI18n();
|
|
235
269
|
const { width: viewportWidth } = useBreakpoint();
|
|
236
270
|
const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
|
|
237
271
|
const sidebarOpen = ref(false);
|
|
238
272
|
|
|
273
|
+
// ── desktop icon rail + hover peek (sidebarCollapsible) ──────────
|
|
274
|
+
// SEMI-CONTROLLED rail state: a local session-only ref is the live
|
|
275
|
+
// state, so the toggle works with NO channel bound at all (the
|
|
276
|
+
// session-only default); a host that binds v-model:sidebar-rail-style
|
|
277
|
+
// hears every flip through update:sidebarRailStyle and its prop then
|
|
278
|
+
// wins on the way back in (the watch below). Same shape as
|
|
279
|
+
// HkSidebar's collapsed handling.
|
|
280
|
+
const railCollapsedLocal = ref(props.sidebarRailStyle === "rail");
|
|
281
|
+
watch(
|
|
282
|
+
() => props.sidebarRailStyle,
|
|
283
|
+
(v) => {
|
|
284
|
+
railCollapsedLocal.value = v === "rail";
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
const railCollapsed = computed({
|
|
288
|
+
get: () => railCollapsedLocal.value,
|
|
289
|
+
set: (v: boolean) => {
|
|
290
|
+
railCollapsedLocal.value = v;
|
|
291
|
+
emit("update:sidebarRailStyle", v ? "rail" : "expanded");
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
const rail = useSidebarRail({
|
|
295
|
+
collapsed: railCollapsed,
|
|
296
|
+
peekDelay: () => props.sidebarPeekDelay,
|
|
297
|
+
peekGrace: () => props.sidebarPeekGrace,
|
|
298
|
+
});
|
|
299
|
+
const railActive = computed(() => props.sidebarCollapsible && railCollapsed.value);
|
|
300
|
+
// The LAYOUT width — the core contract of the peek: rail width while
|
|
301
|
+
// collapsed OR peeking (the peek overlays; it never reflows content),
|
|
302
|
+
// full width while expanded.
|
|
303
|
+
const hostSidebarWidth = computed(() =>
|
|
304
|
+
railActive.value ? props.sidebarCollapsedWidth : props.sidebarWidth,
|
|
305
|
+
);
|
|
306
|
+
// The PANEL width — full while expanded or peeking (the slide-out
|
|
307
|
+
// lever), rail width otherwise.
|
|
308
|
+
const peekPanelWidth = computed(() =>
|
|
309
|
+
railActive.value && !rail.peeking.value ? props.sidebarCollapsedWidth : props.sidebarWidth,
|
|
310
|
+
);
|
|
311
|
+
// Crossing under the breakpoint unmounts the desktop sidebar — the
|
|
312
|
+
// peek must not survive into the mobile drawer era (or reappear when
|
|
313
|
+
// the viewport grows back).
|
|
314
|
+
watch(isDesktop, (desktop) => {
|
|
315
|
+
if (!desktop) rail.closePeek();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/** Nav rows close the mobile drawer (their existing onNavigate
|
|
319
|
+
* contract) AND an open peek — mirroring the drawer's
|
|
320
|
+
* navigate-and-dismiss behavior on desktop hover. */
|
|
321
|
+
const onSidebarNavigate = () => {
|
|
322
|
+
rail.closePeek();
|
|
323
|
+
closeSidebar();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const onSidebarHostKeydown = (e: KeyboardEvent) => {
|
|
327
|
+
if (rail.onKeydown(e)) e.stopPropagation();
|
|
328
|
+
};
|
|
329
|
+
|
|
239
330
|
const contentStyle = computed(() => ({
|
|
240
331
|
padding: isDesktop.value || !props.contentBleedOnMobile
|
|
241
332
|
? props.contentPadding
|
|
@@ -299,17 +390,50 @@ export const HkFaceShell = defineComponent({
|
|
|
299
390
|
|
|
300
391
|
<div class="s-admin-shell-body" style={{ paddingBottom: footerPad() }}>
|
|
301
392
|
{isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
393
|
+
props.sidebarCollapsible ? (
|
|
394
|
+
<aside
|
|
395
|
+
class="hk-sidebar-rail-host"
|
|
396
|
+
data-rail={railActive.value || undefined}
|
|
397
|
+
data-peeking={rail.peeking.value || undefined}
|
|
398
|
+
style={{ width: hostSidebarWidth.value, flexShrink: 0 }}
|
|
399
|
+
onPointerenter={rail.onPointerEnter}
|
|
400
|
+
onPointerleave={rail.onPointerLeave}
|
|
401
|
+
onKeydown={onSidebarHostKeydown}
|
|
402
|
+
>
|
|
403
|
+
{/* The visual panel — absolutely positioned over the host
|
|
404
|
+
(HkSidebarRail.scss). Its width is the peek's slide-out
|
|
405
|
+
lever; the host width above is the layout footprint and
|
|
406
|
+
NEVER grows during a peek, so the <main> sibling keeps
|
|
407
|
+
its exact width while the drawer covers it. */}
|
|
408
|
+
<div
|
|
409
|
+
class="hk-sidebar-rail-panel"
|
|
410
|
+
style={{
|
|
411
|
+
width: peekPanelWidth.value,
|
|
412
|
+
borderInlineEnd: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
|
|
413
|
+
background: "rgb(var(--color-surface))",
|
|
414
|
+
}}
|
|
415
|
+
>
|
|
416
|
+
{slots.sidebar({
|
|
417
|
+
collapsed: railActive.value && !rail.peeking.value,
|
|
418
|
+
onNavigate: onSidebarNavigate,
|
|
419
|
+
peeking: rail.peeking.value,
|
|
420
|
+
})}
|
|
421
|
+
<HkSidebarRailToggle expanded={!railActive.value} onToggle={rail.toggle} />
|
|
422
|
+
</div>
|
|
423
|
+
</aside>
|
|
424
|
+
) : (
|
|
425
|
+
<aside
|
|
426
|
+
style={{
|
|
427
|
+
width: props.sidebarWidth,
|
|
428
|
+
flexShrink: 0,
|
|
429
|
+
borderRight: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
|
|
430
|
+
background: "rgb(var(--color-surface))",
|
|
431
|
+
overflow: "hidden",
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
{slots.sidebar({ collapsed: false, onNavigate: closeSidebar })}
|
|
435
|
+
</aside>
|
|
436
|
+
)
|
|
313
437
|
)}
|
|
314
438
|
<main class="s-admin-shell-main" data-content-mode={props.contentMode}>
|
|
315
439
|
{props.contentMode === "stage" ? (
|