@celestia-island/hikari 0.40.11 → 0.40.13
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/HkDrawer.scss +11 -12
- package/src/components/HkErrorBoundary.test.tsx +103 -0
- package/src/components/HkErrorBoundary.tsx +65 -43
- package/src/components/HkErrorLanding.scss +9 -0
- package/src/components/HkErrorLanding.test.tsx +8 -0
- package/src/components/HkErrorLanding.tsx +12 -1
- package/src/components/HkModal.scss +12 -14
- package/src/components/HkPopover.scss +7 -0
- package/src/components/HkPopover.tsx +20 -9
- package/src/components/HkSelect.scss +8 -0
- package/src/components/HkSelectPanel.tsx +6 -1
- package/src/components/_scrim-fade.scss +59 -0
- package/src/components/scrim-fade.contract.test.ts +80 -0
- package/src/errorReporting/HkErrorReportingOverlay.scss +9 -0
- package/src/errorReporting/HkErrorReportingOverlay.tsx +103 -0
- package/src/errorReporting/index.test.tsx +165 -0
- package/src/errorReporting/index.ts +158 -0
- package/src/errorReporting/state.ts +104 -0
- package/src/i18n/locales/ar/errors.json +3 -1
- package/src/i18n/locales/de/errors.json +3 -1
- package/src/i18n/locales/en/errors.json +3 -1
- package/src/i18n/locales/es/errors.json +3 -1
- package/src/i18n/locales/fr/errors.json +3 -1
- package/src/i18n/locales/ja/errors.json +3 -1
- package/src/i18n/locales/ko/errors.json +3 -1
- package/src/i18n/locales/pt/errors.json +3 -1
- package/src/i18n/locales/ru/errors.json +3 -1
- package/src/i18n/locales/zh-Hans/errors.json +3 -1
- package/src/i18n/locales/zh-Hant/errors.json +3 -1
- package/src/index.ts +10 -0
- package/src/components/HkErrorBoundary.scss +0 -67
package/package.json
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "./scrim-fade" as sf;
|
|
2
|
+
|
|
1
3
|
.hk-drawer-overlay {
|
|
2
4
|
position: fixed;
|
|
3
5
|
inset: 0;
|
|
@@ -5,18 +7,15 @@
|
|
|
5
7
|
backdrop-filter: var(--hk-drawer-overlay-blur, blur(6px));
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.hk-drawer-overlay-leave-to {
|
|
18
|
-
opacity: 0;
|
|
19
|
-
}
|
|
10
|
+
// Overlay enter/leave — fade-only classes from the shared window-layer
|
|
11
|
+
// contract (./_scrim-fade.scss); the panel below owns all the motion.
|
|
12
|
+
@include sf.scrim-fade(
|
|
13
|
+
"hk-drawer-overlay",
|
|
14
|
+
$enter-duration: var(--hi-duration-normal, 0.3s),
|
|
15
|
+
$enter-ease: var(--hi-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
|
|
16
|
+
$leave-duration: var(--hi-duration-normal, 0.3s),
|
|
17
|
+
$leave-ease: var(--hi-ease-in, cubic-bezier(0.4, 0, 1, 1))
|
|
18
|
+
);
|
|
20
19
|
|
|
21
20
|
.hk-drawer-panel {
|
|
22
21
|
position: fixed;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkErrorBoundary from "./HkErrorBoundary";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mountWith(child: Component, props: Record<string, unknown> = {}) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({
|
|
12
|
+
render: () => h(HkErrorBoundary, props, { default: () => h(child) }),
|
|
13
|
+
});
|
|
14
|
+
app.config.errorHandler = () => {}; // Silence Vue's own duplicate logging.
|
|
15
|
+
app.mount(container);
|
|
16
|
+
mounts.push({ app, container });
|
|
17
|
+
return container;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const Boom = defineComponent({
|
|
21
|
+
name: "Boom",
|
|
22
|
+
setup() {
|
|
23
|
+
throw new TypeError("Cannot read properties of undefined (reading 'length')");
|
|
24
|
+
},
|
|
25
|
+
render: () => h("div", "never"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const Fine = defineComponent({
|
|
29
|
+
name: "Fine",
|
|
30
|
+
render: () => h("div", { class: "fine-child" }, "fine"),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const { app, container } of mounts) {
|
|
35
|
+
app.unmount();
|
|
36
|
+
container.remove();
|
|
37
|
+
}
|
|
38
|
+
mounts.length = 0;
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("HkErrorBoundary", () => {
|
|
43
|
+
it("renders children while nothing throws", () => {
|
|
44
|
+
const el = mountWith(Fine);
|
|
45
|
+
expect(el.querySelector(".fine-child")).not.toBeNull();
|
|
46
|
+
expect(el.querySelector(".hk-error-landing")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("captures a crash and renders the inline error landing", async () => {
|
|
50
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
51
|
+
const el = mountWith(Boom);
|
|
52
|
+
await nextTick();
|
|
53
|
+
|
|
54
|
+
const landing = el.querySelector(".hk-error-landing");
|
|
55
|
+
expect(landing).not.toBeNull();
|
|
56
|
+
expect(landing!.classList.contains("is-inline")).toBe(true);
|
|
57
|
+
expect(el.querySelector(".hk-error-landing__code")!.textContent).toBe("TypeError");
|
|
58
|
+
expect(el.querySelector(".hk-error-landing__desc")!.textContent)
|
|
59
|
+
.toBe("Cannot read properties of undefined (reading 'length')");
|
|
60
|
+
// Raw details ride in the JSON tree.
|
|
61
|
+
expect(el.querySelector(".s-tool-json-tree")).not.toBeNull();
|
|
62
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to the default headline when no title is given", async () => {
|
|
66
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
67
|
+
const el = mountWith(Boom);
|
|
68
|
+
await nextTick();
|
|
69
|
+
expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("Something went wrong");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("honours a custom title override", async () => {
|
|
73
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
74
|
+
const el = mountWith(Boom, { errorTitle: "发生了错误" });
|
|
75
|
+
await nextTick();
|
|
76
|
+
expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("发生了错误");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("recovers via the retry action", async () => {
|
|
80
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
81
|
+
const el = mountWith(Boom, { retryLabel: "重试" });
|
|
82
|
+
await nextTick();
|
|
83
|
+
|
|
84
|
+
const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>("button"));
|
|
85
|
+
const retry = buttons.find((b) => b.textContent === "重试");
|
|
86
|
+
expect(retry).toBeDefined();
|
|
87
|
+
retry!.click();
|
|
88
|
+
await nextTick();
|
|
89
|
+
// The slot rerender throws again, so the landing stays — but the error
|
|
90
|
+
// ref was cleared and recaptured (boundary proven by a fresh record).
|
|
91
|
+
expect(el.querySelector(".hk-error-landing")).not.toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps the custom fallback render prop contract", async () => {
|
|
95
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
96
|
+
const el = mountWith(Boom, {
|
|
97
|
+
fallback: (err: string, retry: () => void) => h("div", { class: "custom-fallback" }, [err.slice(0, 10), h("button", { onClick: retry }, "go")]),
|
|
98
|
+
});
|
|
99
|
+
await nextTick();
|
|
100
|
+
expect(el.querySelector(".custom-fallback")).not.toBeNull();
|
|
101
|
+
expect(el.querySelector(".hk-error-landing")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,14 +1,38 @@
|
|
|
1
|
-
import { Copy, RefreshCw
|
|
2
|
-
import { defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { Copy, RefreshCw } from "lucide-vue-next";
|
|
2
|
+
import { computed, defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
|
|
5
3
|
|
|
6
4
|
import { useClipboard } from "../runtime/useClipboard";
|
|
7
5
|
import { useI18n } from "../i18n/context";
|
|
8
6
|
import HButton from "./HkButton";
|
|
9
|
-
import
|
|
10
|
-
import "./
|
|
7
|
+
import { HkErrorLanding } from "./HkErrorLanding";
|
|
8
|
+
import { HkJsonTree } from "./HkJsonTree";
|
|
9
|
+
|
|
10
|
+
interface CapturedError {
|
|
11
|
+
name: string;
|
|
12
|
+
message: string;
|
|
13
|
+
stack: string;
|
|
14
|
+
}
|
|
11
15
|
|
|
16
|
+
function captureError(err: unknown): CapturedError {
|
|
17
|
+
if (err instanceof Error) {
|
|
18
|
+
return { name: err.name || "Error", message: err.message, stack: err.stack || "" };
|
|
19
|
+
}
|
|
20
|
+
return { name: "Error", message: String(err), stack: "" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatError(err: CapturedError): string {
|
|
24
|
+
return err.stack ? `${err.name}: ${err.message}\n\n${err.stack}` : `${err.name}: ${err.message}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HkErrorBoundary — inline crash guard rendering the shared error landing.
|
|
29
|
+
*
|
|
30
|
+
* Captures descendant errors via `onErrorCaptured` and stops propagation.
|
|
31
|
+
* The built-in fallback is the same HkErrorLanding card the family's
|
|
32
|
+
* full-page takeovers use (inline variant): tone icon, headline, the error
|
|
33
|
+
* name as the code chip, the message as the description, the raw
|
|
34
|
+
* name/message/stack in a collapsible JSON tree, plus retry / copy actions.
|
|
35
|
+
*/
|
|
12
36
|
export default defineComponent({
|
|
13
37
|
name: "HkErrorBoundary",
|
|
14
38
|
props: {
|
|
@@ -21,15 +45,12 @@ export default defineComponent({
|
|
|
21
45
|
setup(props, { slots }) {
|
|
22
46
|
const clipboard = useClipboard();
|
|
23
47
|
const { t } = useI18n();
|
|
24
|
-
const error = ref<
|
|
48
|
+
const error = ref<CapturedError | null>(null);
|
|
25
49
|
|
|
26
50
|
onErrorCaptured((err) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
: String(err);
|
|
31
|
-
console.error(`[ErrorBoundary:${props.name}]`, msg);
|
|
32
|
-
error.value = msg;
|
|
51
|
+
const captured = captureError(err);
|
|
52
|
+
console.error(`[ErrorBoundary:${props.name}]`, err);
|
|
53
|
+
error.value = captured;
|
|
33
54
|
return false;
|
|
34
55
|
});
|
|
35
56
|
|
|
@@ -37,48 +58,49 @@ export default defineComponent({
|
|
|
37
58
|
error.value = null;
|
|
38
59
|
}
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
const detailsValue = computed(() => {
|
|
62
|
+
if (!error.value) return undefined;
|
|
63
|
+
const record: Record<string, unknown> = {
|
|
64
|
+
name: error.value.name,
|
|
65
|
+
message: error.value.message,
|
|
66
|
+
};
|
|
67
|
+
if (error.value.stack) record.stack = error.value.stack;
|
|
68
|
+
if (props.name !== "unknown") record.boundary = props.name;
|
|
69
|
+
return record;
|
|
70
|
+
});
|
|
43
71
|
|
|
44
72
|
return () => {
|
|
45
73
|
if (error.value === null) {
|
|
46
74
|
return slots.default?.();
|
|
47
75
|
}
|
|
48
76
|
|
|
77
|
+
const err = error.value;
|
|
78
|
+
|
|
49
79
|
if (props.fallback) {
|
|
50
|
-
return props.fallback(
|
|
80
|
+
return props.fallback(formatError(err), retry);
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<div style={{ maxHeight: "12rem" }}>
|
|
65
|
-
<HScrollContainer>
|
|
66
|
-
{error.value}
|
|
67
|
-
</HScrollContainer>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
<div class="hk-error-boundary-actions">
|
|
71
|
-
<HButton variant="ghost" size="sm" onClick={copyError}>
|
|
72
|
-
<Copy size={12} />
|
|
73
|
-
{props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
|
|
74
|
-
</HButton>
|
|
75
|
-
<HButton variant="outline" size="sm" onClick={retry}>
|
|
84
|
+
<HkErrorLanding
|
|
85
|
+
variant="inline"
|
|
86
|
+
title={props.errorTitle || t("hikari::errors.defaultTitle", "Something went wrong")}
|
|
87
|
+
description={err.message}
|
|
88
|
+
code={err.name}
|
|
89
|
+
>
|
|
90
|
+
{{
|
|
91
|
+
default: () => <HkJsonTree value={detailsValue.value} ariaLabel="stack trace" />,
|
|
92
|
+
actions: () => [
|
|
93
|
+
<HButton variant="primary" size="sm" onClick={retry}>
|
|
76
94
|
<RefreshCw size={12} />
|
|
77
95
|
{props.retryLabel || t("hikari::errorBoundary.retry", "Retry")}
|
|
78
|
-
</HButton
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
</HButton>,
|
|
97
|
+
<HButton variant="ghost" size="sm" onClick={() => clipboard.copy(formatError(err))}>
|
|
98
|
+
<Copy size={12} />
|
|
99
|
+
{props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
|
|
100
|
+
</HButton>,
|
|
101
|
+
],
|
|
102
|
+
}}
|
|
103
|
+
</HkErrorLanding>
|
|
82
104
|
);
|
|
83
105
|
};
|
|
84
106
|
},
|
|
@@ -181,6 +181,15 @@
|
|
|
181
181
|
margin-top: var(--space-16, 1rem);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// Inline variant: the same card without the viewport takeover — used when
|
|
185
|
+
// HkErrorBoundary captures a crash inside a pane and swaps in the landing
|
|
186
|
+
// in-place instead of covering the whole screen.
|
|
187
|
+
.hk-error-landing.is-inline {
|
|
188
|
+
min-height: 0;
|
|
189
|
+
padding: var(--space-20, 1.25rem) var(--space-12, 0.75rem);
|
|
190
|
+
background: transparent;
|
|
191
|
+
}
|
|
192
|
+
|
|
184
193
|
@media (max-width: 480px) {
|
|
185
194
|
.hk-error-landing__card {
|
|
186
195
|
padding: 1.5rem 1.125rem;
|
|
@@ -11,6 +11,7 @@ interface MountOptions {
|
|
|
11
11
|
code?: string;
|
|
12
12
|
status?: number;
|
|
13
13
|
tone?: "error" | "warning" | "info";
|
|
14
|
+
variant?: "page" | "inline";
|
|
14
15
|
detailsOpen?: boolean;
|
|
15
16
|
details?: () => ReturnType<typeof h>;
|
|
16
17
|
actions?: () => ReturnType<typeof h>;
|
|
@@ -28,6 +29,7 @@ function mountLanding(opts: MountOptions = {}) {
|
|
|
28
29
|
code: opts.code ?? "",
|
|
29
30
|
status: opts.status,
|
|
30
31
|
tone: opts.tone ?? "error",
|
|
32
|
+
variant: opts.variant ?? "page",
|
|
31
33
|
detailsOpen: opts.detailsOpen ?? true,
|
|
32
34
|
}, {
|
|
33
35
|
...(opts.details ? { default: opts.details } : {}),
|
|
@@ -99,4 +101,10 @@ describe("HkErrorLanding", () => {
|
|
|
99
101
|
expect(el.querySelector(".fake-action")).not.toBeNull();
|
|
100
102
|
expect(el.querySelector(".fake-brand")).not.toBeNull();
|
|
101
103
|
});
|
|
104
|
+
|
|
105
|
+
it("applies the inline variant class without the page backdrop", () => {
|
|
106
|
+
const el = mountLanding({ variant: "inline", title: "Boom" });
|
|
107
|
+
const root = el.querySelector(".hk-error-landing")!;
|
|
108
|
+
expect(root.classList.contains("is-inline")).toBe(true);
|
|
109
|
+
});
|
|
102
110
|
});
|
|
@@ -7,6 +7,15 @@ import "./HkErrorLanding.scss";
|
|
|
7
7
|
/** Visual severity of the landing icon and accents. */
|
|
8
8
|
export type HErrorTone = "error" | "warning" | "info";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Layout variant of the landing.
|
|
12
|
+
* - `page` (default): owns a full-viewport backdrop — for overlays and
|
|
13
|
+
* standalone error pages.
|
|
14
|
+
* - `inline`: drops the backdrop and viewport height so the same card can
|
|
15
|
+
* live inside a pane captured by HkErrorBoundary.
|
|
16
|
+
*/
|
|
17
|
+
export type HErrorLandingVariant = "page" | "inline";
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* HkErrorLanding — the shared full-page error landing.
|
|
12
21
|
*
|
|
@@ -33,6 +42,8 @@ export const HkErrorLanding = defineComponent({
|
|
|
33
42
|
/** HTTP status chip, e.g. 400. */
|
|
34
43
|
status: { type: Number, default: undefined },
|
|
35
44
|
tone: { type: String as PropType<HErrorTone>, default: "error" },
|
|
45
|
+
/** Layout variant: `page` (viewport backdrop) or `inline` (in-flow card). */
|
|
46
|
+
variant: { type: String as PropType<HErrorLandingVariant>, default: "page" },
|
|
36
47
|
/** Initial expansion of the raw-details section. */
|
|
37
48
|
detailsOpen: { type: Boolean, default: true },
|
|
38
49
|
},
|
|
@@ -48,7 +59,7 @@ export const HkErrorLanding = defineComponent({
|
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
return () => (
|
|
51
|
-
<div class={`hk-error-landing is-${props.tone}`}>
|
|
62
|
+
<div class={`hk-error-landing is-${props.tone}${props.variant === "inline" ? " is-inline" : ""}`}>
|
|
52
63
|
<div class="hk-error-landing__card">
|
|
53
64
|
{slots.brand?.()}
|
|
54
65
|
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// colors, spacing, and typography. Component-specific overrides
|
|
7
7
|
// use the --hk-modal-* namespace.
|
|
8
8
|
|
|
9
|
+
@use "./scrim-fade" as sf;
|
|
10
|
+
|
|
9
11
|
// ------
|
|
10
12
|
// Root wrapper — positioned via popup-manager z-index
|
|
11
13
|
// ------
|
|
@@ -33,20 +35,16 @@
|
|
|
33
35
|
pointer-events: auto;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.hk-modal-overlay-enter-from,
|
|
47
|
-
.hk-modal-overlay-leave-to {
|
|
48
|
-
opacity: 0;
|
|
49
|
-
}
|
|
38
|
+
// Overlay enter/leave — fade-only classes emitted from the shared
|
|
39
|
+
// window-layer contract (./_scrim-fade.scss): the curtain dims the whole
|
|
40
|
+
// viewport in place, never sliding or scaling with the content frame.
|
|
41
|
+
@include sf.scrim-fade(
|
|
42
|
+
"hk-modal-overlay",
|
|
43
|
+
$enter-duration: var(--hk-modal-duration, 0.3s),
|
|
44
|
+
$enter-ease: var(--hk-modal-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
|
|
45
|
+
$leave-duration: var(--hk-modal-duration, 0.3s),
|
|
46
|
+
$leave-ease: var(--hk-modal-ease-in, cubic-bezier(0.4, 0, 1, 1))
|
|
47
|
+
);
|
|
50
48
|
|
|
51
49
|
// ------
|
|
52
50
|
// Content frame
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@use "./menu-item" as mi;
|
|
2
|
+
@use "./scrim-fade" as sf;
|
|
2
3
|
|
|
3
4
|
.hk-popover-backdrop {
|
|
4
5
|
position: fixed;
|
|
@@ -211,6 +212,12 @@
|
|
|
211
212
|
transform: translateY(100%);
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/* Sheet scrim fade — shared window-layer contract (./_scrim-fade.scss):
|
|
216
|
+
* opacity-only, own transition name, in place. The scrim used to mount
|
|
217
|
+
* and unmount bare, snapping the dim curtain on and off around the
|
|
218
|
+
* sliding panel. */
|
|
219
|
+
@include sf.scrim-fade("hk-popover-scrim");
|
|
220
|
+
|
|
214
221
|
@media (prefers-reduced-motion: reduce) {
|
|
215
222
|
.hk-popover-sheet-enter-active,
|
|
216
223
|
.hk-popover-sheet-leave-active {
|
|
@@ -101,8 +101,12 @@ export default defineComponent({
|
|
|
101
101
|
// Open/close motion reported into the unified animation context
|
|
102
102
|
// through the same standard hook wiring every surface shares
|
|
103
103
|
// (useSurfaceTransition) — this component was the pattern's
|
|
104
|
-
// origin and now consumes it like the rest.
|
|
105
|
-
|
|
104
|
+
// origin and now consumes it like the rest. Scrim and panel ride
|
|
105
|
+
// separate named tracks (window-layer contract, ./_scrim-fade.scss)
|
|
106
|
+
// so the backdrop's fade reports and settles on its own.
|
|
107
|
+
const surfaceAnim = useSurfaceTransition(300);
|
|
108
|
+
const anim = surfaceAnim.hooks();
|
|
109
|
+
const scrimAnim = surfaceAnim.hooks("scrim");
|
|
106
110
|
|
|
107
111
|
// Suppress native browser tooltips while the popover is open — and
|
|
108
112
|
// not just on the anchor itself: a `title` on ANY descendant fires
|
|
@@ -525,13 +529,20 @@ export default defineComponent({
|
|
|
525
529
|
|
|
526
530
|
return () => (
|
|
527
531
|
<Teleport to="body">
|
|
528
|
-
{
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
532
|
+
{/* Window-layer contract (./_scrim-fade.scss): the sheet scrim
|
|
533
|
+
fades in place under its own transition name — it used to
|
|
534
|
+
mount and unmount bare, snapping the dim curtain on and off
|
|
535
|
+
around the sliding panel. z rides the live registration,
|
|
536
|
+
which outlives the close until the leave fade finishes. */}
|
|
537
|
+
<Transition name="hk-popover-scrim" appear {...scrimAnim}>
|
|
538
|
+
{sheetMode.value && props.modelValue && props.closeOnBackdrop ? (
|
|
539
|
+
<div
|
|
540
|
+
class="hk-popover-scrim"
|
|
541
|
+
style={{ zIndex: backdropZ.value }}
|
|
542
|
+
onClick={() => close()}
|
|
543
|
+
/>
|
|
544
|
+
) : null}
|
|
545
|
+
</Transition>
|
|
535
546
|
{props.backdrop && !sheetMode.value && props.modelValue && (
|
|
536
547
|
<div
|
|
537
548
|
class="hk-popover-backdrop"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
@use "../tokens";
|
|
2
2
|
@use "./menu-item" as mi;
|
|
3
|
+
@use "./scrim-fade" as sf;
|
|
3
4
|
|
|
4
5
|
/* HkSelect — popup select component */
|
|
5
6
|
|
|
@@ -401,6 +402,13 @@
|
|
|
401
402
|
transform: translateY(100%);
|
|
402
403
|
}
|
|
403
404
|
|
|
405
|
+
/* Scrim fade — the shared window-layer contract (./_scrim-fade.scss):
|
|
406
|
+
* the dim curtain fades in place under its OWN transition name while the
|
|
407
|
+
* panel above slides. It once shared this `hk-select-sheet` name, so the
|
|
408
|
+
* panel's translateY(100%) enter pair dragged the backdrop up from the
|
|
409
|
+
* bottom edge on phones (2026-09-06 report). */
|
|
410
|
+
@include sf.scrim-fade("hk-select-sheet-scrim");
|
|
411
|
+
|
|
404
412
|
@media (prefers-reduced-motion: reduce) {
|
|
405
413
|
.hk-select-sheet-enter-active,
|
|
406
414
|
.hk-select-sheet-leave-active,
|
|
@@ -520,7 +520,12 @@ export default defineComponent({
|
|
|
520
520
|
if (sheetMode.value) {
|
|
521
521
|
return (
|
|
522
522
|
<Teleport to="body">
|
|
523
|
-
|
|
523
|
+
{/* Window-layer contract (./_scrim-fade.scss): the scrim fades
|
|
524
|
+
in place under its OWN transition name. It once shared the
|
|
525
|
+
panel's `hk-select-sheet` name, so the panel's
|
|
526
|
+
translateY(100%) enter pair slid the dim curtain up from
|
|
527
|
+
the bottom edge on phones (2026-09-06 report). */}
|
|
528
|
+
<Transition name="hk-select-sheet-scrim" appear {...sheetScrimAnim.hooks("scrim")}>
|
|
524
529
|
{props.open ? (
|
|
525
530
|
<div
|
|
526
531
|
class="hk-select-sheet-scrim"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// _scrim-fade.scss
|
|
2
|
+
//
|
|
3
|
+
// Window-layer animation contract — the one place every modal-context
|
|
4
|
+
// surface's dim curtain is defined (2026-09-06 user directive).
|
|
5
|
+
//
|
|
6
|
+
// Every windowed surface in hikari (HkModal, HkDrawer, the sheet-docked
|
|
7
|
+
// HkSelectPanel, the sheet-docked HkPopover) is TWO independent layers:
|
|
8
|
+
//
|
|
9
|
+
// overlay / scrim — position: fixed; inset: 0; painted one band below
|
|
10
|
+
// its panel. It renders inside its OWN <Transition> whose name ends
|
|
11
|
+
// in -overlay / -scrim and is NEVER the panel's transition name, and
|
|
12
|
+
// its classes animate OPACITY ONLY: the dim curtain appears over the
|
|
13
|
+
// whole viewport at once, in place — it never slides, scales, or
|
|
14
|
+
// clips, on any breakpoint.
|
|
15
|
+
// panel / content — its own <Transition> with the surface's pop/slide
|
|
16
|
+
// name (desktop: height-clip or anchored pop; phones: bottom-sheet
|
|
17
|
+
// translateY). Transforms live here and nowhere else.
|
|
18
|
+
//
|
|
19
|
+
// Why this file exists: the HkSelectPanel sheet scrim reused the panel's
|
|
20
|
+
// `hk-select-sheet` transition name, so the panel's enter-from pair
|
|
21
|
+
// (`transform: translateY(100%)`) applied to the backdrop too — on phones
|
|
22
|
+
// every second-level dropdown sheet dragged its dim curtain up from the
|
|
23
|
+
// bottom edge instead of fading it in globally. First-level modals were
|
|
24
|
+
// fine (their overlay already had a distinct name), which is what made
|
|
25
|
+
// the drift look deliberate.
|
|
26
|
+
//
|
|
27
|
+
// Surfaces generate their fade classes through `scrim-fade()` so the rule
|
|
28
|
+
// is structural, not per-file discipline. Durations and easings accept
|
|
29
|
+
// any CSS value including var() tokens; the defaults are the sheet family
|
|
30
|
+
// feel (0.25s out / 0.2s in, matching the panel opacity timings).
|
|
31
|
+
// Enforced by scrim-fade.contract.test.ts against the compiled css.
|
|
32
|
+
|
|
33
|
+
@mixin scrim-fade(
|
|
34
|
+
$name,
|
|
35
|
+
$enter-duration: 0.25s,
|
|
36
|
+
$enter-ease: ease-out,
|
|
37
|
+
$leave-duration: 0.2s,
|
|
38
|
+
$leave-ease: ease-in
|
|
39
|
+
) {
|
|
40
|
+
.#{$name}-enter-active {
|
|
41
|
+
transition: opacity $enter-duration $enter-ease;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.#{$name}-leave-active {
|
|
45
|
+
transition: opacity $leave-duration $leave-ease;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.#{$name}-enter-from,
|
|
49
|
+
.#{$name}-leave-to {
|
|
50
|
+
opacity: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (prefers-reduced-motion: reduce) {
|
|
54
|
+
.#{$name}-enter-active,
|
|
55
|
+
.#{$name}-leave-active {
|
|
56
|
+
transition: none;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the window-layer animation rules (2026-09-06 user
|
|
3
|
+
* directive). Every modal-context surface's dim curtain — the
|
|
4
|
+
* HkModal/HkDrawer overlay, the sheet scrim of HkSelectPanel and
|
|
5
|
+
* HkPopover — is an INDEPENDENT full-screen layer that fades in and out
|
|
6
|
+
* in place: opacity only, own transition name, never the panel's.
|
|
7
|
+
*
|
|
8
|
+
* The bug this pins shut: the HkSelectPanel sheet scrim reused the
|
|
9
|
+
* panel's `hk-select-sheet` transition name, so the panel's
|
|
10
|
+
* `translateY(100%)` enter pair applied to the backdrop too — on phones
|
|
11
|
+
* every second-level dropdown sheet dragged its dim curtain up from the
|
|
12
|
+
* bottom edge, while first-level modals (distinct overlay name) looked
|
|
13
|
+
* right.
|
|
14
|
+
*
|
|
15
|
+
* The classes are GENERATED by the shared mixin (_scrim-fade.scss), so
|
|
16
|
+
* this test compiles each surface stylesheet and pins the emitted css —
|
|
17
|
+
* it holds no matter how a surface authors its rules, and fails the
|
|
18
|
+
* moment a curtain regains a transform or a panel loses its slide.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import * as sass from "sass";
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const read = (f: string) => readFileSync(join(here, f), "utf-8");
|
|
28
|
+
|
|
29
|
+
const CURTAINS: Array<{ file: string; curtain: string }> = [
|
|
30
|
+
{ file: "HkModal.scss", curtain: "hk-modal-overlay" },
|
|
31
|
+
{ file: "HkDrawer.scss", curtain: "hk-drawer-overlay" },
|
|
32
|
+
{ file: "HkSelect.scss", curtain: "hk-select-sheet-scrim" },
|
|
33
|
+
{ file: "HkPopover.scss", curtain: "hk-popover-scrim" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** One compiled rule block for `<curtain>-<phase>`, or "" when absent. */
|
|
37
|
+
function rule(css: string, curtain: string, phase: string): string {
|
|
38
|
+
return css.match(new RegExp(`\\.${curtain}-${phase}[^{]*\\{[^}]*\\}`))?.[0] ?? "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("window-layer scrim fade contract", () => {
|
|
42
|
+
for (const { file, curtain } of CURTAINS) {
|
|
43
|
+
it(`${curtain} (${file}) fades in place — opacity only, no transform`, () => {
|
|
44
|
+
const css = sass.compile(join(here, file)).css;
|
|
45
|
+
for (const phase of ["enter-active", "leave-active"]) {
|
|
46
|
+
const block = rule(css, curtain, phase);
|
|
47
|
+
expect(block, `${curtain}-${phase} rule missing`).toContain("transition: opacity");
|
|
48
|
+
expect(block, `${curtain}-${phase} must not move the curtain`).not.toContain("transform");
|
|
49
|
+
}
|
|
50
|
+
for (const phase of ["enter-from", "leave-to"]) {
|
|
51
|
+
const block = rule(css, curtain, phase);
|
|
52
|
+
expect(block, `${curtain}-${phase} rule missing`).toContain("opacity: 0");
|
|
53
|
+
expect(block, `${curtain}-${phase} must not move the curtain`).not.toContain("transform");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it("the sheet PANELS keep their slide — the contract never flattens the panel", () => {
|
|
59
|
+
const select = sass.compile(join(here, "HkSelect.scss")).css;
|
|
60
|
+
expect(rule(select, "hk-select-sheet", "enter-from")).toContain("translateY(100%)");
|
|
61
|
+
const popover = sass.compile(join(here, "HkPopover.scss")).css;
|
|
62
|
+
expect(rule(popover, "hk-popover-sheet", "enter-from")).toContain("translateY(100%)");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("the select sheet scrim rides a transition name distinct from the panel's", () => {
|
|
66
|
+
const src = read("HkSelectPanel.tsx");
|
|
67
|
+
const names = Array.from(src.matchAll(/<Transition name="([^"]+)"/g), (m) => m[1]);
|
|
68
|
+
expect(names).toContain("hk-select-sheet-scrim");
|
|
69
|
+
expect(names).toContain("hk-select-sheet");
|
|
70
|
+
expect(src).toContain('sheetScrimAnim.hooks("scrim")');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("the popover sheet scrim fades instead of mounting bare", () => {
|
|
74
|
+
const src = read("HkPopover.tsx");
|
|
75
|
+
expect(src).toContain('<Transition name="hk-popover-scrim"');
|
|
76
|
+
expect(src).toContain('surfaceAnim.hooks("scrim")');
|
|
77
|
+
// The panel's sheet name stays the slide family — distinct from the scrim.
|
|
78
|
+
expect(src).toContain('"hk-popover-sheet"');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// HkErrorReportingOverlay — fixed full-viewport host for the error landing.
|
|
2
|
+
// Sits above every popup band (modal 1000 / dropdown 2000 / tooltip 3000 /
|
|
3
|
+
// toast 4000): an uncaught-error takeover must never be covered.
|
|
4
|
+
|
|
5
|
+
.hk-error-reporting-overlay {
|
|
6
|
+
position: fixed;
|
|
7
|
+
inset: 0;
|
|
8
|
+
z-index: 5000;
|
|
9
|
+
}
|