@celestia-island/hikari 0.55.110 → 0.55.111
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/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
|
@@ -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";
|