@celestia-island/hikari 0.55.235 → 0.55.237
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/HkAboutModal.scss +43 -3
- package/src/components/HkAboutModal.styles.test.ts +38 -16
- package/src/components/HkFaceShell.rail.test.tsx +35 -0
- package/src/components/HkFaceShell.tsx +27 -6
- package/src/components/HkSidebar.rail.test.tsx +44 -6
- package/src/components/HkSidebar.scss +9 -1
- package/src/components/HkSidebar.styles.test.ts +8 -2
- package/src/components/HkSidebar.tsx +70 -24
- package/src/components/HkSidebarRail.scss +44 -0
- package/src/components/HkSidebarRail.styles.test.ts +25 -0
- package/src/components/HkSidebarRailSwap.test.tsx +254 -0
- package/src/components/HkSidebarRailSwap.tsx +102 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -216,16 +216,53 @@
|
|
|
216
216
|
color: rgb(var(--color-muted));
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// Tone-pill ink, WCAG AA (2026-10-30 record-level leftover; PR #920 R2 S3-⑤).
|
|
220
|
+
//
|
|
221
|
+
// The pill text sits at `--text-2xs` (10px) — WCAG small text — so it owes
|
|
222
|
+
// 4.5:1, and the raw channel colour does not clear that on every palette:
|
|
223
|
+
// hosts swap the preset table wholesale (chest clears every stock key at
|
|
224
|
+
// boot and registers only its brand line), so no palette's value may be
|
|
225
|
+
// hardcoded here. The ink is therefore DERIVED from the live channel in
|
|
226
|
+
// OKLCH, whose perceptual uniformness lets the derivation touch lightness
|
|
227
|
+
// only — hue and chroma pass through, so each tone keeps its identity:
|
|
228
|
+
//
|
|
229
|
+
// light: L capped at 0.5 — darkens only colours too light for the white
|
|
230
|
+
// modal surface (stock amber would sit at 1.9:1);
|
|
231
|
+
// dark: L floored at 0.72 — lifts only colours too dark for the navy one
|
|
232
|
+
// (chest red would sit at 3.3:1).
|
|
233
|
+
//
|
|
234
|
+
// On the two shipped palettes the light cap grips EVERY coloured tone (all
|
|
235
|
+
// six entries sit above L 0.5 — chest light red 0.541 clamps to 0.501); the
|
|
236
|
+
// band's only pass-through is the stock dark positive green (L 0.768,
|
|
237
|
+
// already above the floor). Hue and chroma always survive. Both bounds were
|
|
238
|
+
// solved against the pill's real backdrop —
|
|
239
|
+
// the 10% channel tint composited over the 3%-primary card on
|
|
240
|
+
// `--color-surface` — for the WORST entry of the stock AND chest palettes:
|
|
241
|
+
// every tone lands ≥4.6:1. `light-dark()` resolves through the same
|
|
242
|
+
// color-scheme the theme engine already drives (`[data-mode] body` in
|
|
243
|
+
// theme.scss sets it alongside the channel swap), and an engine without
|
|
244
|
+
// relative-colour support drops the declaration, falling back to the
|
|
245
|
+
// neutral muted ink above — still AA everywhere: ≥4.87:1 even under the
|
|
246
|
+
// coloured pills' own tinted backdrop (4.87–7.20 across both palettes and
|
|
247
|
+
// modes; 5.98–8.06 on the plain neutral pill) — rather than shipping an
|
|
248
|
+
// unreadable tone colour. The border and background keep the raw channel:
|
|
249
|
+
// only the glyphs owe contrast, the tint is decorative.
|
|
219
250
|
.s-about-modal-row-meta[data-tone="positive"] {
|
|
220
251
|
border-color: rgb(var(--color-success) / 32%);
|
|
221
252
|
background: rgb(var(--color-success) / 10%);
|
|
222
|
-
color:
|
|
253
|
+
color: light-dark(
|
|
254
|
+
oklch(from rgb(var(--color-success)) min(l, 0.5) c h),
|
|
255
|
+
oklch(from rgb(var(--color-success)) max(l, 0.72) c h)
|
|
256
|
+
);
|
|
223
257
|
}
|
|
224
258
|
|
|
225
259
|
.s-about-modal-row-meta[data-tone="caution"] {
|
|
226
260
|
border-color: rgb(var(--color-warning) / 32%);
|
|
227
261
|
background: rgb(var(--color-warning) / 10%);
|
|
228
|
-
color:
|
|
262
|
+
color: light-dark(
|
|
263
|
+
oklch(from rgb(var(--color-warning)) min(l, 0.5) c h),
|
|
264
|
+
oklch(from rgb(var(--color-warning)) max(l, 0.72) c h)
|
|
265
|
+
);
|
|
229
266
|
}
|
|
230
267
|
|
|
231
268
|
// Red state: the component is not wired up / unreachable. The row then
|
|
@@ -233,7 +270,10 @@
|
|
|
233
270
|
.s-about-modal-row-meta[data-tone="danger"] {
|
|
234
271
|
border-color: rgb(var(--color-error) / 32%);
|
|
235
272
|
background: rgb(var(--color-error) / 10%);
|
|
236
|
-
color:
|
|
273
|
+
color: light-dark(
|
|
274
|
+
oklch(from rgb(var(--color-error)) min(l, 0.5) c h),
|
|
275
|
+
oklch(from rgb(var(--color-error)) max(l, 0.72) c h)
|
|
276
|
+
);
|
|
237
277
|
}
|
|
238
278
|
|
|
239
279
|
/* ── Links: licenses, external links, credits names and filings ───────── */
|
|
@@ -239,22 +239,44 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
239
239
|
describe("row meta tones", () => {
|
|
240
240
|
const TONE = (tone: string) => `.s-about-modal-row-meta[data-tone=${tone}]`;
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
242
|
+
// Channel behind each coloured tone, and the WCAG-AA ink derived from it.
|
|
243
|
+
// The pill text is `--text-2xs` (10px) — small text, 4.5:1 — and the raw
|
|
244
|
+
// channel misses that on both shipped palettes (stock light warning sits
|
|
245
|
+
// at ~1.9:1, chest dark red at ~3.3:1). The ink is therefore an OKLCH
|
|
246
|
+
// derivation from the live channel: hue and chroma pass through, only
|
|
247
|
+
// lightness moves — capped at 0.5 in light mode, floored at 0.72 in dark
|
|
248
|
+
// (bounds solved so the worst stock AND chest entry clears 4.5:1 against
|
|
249
|
+
// the pill's real backdrop: the 10% channel tint over the 3%-primary
|
|
250
|
+
// card on `--color-surface`). Reverting any arm to the raw channel — or
|
|
251
|
+
// dropping one `light-dark()` side — must turn this red.
|
|
252
|
+
const AA_INK = (channel: string) =>
|
|
253
|
+
`light-dark(` +
|
|
254
|
+
`oklch(from rgb(var(--color-${channel})) min(l, 0.5) c h), ` +
|
|
255
|
+
`oklch(from rgb(var(--color-${channel})) max(l, 0.72) c h))`;
|
|
256
|
+
const TONES: Record<string, string> = {
|
|
257
|
+
positive: "success",
|
|
258
|
+
caution: "warning",
|
|
259
|
+
danger: "error",
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
it("paints every tone in the theme channel with its AA-derived ink", () => {
|
|
263
|
+
for (const [tone, channel] of Object.entries(TONES)) {
|
|
264
|
+
const rule = ruleBody(css, TONE(tone));
|
|
265
|
+
expect(rule, `the ${tone}-tone rule must survive compilation`).not.toBe("");
|
|
266
|
+
expect({
|
|
267
|
+
borderColor: declaration(rule, "border-color"),
|
|
268
|
+
background: declaration(rule, "background"),
|
|
269
|
+
color: declaration(rule, "color"),
|
|
270
|
+
}).toEqual({
|
|
271
|
+
// Same geometry across the tones, only the channel differs; the
|
|
272
|
+
// border and tint keep the RAW channel (decorative identity),
|
|
273
|
+
// while the glyphs get the derived ink. (Dart Sass compiles the
|
|
274
|
+
// seed-relative form without spaces around the slash.)
|
|
275
|
+
borderColor: `rgb(var(--color-${channel})/32%)`,
|
|
276
|
+
background: `rgb(var(--color-${channel})/10%)`,
|
|
277
|
+
color: AA_INK(channel),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
258
280
|
});
|
|
259
281
|
});
|
|
260
282
|
});
|
|
@@ -424,6 +424,41 @@ describe("HkFaceShell rail chrome: toggle placement and the clip wrapper", () =>
|
|
|
424
424
|
expect(clip!.querySelector(".sidebar-mock"), "the sidebar slot renders in the clip").toBeTruthy();
|
|
425
425
|
});
|
|
426
426
|
|
|
427
|
+
it("hands the sidebar slot to the rail/full generations through the SWAP", async () => {
|
|
428
|
+
// The density dress rides the swap's GENERATION, never the host root:
|
|
429
|
+
// a root-level data-rail would restyle the frozen leaving generation
|
|
430
|
+
// mid-dissolve and reintroduce the squeeze this wave fixes. The root
|
|
431
|
+
// keeps data-rail as a STATE MARKER only (hosts/tests read it).
|
|
432
|
+
vi.useFakeTimers();
|
|
433
|
+
vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
|
|
434
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
435
|
+
const c = mountShell({ sidebarCollapsible: true });
|
|
436
|
+
const clip = () =>
|
|
437
|
+
c.panel().querySelector(":scope > .hk-sidebar-rail-clip") as HTMLElement;
|
|
438
|
+
const gens = () =>
|
|
439
|
+
Array.from(clip().querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation"));
|
|
440
|
+
|
|
441
|
+
expect(clip().querySelector(".hk-sidebar-rail-swap"), "the swap mounts inside the clip").toBeTruthy();
|
|
442
|
+
expect(gens()).toHaveLength(1);
|
|
443
|
+
expect(gens()[0]!.hasAttribute("data-rail"), "expanded carries no rail density").toBe(false);
|
|
444
|
+
expect(gens()[0]!.style.width, "the generation pins the shell's sidebar width").toBe("224px");
|
|
445
|
+
expect(c.aside().hasAttribute("data-rail")).toBe(false);
|
|
446
|
+
|
|
447
|
+
c.toggle().click();
|
|
448
|
+
await vi.advanceTimersByTimeAsync(100); // mid-dissolve
|
|
449
|
+
const mid = gens();
|
|
450
|
+
expect(mid.length, "both generations share the swap's cell").toBe(2);
|
|
451
|
+
expect(mid[0]!.hasAttribute("data-rail"), "the leaving copy keeps the full dress").toBe(false);
|
|
452
|
+
expect(mid[0]!.style.width).toBe("224px");
|
|
453
|
+
expect(mid[1]!.hasAttribute("data-rail"), "the entering copy takes the rail dress").toBe(true);
|
|
454
|
+
expect(mid[1]!.style.width, "the rail generation pins the collapsed width").toBe("56px");
|
|
455
|
+
// The slot ran for the entering generation at its density; the root
|
|
456
|
+
// marker reads rail while the DENSITY lives on the generation.
|
|
457
|
+
expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
|
|
458
|
+
expect(c.aside().hasAttribute("data-rail"), "the root keeps the state marker").toBe(true);
|
|
459
|
+
vi.unstubAllGlobals();
|
|
460
|
+
});
|
|
461
|
+
|
|
427
462
|
it("does not touch the legacy sidebar path (no clip wrapper, no rail classes)", () => {
|
|
428
463
|
const c = mountShell();
|
|
429
464
|
expect(c.container.querySelector(".hk-sidebar-rail-clip")).toBeFalsy();
|
|
@@ -2,6 +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 HkSidebarRailSwap from "./HkSidebarRailSwap";
|
|
5
6
|
import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
|
|
6
7
|
|
|
7
8
|
/** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
|
|
@@ -435,6 +436,10 @@ export const HkFaceShell = defineComponent({
|
|
|
435
436
|
<aside
|
|
436
437
|
ref={railHostRef}
|
|
437
438
|
class="hk-sidebar-rail-host"
|
|
439
|
+
// STATE marker only (hosts/tests read "the rail layout is
|
|
440
|
+
// active"): the rail DENSITY dress itself rides the swap's
|
|
441
|
+
// generation inside the clip, so it can freeze on the
|
|
442
|
+
// leaving generation during the dissolve.
|
|
438
443
|
data-rail={railActive.value || undefined}
|
|
439
444
|
data-peeking={rail.peeking.value || undefined}
|
|
440
445
|
// Transient rail -> expanded window (useSidebarRail.pinning):
|
|
@@ -466,13 +471,29 @@ export const HkFaceShell = defineComponent({
|
|
|
466
471
|
border), so the slot's content clips inside this
|
|
467
472
|
wrapper — full rows stay inside rail width while the
|
|
468
473
|
panel width animates. The toggle stays a DIRECT child
|
|
469
|
-
of the panel: it must never be clipped.
|
|
474
|
+
of the panel: it must never be clipped. Inside the
|
|
475
|
+
clip, the crossfade handoff (HkSidebarRailSwap)
|
|
476
|
+
exchanges the rail and full generations through the
|
|
477
|
+
tab-panel dissolve at width-pinned boxes, so the
|
|
478
|
+
slot's content never re-lays-out at the panel's
|
|
479
|
+
transient width — each generation sees its own
|
|
480
|
+
`collapsed` dress, and the leaving copy stays
|
|
481
|
+
frozen (and unclickable) while it dissolves. */}
|
|
470
482
|
<div class="hk-sidebar-rail-clip">
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
483
|
+
<HkSidebarRailSwap
|
|
484
|
+
rail={railActive.value && !rail.peeking.value}
|
|
485
|
+
railWidth={props.sidebarCollapsedWidth}
|
|
486
|
+
expandedWidth={props.sidebarWidth}
|
|
487
|
+
>
|
|
488
|
+
{/* NOT `rail` — the scoped param would shadow the
|
|
489
|
+
useSidebarRail handle the outer bindings read. */}
|
|
490
|
+
{({ rail: density }: { rail: boolean }) =>
|
|
491
|
+
slots.sidebar?.({
|
|
492
|
+
collapsed: density,
|
|
493
|
+
onNavigate: onSidebarNavigate,
|
|
494
|
+
peeking: rail.peeking.value,
|
|
495
|
+
})}
|
|
496
|
+
</HkSidebarRailSwap>
|
|
476
497
|
</div>
|
|
477
498
|
<HkSidebarRailToggle expanded={!railActive.value} onToggle={rail.toggle} />
|
|
478
499
|
</div>
|
|
@@ -53,6 +53,11 @@ function mountSidebar(props: Record<string, unknown> = {}) {
|
|
|
53
53
|
aside: () => container.querySelector("aside.hk-sidebar") as HTMLElement,
|
|
54
54
|
panel: () => container.querySelector("nav.hk-sidebar-panel") as HTMLElement,
|
|
55
55
|
toggle: () => container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement,
|
|
56
|
+
// The ACTIVE (entering/settled) generation of the rail swap — the
|
|
57
|
+
// last-mounted one; during a dissolve there are two.
|
|
58
|
+
generation: () =>
|
|
59
|
+
container.querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation"),
|
|
60
|
+
swap: () => container.querySelector(".hk-sidebar-rail-swap") as HTMLElement,
|
|
56
61
|
};
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -91,7 +96,13 @@ describe("HkSidebar rail capability", () => {
|
|
|
91
96
|
await vi.advanceTimersByTimeAsync(0);
|
|
92
97
|
expect(c.aside().style.width).toBe("64px");
|
|
93
98
|
expect(c.panel().style.width).toBe("64px");
|
|
94
|
-
|
|
99
|
+
// The density moved OFF the root onto the swap's generation: a root
|
|
100
|
+
// attribute flips with the state and would restyle the frozen
|
|
101
|
+
// leaving generation mid-dissolve (the squeeze this wave fixes).
|
|
102
|
+
expect(c.aside().hasAttribute("data-collapsed"), "the rail-mode root never carries the density").toBe(false);
|
|
103
|
+
expect(c.generation().length).toBeGreaterThan(0);
|
|
104
|
+
expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail"), "the generation carries the rail density").toBe(true);
|
|
105
|
+
expect(c.generation()[c.generation().length - 1]!.style.width).toBe("64px");
|
|
95
106
|
});
|
|
96
107
|
|
|
97
108
|
it("peeks as an overlay: the host width never moves", async () => {
|
|
@@ -117,13 +128,13 @@ describe("HkSidebar rail capability", () => {
|
|
|
117
128
|
await vi.advanceTimersByTimeAsync(0);
|
|
118
129
|
expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
|
|
119
130
|
expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(false);
|
|
120
|
-
expect(c.
|
|
131
|
+
expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail"), "rail density while collapsed").toBe(true);
|
|
121
132
|
|
|
122
133
|
c.aside().dispatchEvent(new Event("pointerenter"));
|
|
123
134
|
await vi.advanceTimersByTimeAsync(400);
|
|
124
135
|
expect(c.slotParams[c.slotParams.length - 1]!.collapsed, "full rows during the peek").toBe(false);
|
|
125
136
|
expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(true);
|
|
126
|
-
expect(c.
|
|
137
|
+
expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail"), "no rail density on the peek generation").toBe(false);
|
|
127
138
|
|
|
128
139
|
// closePeek (the nav-row hook) closes the drawer.
|
|
129
140
|
c.slotParams[c.slotParams.length - 1]!.closePeek();
|
|
@@ -198,11 +209,38 @@ describe("HkSidebar rail chrome: toggle placement and the clip wrapper", () => {
|
|
|
198
209
|
expect(clip, "the content clip lives inside the panel").toBeTruthy();
|
|
199
210
|
expect(toggle, "the toggle is a direct child of the panel").toBeTruthy();
|
|
200
211
|
expect(clip!.contains(toggle!), "the toggle is NOT clipped by the wrapper").toBe(false);
|
|
201
|
-
// The bands (header/body/footer) are inside the clip
|
|
212
|
+
// The bands (header/body/footer) are inside the clip — now inside the
|
|
213
|
+
// swap's generation, one per density.
|
|
202
214
|
expect(clip!.querySelector(".hk-sidebar-body")).toBeTruthy();
|
|
215
|
+
expect(clip!.querySelector(".hk-sidebar-rail-swap")).toBeTruthy();
|
|
203
216
|
expect(clip!.contains(c.aside())).toBe(false);
|
|
204
217
|
});
|
|
205
218
|
|
|
219
|
+
it("collapses through the SWAP: both densities coexist, each pinned at its own width", async () => {
|
|
220
|
+
// Freeze rAF to hold the dissolve mid-flight (HkCrossfade.test's
|
|
221
|
+
// timing model): the leaving FULL generation stays frozen at its
|
|
222
|
+
// dress and width while the entering RAIL generation takes over —
|
|
223
|
+
// no generation ever renders at the panel's transient width.
|
|
224
|
+
vi.useFakeTimers();
|
|
225
|
+
vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
|
|
226
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
227
|
+
const c = mountSidebar({ width: "240px", collapsible: true, collapsedWidth: "64px" });
|
|
228
|
+
|
|
229
|
+
c.toggle().click();
|
|
230
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
231
|
+
|
|
232
|
+
const gens = c.generation();
|
|
233
|
+
expect(gens.length, "both generations share the swap's cell mid-dissolve").toBe(2);
|
|
234
|
+
const [leaving, entering] = gens;
|
|
235
|
+
expect(leaving!.hasAttribute("data-rail"), "the leaving copy keeps the full dress").toBe(false);
|
|
236
|
+
expect(leaving!.style.width).toBe("240px");
|
|
237
|
+
expect(entering!.hasAttribute("data-rail"), "the entering copy takes the rail dress").toBe(true);
|
|
238
|
+
expect(entering!.style.width).toBe("64px");
|
|
239
|
+
// The band content lives inside the entering generation.
|
|
240
|
+
expect(entering!.querySelector(".hk-sidebar-body")).toBeTruthy();
|
|
241
|
+
vi.unstubAllGlobals();
|
|
242
|
+
});
|
|
243
|
+
|
|
206
244
|
it("renders the legacy panel with NO clip wrapper (byte-identical non-rail path)", () => {
|
|
207
245
|
const legacy = mountSidebar({ width: "240px" });
|
|
208
246
|
expect(legacy.panel().querySelector(".hk-sidebar-rail-clip")).toBeFalsy();
|
|
@@ -293,7 +331,7 @@ describe("HkSidebar hover suppression: a pointer on the toggle never peeks", ()
|
|
|
293
331
|
c.aside().dispatchEvent(pointerMove(110, 210));
|
|
294
332
|
await vi.advanceTimersByTimeAsync(400 * 4);
|
|
295
333
|
expect(c.aside().hasAttribute("data-peeking"), "parked on the toggle: no peek").toBe(false);
|
|
296
|
-
expect(c.
|
|
334
|
+
expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail")).toBe(true);
|
|
297
335
|
|
|
298
336
|
// Moving off the toggle (still on the rail) re-arms the normal dwell.
|
|
299
337
|
c.aside().dispatchEvent(pointerMove(400, 500));
|
|
@@ -314,7 +352,7 @@ describe("HkSidebar hover suppression: a pointer on the toggle never peeks", ()
|
|
|
314
352
|
c.aside().dispatchEvent(pointerEnterAt(110, 210));
|
|
315
353
|
await vi.advanceTimersByTimeAsync(400 * 4);
|
|
316
354
|
expect(c.aside().hasAttribute("data-peeking"), "enter on the toggle arms nothing").toBe(false);
|
|
317
|
-
expect(c.
|
|
355
|
+
expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail")).toBe(true);
|
|
318
356
|
});
|
|
319
357
|
|
|
320
358
|
it("evaluates suppression on ENTER: entering elsewhere on the rail still peeks", async () => {
|
|
@@ -77,7 +77,15 @@
|
|
|
77
77
|
flex-shrink: 0;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
/* The rail density dress, keyed off EITHER density carrier: the legacy
|
|
81
|
+
* presentational rail (no collapsible prop — the attr rides the root,
|
|
82
|
+
* instant, no animation to mask) or the swapped GENERATION of the rail
|
|
83
|
+
* kit (HkSidebarRailSwap) — the generation's [data-rail] flips with the
|
|
84
|
+
* swap key, so the leaving generation keeps its dress for the dissolve
|
|
85
|
+
* while the entering one takes the new one. A root-level attr cannot do
|
|
86
|
+
* that: it would restyle the frozen leaving copy mid-dissolve. */
|
|
87
|
+
.hk-sidebar[data-collapsed],
|
|
88
|
+
.hk-sidebar-rail-generation[data-rail] {
|
|
81
89
|
.hk-sidebar-header {
|
|
82
90
|
justify-content: center;
|
|
83
91
|
padding: var(--space-12) var(--space-8);
|
|
@@ -75,8 +75,14 @@ describe("HkSidebar scroll end-cap", () => {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
it("keeps the same tail on the collapsed body", () => {
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// The density dress is keyed off EITHER carrier — the legacy root's
|
|
79
|
+
// [data-collapsed] or the rail swap generation's [data-rail] (the
|
|
80
|
+
// generation attr freezes on the leaving copy for the dissolve, a
|
|
81
|
+
// root attr cannot). ruleBody needs the arm its brace follows, so the
|
|
82
|
+
// legacy arm is asserted as text and the body read from the group.
|
|
83
|
+
expect(css).toContain(".hk-sidebar[data-collapsed] .hk-sidebar-body");
|
|
84
|
+
const body = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-body");
|
|
85
|
+
expect(body, "the collapsed body rule exists (grouped density carriers)").not.toBeNull();
|
|
80
86
|
expect(body).toContain("padding-block: var(--space-12) var(--hk-sidebar-scroll-tail)");
|
|
81
87
|
});
|
|
82
88
|
|
|
@@ -3,6 +3,7 @@ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } fro
|
|
|
3
3
|
import "./HkSidebar.scss";
|
|
4
4
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
5
5
|
import { useSidebarRail } from "../composables/useSidebarRail";
|
|
6
|
+
import HkSidebarRailSwap from "./HkSidebarRailSwap";
|
|
6
7
|
import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
|
|
7
8
|
|
|
8
9
|
export default defineComponent({
|
|
@@ -76,6 +77,11 @@ export default defineComponent({
|
|
|
76
77
|
: props.collapsed,
|
|
77
78
|
);
|
|
78
79
|
const peeking = computed(() => props.collapsible && !props.mobile && rail.peeking.value);
|
|
80
|
+
// The rail DENSITY (icon-only dress): collapsed layout, not peeking.
|
|
81
|
+
// In rail mode this is the swap key — the density rides the swapped
|
|
82
|
+
// GENERATION (frozen on the leaving copy for the dissolve), never the
|
|
83
|
+
// host root, so nothing re-lays-out at the panel's transient width.
|
|
84
|
+
const railDensity = computed(() => layoutCollapsed.value && !peeking.value);
|
|
79
85
|
// The host's inline width IS the layout footprint — rail width while
|
|
80
86
|
// collapsed OR peeking (the peek is an overlay; the layout must not
|
|
81
87
|
// shift), full width while expanded.
|
|
@@ -88,21 +94,39 @@ export default defineComponent({
|
|
|
88
94
|
props.mobile ? undefined : layoutCollapsed.value && !peeking.value ? props.collapsedWidth : props.width,
|
|
89
95
|
);
|
|
90
96
|
|
|
91
|
-
// Persistent layout surface —
|
|
92
|
-
//
|
|
97
|
+
// Persistent layout surface — the overlay scrollbar (shared chrome)
|
|
98
|
+
// attaches to the scrolling body band. The rail swap mounts each
|
|
99
|
+
// generation's body FRESH (the leaving copy is frozen and unmounts
|
|
100
|
+
// with the dissolve), so the attach is a re-attach. Mechanism
|
|
101
|
+
// notes (probed against Vue 3.5): a flip patches the refs
|
|
102
|
+
// old -> null -> new in sequence — the unmount of the leaving copy
|
|
103
|
+
// nulls the shared ref before the entering copy's mount re-points
|
|
104
|
+
// it — so the guard below swallows the transient null and the FINAL
|
|
105
|
+
// state always lands on the entering body. The element tracker keeps
|
|
106
|
+
// a same-element watcher fire a no-op (no track churn on plain
|
|
107
|
+
// re-renders); a flip hands the single handle to the new body, which
|
|
108
|
+
// leaves the fading copy without a track for the dissolve — it is
|
|
109
|
+
// pointer-dead and dissolving, so that is cosmetic.
|
|
93
110
|
const bodyRef = ref<HTMLElement>();
|
|
94
111
|
// Positioned wrapper containing ONLY the body — the rail host (the
|
|
95
112
|
// panel also contains the header/footer bands).
|
|
96
113
|
const bodyWrapRef = ref<HTMLElement>();
|
|
97
114
|
let bodyScrollbar: OverlayScrollbarHandle | null = null;
|
|
115
|
+
let bodyScrollbarEl: HTMLElement | null = null;
|
|
98
116
|
|
|
99
|
-
|
|
117
|
+
const attachBodyScrollbar = () => {
|
|
100
118
|
if (!bodyRef.value) return;
|
|
119
|
+
if (bodyScrollbar && bodyScrollbarEl === bodyRef.value) return;
|
|
120
|
+
bodyScrollbar?.detach();
|
|
121
|
+
bodyScrollbarEl = bodyRef.value;
|
|
101
122
|
bodyScrollbar = attachOverlayScrollbars(bodyRef.value, {
|
|
102
123
|
axis: "vertical",
|
|
103
124
|
host: bodyWrapRef.value,
|
|
104
125
|
});
|
|
105
|
-
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
onMounted(attachBodyScrollbar);
|
|
129
|
+
watch([bodyRef, bodyWrapRef], attachBodyScrollbar);
|
|
106
130
|
|
|
107
131
|
onBeforeUnmount(() => {
|
|
108
132
|
bodyScrollbar?.detach();
|
|
@@ -142,27 +166,32 @@ export default defineComponent({
|
|
|
142
166
|
rail.endPinning();
|
|
143
167
|
};
|
|
144
168
|
|
|
145
|
-
/** The panel's inner bands
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
|
|
169
|
+
/** The panel's inner bands, parameterized by the density so the
|
|
170
|
+
* rail swap can render each generation at its OWN dress: the swap
|
|
171
|
+
* invokes this with the entering generation's flag while the leaving
|
|
172
|
+
* copy stays frozen at its last render (per-generation density by
|
|
173
|
+
* construction). In rail mode the bands live inside the swap, which
|
|
174
|
+
* lives inside .hk-sidebar-rail-clip — the rail panel no longer
|
|
175
|
+
* clips (the toggle straddles its content-facing border), so the
|
|
176
|
+
* wrapper is what keeps full-width rows inside the 56px footprint
|
|
177
|
+
* while widths animate. Without rail chrome they stay direct
|
|
178
|
+
* children of the panel, exactly as before (and the toggle is a
|
|
179
|
+
* direct child of the panel in both cases where it exists, so it
|
|
180
|
+
* can never be clipped). */
|
|
181
|
+
const renderBands = (collapsed: boolean) => (
|
|
153
182
|
<>
|
|
154
183
|
{slots.header ? (
|
|
155
184
|
<header class="hk-sidebar-header">{slots.header()}</header>
|
|
156
185
|
) : null}
|
|
157
186
|
<div ref={bodyWrapRef} class="hk-sidebar-body-wrap">
|
|
158
187
|
<div ref={bodyRef} class="hk-sidebar-body">
|
|
159
|
-
{/* Scoped params (ignored by plain slots): the
|
|
160
|
-
|
|
161
|
-
and full rows during a peek, plus the
|
|
162
|
-
nav row click should dismiss the peek
|
|
163
|
-
onNavigate does. */}
|
|
188
|
+
{/* Scoped params (ignored by plain slots): the density that
|
|
189
|
+
owns THIS generation so a host renders icon-only rows
|
|
190
|
+
while collapsed and full rows during a peek, plus the
|
|
191
|
+
peek closer — a nav row click should dismiss the peek
|
|
192
|
+
like the shell's onNavigate does. */}
|
|
164
193
|
{slots.default?.({
|
|
165
|
-
collapsed
|
|
194
|
+
collapsed,
|
|
166
195
|
peeking: peeking.value,
|
|
167
196
|
closePeek: rail.closePeek,
|
|
168
197
|
})}
|
|
@@ -179,10 +208,13 @@ export default defineComponent({
|
|
|
179
208
|
ref={hostRef}
|
|
180
209
|
class={railChrome.value ? "hk-sidebar hk-sidebar-rail-host" : "hk-sidebar"}
|
|
181
210
|
data-mobile={props.mobile || undefined}
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
211
|
+
// LEGACY density carrier only (no rail chrome): [data-collapsed]
|
|
212
|
+
// styles the presentational 56px rail exactly as before. In rail
|
|
213
|
+
// mode the density moves onto the swap's GENERATION — a root-level
|
|
214
|
+
// attribute flips with the state and would restyle the frozen
|
|
215
|
+
// leaving generation mid-dissolve (the squeeze this wave fixes),
|
|
216
|
+
// and a peek renders full rows at full width anyway.
|
|
217
|
+
data-collapsed={!railChrome.value && railDensity.value || undefined}
|
|
186
218
|
data-peeking={peeking.value || undefined}
|
|
187
219
|
// Transient rail -> expanded window (useSidebarRail.pinning): the
|
|
188
220
|
// panel and the panel's inner widths snap while THIS host's width
|
|
@@ -211,9 +243,23 @@ export default defineComponent({
|
|
|
211
243
|
style={{ width: props.mobile ? undefined : panelWidth.value }}
|
|
212
244
|
>
|
|
213
245
|
{railChrome.value ? (
|
|
214
|
-
|
|
246
|
+
/* The crossfade handoff (HkSidebarRailSwap): each generation
|
|
247
|
+
* renders the bands at its OWN dress and width while the clip
|
|
248
|
+
* windows the panel's animating width — no reflow, no wrap,
|
|
249
|
+
* a pure opacity exchange (the tab-panel dissolve). */
|
|
250
|
+
<div class="hk-sidebar-rail-clip">
|
|
251
|
+
<HkSidebarRailSwap
|
|
252
|
+
rail={railDensity.value}
|
|
253
|
+
railWidth={props.collapsedWidth}
|
|
254
|
+
expandedWidth={props.width}
|
|
255
|
+
>
|
|
256
|
+
{/* NOT `rail` — the scoped param would shadow the
|
|
257
|
+
useSidebarRail handle the outer bindings read. */}
|
|
258
|
+
{({ rail: density }: { rail: boolean }) => renderBands(density)}
|
|
259
|
+
</HkSidebarRailSwap>
|
|
260
|
+
</div>
|
|
215
261
|
) : (
|
|
216
|
-
renderBands()
|
|
262
|
+
renderBands(railDensity.value)
|
|
217
263
|
)}
|
|
218
264
|
{railChrome.value && (
|
|
219
265
|
<HkSidebarRailToggle
|
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
* itself cannot clip any more (see below), so
|
|
21
21
|
* this wrapper preserves the rail-width clip
|
|
22
22
|
* during the width transitions.
|
|
23
|
+
* .hk-sidebar-rail-swap the rail/full handoff inside the clip
|
|
24
|
+
* (HkSidebarRailSwap): two width-pinned
|
|
25
|
+
* generations exchanged through HkCrossfade's
|
|
26
|
+
* opacity dissolve, so no content ever reflows
|
|
27
|
+
* at the panel's transient width.
|
|
28
|
+
* .hk-sidebar-rail-generation ONE density's content (rail or full),
|
|
29
|
+
* width-pinned inline by the component; the
|
|
30
|
+
* active density carries [data-rail] HERE (never
|
|
31
|
+
* on the host root — a root attr would restyle
|
|
32
|
+
* the frozen leaving generation mid-dissolve).
|
|
23
33
|
* .hk-sidebar-rail-toggle the hover-reveal circular chevron button
|
|
24
34
|
* (HkSidebarRailToggle) STRADDLING the panel's
|
|
25
35
|
* content-facing border — its center ON the
|
|
@@ -129,6 +139,40 @@
|
|
|
129
139
|
overflow: hidden;
|
|
130
140
|
}
|
|
131
141
|
|
|
142
|
+
/* ── the swap: crossfade handoff between rail and full generations ────
|
|
143
|
+
* (HkSidebarRailSwap, user direction 2026-10-30.) The clip windows; the
|
|
144
|
+
* swap keeps every generation at its ORIGINAL width so nothing reflows
|
|
145
|
+
* while the panel's width animates; HkCrossfade (the tab-panel dissolve)
|
|
146
|
+
* exchanges the two generations through pure opacity. The leaving
|
|
147
|
+
* generation is frozen at its last render and drops pointer events for
|
|
148
|
+
* the dissolve (HkCrossfade's leave rule).
|
|
149
|
+
*
|
|
150
|
+
* The no-reflow pin, precisely: the generations carry their widths
|
|
151
|
+
* INLINE (the component), and the grid inside (HkCrossfade's one-cell
|
|
152
|
+
* stack) sizes its auto track to max-content — a track that NEVER
|
|
153
|
+
* shrinks below the widest mounted generation when the container runs
|
|
154
|
+
* narrower — so both generations lay out at their OWN widths however
|
|
155
|
+
* wide the clip currently is. (The swap host itself DOES track the
|
|
156
|
+
* animating clip width — a non-stretched flex child's fit-content is
|
|
157
|
+
* clamped by the available space — which is fine: the host box is not
|
|
158
|
+
* the layout reference for anything inside it. align-self simply keeps
|
|
159
|
+
* the host from STRETCHING, which would clip nothing but would invite
|
|
160
|
+
* percentage-width content to follow the animation.) The generations
|
|
161
|
+
* fill the swap's height so the bands' flex column (spacer, footer at
|
|
162
|
+
* the bottom) behaves exactly as it did as a direct child of the clip. */
|
|
163
|
+
.hk-sidebar-rail-clip > .hk-sidebar-rail-swap {
|
|
164
|
+
flex: 1;
|
|
165
|
+
min-height: 0;
|
|
166
|
+
align-self: flex-start;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.hk-sidebar-rail-swap .hk-sidebar-rail-generation {
|
|
170
|
+
height: 100%;
|
|
171
|
+
min-height: 0;
|
|
172
|
+
display: flex;
|
|
173
|
+
flex-direction: column;
|
|
174
|
+
}
|
|
175
|
+
|
|
132
176
|
/* The peek's only shadow: cast toward the content side. Offsets cannot
|
|
133
177
|
* flip via logical properties — mirror the cast in RTL (the house
|
|
134
178
|
* [dir="rtl"] override HkSidebar's mobile panel uses). */
|
|
@@ -131,6 +131,31 @@ describe("HkSidebarRail geometry contract", () => {
|
|
|
131
131
|
expect(clip).toContain("flex-direction: column");
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
it("the swap holds every generation at its OWN width (the no-reflow pin)", () => {
|
|
135
|
+
// The crossfade handoff (HkSidebarRailSwap): the generations carry
|
|
136
|
+
// their widths INLINE and HkCrossfade's auto track never shrinks
|
|
137
|
+
// below the widest mounted generation, so neither generation is ever
|
|
138
|
+
// laid out at the panel's transient width while it animates — the
|
|
139
|
+
// structural guarantee behind "content is never squeezed". The swap
|
|
140
|
+
// host itself only needs to not STRETCH with the animating clip
|
|
141
|
+
// (align-self): the host box is not the layout reference for the
|
|
142
|
+
// generations inside it.
|
|
143
|
+
const swap = ruleBody(css, ".hk-sidebar-rail-clip > .hk-sidebar-rail-swap");
|
|
144
|
+
expect(swap, "the swap sizing rule exists").not.toBeNull();
|
|
145
|
+
expect(swap).toContain("flex: 1");
|
|
146
|
+
expect(swap).toContain("min-height: 0");
|
|
147
|
+
expect(swap).toContain("align-self: flex-start");
|
|
148
|
+
|
|
149
|
+
// Each generation is the flex column the bands expect, filling the
|
|
150
|
+
// swap's height (spacer + footer-at-bottom behave as they did as
|
|
151
|
+
// direct children of the clip).
|
|
152
|
+
const gen = ruleBody(css, ".hk-sidebar-rail-swap .hk-sidebar-rail-generation");
|
|
153
|
+
expect(gen, "the generation rule exists").not.toBeNull();
|
|
154
|
+
expect(gen).toContain("height: 100%");
|
|
155
|
+
expect(gen).toContain("display: flex");
|
|
156
|
+
expect(gen).toContain("flex-direction: column");
|
|
157
|
+
});
|
|
158
|
+
|
|
134
159
|
it("straddles the panel border: half the toggle outside, center on the border line", () => {
|
|
135
160
|
const toggle = ruleBody(css, ".hk-sidebar-rail-toggle");
|
|
136
161
|
expect(toggle, "the toggle rule exists").not.toBeNull();
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkSidebarRailSwap from "./HkSidebarRailSwap";
|
|
5
|
+
import { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkSidebarRailSwap contract (2026-10-30) — the rail/full content handoff.
|
|
9
|
+
*
|
|
10
|
+
* What this file pins:
|
|
11
|
+
*
|
|
12
|
+
* 1. WIDTH PINS: each generation renders at ITS density's width (the
|
|
13
|
+
* rail/expanded props) with the density attribute (`data-rail`) on the
|
|
14
|
+
* GENERATION — never the host root — so the leaving generation keeps
|
|
15
|
+
* its dress while the entering one takes the new one.
|
|
16
|
+
* 2. THE DISSOLVE is HkCrossfade's simultaneous exchange (the tab-panel
|
|
17
|
+
* mechanism): mid-swap both generations share the swap's single grid
|
|
18
|
+
* cell, the leaving copy frozen at its last render and pointer-dead.
|
|
19
|
+
* 3. The scoped `{ rail }` slot param: the entering generation is invoked
|
|
20
|
+
* with the new value; the leaving one keeps what it last rendered.
|
|
21
|
+
* 4. A healthy swap settles on exactly one generation.
|
|
22
|
+
*
|
|
23
|
+
* Timing model: identical to HkCrossfade.test — happy-dom resolves no CSS
|
|
24
|
+
* transition durations, so a working rAF completes the swap inside
|
|
25
|
+
* fake-timer advances, and freezing rAF holds both generations mounted.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const mounts: App[] = [];
|
|
29
|
+
const containers: HTMLElement[] = [];
|
|
30
|
+
|
|
31
|
+
interface SwapSlotParams {
|
|
32
|
+
rail: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
container: HTMLElement;
|
|
37
|
+
swap(): HTMLElement;
|
|
38
|
+
generations(): HTMLElement[];
|
|
39
|
+
params: SwapSlotParams[];
|
|
40
|
+
set(rail: boolean): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mountSwap(opts: { rail?: boolean; railWidth?: string; expandedWidth?: string } = {}): Harness {
|
|
44
|
+
const params: SwapSlotParams[] = [];
|
|
45
|
+
const container = document.createElement("div");
|
|
46
|
+
document.body.appendChild(container);
|
|
47
|
+
containers.push(container);
|
|
48
|
+
|
|
49
|
+
const rail = ref(opts.rail ?? false);
|
|
50
|
+
const Wrapper = defineComponent({
|
|
51
|
+
name: "RailSwapHarness",
|
|
52
|
+
setup() {
|
|
53
|
+
return () =>
|
|
54
|
+
h(
|
|
55
|
+
HkSidebarRailSwap,
|
|
56
|
+
{
|
|
57
|
+
rail: rail.value,
|
|
58
|
+
railWidth: opts.railWidth ?? "56px",
|
|
59
|
+
expandedWidth: opts.expandedWidth ?? "240px",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
default: (p: SwapSlotParams) => {
|
|
63
|
+
params.push(p);
|
|
64
|
+
return h("p", { class: "body" }, p.rail ? "RAIL" : "FULL");
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const app = createApp(Wrapper);
|
|
71
|
+
mounts.push(app);
|
|
72
|
+
app.mount(container);
|
|
73
|
+
return {
|
|
74
|
+
container,
|
|
75
|
+
swap: () => container.querySelector(".hk-sidebar-rail-swap") as HTMLElement,
|
|
76
|
+
generations: () =>
|
|
77
|
+
Array.from(container.querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation")),
|
|
78
|
+
params,
|
|
79
|
+
set: async (v: boolean) => {
|
|
80
|
+
rail.value = v;
|
|
81
|
+
await nextTick();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function freezeRaf(): void {
|
|
87
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
88
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
for (const app of mounts) app.unmount();
|
|
93
|
+
mounts.length = 0;
|
|
94
|
+
document.body.innerHTML = "";
|
|
95
|
+
containers.length = 0;
|
|
96
|
+
vi.unstubAllGlobals();
|
|
97
|
+
vi.useRealTimers();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("HkSidebarRailSwap — generation contract", () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("renders the active generation silently on first paint, pinned at its width", () => {
|
|
106
|
+
const c = mountSwap({ railWidth: "56px", expandedWidth: "240px" });
|
|
107
|
+
// The swap host IS HkCrossfade's host — class fallthrough merges them.
|
|
108
|
+
expect(c.swap().classList.contains("hk-crossfade")).toBe(true);
|
|
109
|
+
|
|
110
|
+
const gens = c.generations();
|
|
111
|
+
expect(gens).toHaveLength(1);
|
|
112
|
+
expect(gens[0]!.style.width).toBe("240px");
|
|
113
|
+
expect(gens[0]!.hasAttribute("data-rail"), "expanded carries no rail density").toBe(false);
|
|
114
|
+
expect(c.params[c.params.length - 1]!.rail).toBe(false);
|
|
115
|
+
expect(gens[0]!.textContent).toContain("FULL");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("the rail generation pins the rail width and carries the density", async () => {
|
|
119
|
+
const c = mountSwap({ railWidth: "64px" });
|
|
120
|
+
await c.set(true);
|
|
121
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
122
|
+
const gens = c.generations();
|
|
123
|
+
expect(gens).toHaveLength(1);
|
|
124
|
+
expect(gens[0]!.style.width).toBe("64px");
|
|
125
|
+
expect(gens[0]!.hasAttribute("data-rail")).toBe(true);
|
|
126
|
+
expect(c.params[c.params.length - 1]!.rail).toBe(true);
|
|
127
|
+
expect(gens[0]!.textContent).toContain("RAIL");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("applies the width props VERBATIM (a var() token must survive the passthrough)", async () => {
|
|
131
|
+
// happy-dom's CSSOM silently drops a var() width from the parsed
|
|
132
|
+
// inline style, so the token passthrough is pinned in SOURCE (the
|
|
133
|
+
// house source-pin style): the generation's width is the raw prop —
|
|
134
|
+
// no re-resolution, no fallback substitution — which is what lets a
|
|
135
|
+
// bespoke host pass its own var(--app-sidebar-w, 240px) token.
|
|
136
|
+
const { readFileSync } = await import("node:fs");
|
|
137
|
+
const { dirname, join } = await import("node:path");
|
|
138
|
+
const { fileURLToPath } = await import("node:url");
|
|
139
|
+
const src = readFileSync(
|
|
140
|
+
join(dirname(fileURLToPath(import.meta.url)), "HkSidebarRailSwap.tsx"),
|
|
141
|
+
"utf8",
|
|
142
|
+
);
|
|
143
|
+
expect(src).toContain("width: props.rail ? props.railWidth : props.expandedWidth");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("HkSidebarRailSwap — the dissolve (HkCrossfade's mechanism)", () => {
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
vi.useFakeTimers();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("holds BOTH generations mid-swap: leaving FROZEN at its dress, entering at the new one", async () => {
|
|
153
|
+
freezeRaf();
|
|
154
|
+
const c = mountSwap({ railWidth: "56px", expandedWidth: "240px" });
|
|
155
|
+
await c.set(true);
|
|
156
|
+
await vi.advanceTimersByTimeAsync(200); // inside the dissolve window
|
|
157
|
+
|
|
158
|
+
const gens = c.generations();
|
|
159
|
+
expect(gens, "both generations share the cell mid-swap").toHaveLength(2);
|
|
160
|
+
// DOM order: leaving first, entering last.
|
|
161
|
+
const [leaving, entering] = gens;
|
|
162
|
+
expect(leaving!.hasAttribute("data-rail"), "the leaving copy keeps the EXPANDED dress").toBe(false);
|
|
163
|
+
expect(leaving!.style.width).toBe("240px");
|
|
164
|
+
expect(leaving!.textContent).toContain("FULL");
|
|
165
|
+
expect(entering!.hasAttribute("data-rail"), "the entering copy takes the rail dress").toBe(true);
|
|
166
|
+
expect(entering!.style.width).toBe("56px");
|
|
167
|
+
expect(entering!.textContent).toContain("RAIL");
|
|
168
|
+
// The leaving ITEM is pointer-dead for the dissolve (HkCrossfade's
|
|
169
|
+
// leave rule) — the frozen generation cannot intercept clicks.
|
|
170
|
+
const leavingItem = leaving!.parentElement!;
|
|
171
|
+
expect(leavingItem.classList.contains("hk-crossfade-leave-active")).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("a healthy swap settles on the entering generation alone", async () => {
|
|
175
|
+
const c = mountSwap({ railWidth: "56px" });
|
|
176
|
+
await c.set(true);
|
|
177
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
178
|
+
const gens = c.generations();
|
|
179
|
+
expect(gens).toHaveLength(1);
|
|
180
|
+
expect(gens[0]!.hasAttribute("data-rail")).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("reversing mid-dissolve still lands on the live density — and settles", async () => {
|
|
184
|
+
freezeRaf();
|
|
185
|
+
const c = mountSwap({});
|
|
186
|
+
await c.set(true);
|
|
187
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
188
|
+
await c.set(false); // flipped back before the first dissolve settled
|
|
189
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
190
|
+
const last = c.generations()[c.generations().length - 1]!;
|
|
191
|
+
expect(last.hasAttribute("data-rail")).toBe(false);
|
|
192
|
+
expect(last.style.width).toBe("240px");
|
|
193
|
+
|
|
194
|
+
// Release rAF and let the choreography finish: whatever stale items
|
|
195
|
+
// the reversal stranded must sweep, settling on exactly one
|
|
196
|
+
// generation at the live density (the watchdog's degraded path
|
|
197
|
+
// would otherwise leave wedged copies mounted).
|
|
198
|
+
vi.unstubAllGlobals();
|
|
199
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 200);
|
|
200
|
+
const settled = c.generations();
|
|
201
|
+
expect(settled).toHaveLength(1);
|
|
202
|
+
expect(settled[0]!.hasAttribute("data-rail")).toBe(false);
|
|
203
|
+
expect(settled[0]!.style.width).toBe("240px");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("FREEZES the leaving generation under a host re-render mid-dissolve", async () => {
|
|
207
|
+
// THE central guarantee: the leaving copy is not re-rendered while
|
|
208
|
+
// it dissolves — a host re-render (peek state, live store data) must
|
|
209
|
+
// not restyle or rewrite it, or the density handoff would reintroduce
|
|
210
|
+
// the squeeze this component exists to fix. The harness re-renders
|
|
211
|
+
// the whole slot with NEW live text mid-dissolve; the leaving copy
|
|
212
|
+
// must keep the OLD text while the entering copy shows the new one.
|
|
213
|
+
freezeRaf();
|
|
214
|
+
const container = document.createElement("div");
|
|
215
|
+
document.body.appendChild(container);
|
|
216
|
+
containers.push(container);
|
|
217
|
+
|
|
218
|
+
const rail = ref(false);
|
|
219
|
+
const live = ref("v1");
|
|
220
|
+
const Wrapper = defineComponent({
|
|
221
|
+
name: "RailSwapFreezeHarness",
|
|
222
|
+
setup() {
|
|
223
|
+
return () =>
|
|
224
|
+
h(
|
|
225
|
+
HkSidebarRailSwap,
|
|
226
|
+
{ rail: rail.value, railWidth: "56px", expandedWidth: "240px" },
|
|
227
|
+
{
|
|
228
|
+
default: ({ rail: r }: { rail: boolean }) =>
|
|
229
|
+
h("p", { class: "body" }, (r ? "RAIL:" : "FULL:") + live.value),
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
const app = createApp(Wrapper);
|
|
235
|
+
mounts.push(app);
|
|
236
|
+
app.mount(container);
|
|
237
|
+
|
|
238
|
+
rail.value = true; // start the FULL -> RAIL dissolve
|
|
239
|
+
await nextTick();
|
|
240
|
+
await vi.advanceTimersByTimeAsync(120);
|
|
241
|
+
live.value = "v2"; // the host re-renders mid-dissolve
|
|
242
|
+
await nextTick();
|
|
243
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
244
|
+
|
|
245
|
+
const gens = Array.from(
|
|
246
|
+
container.querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation"),
|
|
247
|
+
);
|
|
248
|
+
expect(gens).toHaveLength(2);
|
|
249
|
+
// The frozen leaving copy keeps its last-rendered text (FULL:v1);
|
|
250
|
+
// the entering copy re-renders live (RAIL:v2).
|
|
251
|
+
expect(gens[0]!.textContent).toBe("FULL:v1");
|
|
252
|
+
expect(gens[1]!.textContent).toBe("RAIL:v2");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
|
|
3
|
+
import HkCrossfade from "./HkCrossfade";
|
|
4
|
+
|
|
5
|
+
import "./HkSidebarRail.scss";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkSidebarRailSwap — the rail/full CONTENT handoff (2026-10-30).
|
|
9
|
+
*
|
|
10
|
+
* THE DEFECT this closes: a rail <-> expanded flip animates the panel's
|
|
11
|
+
* `width` (the slide lever) while the density dress (`[data-rail]` /
|
|
12
|
+
* the slot's scoped state) flips INSTANTLY. The single mounted body is
|
|
13
|
+
* therefore re-laid-out at the ANIMATING panel width — expanding mounts
|
|
14
|
+
* full-width rows inside a still-56px panel (labels wrap and ellipsize,
|
|
15
|
+
* then relax as the panel grows: the reported "squeezed" content) and
|
|
16
|
+
* collapsing snaps rows to icon tiles that glide through the still-wide
|
|
17
|
+
* panel. Both read as the content being crushed for the transition's
|
|
18
|
+
* ~300ms, even though nothing is wrong before or after it.
|
|
19
|
+
*
|
|
20
|
+
* THE CONTRACT (user direction 2026-10-30): during the handoff the
|
|
21
|
+
* content keeps its ORIGINAL width and the clip does the windowing
|
|
22
|
+
* (`overflow: hidden` — `.hk-sidebar-rail-clip`, already in the kit),
|
|
23
|
+
* and the old and new generations exchange through an OPACITY dissolve —
|
|
24
|
+
* the SAME primitive tab panels swap through (HkCrossfade via
|
|
25
|
+
* HkTransitionContainer; the paneSwap contract's standard mechanism, so
|
|
26
|
+
* this component composes it rather than inventing a bespoke fade).
|
|
27
|
+
*
|
|
28
|
+
* Per generation:
|
|
29
|
+
* - the generation wrapper is PINNED at its own width (`railWidth` /
|
|
30
|
+
* `expandedWidth` inline) and anchored at the docked inline-start
|
|
31
|
+
* edge, so neither generation ever reflows while the panel animates;
|
|
32
|
+
* - the swap host itself NEVER stretches to the animating clip width
|
|
33
|
+
* (HkSidebarRail.scss: shrink-to-fit at the widest generation) — the
|
|
34
|
+
* "original content width" guarantee is structural, not per-frame;
|
|
35
|
+
* - the generation carries `data-rail` while the rail density is
|
|
36
|
+
* active, so hosts scope their density dress to the GENERATION, never
|
|
37
|
+
* to the host root (a root-level attribute flips with the state and
|
|
38
|
+
* would restyle the frozen leaving generation mid-dissolve);
|
|
39
|
+
* - the leaving generation is FROZEN at its last render (Vue keeps the
|
|
40
|
+
* keyed leaving item unpatched) and drops pointer events for the
|
|
41
|
+
* dissolve (HkCrossfade's leave rule), so the dead copy can neither
|
|
42
|
+
* update nor intercept clicks.
|
|
43
|
+
*
|
|
44
|
+
* Slot: the default slot receives `{ rail }` — render the body as a
|
|
45
|
+
* function of it. The ENTERING generation is invoked with the new value;
|
|
46
|
+
* the LEAVING one keeps whatever it last rendered (per-generation density
|
|
47
|
+
* by construction). Hosts that ignore the param and close over their own
|
|
48
|
+
* state get the same freeze for free — but the param is the sanctioned
|
|
49
|
+
* channel because it also drives per-generation branching (icon hints,
|
|
50
|
+
* aria labels) that CSS cannot express.
|
|
51
|
+
*
|
|
52
|
+
* Trade-offs (documented, accepted): each flip MOUNTS the entering
|
|
53
|
+
* generation fresh, exactly like a tab-panel swap —
|
|
54
|
+
* - a scrolled sidebar body starts the entering copy at the top, and
|
|
55
|
+
* uncontrolled state inside the slot (an expanded nav group, hover)
|
|
56
|
+
* does not carry over. Same-family precedent (HkTabs panels, admin
|
|
57
|
+
* views); rail flips are rare, and preserving scroll across a width
|
|
58
|
+
* change whose row heights differ would map positions, not restore
|
|
59
|
+
* them. A peek OPEN also hands hover to the full row under the
|
|
60
|
+
* resting pointer without user intent — inherent to any dissolve.
|
|
61
|
+
* - FOCUS: when the leaving generation unmounts at the end of the
|
|
62
|
+
* dissolve, focus inside it (a nav row focused in an open peek)
|
|
63
|
+
* drops to <body> — the sharpest edge is Escape-closing a peek from
|
|
64
|
+
* a focused row, which also drops the host's :focus-within (the
|
|
65
|
+
* toggle's keyboard reveal) until the next Tab. Hosts that need a
|
|
66
|
+
* softer landing can refocus the toggle from the peek-closer path.
|
|
67
|
+
*
|
|
68
|
+
* Reduced motion: HkCrossfade keeps a short opacity exchange (a fade
|
|
69
|
+
* carries no movement) while the kit's sheets kill the width slides —
|
|
70
|
+
* the handoff degrades to a quick dip, never a squeeze.
|
|
71
|
+
*/
|
|
72
|
+
export default defineComponent({
|
|
73
|
+
name: "HkSidebarRailSwap",
|
|
74
|
+
props: {
|
|
75
|
+
/** Which generation is active: true = the icon rail (the collapsed
|
|
76
|
+
* density), false = the full sidebar (including a peek). */
|
|
77
|
+
rail: { type: Boolean, default: false },
|
|
78
|
+
/** Pinned width of the RAIL generation. The host's collapsed-width
|
|
79
|
+
* token (HkSidebar's `collapsedWidth`, the shell's
|
|
80
|
+
* `sidebarCollapsedWidth`, wowsp's 56px). */
|
|
81
|
+
railWidth: { type: String, default: "56px" },
|
|
82
|
+
/** Pinned width of the EXPANDED generation. The host's full sidebar
|
|
83
|
+
* width token — a CSS length or a var() reference. */
|
|
84
|
+
expandedWidth: { type: String, default: "280px" },
|
|
85
|
+
},
|
|
86
|
+
setup(props, { slots }) {
|
|
87
|
+
return () => (
|
|
88
|
+
// The class rides HkCrossfade's host (Vue class fallthrough), so
|
|
89
|
+
// HkSidebarRail.scss can size the swap INSIDE the clip without a
|
|
90
|
+
// second wrapper element.
|
|
91
|
+
<HkCrossfade class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
|
|
92
|
+
<div
|
|
93
|
+
class="hk-sidebar-rail-generation"
|
|
94
|
+
data-rail={props.rail || undefined}
|
|
95
|
+
style={{ width: props.rail ? props.railWidth : props.expandedWidth }}
|
|
96
|
+
>
|
|
97
|
+
{slots.default?.({ rail: props.rail })}
|
|
98
|
+
</div>
|
|
99
|
+
</HkCrossfade>
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -104,6 +104,12 @@ export {
|
|
|
104
104
|
RAIL_TOGGLE_SELECTOR,
|
|
105
105
|
RAIL_TOGGLE_SUPPRESS_PAD,
|
|
106
106
|
} from "./components/HkSidebarRailToggle";
|
|
107
|
+
// The rail/full content handoff: two width-pinned generations exchanged
|
|
108
|
+
// through HkCrossfade's opacity dissolve (the tab-panel mechanism), so
|
|
109
|
+
// sidebar content never re-lays-out at the panel's transient width while
|
|
110
|
+
// it collapses or expands. Bespoke hosts (wowsp's own Sidebar) mount it
|
|
111
|
+
// inside their .hk-sidebar-rail-clip exactly like HkSidebar/HkFaceShell.
|
|
112
|
+
export { default as HkSidebarRailSwap } from "./components/HkSidebarRailSwap";
|
|
107
113
|
export { default as HSkeleton } from "./components/HkSkeleton";
|
|
108
114
|
export { default as HSkeletonList } from "./components/HkSkeletonList";
|
|
109
115
|
export { default as HSlider } from "./components/HkSlider";
|