@celestia-island/hikari 0.55.232 → 0.55.234
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/HkAboutBody.test.tsx +35 -0
- package/src/components/HkAboutModal.scss +8 -0
- package/src/components/HkAboutModal.styles.test.ts +58 -30
- package/src/components/HkAboutModal.tsx +16 -2
- package/src/components/HkAdminShell.tsx +4 -2
- package/src/components/HkFaceShell.rail.test.tsx +208 -0
- package/src/components/HkFaceShell.tsx +70 -14
- package/src/components/HkMenu.rail.test.tsx +341 -28
- package/src/components/HkMenu.scss +47 -13
- package/src/components/HkMenu.tsx +109 -32
- package/src/components/HkSidebar.rail.test.tsx +228 -0
- package/src/components/HkSidebar.tsx +92 -28
- package/src/components/HkSidebarRail.scss +122 -23
- package/src/components/HkSidebarRail.styles.test.ts +113 -21
- package/src/components/HkSidebarRailToggle.test.tsx +51 -1
- package/src/components/HkSidebarRailToggle.tsx +47 -2
- package/src/composables/useSidebarRail.test.ts +239 -2
- package/src/composables/useSidebarRail.ts +178 -22
- package/src/index.ts +11 -2
package/package.json
CHANGED
|
@@ -16,6 +16,9 @@ import { HkAboutBody } from "./HkAboutModal";
|
|
|
16
16
|
* - the default slot appends host content after the copyright footer
|
|
17
17
|
* - without a backdrop factory no backdrop layer renders (a body host may
|
|
18
18
|
* not want the decorative canvas at all)
|
|
19
|
+
* - a version row's `metaTone: "danger"` reaches the pill as
|
|
20
|
+
* `data-tone="danger"`, and an EMPTY `value` drops the mono number cell
|
|
21
|
+
* entirely (the "component not wired up" row shows only its red label)
|
|
19
22
|
*
|
|
20
23
|
* (Repo test convention: raw createApp + document queries, no
|
|
21
24
|
* @vue/test-utils dependency.)
|
|
@@ -121,4 +124,36 @@ describe("HkAboutBody extraction", () => {
|
|
|
121
124
|
|
|
122
125
|
expect(document.body.querySelector(".s-about-modal-backdrop")).toBeNull();
|
|
123
126
|
});
|
|
127
|
+
|
|
128
|
+
it("tints a version row's meta pill with the danger tone", async () => {
|
|
129
|
+
mountBody({
|
|
130
|
+
componentVersions: [
|
|
131
|
+
{ label: "Edge engine", value: "1.4.0", meta: "Not connected", metaTone: "danger" },
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
await flush();
|
|
135
|
+
|
|
136
|
+
const pill = query(".s-about-modal-row-meta");
|
|
137
|
+
expect(pill.getAttribute("data-tone")).toBe("danger");
|
|
138
|
+
expect(pill.textContent).toBe("Not connected");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("drops the mono number cell when a row's value is empty", async () => {
|
|
142
|
+
mountBody({
|
|
143
|
+
componentVersions: [
|
|
144
|
+
// A host with nothing to report names the state in `meta` and leaves
|
|
145
|
+
// `value` empty (the "not wired up" row).
|
|
146
|
+
{ label: "Edge engine", value: "", meta: "Not connected", metaTone: "danger" },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
await flush();
|
|
150
|
+
|
|
151
|
+
// No blank tabular slot: the mono cell is absent, not just empty.
|
|
152
|
+
expect(document.body.querySelector(".s-about-modal-row-number")).toBeNull();
|
|
153
|
+
// The label and the pill still carry the row.
|
|
154
|
+
expect(query(".s-about-modal-row-label").textContent).toContain("Edge engine");
|
|
155
|
+
const pill = query(".s-about-modal-row-meta");
|
|
156
|
+
expect(pill.textContent).toBe("Not connected");
|
|
157
|
+
expect(pill.getAttribute("data-tone")).toBe("danger");
|
|
158
|
+
});
|
|
124
159
|
});
|
|
@@ -228,6 +228,14 @@
|
|
|
228
228
|
color: rgb(var(--color-warning));
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// Red state: the component is not wired up / unreachable. The row then
|
|
232
|
+
// carries no version at all, so this pill is the whole value cell.
|
|
233
|
+
.s-about-modal-row-meta[data-tone="danger"] {
|
|
234
|
+
border-color: rgb(var(--color-error) / 32%);
|
|
235
|
+
background: rgb(var(--color-error) / 10%);
|
|
236
|
+
color: rgb(var(--color-error));
|
|
237
|
+
}
|
|
238
|
+
|
|
231
239
|
/* ── Links: licenses, external links, credits names and filings ───────── */
|
|
232
240
|
|
|
233
241
|
.s-about-modal-chips {
|
|
@@ -29,6 +29,36 @@ function classesFrom(source: string): Set<string> {
|
|
|
29
29
|
return found;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Body of one compiled rule, by exact selector (Sass drops the quotes around
|
|
34
|
+
* an attribute value: `[data-face=plain]`).
|
|
35
|
+
*
|
|
36
|
+
* The selector must compile exactly once: a second copy — say inside a media
|
|
37
|
+
* query, or a later override — would otherwise stay invisible here while
|
|
38
|
+
* still winning the cascade.
|
|
39
|
+
*/
|
|
40
|
+
function ruleBody(sheet: string, selector: string): string {
|
|
41
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
const matches = [...sheet.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))];
|
|
43
|
+
expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
|
|
44
|
+
return matches[0]?.[1] ?? "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One declaration's value inside a rule body.
|
|
49
|
+
*
|
|
50
|
+
* A property declared twice is a failure rather than a coincidence: CSS
|
|
51
|
+
* resolves a repeat last-wins, so a first-match reader would report the stale
|
|
52
|
+
* value and happily accept the override.
|
|
53
|
+
*/
|
|
54
|
+
function declaration(body: string, property: string): string {
|
|
55
|
+
const values = [
|
|
56
|
+
...body.matchAll(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, "g")),
|
|
57
|
+
].map((match) => match[1]!.trim());
|
|
58
|
+
expect(values.length, `${property} is declared at most once`).toBeLessThanOrEqual(1);
|
|
59
|
+
return values[0] ?? "";
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
describe("HkAboutModal stylesheet contract", () => {
|
|
33
63
|
const tsx = readFileSync(resolve(componentDir, "HkAboutModal.tsx"), "utf8");
|
|
34
64
|
const scss = readFileSync(resolve(componentDir, "HkAboutModal.scss"), "utf8");
|
|
@@ -67,36 +97,6 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
67
97
|
* into chips. These assertions read the compiled declarations instead.
|
|
68
98
|
*/
|
|
69
99
|
describe("link faces", () => {
|
|
70
|
-
/**
|
|
71
|
-
* Body of one compiled rule, by exact selector (Sass drops the quotes
|
|
72
|
-
* around an attribute value: `[data-face=plain]`).
|
|
73
|
-
*
|
|
74
|
-
* The selector must compile exactly once: a second copy — say inside a
|
|
75
|
-
* media query, or a later override — would otherwise stay invisible here
|
|
76
|
-
* while still winning the cascade.
|
|
77
|
-
*/
|
|
78
|
-
function ruleBody(sheet: string, selector: string): string {
|
|
79
|
-
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
-
const matches = [...sheet.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))];
|
|
81
|
-
expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
|
|
82
|
-
return matches[0]?.[1] ?? "";
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* One declaration's value inside a rule body.
|
|
87
|
-
*
|
|
88
|
-
* A property declared twice is a failure rather than a coincidence: CSS
|
|
89
|
-
* resolves a repeat last-wins, so a first-match reader would report the
|
|
90
|
-
* stale value and happily accept the override.
|
|
91
|
-
*/
|
|
92
|
-
function declaration(body: string, property: string): string {
|
|
93
|
-
const values = [
|
|
94
|
-
...body.matchAll(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, "g")),
|
|
95
|
-
].map((match) => match[1]!.trim());
|
|
96
|
-
expect(values.length, `${property} is declared at most once`).toBeLessThanOrEqual(1);
|
|
97
|
-
return values[0] ?? "";
|
|
98
|
-
}
|
|
99
|
-
|
|
100
100
|
const PLAIN = ".s-about-modal-link[data-face=plain]";
|
|
101
101
|
|
|
102
102
|
it("keeps the plain face a bare text link", () => {
|
|
@@ -229,4 +229,32 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
229
229
|
expect(iconAt).toBeGreaterThan(plainAt);
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Row-meta tones are self-contained rules, so the class-name checks above
|
|
235
|
+
* cannot see them: reverting a tone to `neutral` (or dropping its rule)
|
|
236
|
+
* leaves the `data-tone` attribute intact and the runtime suite green while
|
|
237
|
+
* the pill silently loses its colour — the whole point of the tone.
|
|
238
|
+
*/
|
|
239
|
+
describe("row meta tones", () => {
|
|
240
|
+
const TONE = (tone: string) => `.s-about-modal-row-meta[data-tone=${tone}]`;
|
|
241
|
+
|
|
242
|
+
it("paints the danger tone in the theme's error colour", () => {
|
|
243
|
+
const danger = ruleBody(css, TONE("danger"));
|
|
244
|
+
expect(danger, "the danger-tone rule must survive compilation").not.toBe("");
|
|
245
|
+
expect({
|
|
246
|
+
borderColor: declaration(danger, "border-color"),
|
|
247
|
+
background: declaration(danger, "background"),
|
|
248
|
+
color: declaration(danger, "color"),
|
|
249
|
+
}).toEqual({
|
|
250
|
+
// Same geometry as the positive / caution tones, only the channel
|
|
251
|
+
// differs — `--color-error` is the theme's existing danger token.
|
|
252
|
+
// (Dart Sass compiles the seed-relative form without spaces around
|
|
253
|
+
// the slash.)
|
|
254
|
+
borderColor: "rgb(var(--color-error)/32%)",
|
|
255
|
+
background: "rgb(var(--color-error)/10%)",
|
|
256
|
+
color: "rgb(var(--color-error))",
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
232
260
|
});
|
|
@@ -73,12 +73,19 @@ export interface HAboutCredit {
|
|
|
73
73
|
* rendered as a separate muted pill so it never melts into the version
|
|
74
74
|
* string. `metaTone` tints that pill when the tag carries a status
|
|
75
75
|
* meaning (e.g. a production environment).
|
|
76
|
+
*
|
|
77
|
+
* `danger` is the red "not wired up / unreachable" state: a host whose
|
|
78
|
+
* component (e.g. an edge master node engine) has no version to report
|
|
79
|
+
* passes an EMPTY `value` and names the state in `meta` with
|
|
80
|
+
* `metaTone: "danger"`. The empty value then renders the pill alone —
|
|
81
|
+
* the mono number cell is dropped rather than leaving a blank tabular
|
|
82
|
+
* slot — so the row reads as one red label instead of `unknown`.
|
|
76
83
|
*/
|
|
77
84
|
export interface HAboutComponentVersion {
|
|
78
85
|
label: string;
|
|
79
86
|
value: string;
|
|
80
87
|
meta?: string;
|
|
81
|
-
metaTone?: "neutral" | "positive" | "caution";
|
|
88
|
+
metaTone?: "neutral" | "positive" | "caution" | "danger";
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
/**
|
|
@@ -288,7 +295,14 @@ export const HkAboutBody = defineComponent({
|
|
|
288
295
|
<div class="s-about-modal-row" key={component.label}>
|
|
289
296
|
<span class="s-about-modal-row-label">{component.label}</span>
|
|
290
297
|
<span class="s-about-modal-row-value">
|
|
291
|
-
|
|
298
|
+
{/* An empty value means the host has no version to
|
|
299
|
+
report (e.g. the component is not wired up) and
|
|
300
|
+
carries the state in `meta` instead — skip the mono
|
|
301
|
+
cell so the row does not open with a blank tabular
|
|
302
|
+
slot and a stray gap. */}
|
|
303
|
+
{component.value && (
|
|
304
|
+
<span class="s-about-modal-row-number">{component.value}</span>
|
|
305
|
+
)}
|
|
292
306
|
{component.meta && (
|
|
293
307
|
<span
|
|
294
308
|
class="s-about-modal-row-meta"
|
|
@@ -39,8 +39,10 @@ export const HkAdminShell = defineComponent({
|
|
|
39
39
|
sidebarWidth: { type: String, default: "224px" },
|
|
40
40
|
/** Desktop icon-rail capability, forwarded to HkFaceShell (see its
|
|
41
41
|
* prop doc): opt in here and the admin sidebar gains the circular
|
|
42
|
-
* hover-reveal toggle
|
|
43
|
-
*
|
|
42
|
+
* hover-reveal toggle STRADDLING the sidebar's content-facing border,
|
|
43
|
+
* the one-icon rail and the overlay peek drawer (with the peek
|
|
44
|
+
* suppressed while the pointer rests on the toggle, and a pinned
|
|
45
|
+
* panel on expand). Declared on the wrapper on purpose — the same
|
|
44
46
|
* honest-forwarding rule as contentMode (a stray attr must not
|
|
45
47
|
* reach the inner shell's declared prop through fallthrough). */
|
|
46
48
|
sidebarCollapsible: { type: Boolean, default: false },
|
|
@@ -368,3 +368,211 @@ describe("HkAdminShell forwarding (source pin)", () => {
|
|
|
368
368
|
expect(container.querySelector(".hk-sidebar-rail-toggle button")).toBeTruthy();
|
|
369
369
|
});
|
|
370
370
|
});
|
|
371
|
+
/** A pointermove with explicit viewport coordinates (the suppression
|
|
372
|
+
* wiring is purely geometric — see HkSidebarRailToggle's helper). */
|
|
373
|
+
function pointerMove(x: number, y: number): MouseEvent {
|
|
374
|
+
const e = new MouseEvent("pointermove", { bubbles: true });
|
|
375
|
+
Object.defineProperty(e, "clientX", { value: x });
|
|
376
|
+
Object.defineProperty(e, "clientY", { value: y });
|
|
377
|
+
return e;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A pointerenter carrying viewport coordinates — the host evaluates the
|
|
381
|
+
* toggle containment on ENTER, before it arms the dwell. */
|
|
382
|
+
function pointerEnterAt(x: number, y: number): MouseEvent {
|
|
383
|
+
const e = new MouseEvent("pointerenter", { bubbles: true });
|
|
384
|
+
Object.defineProperty(e, "clientX", { value: x });
|
|
385
|
+
Object.defineProperty(e, "clientY", { value: y });
|
|
386
|
+
return e;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A transitionend carrying the property name (happy-dom has no
|
|
390
|
+
* TransitionEvent; the host handler filters on `width`). */
|
|
391
|
+
function widthTransitionEnd(): Event {
|
|
392
|
+
const e = new Event("transitionend");
|
|
393
|
+
Object.defineProperty(e, "propertyName", { value: "width" });
|
|
394
|
+
return e;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Pin the toggle wrapper's box — happy-dom lays nothing out. */
|
|
398
|
+
function stubToggleRect(container: HTMLElement, left: number, top: number, size = 24) {
|
|
399
|
+
const el = container.querySelector(".hk-sidebar-rail-toggle") as HTMLElement;
|
|
400
|
+
el.getBoundingClientRect = () =>
|
|
401
|
+
({
|
|
402
|
+
left,
|
|
403
|
+
top,
|
|
404
|
+
right: left + size,
|
|
405
|
+
bottom: top + size,
|
|
406
|
+
width: size,
|
|
407
|
+
height: size,
|
|
408
|
+
x: left,
|
|
409
|
+
y: top,
|
|
410
|
+
toJSON: () => ({}),
|
|
411
|
+
}) as DOMRect;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
describe("HkFaceShell rail chrome: toggle placement and the clip wrapper", () => {
|
|
415
|
+
it("keeps the toggle a DIRECT child of the panel and the slot inside the clip", () => {
|
|
416
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
417
|
+
const panel = c.panel();
|
|
418
|
+
const clip = panel.querySelector(":scope > .hk-sidebar-rail-clip") as HTMLElement | null;
|
|
419
|
+
const toggle = panel.querySelector(":scope > .hk-sidebar-rail-toggle") as HTMLElement | null;
|
|
420
|
+
|
|
421
|
+
expect(clip, "the slot's content clips inside the wrapper").toBeTruthy();
|
|
422
|
+
expect(toggle, "the toggle is a direct child of the panel").toBeTruthy();
|
|
423
|
+
expect(clip!.contains(toggle!), "the toggle is never inside the clip").toBe(false);
|
|
424
|
+
expect(clip!.querySelector(".sidebar-mock"), "the sidebar slot renders in the clip").toBeTruthy();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("does not touch the legacy sidebar path (no clip wrapper, no rail classes)", () => {
|
|
428
|
+
const c = mountShell();
|
|
429
|
+
expect(c.container.querySelector(".hk-sidebar-rail-clip")).toBeFalsy();
|
|
430
|
+
expect(c.container.querySelector(".hk-sidebar-rail-panel")).toBeFalsy();
|
|
431
|
+
expect(c.container.querySelector(".sidebar-mock")).toBeTruthy();
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("HkFaceShell pin window: expand must not replay the slide", () => {
|
|
436
|
+
beforeEach(() => {
|
|
437
|
+
vi.useFakeTimers();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("pins on a rail -> expanded flip and clears on the host width transition", async () => {
|
|
441
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
442
|
+
c.toggle().click();
|
|
443
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
444
|
+
expect(c.aside().hasAttribute("data-pinning"), "collapsing never pins").toBe(false);
|
|
445
|
+
|
|
446
|
+
c.toggle().click();
|
|
447
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
448
|
+
expect(c.aside().hasAttribute("data-pinning"), "the expand window is pinned").toBe(true);
|
|
449
|
+
// The panel is already at its final width — only the host animates.
|
|
450
|
+
expect(c.panel().style.width).toBe("224px");
|
|
451
|
+
expect(c.aside().style.width).toBe("224px");
|
|
452
|
+
|
|
453
|
+
c.aside().dispatchEvent(widthTransitionEnd());
|
|
454
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
455
|
+
expect(c.aside().hasAttribute("data-pinning")).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("clears the pin window by the fallback when no transition fires", async () => {
|
|
459
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
460
|
+
c.toggle().click();
|
|
461
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
462
|
+
c.toggle().click();
|
|
463
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
464
|
+
expect(c.aside().hasAttribute("data-pinning")).toBe(true);
|
|
465
|
+
|
|
466
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
467
|
+
expect(c.aside().hasAttribute("data-pinning")).toBe(false);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("collapsing closes an open pin window immediately", async () => {
|
|
471
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
472
|
+
c.toggle().click();
|
|
473
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
474
|
+
c.toggle().click();
|
|
475
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
476
|
+
expect(c.aside().hasAttribute("data-pinning")).toBe(true);
|
|
477
|
+
|
|
478
|
+
c.toggle().click();
|
|
479
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
480
|
+
expect(c.aside().hasAttribute("data-pinning")).toBe(false);
|
|
481
|
+
expect(c.aside().style.width).toBe("56px");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("a child transitionend never ends the window (filtered to the host's width)", async () => {
|
|
485
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
486
|
+
c.toggle().click();
|
|
487
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
488
|
+
c.toggle().click();
|
|
489
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
490
|
+
|
|
491
|
+
c.panel().dispatchEvent(widthTransitionEnd());
|
|
492
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
493
|
+
expect(c.aside().hasAttribute("data-pinning")).toBe(true);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe("HkFaceShell hover suppression: a pointer on the toggle never peeks", () => {
|
|
498
|
+
beforeEach(() => {
|
|
499
|
+
vi.useFakeTimers();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("suppresses the dwell while the pointer rests on the toggle, re-arms when it moves off", async () => {
|
|
503
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
504
|
+
c.toggle().click();
|
|
505
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
506
|
+
stubToggleRect(c.container, 100, 200);
|
|
507
|
+
|
|
508
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
509
|
+
c.aside().dispatchEvent(pointerMove(110, 210));
|
|
510
|
+
await vi.advanceTimersByTimeAsync(400 * 4);
|
|
511
|
+
expect(c.aside().hasAttribute("data-peeking"), "parked on the toggle: no peek").toBe(false);
|
|
512
|
+
expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
|
|
513
|
+
|
|
514
|
+
c.aside().dispatchEvent(pointerMove(400, 500));
|
|
515
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
516
|
+
expect(c.aside().hasAttribute("data-peeking"), "off the toggle the peek arms normally").toBe(true);
|
|
517
|
+
expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("evaluates suppression on ENTER: crossing the boundary onto the toggle arms nothing", async () => {
|
|
521
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
522
|
+
c.toggle().click();
|
|
523
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
524
|
+
stubToggleRect(c.container, 100, 200);
|
|
525
|
+
|
|
526
|
+
c.aside().dispatchEvent(pointerEnterAt(110, 210));
|
|
527
|
+
await vi.advanceTimersByTimeAsync(400 * 4);
|
|
528
|
+
expect(c.aside().hasAttribute("data-peeking"), "enter on the toggle arms nothing").toBe(false);
|
|
529
|
+
expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("evaluates suppression on ENTER: entering elsewhere on the rail still peeks", async () => {
|
|
533
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
534
|
+
c.toggle().click();
|
|
535
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
536
|
+
stubToggleRect(c.container, 100, 200);
|
|
537
|
+
|
|
538
|
+
c.aside().dispatchEvent(pointerEnterAt(400, 500));
|
|
539
|
+
await vi.advanceTimersByTimeAsync(399);
|
|
540
|
+
expect(c.aside().hasAttribute("data-peeking"), "one tick before the dwell").toBe(false);
|
|
541
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
542
|
+
expect(c.aside().hasAttribute("data-peeking"), "off the toggle the enter arms the dwell").toBe(true);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("ignores a pointerleave whose relatedTarget is still inside the host", async () => {
|
|
546
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
547
|
+
c.toggle().click();
|
|
548
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
549
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
550
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
551
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(true);
|
|
552
|
+
|
|
553
|
+
const churn = POINTER_LEAVE();
|
|
554
|
+
Object.defineProperty(churn, "relatedTarget", { value: c.container.querySelector(".sidebar-mock") });
|
|
555
|
+
c.aside().dispatchEvent(churn);
|
|
556
|
+
await vi.advanceTimersByTimeAsync(150 * 4);
|
|
557
|
+
expect(c.aside().hasAttribute("data-peeking"), "DOM churn is not a leave").toBe(true);
|
|
558
|
+
|
|
559
|
+
c.aside().dispatchEvent(POINTER_LEAVE());
|
|
560
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
561
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(false);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("clicking the toggle during an open peek still pins the sidebar expanded", async () => {
|
|
565
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
566
|
+
c.toggle().click();
|
|
567
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
568
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
569
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
570
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(true);
|
|
571
|
+
|
|
572
|
+
c.toggle().click();
|
|
573
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
574
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(false);
|
|
575
|
+
expect(c.aside().style.width).toBe("224px");
|
|
576
|
+
expect(c.aside().hasAttribute("data-pinning"), "the peek pin is a rail -> expanded flip").toBe(true);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
@@ -2,7 +2,7 @@ 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
4
|
import { useSidebarRail } from "../composables/useSidebarRail";
|
|
5
|
-
import HkSidebarRailToggle from "./HkSidebarRailToggle";
|
|
5
|
+
import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
|
|
6
6
|
|
|
7
7
|
/** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
|
|
8
8
|
* padding (`inherit 0 inherit` is rejected by every engine, measured in
|
|
@@ -171,13 +171,22 @@ export const HkFaceShell = defineComponent({
|
|
|
171
171
|
sidebarCollapsed: { type: Boolean, default: false },
|
|
172
172
|
sidebarWidth: { type: String, default: "224px" },
|
|
173
173
|
/** OPT-IN icon-rail capability for the desktop sidebar (2026-10-30):
|
|
174
|
-
* mounts the hover-reveal circular chevron toggle
|
|
175
|
-
* content-facing
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
174
|
+
* mounts the hover-reveal circular chevron toggle STRADDLING the
|
|
175
|
+
* sidebar's content-facing border (its center on the border line,
|
|
176
|
+
* half the circle over the content), lets a click collapse the
|
|
177
|
+
* sidebar to a one-icon rail, and — while collapsed — opens a
|
|
178
|
+
* hover-peek drawer that slides the full sidebar OVER the content as
|
|
179
|
+
* an overlay (the layout keeps the rail width; the content area never
|
|
180
|
+
* shifts).
|
|
181
|
+
*
|
|
182
|
+
* Two behaviors ride the same opt-in: a pointer resting on the toggle
|
|
183
|
+
* SUPPRESSES the peek dwell (it sits on the rail's hover surface and
|
|
184
|
+
* would otherwise open the drawer out from under the cursor), and a
|
|
185
|
+
* rail -> expanded flip PINNS the panel at its final width for the
|
|
186
|
+
* transition window, so only the content area animates instead of the
|
|
187
|
+
* sidebar re-sliding. Timing lives in useSidebarRail (dwell, grace,
|
|
188
|
+
* suppression, Escape, pinning); geometry in HkSidebarRail.scss
|
|
189
|
+
* (.hk-sidebar-rail-host/-panel/-clip/-toggle).
|
|
181
190
|
* False (default) renders the sidebar exactly as before. Below
|
|
182
191
|
* mobileBreakpoint nothing changes — the mobile drawer stays the
|
|
183
192
|
* only nav surface. */
|
|
@@ -291,10 +300,15 @@ export const HkFaceShell = defineComponent({
|
|
|
291
300
|
emit("update:sidebarRailStyle", v ? "rail" : "expanded");
|
|
292
301
|
},
|
|
293
302
|
});
|
|
303
|
+
// The host REGION element (the rail <aside>) — the machine uses it to
|
|
304
|
+
// tell a real pointerleave from DOM churn during the peek/width
|
|
305
|
+
// transitions.
|
|
306
|
+
const railHostRef = ref<HTMLElement>();
|
|
294
307
|
const rail = useSidebarRail({
|
|
295
308
|
collapsed: railCollapsed,
|
|
296
309
|
peekDelay: () => props.sidebarPeekDelay,
|
|
297
310
|
peekGrace: () => props.sidebarPeekGrace,
|
|
311
|
+
host: () => railHostRef.value,
|
|
298
312
|
});
|
|
299
313
|
const railActive = computed(() => props.sidebarCollapsible && railCollapsed.value);
|
|
300
314
|
// The LAYOUT width — the core contract of the peek: rail width while
|
|
@@ -327,6 +341,33 @@ export const HkFaceShell = defineComponent({
|
|
|
327
341
|
if (rail.onKeydown(e)) e.stopPropagation();
|
|
328
342
|
};
|
|
329
343
|
|
|
344
|
+
/** A pointer parked on the toggle must NOT arm the peek dwell — the
|
|
345
|
+
* toggle straddles the rail's hover surface, so the drawer would open
|
|
346
|
+
* under the resting cursor and jump the button away from it (the
|
|
347
|
+
* reported twitch). Containment is GEOMETRIC: before the reveal the
|
|
348
|
+
* toggle is opacity:0 / pointer-events:none, so event.target cannot
|
|
349
|
+
* report it.
|
|
350
|
+
*
|
|
351
|
+
* ENTER evaluates it too, and BEFORE the arm: a pointer that crosses
|
|
352
|
+
* the boundary and stops exactly on the toggle (or a synthetic enter
|
|
353
|
+
* with no following move) must not arm the dwell. */
|
|
354
|
+
const onSidebarHostPointerEnter = (e: PointerEvent) => {
|
|
355
|
+
rail.setPeekSuppressed(isPointerOverRailToggle(railHostRef.value, e.clientX, e.clientY));
|
|
356
|
+
rail.onPointerEnter();
|
|
357
|
+
};
|
|
358
|
+
const onSidebarHostPointerMove = (e: PointerEvent) => {
|
|
359
|
+
rail.setPeekSuppressed(isPointerOverRailToggle(railHostRef.value, e.clientX, e.clientY));
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** The pin window ends with the HOST's own width transition; child
|
|
363
|
+
* transitionend events (the panel, a nested HkSidebar) bubble here
|
|
364
|
+
* too, so filter to this element's width. */
|
|
365
|
+
const onSidebarHostTransitionEnd = (e: TransitionEvent) => {
|
|
366
|
+
if (e.target !== e.currentTarget) return;
|
|
367
|
+
if (e.propertyName !== "width") return;
|
|
368
|
+
rail.endPinning();
|
|
369
|
+
};
|
|
370
|
+
|
|
330
371
|
const contentStyle = computed(() => ({
|
|
331
372
|
padding: isDesktop.value || !props.contentBleedOnMobile
|
|
332
373
|
? props.contentPadding
|
|
@@ -392,12 +433,20 @@ export const HkFaceShell = defineComponent({
|
|
|
392
433
|
{isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
|
|
393
434
|
props.sidebarCollapsible ? (
|
|
394
435
|
<aside
|
|
436
|
+
ref={railHostRef}
|
|
395
437
|
class="hk-sidebar-rail-host"
|
|
396
438
|
data-rail={railActive.value || undefined}
|
|
397
439
|
data-peeking={rail.peeking.value || undefined}
|
|
440
|
+
// Transient rail -> expanded window (useSidebarRail.pinning):
|
|
441
|
+
// the panel and any nested sidebar snap to their final width
|
|
442
|
+
// while THIS host's width transition plays — expanding must
|
|
443
|
+
// not replay the sidebar slide.
|
|
444
|
+
data-pinning={rail.pinning.value || undefined}
|
|
398
445
|
style={{ width: hostSidebarWidth.value, flexShrink: 0 }}
|
|
399
|
-
onPointerenter={
|
|
446
|
+
onPointerenter={onSidebarHostPointerEnter}
|
|
400
447
|
onPointerleave={rail.onPointerLeave}
|
|
448
|
+
onPointermove={onSidebarHostPointerMove}
|
|
449
|
+
onTransitionend={onSidebarHostTransitionEnd}
|
|
401
450
|
onKeydown={onSidebarHostKeydown}
|
|
402
451
|
>
|
|
403
452
|
{/* The visual panel — absolutely positioned over the host
|
|
@@ -413,11 +462,18 @@ export const HkFaceShell = defineComponent({
|
|
|
413
462
|
background: "rgb(var(--color-surface))",
|
|
414
463
|
}}
|
|
415
464
|
>
|
|
416
|
-
{
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
465
|
+
{/* The panel no longer clips (the toggle straddles its
|
|
466
|
+
border), so the slot's content clips inside this
|
|
467
|
+
wrapper — full rows stay inside rail width while the
|
|
468
|
+
panel width animates. The toggle stays a DIRECT child
|
|
469
|
+
of the panel: it must never be clipped. */}
|
|
470
|
+
<div class="hk-sidebar-rail-clip">
|
|
471
|
+
{slots.sidebar({
|
|
472
|
+
collapsed: railActive.value && !rail.peeking.value,
|
|
473
|
+
onNavigate: onSidebarNavigate,
|
|
474
|
+
peeking: rail.peeking.value,
|
|
475
|
+
})}
|
|
476
|
+
</div>
|
|
421
477
|
<HkSidebarRailToggle expanded={!railActive.value} onToggle={rail.toggle} />
|
|
422
478
|
</div>
|
|
423
479
|
</aside>
|