@celestia-island/hikari 0.55.43 → 0.55.45
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/animation/registerAnimations.ts +2 -0
- package/src/components/HkLoadingVeil.scss +64 -0
- package/src/components/HkLoadingVeil.test.tsx +89 -0
- package/src/components/HkLoadingVeil.tsx +41 -0
- package/src/composables/useContextMenu.test.tsx +16 -0
- package/src/composables/useContextMenu.ts +11 -1
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -106,6 +106,8 @@ registerCssAnimation("hk-placeholder-marquee-scroll", { infinite: true });
|
|
|
106
106
|
// styles/admin-tokens.scss — admin header emergency-stop chip while
|
|
107
107
|
// engaged (loops forever).
|
|
108
108
|
registerCssAnimation("s-admin-header-emergency-pulse", { infinite: true });
|
|
109
|
+
registerCssAnimation("hk-loading-veil-in");
|
|
110
|
+
registerCssAnimation("hk-loading-veil-spin", { infinite: true });
|
|
109
111
|
// The styles/theme/mixins.scss ambient/glow/entrance keyframes were
|
|
110
112
|
// retired with the legacy Gen-1 sheet vendoring (hikari #476) — their
|
|
111
113
|
// registrar entries went with them.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// HkLoadingVeil — fullscreen semi-transparent blocking overlay.
|
|
2
|
+
// The z-index sits above the toast band (--hk-z-toast) so the veil is
|
|
3
|
+
// the topmost painted surface while active; pointer-events: all on the
|
|
4
|
+
// root swallows every interaction beneath (the blocking contract).
|
|
5
|
+
.hk-loading-veil {
|
|
6
|
+
position: fixed;
|
|
7
|
+
inset: 0;
|
|
8
|
+
z-index: calc(var(--hk-z-toast, 4000) + 100);
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
// Semi-transparent backdrop with a slight blur: the page beneath
|
|
13
|
+
// stays readable (orientation) but clearly inactive.
|
|
14
|
+
background: rgb(var(--color-background, 214 236 240) / 0.55);
|
|
15
|
+
backdrop-filter: blur(6px);
|
|
16
|
+
-webkit-backdrop-filter: blur(6px);
|
|
17
|
+
pointer-events: all;
|
|
18
|
+
animation: hk-loading-veil-in 150ms ease-out;
|
|
19
|
+
|
|
20
|
+
// Both light and dark themes ride the --color-background token.
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.hk-loading-veil-body {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
align-items: center;
|
|
27
|
+
gap: 12px;
|
|
28
|
+
padding: 24px 32px;
|
|
29
|
+
border-radius: 12px;
|
|
30
|
+
background: rgb(var(--color-surface, 240 244 248) / 0.85);
|
|
31
|
+
box-shadow: 0 8px 32px rgb(0 0 0 / 0.12);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.hk-loading-veil-spinner {
|
|
35
|
+
display: inline-block;
|
|
36
|
+
width: 28px;
|
|
37
|
+
height: 28px;
|
|
38
|
+
border: 3px solid rgb(var(--color-border, 100 100 100) / 0.4);
|
|
39
|
+
border-top-color: rgb(var(--color-primary, 122 162 247));
|
|
40
|
+
border-radius: 50%;
|
|
41
|
+
animation: hk-loading-veil-spin 0.8s linear infinite;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.hk-loading-veil-label {
|
|
45
|
+
margin: 0;
|
|
46
|
+
font-size: 0.875rem;
|
|
47
|
+
color: rgb(var(--color-text, 30 30 30));
|
|
48
|
+
text-align: center;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@keyframes hk-loading-veil-spin {
|
|
52
|
+
to {
|
|
53
|
+
transform: rotate(360deg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@keyframes hk-loading-veil-in {
|
|
58
|
+
from {
|
|
59
|
+
opacity: 0;
|
|
60
|
+
}
|
|
61
|
+
to {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkLoadingVeil from "./HkLoadingVeil";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkLoadingVeil contract tests:
|
|
8
|
+
* - nothing renders while inactive (zero DOM footprint)
|
|
9
|
+
* - active paints the veil root, the spinner, and the label
|
|
10
|
+
* - no label prop → no label node (spinner-only)
|
|
11
|
+
* - reactive: active toggles appearance/disappearance
|
|
12
|
+
* - the root carries role=status / aria-live=polite / the label as
|
|
13
|
+
* aria-label (screen readers announce the blocking state)
|
|
14
|
+
*
|
|
15
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
16
|
+
* @vue/test-utils dependency.)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const mounts: Array<() => void> = [];
|
|
20
|
+
|
|
21
|
+
function mountVeil(props: { active?: boolean; label?: string } = {}) {
|
|
22
|
+
const container = document.createElement("div");
|
|
23
|
+
document.body.appendChild(container);
|
|
24
|
+
const active = ref(props.active ?? false);
|
|
25
|
+
const app = createApp({
|
|
26
|
+
setup() {
|
|
27
|
+
return () =>
|
|
28
|
+
h(HkLoadingVeil, {
|
|
29
|
+
active: active.value,
|
|
30
|
+
label: props.label ?? "",
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
app.mount(container);
|
|
35
|
+
mounts.push(() => {
|
|
36
|
+
app.unmount();
|
|
37
|
+
container.remove();
|
|
38
|
+
});
|
|
39
|
+
return { active, container };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
while (mounts.length) mounts.pop()?.();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("HkLoadingVeil", () => {
|
|
47
|
+
it("renders nothing while inactive", async () => {
|
|
48
|
+
const { container } = mountVeil({ active: false });
|
|
49
|
+
await nextTick();
|
|
50
|
+
expect(container.querySelector(".hk-loading-veil")).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders the veil, spinner and label while active", async () => {
|
|
54
|
+
const { container } = mountVeil({ active: true, label: "Opening…" });
|
|
55
|
+
await nextTick();
|
|
56
|
+
expect(container.querySelector(".hk-loading-veil")).not.toBeNull();
|
|
57
|
+
expect(container.querySelector(".hk-loading-veil-spinner")).not.toBeNull();
|
|
58
|
+
const label = container.querySelector(".hk-loading-veil-label");
|
|
59
|
+
expect(label?.textContent).toBe("Opening…");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("omits the label node when no label is given", async () => {
|
|
63
|
+
const { container } = mountVeil({ active: true });
|
|
64
|
+
await nextTick();
|
|
65
|
+
expect(container.querySelector(".hk-loading-veil-spinner")).not.toBeNull();
|
|
66
|
+
expect(container.querySelector(".hk-loading-veil-label")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("appears and disappears reactively with the active prop", async () => {
|
|
70
|
+
const { active, container } = mountVeil({ active: false });
|
|
71
|
+
await nextTick();
|
|
72
|
+
expect(container.querySelector(".hk-loading-veil")).toBeNull();
|
|
73
|
+
active.value = true;
|
|
74
|
+
await nextTick();
|
|
75
|
+
expect(container.querySelector(".hk-loading-veil")).not.toBeNull();
|
|
76
|
+
active.value = false;
|
|
77
|
+
await nextTick();
|
|
78
|
+
expect(container.querySelector(".hk-loading-veil")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("carries role=status and the label as aria-label", async () => {
|
|
82
|
+
const { container } = mountVeil({ active: true, label: "Loading backend" });
|
|
83
|
+
await nextTick();
|
|
84
|
+
const root = container.querySelector(".hk-loading-veil");
|
|
85
|
+
expect(root?.getAttribute("role")).toBe("status");
|
|
86
|
+
expect(root?.getAttribute("aria-label")).toBe("Loading backend");
|
|
87
|
+
expect(root?.getAttribute("aria-live")).toBe("polite");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkLoadingVeil — a fullscreen semi-transparent blocking overlay for
|
|
3
|
+
* navigation-span loading (the "one tap, no dead window" pattern).
|
|
4
|
+
*
|
|
5
|
+
* While `active`, the veil paints above everything (z-index above the
|
|
6
|
+
* toast band): a dimmed backdrop with backdrop-blur, a centered spinner
|
|
7
|
+
* and a one-line label, and it swallows ALL pointer events — the user
|
|
8
|
+
* cannot double-tap, scroll, or interact with anything beneath. When
|
|
9
|
+
* `active` turns false the veil fades out (150 ms) and pointer events
|
|
10
|
+
* return.
|
|
11
|
+
*
|
|
12
|
+
* Use it when a click must IMMEDIATELY show progress across a lazy
|
|
13
|
+
* boundary (the frontend→backend console jump): the toast band is
|
|
14
|
+
* top-right and non-blocking — callers that need true blocking (one
|
|
15
|
+
* click, fullscreen feedback, zero interaction until ready) mount this
|
|
16
|
+
* instead. It is NOT a modal: no scroll lock, no focus trap, no
|
|
17
|
+
* Esc-close — the caller's flight owns the lifetime.
|
|
18
|
+
*/
|
|
19
|
+
import { defineComponent } from "vue";
|
|
20
|
+
import "./HkLoadingVeil.scss";
|
|
21
|
+
|
|
22
|
+
export default defineComponent({
|
|
23
|
+
name: "HkLoadingVeil",
|
|
24
|
+
props: {
|
|
25
|
+
/** Paint + block while true; fade out on false. */
|
|
26
|
+
active: { type: Boolean, default: false },
|
|
27
|
+
/** One-line label under the spinner (already-localized by caller). */
|
|
28
|
+
label: { type: String, default: "" },
|
|
29
|
+
},
|
|
30
|
+
setup(props) {
|
|
31
|
+
return () =>
|
|
32
|
+
props.active ? (
|
|
33
|
+
<div class="hk-loading-veil" role="status" aria-live="polite" aria-label={props.label || "loading"}>
|
|
34
|
+
<div class="hk-loading-veil-body">
|
|
35
|
+
<span class="hk-loading-veil-spinner" aria-hidden="true" />
|
|
36
|
+
{props.label ? <p class="hk-loading-veil-label">{props.label}</p> : null}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
) : null;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -385,6 +385,22 @@ describe("useContextMenu + HkContextMenuProvider", () => {
|
|
|
385
385
|
unbind();
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
it("keeps real right-clicks working inside the suppression window (only long-press synthesis is swallowed)", () => {
|
|
389
|
+
const target = document.createElement("div");
|
|
390
|
+
target.setAttribute("data-bind-target", "");
|
|
391
|
+
document.body.appendChild(target);
|
|
392
|
+
const { opens, api } = mockApi();
|
|
393
|
+
const unbind = bindContextMenu(target, api, (point) => {
|
|
394
|
+
return { x: point.x, y: point.y, items: [{ key: "a", label: "A" }] };
|
|
395
|
+
});
|
|
396
|
+
// Two genuine pointer right-clicks, ~0ms apart: BOTH open (a
|
|
397
|
+
// desktop user re-aiming fast must not lose the second menu).
|
|
398
|
+
target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
|
|
399
|
+
target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 30, clientY: 30 }));
|
|
400
|
+
expect(opens).toHaveLength(2);
|
|
401
|
+
unbind();
|
|
402
|
+
});
|
|
403
|
+
|
|
388
404
|
it("lets a null build defer to outer bindings without preventing", () => {
|
|
389
405
|
const target = document.createElement("div");
|
|
390
406
|
target.setAttribute("data-bind-target", "");
|
|
@@ -110,6 +110,12 @@ export function bindContextMenu(
|
|
|
110
110
|
let holdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
111
|
let holdOrigin: { x: number; y: number } | null = null;
|
|
112
112
|
let lastOpenedAt = 0;
|
|
113
|
+
/** The kind that last opened a menu — the suppression window only
|
|
114
|
+
* guards the LONG-PRESS path (platforms synthesize a native
|
|
115
|
+
* contextmenu right after ours). A desktop user right-clicking twice
|
|
116
|
+
* within the window is two REAL gestures: the second one must open
|
|
117
|
+
* its own menu, never be swallowed. */
|
|
118
|
+
let lastOpenKind: ContextTriggerPoint["kind"] | null = null;
|
|
113
119
|
|
|
114
120
|
const cancelHold = () => {
|
|
115
121
|
if (holdTimer !== null) {
|
|
@@ -123,6 +129,7 @@ export function bindContextMenu(
|
|
|
123
129
|
const request = build(point);
|
|
124
130
|
if (!request) return false;
|
|
125
131
|
lastOpenedAt = performance.now();
|
|
132
|
+
lastOpenKind = point.kind;
|
|
126
133
|
api.open(request);
|
|
127
134
|
return true;
|
|
128
135
|
};
|
|
@@ -130,7 +137,10 @@ export function bindContextMenu(
|
|
|
130
137
|
const onContextMenu = (event: MouseEvent) => {
|
|
131
138
|
// Our own long-press just opened the menu; a synthesized native
|
|
132
139
|
// event for the same gesture must not re-open (or replace) it.
|
|
133
|
-
if (
|
|
140
|
+
if (
|
|
141
|
+
lastOpenKind === "longpress"
|
|
142
|
+
&& performance.now() - lastOpenedAt < CONTEXT_SUPPRESS_MS
|
|
143
|
+
) {
|
|
134
144
|
event.preventDefault();
|
|
135
145
|
return;
|
|
136
146
|
}
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,7 @@ export { default as HRadio } from "./components/HkRadio";
|
|
|
80
80
|
export { default as HScrollContainer } from "./components/HkScrollContainer";
|
|
81
81
|
export { default as HSearchInput } from "./components/HkSearchInput";
|
|
82
82
|
export { default as HSplash } from "./components/HkSplash";
|
|
83
|
+
export { default as HkLoadingVeil } from "./components/HkLoadingVeil";
|
|
83
84
|
export { default as HSelect } from "./components/HkSelect";
|
|
84
85
|
export { default as HSelectPanel, type SelectPanelPlacement } from "./components/HkSelectPanel";
|
|
85
86
|
export { default as HSidebar } from "./components/HkSidebar";
|