@celestia-island/hikari 0.55.110 → 0.55.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/HkAdminShell.padding.test.ts +205 -0
- package/src/components/HkAdminShell.test.tsx +266 -6
- package/src/components/HkAdminShell.tsx +152 -18
- package/src/components/HkFab.test.tsx +34 -2
- package/src/components/HkFab.tsx +33 -5
- package/src/components/HkFloatingLayer.scss +58 -0
- package/src/components/HkFloatingLayer.styles.test.ts +58 -0
- package/src/components/HkFloatingLayer.test.tsx +145 -0
- package/src/components/HkFloatingLayer.tsx +112 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { mobileSideGutterFree, scanPaddingSides, splitPaddingSides } from "./HkAdminShell";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Direct unit tests for the padding-shorthand helpers behind
|
|
7
|
+
* `HkAdminShell`'s `contentBleedOnMobile`.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS FILE EXISTS (and why the component-level suite cannot replace
|
|
10
|
+
* it): the DOM assertions in HkAdminShell.test.tsx run through
|
|
11
|
+
* happy-dom's CSS parser, which refuses to round-trip exactly the values
|
|
12
|
+
* these helpers exist to protect — `var(...)` with a fallback, `clamp()`,
|
|
13
|
+
* a quoted argument — and normalizes the ones it does keep, so a
|
|
14
|
+
* regression there is invisible at the component level (round-2 and
|
|
15
|
+
* round-3 verification both proved a surviving mutant that the DOM suite
|
|
16
|
+
* scored green). The helpers are exported for this file only: they are
|
|
17
|
+
* not re-exported from the package barrel.
|
|
18
|
+
*
|
|
19
|
+
* The contract, in one line each:
|
|
20
|
+
* - `splitPaddingSides` splits on TOP-LEVEL whitespace only (functions
|
|
21
|
+
* and quoted arguments are one track), tolerates tabs/newlines and
|
|
22
|
+
* surrounding whitespace, and never lets a stray `)` invert the depth;
|
|
23
|
+
* - `mobileSideGutterFree` keeps the vertical tracks, zeroes the
|
|
24
|
+
* horizontal ones, and returns the value VERBATIM whenever the rewrite
|
|
25
|
+
* could produce a declaration a browser would drop whole.
|
|
26
|
+
*/
|
|
27
|
+
describe("splitPaddingSides", () => {
|
|
28
|
+
it("splits a plain shorthand into its tracks", () => {
|
|
29
|
+
expect(splitPaddingSides("2rem")).toEqual(["2rem"]);
|
|
30
|
+
expect(splitPaddingSides("1rem 2rem")).toEqual(["1rem", "2rem"]);
|
|
31
|
+
expect(splitPaddingSides("1rem 2rem 3rem")).toEqual(["1rem", "2rem", "3rem"]);
|
|
32
|
+
expect(splitPaddingSides("1rem 2rem 3rem 4rem")).toEqual(["1rem", "2rem", "3rem", "4rem"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats every CSS separator like a space and drops no empty track", () => {
|
|
36
|
+
expect(splitPaddingSides("1rem\t2rem")).toEqual(["1rem", "2rem"]);
|
|
37
|
+
expect(splitPaddingSides("1rem\n2rem")).toEqual(["1rem", "2rem"]);
|
|
38
|
+
expect(splitPaddingSides("1rem \n\t 2rem")).toEqual(["1rem", "2rem"]);
|
|
39
|
+
expect(splitPaddingSides(" 1rem 2rem ")).toEqual(["1rem", "2rem"]);
|
|
40
|
+
expect(splitPaddingSides("")).toEqual([]);
|
|
41
|
+
expect(splitPaddingSides(" ")).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps a function value as ONE track, however many spaces it holds", () => {
|
|
45
|
+
expect(splitPaddingSides("calc(1rem + 2px)")).toEqual(["calc(1rem + 2px)"]);
|
|
46
|
+
expect(splitPaddingSides("calc(1rem + 2px) 1rem")).toEqual(["calc(1rem + 2px)", "1rem"]);
|
|
47
|
+
expect(splitPaddingSides("1rem calc(2px + 3px)")).toEqual(["1rem", "calc(2px + 3px)"]);
|
|
48
|
+
// A space directly after "(" is inside the function too.
|
|
49
|
+
expect(splitPaddingSides("calc( 1rem + 2px ) 1rem")).toEqual(["calc( 1rem + 2px )", "1rem"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("balances nested parentheses", () => {
|
|
53
|
+
expect(splitPaddingSides("clamp(1rem, min(2vw, 3px), 4rem) 2rem"))
|
|
54
|
+
.toEqual(["clamp(1rem, min(2vw, 3px), 4rem)", "2rem"]);
|
|
55
|
+
expect(splitPaddingSides("calc(1rem + calc(2px * 2))"))
|
|
56
|
+
.toEqual(["calc(1rem + calc(2px * 2))"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a quoted argument intact, its parens and spaces included", () => {
|
|
60
|
+
expect(splitPaddingSides("var(--pad, \") 1rem\") 2rem"))
|
|
61
|
+
.toEqual(["var(--pad, \") 1rem\")", "2rem"]);
|
|
62
|
+
expect(splitPaddingSides("var(--pad, ' + ')")).toEqual(["var(--pad, ' + ')"]);
|
|
63
|
+
// A paren inside a SINGLE-quoted argument, and the other quote char
|
|
64
|
+
// inside it: only the matching quote closes the string.
|
|
65
|
+
expect(splitPaddingSides("var(--pad, ') x') 2rem"))
|
|
66
|
+
.toEqual(["var(--pad, ') x')", "2rem"]);
|
|
67
|
+
expect(splitPaddingSides("var(--pad, 'a\") b') 2rem"))
|
|
68
|
+
.toEqual(["var(--pad, 'a\") b')", "2rem"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("opens a quote at top level too (and reports it unterminated)", () => {
|
|
72
|
+
expect(splitPaddingSides("\"a b")).toEqual(["\u0022a b"]);
|
|
73
|
+
expect(scanPaddingSides("\"a b").balanced).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("never lets a stray close paren invert the depth", () => {
|
|
77
|
+
// Malformed CSS in, but the tracks must not silently merge: without
|
|
78
|
+
// the clamp the `)` would drive the depth to -1 and swallow the next
|
|
79
|
+
// separator.
|
|
80
|
+
expect(splitPaddingSides("1rem ) 2rem")).toEqual(["1rem", ")", "2rem"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("reports an unterminated function or quote as unbalanced", () => {
|
|
84
|
+
expect(scanPaddingSides("calc(1rem + 2px")).toEqual({
|
|
85
|
+
sides: ["calc(1rem + 2px"],
|
|
86
|
+
balanced: false,
|
|
87
|
+
});
|
|
88
|
+
expect(scanPaddingSides("1rem 2rem").balanced).toBe(true);
|
|
89
|
+
expect(scanPaddingSides("calc(1rem + 2px) 1rem").balanced).toBe(true);
|
|
90
|
+
expect(scanPaddingSides("var(--pad, ') x')").balanced).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("mobileSideGutterFree", () => {
|
|
95
|
+
it("keeps the vertical tracks and zeroes the horizontal ones", () => {
|
|
96
|
+
expect(mobileSideGutterFree("2rem")).toBe("2rem 0 2rem");
|
|
97
|
+
expect(mobileSideGutterFree("1rem 2rem")).toBe("1rem 0 1rem");
|
|
98
|
+
expect(mobileSideGutterFree("1rem 2rem 3rem")).toBe("1rem 0 3rem");
|
|
99
|
+
expect(mobileSideGutterFree("1rem 2rem 3rem 4rem")).toBe("1rem 0 3rem");
|
|
100
|
+
expect(mobileSideGutterFree("0")).toBe("0 0 0");
|
|
101
|
+
expect(mobileSideGutterFree(" 1rem\t2rem ")).toBe("1rem 0 1rem");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps a function value whole", () => {
|
|
105
|
+
expect(mobileSideGutterFree("calc(1rem + 2px)")).toBe("calc(1rem + 2px) 0 calc(1rem + 2px)");
|
|
106
|
+
expect(mobileSideGutterFree("calc(1rem + 2px) 1rem")).toBe("calc(1rem + 2px) 0 calc(1rem + 2px)");
|
|
107
|
+
expect(mobileSideGutterFree("5rem clamp(1rem, min(2vw, 3px), 4rem)")).toBe("5rem 0 5rem");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns the value verbatim when the rewrite cannot be expressed", () => {
|
|
111
|
+
// Nothing to strip.
|
|
112
|
+
expect(mobileSideGutterFree("")).toBe("");
|
|
113
|
+
expect(mobileSideGutterFree(" ")).toBe(" ");
|
|
114
|
+
// A CSS-wide keyword cannot be combined per side: `inherit 0 inherit`
|
|
115
|
+
// is not a padding at all, so bleeding would cost the page its
|
|
116
|
+
// vertical clearance.
|
|
117
|
+
for (const keyword of ["inherit", "initial", "unset", "revert", "revert-layer"]) {
|
|
118
|
+
expect(mobileSideGutterFree(keyword), keyword).toBe(keyword);
|
|
119
|
+
expect(mobileSideGutterFree(keyword.toUpperCase()), keyword).toBe(keyword.toUpperCase());
|
|
120
|
+
}
|
|
121
|
+
// A comment's text is not a track; rebuilding would emit one
|
|
122
|
+
// ("1rem 0 x") that a browser drops whole. An UNCLOSED comment bails
|
|
123
|
+
// the same way, and so does a quoted `/*` — the guard reads the raw
|
|
124
|
+
// text and does not try to be clever about strings.
|
|
125
|
+
expect(mobileSideGutterFree("1rem /* x */ 2rem")).toBe("1rem /* x */ 2rem");
|
|
126
|
+
expect(mobileSideGutterFree("1rem /* x 2rem")).toBe("1rem /* x 2rem");
|
|
127
|
+
expect(mobileSideGutterFree("var(--pad, \"/*\") 2rem")).toBe("var(--pad, \"/*\") 2rem");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("refuses any value carrying a backslash escape", () => {
|
|
131
|
+
// The scanner does not decode CSS escapes, and an escape can hide a
|
|
132
|
+
// quote, a paren or a whole keyword — `"\69 nherit"` IS `inherit` to
|
|
133
|
+
// an engine (measured in Chromium), so the literal text is not the
|
|
134
|
+
// value and no rewrite of it can be trusted. Round 4/5 verification
|
|
135
|
+
// measured every one of these losing all four sides to a dropped
|
|
136
|
+
// declaration.
|
|
137
|
+
const escaped = [
|
|
138
|
+
"\\69 nherit",
|
|
139
|
+
"var(--pad, \"a\\\"b\") 2rem",
|
|
140
|
+
"var(--pad, 'a\\'b') 2rem",
|
|
141
|
+
"var(--pad, a\\(b) 2rem",
|
|
142
|
+
"\"a\\\" b\" 1rem",
|
|
143
|
+
];
|
|
144
|
+
for (const value of escaped) {
|
|
145
|
+
expect(mobileSideGutterFree(value), value).toBe(value);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("refuses a value whose functions or quotes never close", () => {
|
|
150
|
+
// CSS auto-closes an open function at EOF, so the AUTHORED value can
|
|
151
|
+
// be a real padding (Chromium: 18px on all four sides for
|
|
152
|
+
// "calc(1rem + 2px") while the rewrite of it is not — the rewrite
|
|
153
|
+
// would cost the page every side, vertical included.
|
|
154
|
+
const unterminated = [
|
|
155
|
+
"calc(1rem + 2px",
|
|
156
|
+
"calc(1rem",
|
|
157
|
+
"min(1rem, 2rem",
|
|
158
|
+
"1rem calc(2px",
|
|
159
|
+
"\"a b",
|
|
160
|
+
"var(--pad, 'x",
|
|
161
|
+
];
|
|
162
|
+
for (const value of unterminated) {
|
|
163
|
+
expect(mobileSideGutterFree(value), value).toBe(value);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("refuses a value whose priority annotation cannot ride the rewrite", () => {
|
|
168
|
+
// `!important` annotates the DECLARATION, not a track. Rewriting it
|
|
169
|
+
// would either drop the priority (`2rem !important` -> `2rem 0 2rem`)
|
|
170
|
+
// or rebuild a declaration every engine drops (`inherit !important`
|
|
171
|
+
// -> `inherit 0 inherit`, measured in Chromium as rejected whole,
|
|
172
|
+
// with the page falling back to the cascade). Round 5 found the
|
|
173
|
+
// family; the value now passes through untouched.
|
|
174
|
+
const flagged = [
|
|
175
|
+
"2rem !important",
|
|
176
|
+
"1rem 2rem 3rem !important",
|
|
177
|
+
"inherit !important",
|
|
178
|
+
"unset !important",
|
|
179
|
+
"initial!important",
|
|
180
|
+
"revert-layer !important",
|
|
181
|
+
"2rem ! important",
|
|
182
|
+
"2rem !IMPORTANT",
|
|
183
|
+
"2rem !important ",
|
|
184
|
+
];
|
|
185
|
+
for (const value of flagged) {
|
|
186
|
+
expect(mobileSideGutterFree(value), value).toBe(value);
|
|
187
|
+
}
|
|
188
|
+
// …and only a TRAILING annotation is one: mid-value text is just
|
|
189
|
+
// another track (the authored value is invalid CSS either way, so
|
|
190
|
+
// this guard makes no promise about it).
|
|
191
|
+
expect(mobileSideGutterFree("1rem !important 2rem")).toBe("1rem 0 2rem");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("still rewrites a keyword-shaped track that is not alone", () => {
|
|
195
|
+
// A CSS-wide keyword is only valid as a declaration's SOLE value, so
|
|
196
|
+
// "inherit 2rem" is invalid CSS whatever we do with it, and its
|
|
197
|
+
// rewrite is dropped exactly like the authored value. The lone
|
|
198
|
+
// spelling above IS valid, which is why it bails instead. ("1rem
|
|
199
|
+
// inherit" is the looser case: the authored value is already
|
|
200
|
+
// invalid, while the rewrite happens to be valid — a repair, not a
|
|
201
|
+
// loss.)
|
|
202
|
+
expect(mobileSideGutterFree("inherit 2rem")).toBe("inherit 0 inherit");
|
|
203
|
+
expect(mobileSideGutterFree("1rem inherit")).toBe("1rem 0 1rem");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -42,7 +42,9 @@ import { HkAdminShell } from "./HkAdminShell";
|
|
|
42
42
|
* 3. The header slot receives `onOpenDrawer` so a header trigger (the
|
|
43
43
|
* avatar in drawer action mode) can open the nav drawer.
|
|
44
44
|
* 4. Content padding rides an inner wrapper inside the scroll viewport
|
|
45
|
-
* (card box-shadows are never clipped at the viewport edges)
|
|
45
|
+
* (card box-shadows are never clipped at the viewport edges), and the
|
|
46
|
+
* mobile side-gutter drop happens ONLY for a page that declares
|
|
47
|
+
* `contentBleedOnMobile` — an ordinary page keeps its phone gutters.
|
|
46
48
|
*
|
|
47
49
|
* (Repo test convention: raw createApp + container queries, no
|
|
48
50
|
* @vue/test-utils dependency.)
|
|
@@ -246,15 +248,86 @@ describe("HkAdminShell", () => {
|
|
|
246
248
|
expect(inner?.style.padding).toBe("2rem");
|
|
247
249
|
});
|
|
248
250
|
|
|
249
|
-
it("
|
|
250
|
-
//
|
|
251
|
+
it("keeps the content padding on phones unless the page declares a bleed", async () => {
|
|
252
|
+
// 2026-10-04 regression: the shell used to drop the side gutters on
|
|
253
|
+
// EVERY page below the breakpoint, so an ordinary roster / overview
|
|
254
|
+
// page rendered flush against the phone edges. The drop is opt-in.
|
|
251
255
|
// Asserted via longhands: happy-dom's shorthand parser collapses
|
|
252
256
|
// 3-value paddings, so style.padding cannot distinguish them here.
|
|
253
257
|
setWidth(390);
|
|
254
|
-
const
|
|
258
|
+
const plain = mount(shellNode(
|
|
255
259
|
{ navTitle: "Navigation", contentPadding: "2rem" },
|
|
256
260
|
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
257
261
|
));
|
|
262
|
+
const plainInner = paddingInner(plain);
|
|
263
|
+
expect(plainInner?.style.paddingTop).toBe("2rem");
|
|
264
|
+
expect(plainInner?.style.paddingBottom).toBe("2rem");
|
|
265
|
+
expect(plainInner?.style.paddingLeft).toBe("2rem");
|
|
266
|
+
expect(plainInner?.style.paddingRight).toBe("2rem");
|
|
267
|
+
|
|
268
|
+
// A multi-value value stays verbatim too — no per-side rewriting of
|
|
269
|
+
// a page that never asked for the bleed.
|
|
270
|
+
const plainMulti = mount(shellNode(
|
|
271
|
+
{ navTitle: "Navigation", contentPadding: "1rem 2rem" },
|
|
272
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
273
|
+
));
|
|
274
|
+
const plainMultiInner = paddingInner(plainMulti);
|
|
275
|
+
expect(plainMultiInner?.style.paddingTop).toBe("1rem");
|
|
276
|
+
expect(plainMultiInner?.style.paddingLeft).toBe("2rem");
|
|
277
|
+
expect(plainMultiInner?.style.paddingRight).toBe("2rem");
|
|
278
|
+
|
|
279
|
+
// Resizing within the sub-breakpoint range never changes it either —
|
|
280
|
+
// asserted across the WHOLE range, not at hand-picked widths. The
|
|
281
|
+
// regression is "an ordinary page loses its gutters on phones", and
|
|
282
|
+
// rounds 2 and 3 both escaped guards that sampled a few widths by
|
|
283
|
+
// moving the leak into a band those widths miss (a mutant confined to
|
|
284
|
+
// [400, 499] would strip the gutters of a Pixel 412 / iPhone 430
|
|
285
|
+
// while the suite stayed green).
|
|
286
|
+
for (const width of [320, 360, 390, 412, 430, 500, 640, 700, 768, 900, 1023]) {
|
|
287
|
+
setWidth(width);
|
|
288
|
+
await nextTick();
|
|
289
|
+
expect(plainInner?.style.paddingLeft, `at ${width}px`).toBe("2rem");
|
|
290
|
+
expect(plainInner?.style.paddingRight, `at ${width}px`).toBe("2rem");
|
|
291
|
+
// …VERTICAL included: the regression is not only "the sides moved".
|
|
292
|
+
expect(plainInner?.style.paddingTop, `at ${width}px`).toBe("2rem");
|
|
293
|
+
expect(plainInner?.style.paddingBottom, `at ${width}px`).toBe("2rem");
|
|
294
|
+
expect(plainMultiInner?.style.paddingLeft, `at ${width}px (multi)`).toBe("2rem");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A custom breakpoint on an UNDECLARED page must not move its gutters
|
|
298
|
+
// either (round 4 mutated the guard to depend on the 1024 default and
|
|
299
|
+
// every custom-breakpoint mount in this file declared a bleed).
|
|
300
|
+
const plainNarrow = mount(shellNode(
|
|
301
|
+
{ navTitle: "Navigation", contentPadding: "2rem", mobileBreakpoint: 768 },
|
|
302
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
303
|
+
));
|
|
304
|
+
setWidth(500);
|
|
305
|
+
await nextTick();
|
|
306
|
+
expect(paddingInner(plainNarrow)?.style.paddingLeft).toBe("2rem");
|
|
307
|
+
expect(paddingInner(plainNarrow)?.style.paddingRight).toBe("2rem");
|
|
308
|
+
|
|
309
|
+
// An explicit `false` reads exactly like an absent prop.
|
|
310
|
+
const declaredOff = mount(shellNode(
|
|
311
|
+
{ navTitle: "Navigation", contentPadding: "1rem 2rem", contentBleedOnMobile: false },
|
|
312
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
313
|
+
));
|
|
314
|
+
expect(paddingInner(declaredOff)?.style.paddingLeft).toBe("2rem");
|
|
315
|
+
|
|
316
|
+
// Above the breakpoint it is verbatim for the declared page too.
|
|
317
|
+
setWidth(1280);
|
|
318
|
+
await nextTick();
|
|
319
|
+
expect(mobileInnerPadding(plain)).toBe("2rem");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("drops the horizontal half of the content padding below the mobile breakpoint when the page declares a bleed", async () => {
|
|
323
|
+
// A page that declares `contentBleedOnMobile` (a 2D board or the 3D
|
|
324
|
+
// scene) loses the side gutters on phones; vertical clearance
|
|
325
|
+
// survives. Asserted via longhands — see the note above.
|
|
326
|
+
setWidth(390);
|
|
327
|
+
const mobileShell = mount(shellNode(
|
|
328
|
+
{ navTitle: "Navigation", contentPadding: "2rem", contentBleedOnMobile: true },
|
|
329
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
330
|
+
));
|
|
258
331
|
const inner = paddingInner(mobileShell);
|
|
259
332
|
expect(inner?.style.paddingTop).toBe("2rem");
|
|
260
333
|
expect(inner?.style.paddingBottom).toBe("2rem");
|
|
@@ -264,7 +337,7 @@ describe("HkAdminShell", () => {
|
|
|
264
337
|
// Multi-value shorthand keeps per-side meaning: vertical survives,
|
|
265
338
|
// horizontal is zeroed (never the inverted "1rem 2rem 0").
|
|
266
339
|
const multi = mount(shellNode(
|
|
267
|
-
{ navTitle: "Navigation", contentPadding: "1rem 2rem" },
|
|
340
|
+
{ navTitle: "Navigation", contentPadding: "1rem 2rem", contentBleedOnMobile: true },
|
|
268
341
|
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
269
342
|
));
|
|
270
343
|
const multiInner = paddingInner(multi);
|
|
@@ -273,9 +346,196 @@ describe("HkAdminShell", () => {
|
|
|
273
346
|
expect(multiInner?.style.paddingLeft).toBe("0px");
|
|
274
347
|
expect(multiInner?.style.paddingRight).toBe("0px");
|
|
275
348
|
|
|
276
|
-
//
|
|
349
|
+
// Three-value shorthand: the vertical tracks are the FIRST and the
|
|
350
|
+
// THIRD, so the parser has to keep top and bottom in order. The
|
|
351
|
+
// 1- and 2-value cases above cannot see a swapped pair — with ≤2
|
|
352
|
+
// values the bottom track defaults to the top one, which makes the
|
|
353
|
+
// correct and the transposed string identical (round-1 verification
|
|
354
|
+
// mutation (e) survived on exactly that hole).
|
|
355
|
+
const triple = mount(shellNode(
|
|
356
|
+
{ navTitle: "Navigation", contentPadding: "1rem 2rem 3rem", contentBleedOnMobile: true },
|
|
357
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
358
|
+
));
|
|
359
|
+
const tripleInner = paddingInner(triple);
|
|
360
|
+
expect(tripleInner?.style.paddingTop).toBe("1rem");
|
|
361
|
+
expect(tripleInner?.style.paddingBottom).toBe("3rem");
|
|
362
|
+
expect(tripleInner?.style.paddingLeft).toBe("0px");
|
|
363
|
+
expect(tripleInner?.style.paddingRight).toBe("0px");
|
|
364
|
+
|
|
365
|
+
// Four-value shorthand: right and left are DIFFERENT tracks, and the
|
|
366
|
+
// rebuilt declaration has no horizontal track at all — both sides
|
|
367
|
+
// must read zero. A parser that lands a horizontal value in the wrong
|
|
368
|
+
// slot is invisible to the shorter cases (round-2 verification
|
|
369
|
+
// mutated "1rem 2rem 3rem 4rem" into "1rem 0 3rem 2rem" and the suite
|
|
370
|
+
// stayed green).
|
|
371
|
+
const quad = mount(shellNode(
|
|
372
|
+
{ navTitle: "Navigation", contentPadding: "1rem 2rem 3rem 4rem", contentBleedOnMobile: true },
|
|
373
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
374
|
+
));
|
|
375
|
+
const quadInner = paddingInner(quad);
|
|
376
|
+
expect(quadInner?.style.paddingTop).toBe("1rem");
|
|
377
|
+
expect(quadInner?.style.paddingBottom).toBe("3rem");
|
|
378
|
+
expect(quadInner?.style.paddingLeft).toBe("0px");
|
|
379
|
+
expect(quadInner?.style.paddingRight).toBe("0px");
|
|
380
|
+
|
|
381
|
+
// A function value is ONE track: whitespace inside `calc(...)` belongs
|
|
382
|
+
// to the value, not to the shorthand. Splitting on plain whitespace
|
|
383
|
+
// shreds it into three tracks and rebuilds an invalid declaration,
|
|
384
|
+
// which a real browser drops WHOLE — taking the vertical clearance
|
|
385
|
+
// this prop promises to keep with it (rounds 2 and 3 both hit this on
|
|
386
|
+
// the shell as it shipped). happy-dom stores calc() values, so the
|
|
387
|
+
// longhands are readable here; it cannot round-trip var()/min(), which
|
|
388
|
+
// is why calc() is the probe.
|
|
389
|
+
const fn = mount(shellNode(
|
|
390
|
+
{ navTitle: "Navigation", contentPadding: "calc(1rem + 2px) 1rem", contentBleedOnMobile: true },
|
|
391
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
392
|
+
));
|
|
393
|
+
const fnInner = paddingInner(fn);
|
|
394
|
+
expect(fnInner?.style.paddingTop).toBe("calc(1rem + 2px)");
|
|
395
|
+
expect(fnInner?.style.paddingBottom).toBe("calc(1rem + 2px)");
|
|
396
|
+
expect(fnInner?.style.paddingLeft).toBe("0px");
|
|
397
|
+
expect(fnInner?.style.paddingRight).toBe("0px");
|
|
398
|
+
|
|
399
|
+
// NESTED functions are one track as well (the depth counter, not a
|
|
400
|
+
// single-level check): a bare `clamp()` cannot be the probe because
|
|
401
|
+
// happy-dom rejects it outright, so the function rides in the second
|
|
402
|
+
// track of a plain length.
|
|
403
|
+
const nested = mount(shellNode(
|
|
404
|
+
{ navTitle: "Navigation", contentPadding: "5rem clamp(1rem, min(2vw, 3px), 4rem)", contentBleedOnMobile: true },
|
|
405
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
406
|
+
));
|
|
407
|
+
const nestedInner = paddingInner(nested);
|
|
408
|
+
expect(nestedInner?.style.paddingTop).toBe("5rem");
|
|
409
|
+
expect(nestedInner?.style.paddingBottom).toBe("5rem");
|
|
410
|
+
expect(nestedInner?.style.paddingLeft).toBe("0px");
|
|
411
|
+
expect(nestedInner?.style.paddingRight).toBe("0px");
|
|
412
|
+
|
|
413
|
+
// …and so is a space directly after the opening parenthesis.
|
|
414
|
+
const spacedFn = mount(shellNode(
|
|
415
|
+
{ navTitle: "Navigation", contentPadding: "calc( 1rem + 2px ) 1rem", contentBleedOnMobile: true },
|
|
416
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
417
|
+
));
|
|
418
|
+
const spacedFnInner = paddingInner(spacedFn);
|
|
419
|
+
expect(spacedFnInner?.style.paddingTop).toBe("calc( 1rem + 2px )");
|
|
420
|
+
expect(spacedFnInner?.style.paddingBottom).toBe("calc( 1rem + 2px )");
|
|
421
|
+
expect(spacedFnInner?.style.paddingLeft).toBe("0px");
|
|
422
|
+
|
|
423
|
+
// A lone CSS-wide keyword cannot be rewritten per side (`inherit 0
|
|
424
|
+
// inherit` is not a padding — every engine rejects it), so the value
|
|
425
|
+
// passes through VERBATIM: the page keeps its padding instead of
|
|
426
|
+
// losing all four sides to a dropped declaration. (The comment case
|
|
427
|
+
// behaves the same way; it is pinned in HkAdminShell.padding.test.ts
|
|
428
|
+
// because happy-dom refuses to store a comment-bearing value at all.)
|
|
429
|
+
for (const keyword of ["inherit", "unset"]) {
|
|
430
|
+
const wide = mount(shellNode(
|
|
431
|
+
{ navTitle: "Navigation", contentPadding: keyword, contentBleedOnMobile: true },
|
|
432
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
433
|
+
));
|
|
434
|
+
expect(paddingInner(wide)?.style.padding, keyword).toBe(keyword);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Tabs and newlines are valid CSS separators, and the whitespace
|
|
438
|
+
// around a shorthand is not a track of its own ("\t1rem 2rem" must
|
|
439
|
+
// read exactly like "1rem 2rem", never like a three-track value).
|
|
440
|
+
for (const padded of ["1rem\t2rem", "1rem\n2rem", " 1rem 2rem ", "\t1rem 2rem"]) {
|
|
441
|
+
const ws = mount(shellNode(
|
|
442
|
+
{ navTitle: "Navigation", contentPadding: padded, contentBleedOnMobile: true },
|
|
443
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
444
|
+
));
|
|
445
|
+
const wsInner = paddingInner(ws);
|
|
446
|
+
expect(wsInner?.style.paddingTop, JSON.stringify(padded)).toBe("1rem");
|
|
447
|
+
expect(wsInner?.style.paddingBottom, JSON.stringify(padded)).toBe("1rem");
|
|
448
|
+
expect(wsInner?.style.paddingLeft, JSON.stringify(padded)).toBe("0px");
|
|
449
|
+
expect(wsInner?.style.paddingRight, JSON.stringify(padded)).toBe("0px");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Nothing to strip: an empty value passes through untouched, it does
|
|
453
|
+
// NOT become a zero padding of its own.
|
|
454
|
+
const blank = mount(shellNode(
|
|
455
|
+
{ navTitle: "Navigation", contentPadding: "", contentBleedOnMobile: true },
|
|
456
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
457
|
+
));
|
|
458
|
+
expect(paddingInner(blank)?.style.padding).toBe("");
|
|
459
|
+
|
|
460
|
+
// The declared page bleeds across the WHOLE sub-breakpoint range, not
|
|
461
|
+
// just at the hand-picked widths above: round 4 mutated the bleed to
|
|
462
|
+
// stop inside [400, 499) and nothing in this file sampled a declared
|
|
463
|
+
// page in that band (412 / 430 px phones included).
|
|
464
|
+
for (const width of [320, 360, 390, 412, 430, 500, 640, 700, 768, 900, 1023]) {
|
|
465
|
+
setWidth(width);
|
|
466
|
+
await nextTick();
|
|
467
|
+
expect(paddingInner(mobileShell)?.style.paddingLeft, `declared at ${width}px`).toBe("0px");
|
|
468
|
+
expect(paddingInner(mobileShell)?.style.paddingRight, `declared at ${width}px`).toBe("0px");
|
|
469
|
+
expect(paddingInner(mobileShell)?.style.paddingTop, `declared at ${width}px`).toBe("2rem");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// The shell's own default padding (1.5rem) is what a consumer that
|
|
473
|
+
// passes nothing actually gets — pinned here because this file
|
|
474
|
+
// otherwise only ever exercises explicit values.
|
|
475
|
+
const defaulted = mount(shellNode(
|
|
476
|
+
{ navTitle: "Navigation", contentBleedOnMobile: true },
|
|
477
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
478
|
+
));
|
|
479
|
+
setWidth(390);
|
|
480
|
+
await nextTick();
|
|
481
|
+
expect(paddingInner(defaulted)?.style.paddingTop).toBe("1.5rem");
|
|
482
|
+
expect(paddingInner(defaulted)?.style.paddingBottom).toBe("1.5rem");
|
|
483
|
+
expect(paddingInner(defaulted)?.style.paddingLeft).toBe("0px");
|
|
484
|
+
expect(paddingInner(defaulted)?.style.paddingRight).toBe("0px");
|
|
485
|
+
|
|
486
|
+
// The gate follows THIS shell's breakpoint, and the boundary is the
|
|
487
|
+
// breakpoint itself: 1023 is still mobile (bleed), 1024 is desktop
|
|
488
|
+
// (verbatim) — the same `>=` the sidebar takes over on.
|
|
489
|
+
setWidth(1023);
|
|
490
|
+
await nextTick();
|
|
491
|
+
expect(paddingInner(mobileShell)?.style.paddingLeft).toBe("0px");
|
|
492
|
+
setWidth(1024);
|
|
493
|
+
await nextTick();
|
|
494
|
+
expect(paddingInner(mobileShell)?.style.paddingLeft).toBe("2rem");
|
|
495
|
+
|
|
496
|
+
// …and a custom breakpoint moves it: the padding gate must read the
|
|
497
|
+
// same `isDesktop` the layout does, not the 1024 default (round 3
|
|
498
|
+
// mutated it to a hardcoded 1024 and the suite stayed green).
|
|
499
|
+
const narrow = mount(shellNode(
|
|
500
|
+
{ navTitle: "Navigation", contentPadding: "2rem", contentBleedOnMobile: true, mobileBreakpoint: 768 },
|
|
501
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
502
|
+
));
|
|
503
|
+
setWidth(767);
|
|
504
|
+
await nextTick();
|
|
505
|
+
expect(paddingInner(narrow)?.style.paddingLeft).toBe("0px");
|
|
506
|
+
setWidth(768);
|
|
507
|
+
await nextTick();
|
|
508
|
+
expect(paddingInner(narrow)?.style.paddingLeft).toBe("2rem");
|
|
509
|
+
setWidth(800);
|
|
510
|
+
await nextTick();
|
|
511
|
+
expect(paddingInner(narrow)?.style.paddingLeft).toBe("2rem");
|
|
512
|
+
|
|
513
|
+
// …in BOTH directions: a breakpoint raised above the 1024 default must
|
|
514
|
+
// move the padding gate too. Round 3 mutated the gate to saturate at
|
|
515
|
+
// 1024 and only a downward custom breakpoint was covered, so the
|
|
516
|
+
// mirror image survived.
|
|
517
|
+
const tablet = mount(shellNode(
|
|
518
|
+
{ navTitle: "Navigation", contentPadding: "2rem", contentBleedOnMobile: true, mobileBreakpoint: 1440 },
|
|
519
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
520
|
+
));
|
|
521
|
+
setWidth(1200);
|
|
522
|
+
await nextTick();
|
|
523
|
+
expect(paddingInner(tablet)?.style.paddingLeft).toBe("0px");
|
|
524
|
+
setWidth(1440);
|
|
525
|
+
await nextTick();
|
|
526
|
+
expect(paddingInner(tablet)?.style.paddingLeft).toBe("2rem");
|
|
527
|
+
|
|
528
|
+
// The declaration never reaches desktop: crossing back above the
|
|
529
|
+
// breakpoint restores the verbatim padding.
|
|
277
530
|
setWidth(1280);
|
|
278
531
|
await nextTick();
|
|
279
532
|
expect(mobileInnerPadding(mobileShell)).toBe("2rem");
|
|
533
|
+
expect(mobileInnerPadding(multi)).toBe("1rem 2rem");
|
|
534
|
+
// …per side, too: the restored shorthand must not be a collapsed
|
|
535
|
+
// approximation of the three tracks it came from.
|
|
536
|
+
expect(tripleInner?.style.paddingTop).toBe("1rem");
|
|
537
|
+
expect(tripleInner?.style.paddingBottom).toBe("3rem");
|
|
538
|
+
expect(tripleInner?.style.paddingLeft).toBe("2rem");
|
|
539
|
+
expect(tripleInner?.style.paddingRight).toBe("2rem");
|
|
280
540
|
});
|
|
281
541
|
});
|
|
@@ -3,6 +3,133 @@ import { HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikar
|
|
|
3
3
|
import { provideActionBar } from "../composables/useActionBar";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
|
|
6
|
+
/** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
|
|
7
|
+
* padding (`inherit 0 inherit` is rejected by every engine, measured in
|
|
8
|
+
* Chromium), so the declaration would be dropped WHOLE and the page
|
|
9
|
+
* would lose the vertical clearance too — worse than not bleeding. */
|
|
10
|
+
const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A scanned padding shorthand: the top-level `sides` plus whether the
|
|
14
|
+
* value ENDED with every function closed and every quote terminated.
|
|
15
|
+
* `balanced: false` means the scan cannot know where the value ends —
|
|
16
|
+
* CSS closes an open function at EOF, so the authored declaration can
|
|
17
|
+
* still be valid while a rewrite of it cannot be.
|
|
18
|
+
*/
|
|
19
|
+
export interface PaddingSidesScan {
|
|
20
|
+
sides: string[];
|
|
21
|
+
balanced: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Scan a CSS padding shorthand into its top-level tracks: split on
|
|
26
|
+
* TOP-LEVEL whitespace, quote- and function-aware, so whitespace inside
|
|
27
|
+
* a function belongs to that value — `calc(1rem + 2px)`,
|
|
28
|
+
* `var(--pad, 1rem)` and a quoted argument are ONE track. A plain
|
|
29
|
+
* `split(/\s+/)` shreds them (`["calc(1rem", "+", "2px)"]`), and the
|
|
30
|
+
* rebuilt shorthand is invalid CSS — a browser drops the whole
|
|
31
|
+
* declaration, taking with it the vertical clearance
|
|
32
|
+
* `contentBleedOnMobile` promises to keep (rounds 2 and 3 of this change
|
|
33
|
+
* hit exactly that; rounds 4 and 5 the escape and EOF variants). The
|
|
34
|
+
* depth counter balances parentheses, so nested functions
|
|
35
|
+
* (`clamp(1rem, min(2vw, 3px), 4rem)`) survive; a stray `)` never drives
|
|
36
|
+
* the depth negative. Tabs and newlines are valid CSS separators and
|
|
37
|
+
* split like spaces, and the whitespace around a shorthand yields no
|
|
38
|
+
* empty tracks.
|
|
39
|
+
*
|
|
40
|
+
* Exported for its own unit test: happy-dom cannot round-trip
|
|
41
|
+
* `var()`/`clamp()` values or the hostile inputs below, so the
|
|
42
|
+
* component-level suite cannot observe them.
|
|
43
|
+
*/
|
|
44
|
+
export function scanPaddingSides(value: string): PaddingSidesScan {
|
|
45
|
+
const sides: string[] = [];
|
|
46
|
+
let current = "";
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let quote = "";
|
|
49
|
+
for (const ch of value) {
|
|
50
|
+
if (quote) {
|
|
51
|
+
current += ch;
|
|
52
|
+
if (ch === quote) quote = "";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === "\"" || ch === "'") {
|
|
56
|
+
quote = ch;
|
|
57
|
+
current += ch;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === "(") depth += 1;
|
|
61
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
62
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
63
|
+
if (current) sides.push(current);
|
|
64
|
+
current = "";
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
current += ch;
|
|
68
|
+
}
|
|
69
|
+
if (current) sides.push(current);
|
|
70
|
+
return { sides, balanced: depth === 0 && quote === "" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The tracks of {@link scanPaddingSides} — for callers that only need
|
|
74
|
+
* the split, and for the unit test that pins the splitting rules. */
|
|
75
|
+
export function splitPaddingSides(value: string): string[] {
|
|
76
|
+
return scanPaddingSides(value).sides;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The side-gutter-free form of a padding shorthand: the vertical tracks
|
|
81
|
+
* survive, the horizontal ones become zero (`"1rem 2rem"` -> `"1rem 0
|
|
82
|
+
* 1rem"`). The value is returned VERBATIM — the page keeps its padding
|
|
83
|
+
* and simply does not bleed — whenever the shorthand cannot be rewritten
|
|
84
|
+
* safely. That fallback IS the contract: emitting a declaration a browser
|
|
85
|
+
* drops costs the page ALL FOUR sides, including the vertical clearance
|
|
86
|
+
* this prop exists to keep.
|
|
87
|
+
*
|
|
88
|
+
* Verbatim cases:
|
|
89
|
+
*
|
|
90
|
+
* - an empty / whitespace-only value has nothing to strip;
|
|
91
|
+
* - a LONE CSS-wide keyword cannot be combined per side (`inherit 0
|
|
92
|
+
* inherit` is not a padding — every engine rejects it);
|
|
93
|
+
* - the value contains a CSS comment, whose text would land in the
|
|
94
|
+
* rebuilt declaration as a garbage track (`1rem 0 x`, measured in
|
|
95
|
+
* Chromium: dropped, all four sides lost);
|
|
96
|
+
* - the value contains a backslash: this scanner does not decode CSS
|
|
97
|
+
* escapes, and an escape can hide a quote, a parenthesis or a whole
|
|
98
|
+
* keyword (`\69 nherit` IS `inherit` to an engine) — the literal text
|
|
99
|
+
* is not the value, so no rewrite of it is trustworthy (round 5);
|
|
100
|
+
* - the value ends with a `!important` annotation: the annotation
|
|
101
|
+
* belongs to the DECLARATION, so a per-side rewrite would either
|
|
102
|
+
* silently lose the priority (`2rem !important`) or rebuild the
|
|
103
|
+
* `inherit 0 inherit` every engine rejects (`inherit !important`,
|
|
104
|
+
* measured in Chromium, round 5);
|
|
105
|
+
* - the scan ends inside an open function or an unterminated quote: CSS
|
|
106
|
+
* auto-closes a function at EOF, so `"calc(1rem + 2px"` IS a real
|
|
107
|
+
* padding (measured: all four sides) while its rewrite is not; an
|
|
108
|
+
* unterminated quote is invalid at both ends, and bailing keeps the
|
|
109
|
+
* two paths identical (round 5).
|
|
110
|
+
*
|
|
111
|
+
* Known residual (documented, not fixable at string level): a `var()`
|
|
112
|
+
* whose custom property resolves to MORE than one track cannot keep its
|
|
113
|
+
* vertical half — `padding: var(--multi) 0 var(--multi)` is valid at
|
|
114
|
+
* parse time and invalid at computed-value time, and all four sides
|
|
115
|
+
* collapse. No consumer passes a multi-track custom property today.
|
|
116
|
+
*/
|
|
117
|
+
export function mobileSideGutterFree(value: string): string {
|
|
118
|
+
if (value.includes("/*") || value.includes("\\")) return value;
|
|
119
|
+
// `!important` is an annotation on the DECLARATION, not a track — it
|
|
120
|
+
// cannot ride a per-side rewrite: `2rem !important` would silently lose
|
|
121
|
+
// its priority, and `inherit !important` would rebuild into the
|
|
122
|
+
// `inherit 0 inherit` every engine drops (measured in Chromium, round
|
|
123
|
+
// 5). Bail out and let the authored padding stand.
|
|
124
|
+
if (/!\s*important\s*$/i.test(value)) return value;
|
|
125
|
+
const { sides, balanced } = scanPaddingSides(value);
|
|
126
|
+
if (!balanced) return value;
|
|
127
|
+
if (sides.length === 0) return value;
|
|
128
|
+
if (sides.length === 1 && CSS_WIDE_KEYWORDS.has(sides[0].toLowerCase())) return value;
|
|
129
|
+
const [top, , bottom] = sides;
|
|
130
|
+
return `${top} 0 ${bottom ?? top}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
6
133
|
export const HkAdminShell = defineComponent({
|
|
7
134
|
name: "HkAdminShell",
|
|
8
135
|
props: {
|
|
@@ -21,12 +148,29 @@ export const HkAdminShell = defineComponent({
|
|
|
21
148
|
drawerPanelClass: { type: String, default: undefined },
|
|
22
149
|
/** Content-area padding (inside the scroll viewport). Padding is
|
|
23
150
|
* applied to an inner wrapper rather than the scroll container so
|
|
24
|
-
* card box-shadows are never clipped at the viewport edges.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* area must span the full width for 2D panels and the 3D scene —
|
|
28
|
-
* only the vertical clearance survives). */
|
|
151
|
+
* card box-shadows are never clipped at the viewport edges. Applied
|
|
152
|
+
* verbatim at every width unless the page opts into
|
|
153
|
+
* `contentBleedOnMobile`. */
|
|
29
154
|
contentPadding: { type: String, default: "1.5rem" },
|
|
155
|
+
/** Whether the content on screen is a CANVAS — a 2D board or the 3D
|
|
156
|
+
* scene — that must span the full phone width. OPT-IN, declared by
|
|
157
|
+
* the page that renders the canvas (2026-10-04 user direction: the
|
|
158
|
+
* side-gutter drop is a PER-PAGE declaration, never a shell-wide
|
|
159
|
+
* rule — an ordinary page keeps its side gutters on phones).
|
|
160
|
+
*
|
|
161
|
+
* When true, below `mobileBreakpoint` the horizontal half of
|
|
162
|
+
* `contentPadding` is dropped while the vertical clearance
|
|
163
|
+
* survives; desktop is never affected and the verbatim padding is
|
|
164
|
+
* restored when the viewport crosses back above the breakpoint.
|
|
165
|
+
* A `contentPadding` that cannot be rewritten per side (empty, a
|
|
166
|
+
* lone CSS-wide keyword, or a value this scanner cannot trust: one
|
|
167
|
+
* carrying a CSS comment or a backslash escape, one ending in a
|
|
168
|
+
* `!important` annotation, or one whose functions/quotes never
|
|
169
|
+
* close) falls back to the verbatim value — see
|
|
170
|
+
* `mobileSideGutterFree`; the page then keeps its gutters instead
|
|
171
|
+
* of risking a declaration a browser would drop whole.
|
|
172
|
+
* Default false: the padding reads the same at every width. */
|
|
173
|
+
contentBleedOnMobile: { type: Boolean, default: false },
|
|
30
174
|
},
|
|
31
175
|
setup(props, { slots }) {
|
|
32
176
|
const { t } = useI18n();
|
|
@@ -34,20 +178,10 @@ export const HkAdminShell = defineComponent({
|
|
|
34
178
|
const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
|
|
35
179
|
const sidebarOpen = ref(false);
|
|
36
180
|
|
|
37
|
-
// Content padding with the mobile side-gutter rule applied: desktop
|
|
38
|
-
// gets the value verbatim; below the breakpoint the horizontal
|
|
39
|
-
// tracks become zero while the vertical tracks survive. The CSS
|
|
40
|
-
// padding shorthand is parsed per side so multi-value values keep
|
|
41
|
-
// their meaning ("1rem 2rem" -> "1rem 0", not the invalid or
|
|
42
|
-
// inverted "1rem 2rem 0"). An empty value passes through untouched.
|
|
43
|
-
const mobileSideGutterFree = (value: string): string => {
|
|
44
|
-
const sides = value.trim().split(/\s+/).filter(Boolean);
|
|
45
|
-
if (sides.length === 0) return value;
|
|
46
|
-
const [top, , bottom] = sides;
|
|
47
|
-
return `${top} 0 ${bottom ?? top}`;
|
|
48
|
-
};
|
|
49
181
|
const contentStyle = computed(() => ({
|
|
50
|
-
padding: isDesktop.value
|
|
182
|
+
padding: isDesktop.value || !props.contentBleedOnMobile
|
|
183
|
+
? props.contentPadding
|
|
184
|
+
: mobileSideGutterFree(props.contentPadding),
|
|
51
185
|
}));
|
|
52
186
|
|
|
53
187
|
const actionBar = provideActionBar();
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vitest } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkFab from "./HkFab";
|
|
5
|
+
import { POPUP_Z_BANDS } from "../runtime/usePopupManager";
|
|
5
6
|
|
|
6
7
|
const mounts: ReturnType<typeof createApp>[] = [];
|
|
7
8
|
const containers: HTMLElement[] = [];
|
|
@@ -41,8 +42,14 @@ function mountFab(props: Record<string, unknown> = {}): Harness {
|
|
|
41
42
|
mounts.push(app);
|
|
42
43
|
app.mount(container);
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// A layer="top" fab teleports into the floating layer on <body>;
|
|
46
|
+
// fall back to the document scope so both modes resolve.
|
|
47
|
+
const root =
|
|
48
|
+
container.querySelector<HTMLElement>(".hk-fab") ??
|
|
49
|
+
document.body.querySelector<HTMLElement>(".hk-fab");
|
|
50
|
+
const button =
|
|
51
|
+
container.querySelector<HTMLButtonElement>(".hk-fab-button") ??
|
|
52
|
+
document.body.querySelector<HTMLButtonElement>(".hk-fab-button");
|
|
46
53
|
if (!root || !button) throw new Error("HkFab did not render its shell");
|
|
47
54
|
harness.root = root;
|
|
48
55
|
harness.button = button;
|
|
@@ -215,3 +222,28 @@ describe("HkFab speed dial", () => {
|
|
|
215
222
|
expect(toggles).toEqual([true, false, true, false]);
|
|
216
223
|
});
|
|
217
224
|
});
|
|
225
|
+
|
|
226
|
+
describe("HkFab layer", () => {
|
|
227
|
+
it("host (default) keeps the fab anchored in the host tree", () => {
|
|
228
|
+
const m = mountFab({ ariaLabel: "Host", positioning: "fixed" });
|
|
229
|
+
expect(document.body.querySelector(".hk-floating-layer")).toBeNull();
|
|
230
|
+
expect(m.root.getAttribute("data-positioning")).toBe("fixed");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("top wraps the fab in an HkFloatingLayer above the window band", () => {
|
|
234
|
+
const m = mountFab({ ariaLabel: "Float", layer: "top", corner: "bottom-right" });
|
|
235
|
+
const layer = document.body.querySelector<HTMLElement>(".hk-floating-layer");
|
|
236
|
+
expect(layer).not.toBeNull();
|
|
237
|
+
expect(layer!.contains(m.root)).toBe(true);
|
|
238
|
+
// Geometry is owned by the layer alone once floated.
|
|
239
|
+
expect(m.root.getAttribute("data-positioning")).toBeNull();
|
|
240
|
+
expect(m.root.getAttribute("data-corner")).toBeNull();
|
|
241
|
+
// Offset vars hand off to the layer as well — the fab keeps none.
|
|
242
|
+
expect(m.root.style.getPropertyValue("--hk-fab-offset-x")).toBe("");
|
|
243
|
+
expect(m.root.style.getPropertyValue("--hk-fab-offset-y")).toBe("");
|
|
244
|
+
expect(layer!.getAttribute("data-corner")).toBe("bottom-right");
|
|
245
|
+
const z = Number(layer!.style.getPropertyValue("--hk-float-z"));
|
|
246
|
+
expect(z).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
|
|
247
|
+
expect(z).toBeLessThan(POPUP_Z_BANDS.toast);
|
|
248
|
+
});
|
|
249
|
+
});
|
package/src/components/HkFab.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "vue";
|
|
7
7
|
|
|
8
8
|
import { iconByName } from "../composables/iconRegistry";
|
|
9
|
+
import HkFloatingLayer from "./HkFloatingLayer";
|
|
9
10
|
import "./HkFab.scss";
|
|
10
11
|
|
|
11
12
|
/** One entry of the HkFab speed dial. */
|
|
@@ -21,6 +22,7 @@ type FabCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
|
21
22
|
type FabSize = "sm" | "md" | "lg";
|
|
22
23
|
type FabVariant = "primary" | "glass";
|
|
23
24
|
type FabExpandDirection = "up" | "down" | "left" | "right";
|
|
25
|
+
type FabLayer = "host" | "top";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Material-design-style floating action button with an optional
|
|
@@ -46,6 +48,13 @@ export default defineComponent({
|
|
|
46
48
|
disabled: { type: Boolean, default: false },
|
|
47
49
|
actions: { type: Array as PropType<HFabAction[]>, default: undefined },
|
|
48
50
|
expandDirection: { type: String as PropType<FabExpandDirection>, default: "up" },
|
|
51
|
+
/** "top" floats the fab above every window (HkFloatingLayer — the
|
|
52
|
+
* popup manager's tooltip band, teleported to <body>): dials stay
|
|
53
|
+
* reachable no matter which modal/sheet opens over them. The layer
|
|
54
|
+
* owns the geometry when floated — `positioning` is ignored, the
|
|
55
|
+
* corner/offset props hand off to it. "host" keeps the classic
|
|
56
|
+
* in-tree anchoring (the historical default, byte-identical). */
|
|
57
|
+
layer: { type: String as PropType<FabLayer>, default: "host" },
|
|
49
58
|
},
|
|
50
59
|
emits: {
|
|
51
60
|
click: (_e: MouseEvent) => true,
|
|
@@ -116,6 +125,8 @@ export default defineComponent({
|
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
return () => {
|
|
128
|
+
const floated = props.layer === "top";
|
|
129
|
+
|
|
119
130
|
// Icon precedence: slot > registry > inline chevron-down fallback.
|
|
120
131
|
// `as any` matches HkIcon — the registry returns a loose Component.
|
|
121
132
|
const RegistryIcon = props.icon ? (iconByName(props.icon) as any) : null;
|
|
@@ -144,19 +155,19 @@ export default defineComponent({
|
|
|
144
155
|
</span>
|
|
145
156
|
);
|
|
146
157
|
|
|
147
|
-
|
|
158
|
+
const fab = (
|
|
148
159
|
<div
|
|
149
160
|
ref={rootRef}
|
|
150
161
|
class="hk-fab"
|
|
151
|
-
data-positioning={props.positioning}
|
|
152
|
-
data-corner={props.corner}
|
|
162
|
+
data-positioning={floated ? undefined : props.positioning}
|
|
163
|
+
data-corner={floated ? undefined : props.corner}
|
|
153
164
|
data-size={props.size}
|
|
154
165
|
data-variant={props.variant}
|
|
155
166
|
data-expand={props.expandDirection}
|
|
156
167
|
data-expanded={isOpen.value ? "true" : undefined}
|
|
157
168
|
style={{
|
|
158
|
-
"--hk-fab-offset-x": props.offsetX,
|
|
159
|
-
"--hk-fab-offset-y": props.offsetY,
|
|
169
|
+
"--hk-fab-offset-x": floated ? undefined : props.offsetX,
|
|
170
|
+
"--hk-fab-offset-y": floated ? undefined : props.offsetY,
|
|
160
171
|
}}
|
|
161
172
|
>
|
|
162
173
|
{(props.actions?.length ?? 0) > 0 && (
|
|
@@ -196,6 +207,23 @@ export default defineComponent({
|
|
|
196
207
|
</button>
|
|
197
208
|
</div>
|
|
198
209
|
);
|
|
210
|
+
|
|
211
|
+
// Floated: the HkFloatingLayer owns viewport anchoring (corner +
|
|
212
|
+
// safe-area + tooltip-band z); the fab itself renders bare inside
|
|
213
|
+
// it (data-positioning/corner stripped above so the geometry is
|
|
214
|
+
// owned by exactly one layer).
|
|
215
|
+
if (floated) {
|
|
216
|
+
return (
|
|
217
|
+
<HkFloatingLayer
|
|
218
|
+
corner={props.corner}
|
|
219
|
+
offsetX={props.offsetX}
|
|
220
|
+
offsetY={props.offsetY}
|
|
221
|
+
>
|
|
222
|
+
{fab}
|
|
223
|
+
</HkFloatingLayer>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return fab;
|
|
199
227
|
};
|
|
200
228
|
},
|
|
201
229
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// HkFloatingLayer.scss — top-layer floating chrome.
|
|
2
|
+
//
|
|
3
|
+
// Anchoring mirrors HkFab's safe-area-aware corner insets, extended per
|
|
4
|
+
// instance by --hk-float-offset-*. The z rides the popup manager's
|
|
5
|
+
// tooltip band via --hk-float-z (inline from the component once the
|
|
6
|
+
// registration resolves); the fallback must stay in sync with
|
|
7
|
+
// POPUP_Z_BANDS.tooltip in runtime/usePopupManager.ts.
|
|
8
|
+
.hk-floating-layer {
|
|
9
|
+
position: fixed;
|
|
10
|
+
z-index: var(--hk-float-z, 3000);
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
gap: var(--space-8, 0.5rem);
|
|
14
|
+
pointer-events: none;
|
|
15
|
+
|
|
16
|
+
--hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-left, 0px));
|
|
17
|
+
--hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-bottom, 0px));
|
|
18
|
+
|
|
19
|
+
&[data-corner$="-right"] {
|
|
20
|
+
--hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-right, 0px));
|
|
21
|
+
right: calc(var(--hk-float-inset-x) + var(--hk-float-offset-x, 0px));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&[data-corner$="-left"] {
|
|
25
|
+
--hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-left, 0px));
|
|
26
|
+
left: calc(var(--hk-float-inset-x) + var(--hk-float-offset-x, 0px));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
&[data-corner^="top"] {
|
|
30
|
+
--hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-top, 0px));
|
|
31
|
+
top: calc(var(--hk-float-inset-y) + var(--hk-float-offset-y, 0px));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
&[data-corner^="bottom"] {
|
|
35
|
+
--hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-bottom, 0px));
|
|
36
|
+
bottom: calc(var(--hk-float-inset-y) + var(--hk-float-offset-y, 0px));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The layer never eats gestures; the widgets inside do. NOTE: for a
|
|
40
|
+
// floated HkFab this rule must LOSE to HkFab's own
|
|
41
|
+
// `.hk-fab { pointer-events: none }` (equal specificity — later sheet
|
|
42
|
+
// wins — and HkFab.tsx imports this component before its own sheet),
|
|
43
|
+
// so the fab root stays transparent while its button re-enables.
|
|
44
|
+
// Do not strengthen this selector or reorder those imports: the
|
|
45
|
+
// layer's gap boxes would start eating clicks.
|
|
46
|
+
> * {
|
|
47
|
+
pointer-events: auto;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A floated HkFab (layer="top") hands viewport anchoring to this
|
|
51
|
+
// layer entirely: drop its own positioning so its corner lands in
|
|
52
|
+
// the layer's flow instead of double-resolving the insets.
|
|
53
|
+
> .hk-fab {
|
|
54
|
+
position: static;
|
|
55
|
+
inset: auto;
|
|
56
|
+
z-index: auto;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiled-stylesheet contract for HkFloatingLayer. The layer IS its
|
|
10
|
+
* CSS (fixed positioning, gesture transparency, the > .hk-fab
|
|
11
|
+
* positioning hand-off), none of which the runtime suite can see —
|
|
12
|
+
* vitest stubs CSS — so the sheet is compiled and asserted here.
|
|
13
|
+
*/
|
|
14
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
|
|
16
|
+
describe("HkFloatingLayer stylesheet contract", () => {
|
|
17
|
+
const tsx = readFileSync(resolve(componentDir, "HkFloatingLayer.tsx"), "utf8");
|
|
18
|
+
const css = compile(resolve(componentDir, "HkFloatingLayer.scss"), {
|
|
19
|
+
style: "expanded",
|
|
20
|
+
}).css;
|
|
21
|
+
|
|
22
|
+
it("compiles the fixed, gesture-transparent top-layer shell", () => {
|
|
23
|
+
expect(css).toContain(".hk-floating-layer {");
|
|
24
|
+
expect(css).toContain("position: fixed;");
|
|
25
|
+
expect(css).toContain("z-index: var(--hk-float-z, 3000);");
|
|
26
|
+
// The layer never eats gestures; the widgets inside do.
|
|
27
|
+
expect(css).toContain("pointer-events: none;");
|
|
28
|
+
expect(css).toContain(".hk-floating-layer > * {");
|
|
29
|
+
expect(css).toContain("pointer-events: auto;");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("anchors all four corners with safe-area-aware insets", () => {
|
|
33
|
+
expect(css).toContain('[data-corner$=-right]');
|
|
34
|
+
expect(css).toContain('[data-corner$=-left]');
|
|
35
|
+
expect(css).toContain('[data-corner^=top]');
|
|
36
|
+
expect(css).toContain('[data-corner^=bottom]');
|
|
37
|
+
for (const side of ["right: calc", "left: calc", "top: calc", "bottom: calc"]) {
|
|
38
|
+
expect(css).toContain(side);
|
|
39
|
+
}
|
|
40
|
+
expect(css).toContain("env(safe-area-inset-left, 0px)");
|
|
41
|
+
expect(css).toContain("env(safe-area-inset-right, 0px)");
|
|
42
|
+
expect(css).toContain("env(safe-area-inset-top, 0px)");
|
|
43
|
+
expect(css).toContain("env(safe-area-inset-bottom, 0px)");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("takes over a floated HkFab's positioning entirely", () => {
|
|
47
|
+
expect(css).toContain(".hk-floating-layer > .hk-fab {");
|
|
48
|
+
expect(css).toContain("position: static;");
|
|
49
|
+
expect(css).toContain("inset: auto;");
|
|
50
|
+
expect(css).toContain("z-index: auto;");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("has no dead selectors: every rule the sheet emits is rendered", () => {
|
|
54
|
+
// The TSX must reference every state the sheet styles.
|
|
55
|
+
expect(tsx).toContain('class="hk-floating-layer"');
|
|
56
|
+
expect(tsx).toContain("data-corner={props.corner}");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkFloatingLayer from "./HkFloatingLayer";
|
|
5
|
+
import { POPUP_Z_BANDS, usePopupManager } from "../runtime/usePopupManager";
|
|
6
|
+
|
|
7
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
8
|
+
const containers: HTMLElement[] = [];
|
|
9
|
+
|
|
10
|
+
/** Mount an HkFloatingLayer through a wrapper app (mirrors the
|
|
11
|
+
* HkFab.test.tsx harness). The layer teleports its node to <body>,
|
|
12
|
+
* so queries go through document.body, not the host container. */
|
|
13
|
+
function mountLayer(props: Record<string, unknown> = {}, slot?: () => unknown) {
|
|
14
|
+
const container = document.createElement("div");
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
containers.push(container);
|
|
17
|
+
|
|
18
|
+
const Wrapper = defineComponent({
|
|
19
|
+
setup() {
|
|
20
|
+
return () => h(HkFloatingLayer, props, slot ? { default: slot } : undefined);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
const app = createApp(Wrapper);
|
|
24
|
+
mounts.push(app);
|
|
25
|
+
app.mount(container);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
app,
|
|
29
|
+
root: () => document.body.querySelector<HTMLElement>(".hk-floating-layer"),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
35
|
+
for (const el of containers.splice(0)) el.remove();
|
|
36
|
+
document.body.innerHTML = "";
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("HkFloatingLayer", () => {
|
|
40
|
+
it("teleports the corner layer to body with a tooltip-band z slot", () => {
|
|
41
|
+
const m = mountLayer({ ariaLabel: "Date dial" });
|
|
42
|
+
const root = m.root();
|
|
43
|
+
expect(root).not.toBeNull();
|
|
44
|
+
// Teleported: outside the wrapper's host subtree.
|
|
45
|
+
expect(containers[0].contains(root!)).toBe(false);
|
|
46
|
+
expect(root!.getAttribute("data-corner")).toBe("bottom-right");
|
|
47
|
+
expect(root!.getAttribute("role")).toBe("group");
|
|
48
|
+
expect(root!.getAttribute("aria-label")).toBe("Date dial");
|
|
49
|
+
// Inline z from the popup manager registration — inside the
|
|
50
|
+
// tooltip band, strictly below the toast band.
|
|
51
|
+
const z = Number(root!.style.getPropertyValue("--hk-float-z"));
|
|
52
|
+
expect(z).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
|
|
53
|
+
expect(z).toBeLessThan(POPUP_Z_BANDS.toast);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("open=false renders nothing at all", () => {
|
|
57
|
+
const m = mountLayer({ open: false });
|
|
58
|
+
expect(m.root()).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("registers exactly one tooltip entry and releases it on unmount", () => {
|
|
62
|
+
const manager = usePopupManager();
|
|
63
|
+
const tooltipCount = () => {
|
|
64
|
+
let n = 0;
|
|
65
|
+
for (const entry of manager.registry.value.values()) {
|
|
66
|
+
if (entry.kind === "tooltip") n++;
|
|
67
|
+
}
|
|
68
|
+
return n;
|
|
69
|
+
};
|
|
70
|
+
const before = tooltipCount();
|
|
71
|
+
// Non-blocking, untitled chrome must stay silent (a future
|
|
72
|
+
// warnUntitled change folding tooltip into the naming rule would
|
|
73
|
+
// dev-warn on every mount — that regression goes red here).
|
|
74
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
75
|
+
const m = mountLayer();
|
|
76
|
+
expect(tooltipCount()).toBe(before + 1);
|
|
77
|
+
m.app.unmount();
|
|
78
|
+
mounts.splice(mounts.indexOf(m.app), 1);
|
|
79
|
+
expect(tooltipCount()).toBe(before);
|
|
80
|
+
expect(warn).not.toHaveBeenCalled();
|
|
81
|
+
warn.mockRestore();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("hide releases the slot; re-show re-registers with a fresh band z", async () => {
|
|
85
|
+
const manager = usePopupManager();
|
|
86
|
+
const tooltipCount = () => {
|
|
87
|
+
let n = 0;
|
|
88
|
+
for (const entry of manager.registry.value.values()) {
|
|
89
|
+
if (entry.kind === "tooltip") n++;
|
|
90
|
+
}
|
|
91
|
+
return n;
|
|
92
|
+
};
|
|
93
|
+
const container = document.createElement("div");
|
|
94
|
+
document.body.appendChild(container);
|
|
95
|
+
containers.push(container);
|
|
96
|
+
|
|
97
|
+
const visible = ref(true);
|
|
98
|
+
const Wrapper = defineComponent({
|
|
99
|
+
setup() {
|
|
100
|
+
return () => h(HkFloatingLayer, { open: visible.value });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const app = createApp(Wrapper);
|
|
104
|
+
mounts.push(app);
|
|
105
|
+
app.mount(container);
|
|
106
|
+
|
|
107
|
+
const layer = () => document.body.querySelector<HTMLElement>(".hk-floating-layer");
|
|
108
|
+
const before = tooltipCount();
|
|
109
|
+
expect(before).toBeGreaterThanOrEqual(1);
|
|
110
|
+
const z1 = Number(layer()!.style.getPropertyValue("--hk-float-z"));
|
|
111
|
+
expect(z1).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
|
|
112
|
+
|
|
113
|
+
// Hide: the node leaves AND the band slot is released — the
|
|
114
|
+
// count test above cannot catch this branch on its own (unmount
|
|
115
|
+
// cleanup would mask it).
|
|
116
|
+
visible.value = false;
|
|
117
|
+
await nextTick();
|
|
118
|
+
expect(tooltipCount()).toBe(before - 1);
|
|
119
|
+
expect(layer()).toBeNull();
|
|
120
|
+
|
|
121
|
+
// Re-show: registered again, first paint already carries the z.
|
|
122
|
+
visible.value = true;
|
|
123
|
+
await nextTick();
|
|
124
|
+
expect(tooltipCount()).toBe(before);
|
|
125
|
+
const z2 = Number(layer()!.style.getPropertyValue("--hk-float-z"));
|
|
126
|
+
expect(z2).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
|
|
127
|
+
|
|
128
|
+
app.unmount();
|
|
129
|
+
mounts.splice(mounts.indexOf(app), 1);
|
|
130
|
+
expect(tooltipCount()).toBe(before - 1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("honours the corner and offset props", () => {
|
|
134
|
+
const m = mountLayer({ corner: "top-left", offsetX: "1rem", offsetY: "2rem" });
|
|
135
|
+
const root = m.root()!;
|
|
136
|
+
expect(root.getAttribute("data-corner")).toBe("top-left");
|
|
137
|
+
expect(root.style.getPropertyValue("--hk-float-offset-x")).toBe("1rem");
|
|
138
|
+
expect(root.style.getPropertyValue("--hk-float-offset-y")).toBe("2rem");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("keeps slot content mounted inside the layer", () => {
|
|
142
|
+
const m = mountLayer({}, () => h("button", { class: "probe" }, "go"));
|
|
143
|
+
expect(m.root()!.querySelector(".probe")).not.toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
onBeforeUnmount,
|
|
4
|
+
ref,
|
|
5
|
+
Teleport,
|
|
6
|
+
watch,
|
|
7
|
+
type PropType,
|
|
8
|
+
} from "vue";
|
|
9
|
+
|
|
10
|
+
import { usePopupManager } from "../runtime/usePopupManager";
|
|
11
|
+
import "./HkFloatingLayer.scss";
|
|
12
|
+
|
|
13
|
+
type FloatCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* HkFloatingLayer — viewport-fixed chrome that floats at the TOP layer.
|
|
17
|
+
*
|
|
18
|
+
* The dial/FAB problem this solves: always-reachable chrome anchored to
|
|
19
|
+
* a page (or a window) dies the moment a real window opens over it —
|
|
20
|
+
* either it is buried under the modal overlay (page pads), or it gets
|
|
21
|
+
* re-rendered as an in-window copy that no longer shares the page's
|
|
22
|
+
* geometry (the per-modal dial copies this layer replaces). Floating at
|
|
23
|
+
* the popup manager's tooltip band makes the chrome independent of the
|
|
24
|
+
* window stack, exactly like a tooltip: above every modal/drawer
|
|
25
|
+
* overlay, below toasts, present on its own merits.
|
|
26
|
+
*
|
|
27
|
+
* Mechanics:
|
|
28
|
+
* - Teleported to <body>, so no transformed ancestor (morph tabs,
|
|
29
|
+
* modal content frames) can re-anchor or clip a `position: fixed`
|
|
30
|
+
* child, and the layer resolves against the viewport full stop.
|
|
31
|
+
* - Registered with the popup manager (kind "tooltip", non-blocking,
|
|
32
|
+
* no scroll lock, no breadcrumb title): the z slot derives from the
|
|
33
|
+
* live registry instead of a magic constant, and the layer shows up
|
|
34
|
+
* in `readHkRuntime("popupManager")` like every other surface.
|
|
35
|
+
* - The layer itself is gesture-transparent (pointer-events: none);
|
|
36
|
+
* the widgets inside catch their own pointers.
|
|
37
|
+
* - Corner insets mirror HkFab's safe-area-aware math, extended per
|
|
38
|
+
* instance by the offset props.
|
|
39
|
+
*
|
|
40
|
+
* - Within the tooltip band, stacking is open-order (the manager's
|
|
41
|
+
* rule): a tooltip that registered later paints above this chrome
|
|
42
|
+
* near the corner, and a layer mounted after a live tooltip paints
|
|
43
|
+
* above it — transient vs persistent chrome trades locally. Do not
|
|
44
|
+
* "fix" this with a magic z. Two layers at one corner overlap;
|
|
45
|
+
* pass offsets to stack them deliberately.
|
|
46
|
+
* - While a modal is open, the modal's focus trap keeps this chrome
|
|
47
|
+
* pointer-reachable but NOT keyboard-reachable — the standing
|
|
48
|
+
* tradeoff for floating chrome; a focus relay is out of scope.
|
|
49
|
+
*
|
|
50
|
+
* Consumers: the shell's quick-action pad, a modal's detached date dial
|
|
51
|
+
* (via `HkFab layer="top"` or direct use) — anything that must survive
|
|
52
|
+
* the next window opening on top of it.
|
|
53
|
+
*/
|
|
54
|
+
export default defineComponent({
|
|
55
|
+
name: "HkFloatingLayer",
|
|
56
|
+
props: {
|
|
57
|
+
/** False unmounts the teleported layer and releases its band slot
|
|
58
|
+
* (true = always shown). House `open` convention, like every other
|
|
59
|
+
* hikari surface. */
|
|
60
|
+
open: { type: Boolean, default: true },
|
|
61
|
+
corner: { type: String as PropType<FloatCorner>, default: "bottom-right" },
|
|
62
|
+
offsetX: { type: String, default: undefined },
|
|
63
|
+
offsetY: { type: String, default: undefined },
|
|
64
|
+
/** Accessible group name for the chrome (e.g. "Date dial"). */
|
|
65
|
+
ariaLabel: { type: String, default: "" },
|
|
66
|
+
},
|
|
67
|
+
setup(props, { slots }) {
|
|
68
|
+
const { register, unregister } = usePopupManager();
|
|
69
|
+
const handle = ref<{ id: string; zIndex: number } | null>(null);
|
|
70
|
+
|
|
71
|
+
// Annotation-band chrome, not a breadcrumb level: non-blocking,
|
|
72
|
+
// untitled, no scroll lock, no outside-dismiss channel. Registered
|
|
73
|
+
// exactly while shown (immediate, so the first paint already
|
|
74
|
+
// carries the band z); a hidden layer holds no slot.
|
|
75
|
+
watch(
|
|
76
|
+
() => props.open,
|
|
77
|
+
(show) => {
|
|
78
|
+
if (show && !handle.value) handle.value = register("tooltip", false);
|
|
79
|
+
else if (!show && handle.value) {
|
|
80
|
+
unregister(handle.value.id);
|
|
81
|
+
handle.value = null;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{ immediate: true },
|
|
85
|
+
);
|
|
86
|
+
onBeforeUnmount(() => {
|
|
87
|
+
if (handle.value) unregister(handle.value.id);
|
|
88
|
+
handle.value = null;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
if (!props.open) return null;
|
|
93
|
+
return (
|
|
94
|
+
<Teleport to="body">
|
|
95
|
+
<div
|
|
96
|
+
class="hk-floating-layer"
|
|
97
|
+
data-corner={props.corner}
|
|
98
|
+
role={props.ariaLabel ? "group" : undefined}
|
|
99
|
+
aria-label={props.ariaLabel || undefined}
|
|
100
|
+
style={{
|
|
101
|
+
"--hk-float-z": handle.value ? String(handle.value.zIndex) : undefined,
|
|
102
|
+
"--hk-float-offset-x": props.offsetX,
|
|
103
|
+
"--hk-float-offset-y": props.offsetY,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{slots.default?.()}
|
|
107
|
+
</div>
|
|
108
|
+
</Teleport>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -689,6 +689,7 @@ export { default as HkDrawer } from "./components/HkDrawer";
|
|
|
689
689
|
export { default as HkEmptyState } from "./components/HkEmptyState";
|
|
690
690
|
export { default as HkExpansionPanel } from "./components/HkExpansionPanel";
|
|
691
691
|
export { default as HkFab } from "./components/HkFab";
|
|
692
|
+
export { default as HkFloatingLayer } from "./components/HkFloatingLayer";
|
|
692
693
|
export { default as HkIcon } from "./components/HkIcon";
|
|
693
694
|
export { default as HkIconButton } from "./components/HkIconButton";
|
|
694
695
|
export { default as HkIconButtonGroup } from "./components/HkIconButtonGroup";
|