@celestia-island/hikari 0.55.264 → 0.55.266
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/HkFaceShell.rail.test.tsx +8 -0
- package/src/components/HkFaceShell.tsx +8 -1
- package/src/components/HkMenu.rail.test.tsx +48 -0
- package/src/components/HkMenu.tsx +31 -5
- package/src/components/HkSidebar.rail.test.tsx +6 -0
- package/src/components/HkSidebar.scss +21 -1
- package/src/components/HkSidebar.styles.test.ts +78 -25
- package/src/components/HkSidebar.tsx +15 -2
- package/src/components/HkSidebarRail.scss +60 -1
- package/src/components/HkSidebarRail.styles.test.ts +79 -0
- package/src/components/HkSidebarRailSwap.tsx +26 -3
- package/src/composables/useRailIconFlight.test.ts +542 -0
- package/src/composables/useRailIconFlight.ts +344 -0
package/package.json
CHANGED
|
@@ -185,6 +185,10 @@ describe("HkFaceShell rail mode", () => {
|
|
|
185
185
|
|
|
186
186
|
expect(c.aside().style.width).toBe("224px");
|
|
187
187
|
expect(c.aside().getAttribute("data-rail")).toBeNull();
|
|
188
|
+
// The shell declares the rail width token for the rail row square's
|
|
189
|
+
// inline track (same contract as HkSidebar's collapsedWidth); the
|
|
190
|
+
// default rides the 56px fallback.
|
|
191
|
+
expect(c.aside().style.getPropertyValue("--hk-sidebar-rail-w")).toBe("56px");
|
|
188
192
|
expect(c.toggle().getAttribute("aria-expanded")).toBe("true");
|
|
189
193
|
|
|
190
194
|
c.toggle().click();
|
|
@@ -265,6 +269,10 @@ describe("HkFaceShell rail mode", () => {
|
|
|
265
269
|
|
|
266
270
|
it("honors a configured rail width", async () => {
|
|
267
271
|
const c = mountShell({ sidebarCollapsible: true, sidebarCollapsedWidth: "72px" });
|
|
272
|
+
expect(
|
|
273
|
+
c.aside().style.getPropertyValue("--hk-sidebar-rail-w"),
|
|
274
|
+
"a non-default collapsed width retunes the square track via the token",
|
|
275
|
+
).toBe("72px");
|
|
268
276
|
c.toggle().click();
|
|
269
277
|
await vi.advanceTimersByTimeAsync(0);
|
|
270
278
|
expect(c.aside().style.width).toBe("72px");
|
|
@@ -461,7 +461,14 @@ export const HkFaceShell = defineComponent({
|
|
|
461
461
|
// while THIS host's width transition plays — expanding must
|
|
462
462
|
// not replay the sidebar slide.
|
|
463
463
|
data-pinning={rail.pinning.value || undefined}
|
|
464
|
-
style={{
|
|
464
|
+
style={{
|
|
465
|
+
width: hostSidebarWidth.value,
|
|
466
|
+
flexShrink: 0,
|
|
467
|
+
// The rail row square's inline track (HkSidebar.scss)
|
|
468
|
+
// derives from this token, so a non-default
|
|
469
|
+
// sidebarCollapsedWidth keeps producing exact squares.
|
|
470
|
+
"--hk-sidebar-rail-w": props.sidebarCollapsedWidth,
|
|
471
|
+
}}
|
|
465
472
|
onPointerenter={onSidebarHostPointerEnter}
|
|
466
473
|
onPointerleave={rail.onPointerLeave}
|
|
467
474
|
onPointermove={onSidebarHostPointerMove}
|
|
@@ -7,6 +7,7 @@ import { createApp, h, nextTick, ref, type Ref } from "vue";
|
|
|
7
7
|
import { Home, Settings } from "lucide-vue-next";
|
|
8
8
|
|
|
9
9
|
import HkMenu, { type HkMenuItem } from "./HkMenu";
|
|
10
|
+
import { RAIL_FLIP_ICON_ATTR } from "../composables/useRailIconFlight";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* HkMenu sidebar-variant ICON-RAIL mode (collapsed prop, 2026-10-30):
|
|
@@ -418,6 +419,53 @@ describe("HkMenu sidebar icon rail", () => {
|
|
|
418
419
|
expect(glyph.textContent).toBe("D");
|
|
419
420
|
});
|
|
420
421
|
|
|
422
|
+
it("stamps every icon cell with data-flip-icon = the row key, in BOTH densities", () => {
|
|
423
|
+
// The rail flip's icon flight (useRailIconFlight) pairs the leaving
|
|
424
|
+
// and entering copies of ONE destination by this attribute; a cell
|
|
425
|
+
// missing it (or carrying the wrong key) silently drops that row out
|
|
426
|
+
// of the continuity choreography. All four render sites — expanded
|
|
427
|
+
// leaf + group toggle, rail leaf + rail group header — must stamp it.
|
|
428
|
+
// The selector is built from the composable's exported constant — the
|
|
429
|
+
// welding that turns a literal drift in HkMenu's JSX (or the JS
|
|
430
|
+
// pairing vocabulary) into a red test instead of a silently dead
|
|
431
|
+
// flight chain (2026-11-02 review).
|
|
432
|
+
const iconKeys = (root: ParentNode): string[] =>
|
|
433
|
+
[...root.querySelectorAll<HTMLElement>(`.hk-menu-item-icon[${RAIL_FLIP_ICON_ATTR}]`)].map((el) =>
|
|
434
|
+
el.getAttribute(RAIL_FLIP_ICON_ATTR) ?? "",
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// An icon on the group exercises the group-toggle sites; the
|
|
438
|
+
// icon-less leaf exercises the asymmetry the flight tolerates: the
|
|
439
|
+
// rail renders a fallback cell for it (keyed!) while the expanded
|
|
440
|
+
// sidebar renders no cell at all — pairing needs BOTH ends, so that
|
|
441
|
+
// row dissolves instead of flying.
|
|
442
|
+
const FULL: HkMenuItem[] = [
|
|
443
|
+
{ key: "home", label: "Home", icon: Home },
|
|
444
|
+
{ key: "plain", label: "Docs" },
|
|
445
|
+
{
|
|
446
|
+
key: "admin",
|
|
447
|
+
label: "Administration",
|
|
448
|
+
icon: Settings,
|
|
449
|
+
children: [{ key: "users", label: "Users", icon: Home }],
|
|
450
|
+
},
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
const expanded = mountMenu(FULL, { defaultExpandGroups: true });
|
|
454
|
+
expect(iconKeys(expanded), "expanded leaf + group toggle icons").toEqual([
|
|
455
|
+
"home",
|
|
456
|
+
"admin",
|
|
457
|
+
"users",
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
const collapsed = mountMenu(FULL, { collapsed: true, defaultExpandGroups: true });
|
|
461
|
+
expect(iconKeys(collapsed), "rail leaves + rail group header icons").toEqual([
|
|
462
|
+
"home",
|
|
463
|
+
"plain",
|
|
464
|
+
"admin",
|
|
465
|
+
"users",
|
|
466
|
+
]);
|
|
467
|
+
});
|
|
468
|
+
|
|
421
469
|
it("still selects from the rail (and stays disabled-aware)", () => {
|
|
422
470
|
const selected: string[] = [];
|
|
423
471
|
const container = document.createElement("div");
|
|
@@ -861,7 +861,12 @@ export default defineComponent({
|
|
|
861
861
|
</span>
|
|
862
862
|
)}
|
|
863
863
|
{item.icon && (
|
|
864
|
-
|
|
864
|
+
// data-flip-icon: the row key opts this cell into the rail
|
|
865
|
+
// flip's icon flight (useRailIconFlight) — the flight pairs
|
|
866
|
+
// the leaving and entering copies of ONE destination by it.
|
|
867
|
+
<span class="hk-menu-item-icon" data-flip-icon={item.key}>
|
|
868
|
+
{h(item.icon, { size: 16 })}
|
|
869
|
+
</span>
|
|
865
870
|
)}
|
|
866
871
|
{item.flag && <span class="hk-menu-flag">{item.flag}</span>}
|
|
867
872
|
<span class="hk-menu-label">{item.label}</span>
|
|
@@ -890,8 +895,14 @@ export default defineComponent({
|
|
|
890
895
|
toggleGroup(item.key);
|
|
891
896
|
}}
|
|
892
897
|
>
|
|
898
|
+
{/* The group toggle's glyph flies too — same key, same
|
|
899
|
+
pairing (the rail header centers the same group icon).
|
|
900
|
+
Still conditional: an icon-less group renders no cell
|
|
901
|
+
here, exactly as before. */}
|
|
893
902
|
{item.icon && (
|
|
894
|
-
<span class="hk-menu-item-icon"
|
|
903
|
+
<span class="hk-menu-item-icon" data-flip-icon={item.key}>
|
|
904
|
+
{h(item.icon, { size: 16 })}
|
|
905
|
+
</span>
|
|
895
906
|
)}
|
|
896
907
|
<span class="hk-menu-label">{item.label}</span>
|
|
897
908
|
{item.badge && <span class="hk-menu-sidebar-badge">{item.badge}</span>}
|
|
@@ -956,8 +967,14 @@ export default defineComponent({
|
|
|
956
967
|
}}
|
|
957
968
|
>
|
|
958
969
|
{/* Icon-less rows fall back to the label's initial — the rail
|
|
959
|
-
must never strand a destination behind a missing glyph.
|
|
960
|
-
|
|
970
|
+
must never strand a destination behind a missing glyph.
|
|
971
|
+
data-flip-icon pairs this cell with the expanded row's
|
|
972
|
+
icon across the density flip (useRailIconFlight). */}
|
|
973
|
+
<span
|
|
974
|
+
class="hk-menu-item-icon"
|
|
975
|
+
data-flip-icon={item.key}
|
|
976
|
+
data-fallback={item.icon ? undefined : true}
|
|
977
|
+
>
|
|
961
978
|
{item.icon ? h(item.icon, { size: 16 }) : item.label.trim().charAt(0).toUpperCase()}
|
|
962
979
|
</span>
|
|
963
980
|
{item.badge && (
|
|
@@ -1002,7 +1019,16 @@ export default defineComponent({
|
|
|
1002
1019
|
toggleGroup(item.key);
|
|
1003
1020
|
}}
|
|
1004
1021
|
>
|
|
1005
|
-
|
|
1022
|
+
{/* The flight pairs the rail header's glyph with the
|
|
1023
|
+
expanded group toggle's icon by the group key. An
|
|
1024
|
+
icon-less group has no expanded cell to pair with, so
|
|
1025
|
+
its placeholder caret simply dissolves — pairing needs
|
|
1026
|
+
both ends, and the flight never invents one. */}
|
|
1027
|
+
<span
|
|
1028
|
+
class="hk-menu-item-icon"
|
|
1029
|
+
data-flip-icon={item.key}
|
|
1030
|
+
data-fallback={item.icon ? undefined : true}
|
|
1031
|
+
>
|
|
1006
1032
|
{item.icon ? h(item.icon, { size: 16 }) : railCaret(expanded, 16)}
|
|
1007
1033
|
</span>
|
|
1008
1034
|
{item.icon && (
|
|
@@ -95,6 +95,12 @@ describe("HkSidebar rail capability", () => {
|
|
|
95
95
|
const c = mountSidebar({ width: "240px", collapsible: true, collapsedWidth: "64px" });
|
|
96
96
|
expect(c.aside().className).toContain("hk-sidebar-rail-host");
|
|
97
97
|
expect(c.aside().style.width).toBe("240px");
|
|
98
|
+
// The rail width travels to the sheet as a token: the rail row
|
|
99
|
+
// square's inline track (HkSidebar.scss) computes from it, so a
|
|
100
|
+
// non-default collapsedWidth keeps exact squares without a second
|
|
101
|
+
// edit. Deleting the declaration regresses non-56 hosts silently
|
|
102
|
+
// (the 56px fallback renders identically at the default).
|
|
103
|
+
expect(c.aside().style.getPropertyValue("--hk-sidebar-rail-w")).toBe("64px");
|
|
98
104
|
|
|
99
105
|
c.toggle().click();
|
|
100
106
|
await vi.advanceTimersByTimeAsync(0);
|
|
@@ -97,7 +97,23 @@
|
|
|
97
97
|
* collapsed paddings used to lift the whole nav by up to two paddings'
|
|
98
98
|
* worth on every flip; a band that needs a different vertical dress in
|
|
99
99
|
* rail mode would have to move the base values for BOTH densities, never
|
|
100
|
-
* add a per-density block padding.
|
|
100
|
+
* add a per-density block padding.
|
|
101
|
+
*
|
|
102
|
+
* THE RAIL ROW SQUARE (2026-11-02 user screenshot: the collapsed
|
|
103
|
+
* selected pill read as "not quite square — a bit squat"). The body's
|
|
104
|
+
* inline track narrows so a rail row is EXACTLY its own min-height
|
|
105
|
+
* square: the rail width minus the menu-item grammar's 34px row, half
|
|
106
|
+
* a side each. The row spans its track (width: 100% in the shared
|
|
107
|
+
* spec), so the hover/active pill fills 34×34 instead of the 32×34
|
|
108
|
+
* oblong a 12px track produced. INLINE ONLY: the block paddings — the
|
|
109
|
+
* 20px leading air and the scroll tail — stay with the base rule, so
|
|
110
|
+
* the vertical rhythm invariant above is untouched. The rail width
|
|
111
|
+
* travels as `--hk-sidebar-rail-w`, declared inline by both rail
|
|
112
|
+
* holders (HkSidebar's `collapsedWidth`, HkFaceShell's
|
|
113
|
+
* `sidebarCollapsedWidth`) — a host retuning the width keeps exact
|
|
114
|
+
* squares with no second edit. The 34px minuend stays a literal
|
|
115
|
+
* because --hk-menu-item-min-h is declared ON each row (the
|
|
116
|
+
* custom-property trap), so this ancestor cannot compute from it. */
|
|
101
117
|
.hk-sidebar[data-collapsed],
|
|
102
118
|
.hk-sidebar-rail-generation[data-rail] {
|
|
103
119
|
.hk-sidebar-header {
|
|
@@ -108,6 +124,10 @@
|
|
|
108
124
|
.hk-sidebar-footer {
|
|
109
125
|
padding: var(--space-16) var(--space-8);
|
|
110
126
|
}
|
|
127
|
+
|
|
128
|
+
.hk-sidebar-body {
|
|
129
|
+
padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2);
|
|
130
|
+
}
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
.hk-sidebar[data-mobile] {
|
|
@@ -76,19 +76,25 @@ describe("HkSidebar scroll end-cap", () => {
|
|
|
76
76
|
|
|
77
77
|
it("the density dress keeps every vertical metric at the expanded value", () => {
|
|
78
78
|
// Vertical rhythm is density-invariant (2026-10-31 user direction:
|
|
79
|
-
// the collapse toggle must not move rows). The body
|
|
80
|
-
//
|
|
81
|
-
//
|
|
79
|
+
// the collapse toggle must not move rows). The body's ONLY density
|
|
80
|
+
// declaration is the rail row square's inline track (2026-11-02 user
|
|
81
|
+
// screenshot: the collapsed selected pill was a 32×34 oblong; the
|
|
82
|
+
// track narrows to 11px a side so the 100%-wide row fills exactly its
|
|
83
|
+
// 34px min-height square) — the leading air AND the scroll tail ride
|
|
84
|
+
// the single base rule in both densities, and the header/footer dress
|
|
82
85
|
// rules keep the expanded BLOCK padding, narrowing only the inline
|
|
83
86
|
// track for the rail column.
|
|
84
|
-
expect(css).not.toContain(".hk-sidebar[data-collapsed] .hk-sidebar-body");
|
|
85
|
-
expect(css).not.toContain(".hk-sidebar-rail-generation[data-rail] .hk-sidebar-body");
|
|
86
|
-
// The two carrier literals above are the compiled spellings; a brand
|
|
87
|
-
// NEW carrier (a third density hook) is a new dress and must re-run
|
|
88
|
-
// this contract — not mechanically foreseeable here.
|
|
89
87
|
// ruleBody needs the arm its brace follows; the dress compiles to one
|
|
90
88
|
// comma-grouped rule per band, so the generation arm is the one the
|
|
91
89
|
// helper can read (the legacy arm shares its body).
|
|
90
|
+
const bodySquare = css.match(
|
|
91
|
+
/\.hk-sidebar\[data-collapsed\]\s*\.hk-sidebar-body[^{]*\{([^}]*)\}/,
|
|
92
|
+
);
|
|
93
|
+
expect(bodySquare, "the rail body inline-track rule exists").not.toBeNull();
|
|
94
|
+
expect(
|
|
95
|
+
bodySquare![1]!.trim(),
|
|
96
|
+
"the body dress is the row square's inline track ALONE — any block spelling here moves rows",
|
|
97
|
+
).toBe("padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2);");
|
|
92
98
|
const header = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-header");
|
|
93
99
|
expect(header, "the collapsed header rule exists (grouped density carriers)").not.toBeNull();
|
|
94
100
|
expect(header).toContain("padding: var(--space-16) var(--space-8)");
|
|
@@ -96,37 +102,84 @@ describe("HkSidebar scroll end-cap", () => {
|
|
|
96
102
|
expect(footer, "the collapsed footer rule exists (grouped density carriers)").not.toBeNull();
|
|
97
103
|
expect(footer).toContain("padding: var(--space-16) var(--space-8)");
|
|
98
104
|
// The stronger, spelling-proof form (whole-dress scan): EVERY
|
|
99
|
-
// padding declaration in ANY dress rule must be EXACTLY the
|
|
100
|
-
// two
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// (`padding-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
105
|
+
// padding declaration in ANY dress rule must be EXACTLY one of the
|
|
106
|
+
// two sanctioned values — the expanded two-track shorthand (header/
|
|
107
|
+
// footer bands) or the body's token-derived square track — exact,
|
|
108
|
+
// not a prefix: a
|
|
109
|
+
// three-track spelling (`padding: var(--space-16) var(--space-8)
|
|
110
|
+
// var(--space-12)`) drifts the block END past a block-start-only
|
|
111
|
+
// check, and a longhand (`padding-block-start:`) hides from a
|
|
112
|
+
// shorthand-only one; both green-holed earlier rounds. Physical
|
|
113
|
+
// `bottom` longhands are banned beside the logical ones; an
|
|
114
|
+
// `padding-inline` on any NON-body rule is rejected the same way (a
|
|
115
|
+
// band that truly needs different block metrics must move the BASE
|
|
116
|
+
// rule for BOTH densities). Margins are banned outright — the bands
|
|
109
117
|
// separate by their paddings alone — and so are the block-FLOW
|
|
110
118
|
// metrics that move rows just the same (gap/row-gap/height/
|
|
111
119
|
// min-height/max-height).
|
|
120
|
+
// Keyed on the carrier SUBSTRINGS, not their bracket spelling: a
|
|
121
|
+
// `[data-collapsed="true"]`-style variant of the same carrier must
|
|
122
|
+
// not slip the scan (2026-11-02 review: the exact-literal filter was
|
|
123
|
+
// a green hole). Boundary (restored): a brand-new carrier MECHANISM
|
|
124
|
+
// — a class-based hook or a third density attribute under a new
|
|
125
|
+
// name — is a new dress and must re-run this contract; not
|
|
126
|
+
// mechanically foreseeable here.
|
|
112
127
|
const dressRules = css
|
|
113
128
|
.split("}")
|
|
114
|
-
.filter((rule) => rule.includes("
|
|
129
|
+
.filter((rule) => rule.includes("data-collapsed") || rule.includes("data-rail"));
|
|
115
130
|
expect(dressRules.length, "the density dress rules exist").toBeGreaterThan(1);
|
|
116
131
|
for (const rule of dressRules) {
|
|
117
132
|
for (const decl of rule.matchAll(
|
|
118
|
-
/\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:[^;}]*/g,
|
|
133
|
+
/\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?|inline(?:-(?:start|end))?))?\s*:[^;}]*/g,
|
|
119
134
|
)) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
const text = decl[0].trim();
|
|
136
|
+
if (/^padding-inline/.test(text)) {
|
|
137
|
+
// Chunk-anchored, not chunk-containment: the chunk carries the
|
|
138
|
+
// rule's own selector before its "{" — requiring THE SELECTOR
|
|
139
|
+
// to be the body's is what actually confines the inline track
|
|
140
|
+
// to the body rule (a raw chunk toContain would pass on a
|
|
141
|
+
// neighbor's selector too — 2026-11-02 review).
|
|
142
|
+
const selector = rule.slice(0, rule.indexOf("{"));
|
|
143
|
+
expect(
|
|
144
|
+
selector,
|
|
145
|
+
`a dress inline track outside the body rule: ${rule}`,
|
|
146
|
+
).toContain(".hk-sidebar-body");
|
|
147
|
+
expect(
|
|
148
|
+
text,
|
|
149
|
+
`a dress inline track drifted from the row square: ${text}`,
|
|
150
|
+
).toBe("padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2)");
|
|
151
|
+
} else {
|
|
152
|
+
expect(
|
|
153
|
+
text,
|
|
154
|
+
`a dress padding is not the expanded two-track shorthand: ${text}`,
|
|
155
|
+
).toBe("padding: var(--space-16) var(--space-8)");
|
|
156
|
+
}
|
|
124
157
|
}
|
|
125
158
|
expect(rule, `a margin crept into the dress: ${rule}`).not.toMatch(
|
|
126
159
|
/\bmargin(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
|
|
127
160
|
);
|
|
161
|
+
// block-FLOW metrics that move rows just the same (line-height
|
|
162
|
+
// already falls under the `height` alternative via the word
|
|
163
|
+
// boundary; the logical `block-size` family spelled out
|
|
164
|
+
// separately — "min-block-size" carries no "height" substring,
|
|
165
|
+
// 2026-11-02 review), plus the block-EDGE spellings a
|
|
166
|
+
// padding/margin scan cannot see (border-block-start-width and
|
|
167
|
+
// inset-block drifted rows while every padding check stayed
|
|
168
|
+
// green).
|
|
128
169
|
expect(rule, `block-flow metric in the dress: ${rule}`).not.toMatch(
|
|
129
|
-
/\b(?:gap|row-gap|height|min-height|max-height)\s*:/,
|
|
170
|
+
/\b(?:gap|row-gap|height|min-height|max-height|(?:min-|max-)?block-size)\s*:/,
|
|
171
|
+
);
|
|
172
|
+
expect(rule, `block-edge border in the dress: ${rule}`).not.toMatch(/\bborder-block/);
|
|
173
|
+
expect(rule, `block-edge inset in the dress: ${rule}`).not.toMatch(/\binset-block/);
|
|
174
|
+
// The PHYSICAL block-edge borders: the lookbehind above deliberately
|
|
175
|
+
// spares "-top"/"-bottom" INSIDE longer words, which let
|
|
176
|
+
// `border-top-width: 6px` push every row below it down while every
|
|
177
|
+
// logical check stayed green (2026-11-02 review).
|
|
178
|
+
expect(rule, `physical block-edge border in the dress: ${rule}`).not.toMatch(
|
|
179
|
+
/\bborder-(?:top|bottom)\b/,
|
|
180
|
+
);
|
|
181
|
+
expect(rule, `physical block offset in the dress: ${rule}`).not.toMatch(
|
|
182
|
+
/(?<![-\w])(?:top|bottom)\s*:/,
|
|
130
183
|
);
|
|
131
184
|
}
|
|
132
185
|
});
|
|
@@ -29,7 +29,10 @@ export default defineComponent({
|
|
|
29
29
|
* no peek, `collapsed` stays the plain presentational 56px rail. */
|
|
30
30
|
collapsible: { type: Boolean, default: false },
|
|
31
31
|
/** Layout width of the icon rail (the one-icon column). Mirrors the
|
|
32
|
-
* 56px this component always used for its collapsed mode.
|
|
32
|
+
* 56px this component always used for its collapsed mode. Declared
|
|
33
|
+
* to the sheet as `--hk-sidebar-rail-w` on this aside, so the rail
|
|
34
|
+
* row square (HkSidebar.scss's body inline track) retunes with it —
|
|
35
|
+
* any width yields exact 34px squares. */
|
|
33
36
|
collapsedWidth: { type: String, default: "56px" },
|
|
34
37
|
/** Hover dwell on the rail before the peek drawer opens, ms. */
|
|
35
38
|
peekDelay: { type: Number, default: 400 },
|
|
@@ -264,7 +267,17 @@ export default defineComponent({
|
|
|
264
267
|
// panel and the panel's inner widths snap while THIS host's width
|
|
265
268
|
// transition plays — expanding must not replay the sidebar slide.
|
|
266
269
|
data-pinning={rail.pinning.value || undefined}
|
|
267
|
-
style={
|
|
270
|
+
style={
|
|
271
|
+
props.mobile
|
|
272
|
+
? undefined
|
|
273
|
+
: {
|
|
274
|
+
width: hostWidth.value,
|
|
275
|
+
// The rail row square's inline track (HkSidebar.scss)
|
|
276
|
+
// derives from this token, so a non-default
|
|
277
|
+
// collapsedWidth keeps producing exact squares.
|
|
278
|
+
"--hk-sidebar-rail-w": props.collapsedWidth,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
268
281
|
onPointerenter={railChrome.value ? onHostPointerEnter : undefined}
|
|
269
282
|
onPointerleave={railChrome.value ? rail.onPointerLeave : undefined}
|
|
270
283
|
onPointermove={railChrome.value ? onHostPointerMove : undefined}
|
|
@@ -159,11 +159,70 @@
|
|
|
159
159
|
* the host from STRETCHING, which would clip nothing but would invite
|
|
160
160
|
* percentage-width content to follow the animation.) The generations
|
|
161
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.
|
|
162
|
+
* the bottom) behaves exactly as it did as a direct child of the clip.
|
|
163
|
+
*
|
|
164
|
+
* position: relative makes the host the containing block for the rail
|
|
165
|
+
* flip's FLIGHT LAYER (useRailIconFlight). The host is the right stage
|
|
166
|
+
* for two reasons, each the inverse of the other's failure mode
|
|
167
|
+
* (2026-11-02 review): its inline-START edge is the docked edge
|
|
168
|
+
* `align-self: flex-start` pins — stable while only the inline-END edge
|
|
169
|
+
* tracks the animating clip width, in LTR and RTL alike (a physical
|
|
170
|
+
* left anchor would drift by the whole width increment in RTL); and it
|
|
171
|
+
* sits OUTSIDE both dissolving items, so the clones never inherit an
|
|
172
|
+
* item's enter fade (enter-from holds opacity 0 for the flight's first
|
|
173
|
+
* frames — an item-anchored clone blinks out exactly when it should
|
|
174
|
+
* substitute). Clone insets are written as LOGICAL offsets from the
|
|
175
|
+
* stable edge; see the composable. */
|
|
163
176
|
.hk-sidebar-rail-clip > .hk-sidebar-rail-swap {
|
|
164
177
|
flex: 1;
|
|
165
178
|
min-height: 0;
|
|
166
179
|
align-self: flex-start;
|
|
180
|
+
position: relative;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* The flight layer + clones (see useRailIconFlight). The layer covers
|
|
184
|
+
* the host — the widest generation's footprint — so a clone never
|
|
185
|
+
* repositions relative to the animating panel; its own geometry is
|
|
186
|
+
* inline (the measured rects, written as LOGICAL insets from the
|
|
187
|
+
* stable inline-start edge). pointer-events stay dead: the layer is
|
|
188
|
+
* paint-only chrome above a pointer-dead dissolve. Clones keep
|
|
189
|
+
* inline-flex centering so the svg sits in the cell exactly as the real
|
|
190
|
+
* icon cell centers it. */
|
|
191
|
+
.hk-sidebar-rail-flight {
|
|
192
|
+
position: absolute;
|
|
193
|
+
inset: 0;
|
|
194
|
+
z-index: 2;
|
|
195
|
+
display: block;
|
|
196
|
+
pointer-events: none;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.hk-sidebar-rail-flight-clone {
|
|
200
|
+
position: absolute;
|
|
201
|
+
display: inline-flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: center;
|
|
204
|
+
margin: 0;
|
|
205
|
+
pointer-events: none;
|
|
206
|
+
will-change: transform;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* During a flight the leaving generation's MATCHED icons hide: the clone
|
|
210
|
+
* paints in their place at the same geometry, full opacity — a
|
|
211
|
+
* substitution, not a fade, so the icon reads as ONE object that
|
|
212
|
+
* travels. Scoped four ways on purpose: only while a flight runs
|
|
213
|
+
* ([data-flipping] on the swap host), only on the dissolving copy (the
|
|
214
|
+
* leave item class), only on opted-in icons ([data-flip-icon]), and
|
|
215
|
+
* only on the cells the choreographer actually PAIRED AND CLONED
|
|
216
|
+
* ([data-flip-flying] — stamped per flight, swept after). That last
|
|
217
|
+
* scope is the pairing contract made visible: an opted-in cell without
|
|
218
|
+
* a partner (a rail fallback letter with no expanded counterpart) is
|
|
219
|
+
* never stamped, so it dissolves instead of vanishing without a
|
|
220
|
+
* substitute (2026-11-02 review). Identity blocks, carets, badges
|
|
221
|
+
* and every other icon keep the plain dissolve. */
|
|
222
|
+
.hk-sidebar-rail-swap[data-flipping]
|
|
223
|
+
.hk-crossfade-item.hk-crossfade-leave-active
|
|
224
|
+
.hk-menu-item-icon[data-flip-flying] {
|
|
225
|
+
visibility: hidden;
|
|
167
226
|
}
|
|
168
227
|
|
|
169
228
|
.hk-sidebar-rail-swap .hk-sidebar-rail-generation {
|
|
@@ -5,6 +5,12 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { compile } from "sass";
|
|
6
6
|
import { describe, expect, it } from "vitest";
|
|
7
7
|
|
|
8
|
+
import {
|
|
9
|
+
RAIL_FLIP_ACTIVE_ATTR,
|
|
10
|
+
RAIL_FLIP_FLYING_ATTR,
|
|
11
|
+
RAIL_FLIP_ICON_ATTR,
|
|
12
|
+
} from "../composables/useRailIconFlight";
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
15
|
* Sidebar rail + peek stylesheet contract (2026-10-30) — the shared
|
|
10
16
|
* geometry behind HkFaceShell's sidebar, HkSidebar and bespoke hosts
|
|
@@ -145,6 +151,20 @@ describe("HkSidebarRail geometry contract", () => {
|
|
|
145
151
|
expect(swap).toContain("flex: 1");
|
|
146
152
|
expect(swap).toContain("min-height: 0");
|
|
147
153
|
expect(swap).toContain("align-self: flex-start");
|
|
154
|
+
// The swap host IS the flight layer's containing block: its
|
|
155
|
+
// inline-start edge is the docked edge (stable in LTR and RTL while
|
|
156
|
+
// only the inline-END edge tracks the animating clip width), and it
|
|
157
|
+
// sits OUTSIDE both dissolving items — clones must not inherit an
|
|
158
|
+
// item's enter fade (enter-from holds opacity 0 for the flight's
|
|
159
|
+
// first frames; an item-anchored clone blinks out at arm time).
|
|
160
|
+
expect(swap).toContain("position: relative");
|
|
161
|
+
// The dissolving items must NOT become containing blocks: a
|
|
162
|
+
// relative item would put the clones inside the very opacity group
|
|
163
|
+
// the flight exists to bypass.
|
|
164
|
+
expect(
|
|
165
|
+
ruleBody(css, ".hk-sidebar-rail-swap .hk-crossfade-item"),
|
|
166
|
+
"the dissolve item must not become the clone's containing block",
|
|
167
|
+
).toBeNull();
|
|
148
168
|
|
|
149
169
|
// Each generation is the flex column the bands expect, filling the
|
|
150
170
|
// swap's height (spacer + footer-at-bottom behave as they did as
|
|
@@ -226,6 +246,65 @@ describe("HkSidebarRail geometry contract", () => {
|
|
|
226
246
|
expect(btn).toContain("--hi-icon-button-radius: 9999px");
|
|
227
247
|
});
|
|
228
248
|
|
|
249
|
+
it("hosts the rail flip's icon flight: paint-only layer, dead clones, triply-scoped hide", () => {
|
|
250
|
+
// useRailIconFlight clones the flipping icons onto a layer ABOVE the
|
|
251
|
+
// dissolve. The layer is chrome: it must never intercept a pointer
|
|
252
|
+
// (the leaving generation is pointer-dead for the dissolve and the
|
|
253
|
+
// layer rides above it) and it lives INSIDE the swap host, so the
|
|
254
|
+
// nobody-clips contract above is untouched.
|
|
255
|
+
const layer = ruleBody(css, ".hk-sidebar-rail-flight");
|
|
256
|
+
expect(layer, "the flight layer rule exists").not.toBeNull();
|
|
257
|
+
expect(layer).toContain("position: absolute");
|
|
258
|
+
expect(layer).toContain("inset: 0");
|
|
259
|
+
expect(layer).toContain("z-index: 2");
|
|
260
|
+
expect(layer).toContain("pointer-events: none");
|
|
261
|
+
|
|
262
|
+
const clone = ruleBody(css, ".hk-sidebar-rail-flight-clone");
|
|
263
|
+
expect(clone, "the clone rule exists").not.toBeNull();
|
|
264
|
+
expect(clone).toContain("position: absolute");
|
|
265
|
+
expect(clone).toContain("pointer-events: none");
|
|
266
|
+
|
|
267
|
+
// The hide rule is scoped FOUR ways — only while a flight is armed
|
|
268
|
+
// ([data-flipping] on the swap host), only on the DISSOLVING copy
|
|
269
|
+
// (the leave item class), only on opted-in icons ([data-flip-icon]),
|
|
270
|
+
// and only on the cells the choreographer actually PAIRED AND CLONED
|
|
271
|
+
// ([data-flip-flying]): identity blocks, carets, badges, unmatched
|
|
272
|
+
// fallbacks and every other icon keep the plain dissolve. The
|
|
273
|
+
// selector is composed from the composable's EXPORTED CONSTANTS —
|
|
274
|
+
// the welding that keeps the JSX/SCSS literals and the JS pairings
|
|
275
|
+
// one vocabulary (a literal drift anywhere turns this red instead of
|
|
276
|
+
// silently double-exposing the glyph).
|
|
277
|
+
const hideSelector =
|
|
278
|
+
`.hk-sidebar-rail-swap[${RAIL_FLIP_ACTIVE_ATTR}] ` +
|
|
279
|
+
`.hk-crossfade-item.hk-crossfade-leave-active ` +
|
|
280
|
+
`.hk-menu-item-icon[${RAIL_FLIP_FLYING_ATTR}]`;
|
|
281
|
+
const hide = ruleBody(css, hideSelector);
|
|
282
|
+
expect(hide, "the flight hide rule exists (all four scopes)").not.toBeNull();
|
|
283
|
+
expect(hide).toContain("visibility: hidden");
|
|
284
|
+
// …and the sheet must not hide on the OPT-IN attribute alone — that
|
|
285
|
+
// spelling would vanish unmatched fallbacks without a substitute.
|
|
286
|
+
expect(
|
|
287
|
+
ruleBody(
|
|
288
|
+
css,
|
|
289
|
+
`.hk-sidebar-rail-swap[${RAIL_FLIP_ACTIVE_ATTR}] ` +
|
|
290
|
+
`.hk-crossfade-item.hk-crossfade-leave-active ` +
|
|
291
|
+
`.hk-menu-item-icon[${RAIL_FLIP_ICON_ATTR}]`,
|
|
292
|
+
),
|
|
293
|
+
"hiding keyed to the opt-in attribute alone is the S1-1 regression",
|
|
294
|
+
).toBeNull();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("leaves no flight transition for reduced motion to kill (the flight is JS-driven)", () => {
|
|
298
|
+
// useRailIconFlight skips itself under prefers-reduced-motion, so the
|
|
299
|
+
// sheet must not grow CSS transitions for the layer/clone that the
|
|
300
|
+
// kit's reduced-motion block would then have to enumerate — a
|
|
301
|
+
// transition here would mean the choreography drifted into CSS.
|
|
302
|
+
const layer = ruleBody(css, ".hk-sidebar-rail-flight");
|
|
303
|
+
expect(layer).not.toContain("transition");
|
|
304
|
+
const clone = ruleBody(css, ".hk-sidebar-rail-flight-clone");
|
|
305
|
+
expect(clone).not.toContain("transition");
|
|
306
|
+
});
|
|
307
|
+
|
|
229
308
|
it("kills every transition under reduced motion", () => {
|
|
230
309
|
const reduceAt = css.indexOf("@media (prefers-reduced-motion: reduce)");
|
|
231
310
|
expect(reduceAt, "the reduced-motion block exists").toBeGreaterThanOrEqual(0);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { defineComponent } from "vue";
|
|
1
|
+
import { defineComponent, ref, type ComponentPublicInstance } from "vue";
|
|
2
2
|
|
|
3
|
+
import { useRailIconFlight } from "../composables/useRailIconFlight";
|
|
3
4
|
import HkCrossfade from "./HkCrossfade";
|
|
4
5
|
|
|
5
6
|
import "./HkSidebarRail.scss";
|
|
@@ -77,6 +78,18 @@ import "./HkSidebarRail.scss";
|
|
|
77
78
|
* dissolve (HkCrossfade), so the frozen copy is neither tabbable nor
|
|
78
79
|
* announced while the live one fades in.
|
|
79
80
|
*
|
|
81
|
+
* THE ICON FLIGHT (2026-11-02, useRailIconFlight): a pure dissolve
|
|
82
|
+
* cannot show the icons' small horizontal journey into the rail column —
|
|
83
|
+
* both copies fade, nothing moves (user direction: "I can't tell the
|
|
84
|
+
* icon slides slightly left as it collapses"). This component hands the
|
|
85
|
+
* flip to the flight choreographer: icons opted in by HkMenu
|
|
86
|
+
* (`data-flip-icon`) are cloned into a flight layer on THIS host and
|
|
87
|
+
* animated from the leaving rect to the entering one, while the leaving
|
|
88
|
+
* copies hide (`data-flipping` + the leave class) and the rest of the
|
|
89
|
+
* content keeps the plain dissolve. Skipped under reduced motion / the
|
|
90
|
+
* animation switch / no WAAPI / no matching pair — every such case
|
|
91
|
+
* degrades to today's dissolve.
|
|
92
|
+
*
|
|
80
93
|
* Reduced motion: HkCrossfade keeps a short opacity exchange (a fade
|
|
81
94
|
* carries no movement) while the kit's sheets kill the width slides —
|
|
82
95
|
* the handoff degrades to a quick dip, never a squeeze.
|
|
@@ -89,18 +102,28 @@ export default defineComponent({
|
|
|
89
102
|
rail: { type: Boolean, default: false },
|
|
90
103
|
/** Pinned width of the RAIL generation. The host's collapsed-width
|
|
91
104
|
* token (HkSidebar's `collapsedWidth`, the shell's
|
|
92
|
-
* `sidebarCollapsedWidth`, wowsp's 56px).
|
|
105
|
+
* `sidebarCollapsedWidth`, wowsp's 56px). The rail row square's
|
|
106
|
+
* body inline track reads the same width as
|
|
107
|
+
* `--hk-sidebar-rail-w` — declared by the rail holder (HkSidebar /
|
|
108
|
+
* HkFaceShell), so a non-default width keeps exact squares. */
|
|
93
109
|
railWidth: { type: String, default: "56px" },
|
|
94
110
|
/** Pinned width of the EXPANDED generation. The host's full sidebar
|
|
95
111
|
* width token — a CSS length or a var() reference. */
|
|
96
112
|
expandedWidth: { type: String, default: "280px" },
|
|
97
113
|
},
|
|
98
114
|
setup(props, { slots }) {
|
|
115
|
+
// The swap host IS this component's root element — HkCrossfade's
|
|
116
|
+
// host div (class fallthrough). The component ref's $el reaches it
|
|
117
|
+
// without adding a wrapper (the clip's child combinator styles
|
|
118
|
+
// `.hk-sidebar-rail-clip > .hk-sidebar-rail-swap` directly).
|
|
119
|
+
const swapRef = ref<ComponentPublicInstance | null>(null);
|
|
120
|
+
const swapEl = (): HTMLElement | null => (swapRef.value?.$el as HTMLElement | null) ?? null;
|
|
121
|
+
useRailIconFlight(swapEl, () => props.rail, () => (props.rail ? "rail" : "full"));
|
|
99
122
|
return () => (
|
|
100
123
|
// The class rides HkCrossfade's host (Vue class fallthrough), so
|
|
101
124
|
// HkSidebarRail.scss can size the swap INSIDE the clip without a
|
|
102
125
|
// second wrapper element.
|
|
103
|
-
<HkCrossfade class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
|
|
126
|
+
<HkCrossfade ref={swapRef} class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
|
|
104
127
|
<div
|
|
105
128
|
class="hk-sidebar-rail-generation"
|
|
106
129
|
data-rail={props.rail || undefined}
|