@celestia-island/hikari 0.36.0 → 0.37.0
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/HkErrorLanding.scss +179 -0
- package/src/components/HkErrorLanding.test.tsx +102 -0
- package/src/components/HkErrorLanding.tsx +92 -0
- package/src/components/HkJsonTree.scss +161 -0
- package/src/components/HkJsonTree.test.tsx +111 -0
- package/src/components/HkJsonTree.tsx +310 -0
- package/src/components/HkToolBlock.scss +20 -163
- package/src/components/HkToolBlock.tsx +3 -248
- package/src/i18n/locales/ar/errors.json +6 -0
- package/src/i18n/locales/de/errors.json +6 -0
- package/src/i18n/locales/en/errors.json +6 -0
- package/src/i18n/locales/es/errors.json +6 -0
- package/src/i18n/locales/fr/errors.json +6 -0
- package/src/i18n/locales/ja/errors.json +6 -0
- package/src/i18n/locales/ko/errors.json +6 -0
- package/src/i18n/locales/pt/errors.json +6 -0
- package/src/i18n/locales/ru/errors.json +6 -0
- package/src/i18n/locales/zh-Hans/errors.json +6 -0
- package/src/i18n/locales/zh-Hant/errors.json +6 -0
- package/src/index.ts +9 -2
package/package.json
CHANGED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// HkErrorLanding — shared error landing page.
|
|
2
|
+
//
|
|
3
|
+
// Token strategy: every color reads the host's RGB-triplet theme vars and
|
|
4
|
+
// falls back to a neutral light scheme, so the component renders correctly
|
|
5
|
+
// standalone (server error page without hikari theme init) and adapts to
|
|
6
|
+
// host themes (dark mode etc.) when the vars exist.
|
|
7
|
+
|
|
8
|
+
.hk-error-landing {
|
|
9
|
+
--hel-background: var(--color-background, 246 247 249);
|
|
10
|
+
--hel-surface: var(--color-surface, 255 255 255);
|
|
11
|
+
--hel-text: var(--color-text, 30 32 36);
|
|
12
|
+
--hel-muted: var(--color-muted, 118 122 128);
|
|
13
|
+
--hel-border: var(--color-border, 222 224 228);
|
|
14
|
+
--hel-primary: var(--color-primary, 58 118 236);
|
|
15
|
+
--hel-error: var(--color-error, 226 84 84);
|
|
16
|
+
--hel-warning: var(--color-warning, 222 168 62);
|
|
17
|
+
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
min-height: 100dvh;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
padding: var(--space-20, 1.25rem);
|
|
24
|
+
background: rgb(var(--hel-background));
|
|
25
|
+
color: rgb(var(--hel-text));
|
|
26
|
+
font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.hk-error-landing__card {
|
|
30
|
+
width: min(430px, 100%);
|
|
31
|
+
background: rgb(var(--hel-surface));
|
|
32
|
+
border: 1px solid rgb(var(--hel-border) / 70%);
|
|
33
|
+
border-radius: var(--radius-lg, 16px);
|
|
34
|
+
box-shadow: var(--shadow-lg, 0 18px 48px rgb(0 0 0 / 8%));
|
|
35
|
+
padding: 2rem 1.75rem;
|
|
36
|
+
text-align: center;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.hk-error-landing__brand {
|
|
40
|
+
display: flex;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
margin-bottom: var(--space-16, 1rem);
|
|
43
|
+
|
|
44
|
+
&:empty {
|
|
45
|
+
display: none;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hk-error-landing__icon {
|
|
50
|
+
width: 58px;
|
|
51
|
+
height: 58px;
|
|
52
|
+
margin: 0 auto var(--space-16, 1rem);
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
border-radius: 50%;
|
|
57
|
+
|
|
58
|
+
&,
|
|
59
|
+
svg {
|
|
60
|
+
color: rgb(var(--hel-error));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
background: rgb(var(--hel-error) / 12%);
|
|
64
|
+
border: 1px solid rgb(var(--hel-error) / 22%);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.hk-error-landing.is-warning .hk-error-landing__icon {
|
|
68
|
+
&,
|
|
69
|
+
svg {
|
|
70
|
+
color: rgb(var(--hel-warning));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
background: rgb(var(--hel-warning) / 12%);
|
|
74
|
+
border-color: rgb(var(--hel-warning) / 24%);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.hk-error-landing.is-info .hk-error-landing__icon {
|
|
78
|
+
&,
|
|
79
|
+
svg {
|
|
80
|
+
color: rgb(var(--hel-primary));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
background: rgb(var(--hel-primary) / 10%);
|
|
84
|
+
border-color: rgb(var(--hel-primary) / 20%);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.hk-error-landing__title {
|
|
88
|
+
margin: 0;
|
|
89
|
+
font-size: var(--text-lg, 1.125rem);
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
line-height: 1.35;
|
|
92
|
+
color: rgb(var(--hel-text));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.hk-error-landing__desc {
|
|
96
|
+
margin: var(--space-8, 0.5rem) 0 0;
|
|
97
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
98
|
+
line-height: 1.6;
|
|
99
|
+
color: rgb(var(--hel-muted));
|
|
100
|
+
white-space: pre-line;
|
|
101
|
+
overflow-wrap: anywhere;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.hk-error-landing__meta {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
flex-wrap: wrap;
|
|
109
|
+
gap: var(--space-6, 0.375rem);
|
|
110
|
+
margin-top: var(--space-10, 0.625rem);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.hk-error-landing__code {
|
|
114
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
115
|
+
font-size: var(--text-xs, 0.75rem);
|
|
116
|
+
color: rgb(var(--hel-muted));
|
|
117
|
+
background: rgb(var(--hel-muted) / 8%);
|
|
118
|
+
border: 1px solid rgb(var(--hel-border) / 80%);
|
|
119
|
+
border-radius: var(--radius-sm, 4px);
|
|
120
|
+
padding: 0.1em 0.55em;
|
|
121
|
+
overflow-wrap: anywhere;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.hk-error-landing__status {
|
|
125
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
126
|
+
font-size: var(--text-xs, 0.75rem);
|
|
127
|
+
color: rgb(var(--hel-muted));
|
|
128
|
+
border: 1px dashed rgb(var(--hel-border));
|
|
129
|
+
border-radius: var(--radius-sm, 4px);
|
|
130
|
+
padding: 0.1em 0.55em;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.hk-error-landing__details {
|
|
134
|
+
margin-top: var(--space-16, 1rem);
|
|
135
|
+
text-align: left;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.hk-error-landing__details-toggle {
|
|
139
|
+
display: inline-flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: 4px;
|
|
142
|
+
border: none;
|
|
143
|
+
background: transparent;
|
|
144
|
+
padding: 2px 4px;
|
|
145
|
+
font: inherit;
|
|
146
|
+
font-size: var(--text-xs, 0.75rem);
|
|
147
|
+
color: rgb(var(--hel-muted));
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
border-radius: var(--radius-xs, 4px);
|
|
150
|
+
|
|
151
|
+
&:hover {
|
|
152
|
+
color: rgb(var(--hel-text));
|
|
153
|
+
background: rgb(var(--hel-muted) / 8%);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
&:focus-visible {
|
|
157
|
+
outline: 2px solid rgb(var(--hel-primary) / 60%);
|
|
158
|
+
outline-offset: 1px;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.hk-error-landing__details-body {
|
|
163
|
+
margin-top: var(--space-6, 0.375rem);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.hk-error-landing__actions {
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: center;
|
|
170
|
+
flex-wrap: wrap;
|
|
171
|
+
gap: var(--space-8, 0.5rem);
|
|
172
|
+
margin-top: var(--space-16, 1rem);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@media (max-width: 480px) {
|
|
176
|
+
.hk-error-landing__card {
|
|
177
|
+
padding: 1.5rem 1.125rem;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkErrorLanding } from "./HkErrorLanding";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
interface MountOptions {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
code?: string;
|
|
12
|
+
status?: number;
|
|
13
|
+
tone?: "error" | "warning" | "info";
|
|
14
|
+
detailsOpen?: boolean;
|
|
15
|
+
details?: () => ReturnType<typeof h>;
|
|
16
|
+
actions?: () => ReturnType<typeof h>;
|
|
17
|
+
brand?: () => ReturnType<typeof h>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mountLanding(opts: MountOptions = {}) {
|
|
21
|
+
const container = document.createElement("div");
|
|
22
|
+
document.body.appendChild(container);
|
|
23
|
+
const app = createApp({
|
|
24
|
+
render: () =>
|
|
25
|
+
h(HkErrorLanding, {
|
|
26
|
+
title: opts.title ?? "",
|
|
27
|
+
description: opts.description ?? "",
|
|
28
|
+
code: opts.code ?? "",
|
|
29
|
+
status: opts.status,
|
|
30
|
+
tone: opts.tone ?? "error",
|
|
31
|
+
detailsOpen: opts.detailsOpen ?? true,
|
|
32
|
+
}, {
|
|
33
|
+
...(opts.details ? { default: opts.details } : {}),
|
|
34
|
+
...(opts.actions ? { actions: opts.actions } : {}),
|
|
35
|
+
...(opts.brand ? { brand: opts.brand } : {}),
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
app.mount(container);
|
|
39
|
+
mounts.push({ app, container });
|
|
40
|
+
return container;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
for (const { app, container } of mounts) {
|
|
45
|
+
app.unmount();
|
|
46
|
+
container.remove();
|
|
47
|
+
}
|
|
48
|
+
mounts.length = 0;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("HkErrorLanding", () => {
|
|
52
|
+
it("renders the fallback title when none is given", () => {
|
|
53
|
+
const el = mountLanding({});
|
|
54
|
+
expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("Something went wrong");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders the given title, description, and meta chips", () => {
|
|
58
|
+
const el = mountLanding({ title: "OAuth failed", description: "line1\nline2", code: "unknown_provider", status: 400 });
|
|
59
|
+
expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("OAuth failed");
|
|
60
|
+
expect(el.querySelector(".hk-error-landing__code")!.textContent).toBe("unknown_provider");
|
|
61
|
+
expect(el.querySelector(".hk-error-landing__status")!.textContent).toBe("HTTP 400");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("omits meta chips when neither code nor status is set", () => {
|
|
65
|
+
const el = mountLanding({ title: "Boom" });
|
|
66
|
+
expect(el.querySelector(".hk-error-landing__meta")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders the details slot with the raw JSON tree pane and toggles it", async () => {
|
|
70
|
+
const el = mountLanding({
|
|
71
|
+
details: () => h("pre", { class: "s-tool-json-tree" }, "raw"),
|
|
72
|
+
});
|
|
73
|
+
const toggle = el.querySelector<HTMLButtonElement>(".hk-error-landing__details-toggle")!;
|
|
74
|
+
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
|
75
|
+
expect(el.querySelector(".hk-error-landing__details-body")).not.toBeNull();
|
|
76
|
+
toggle.click();
|
|
77
|
+
await nextTick();
|
|
78
|
+
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
|
79
|
+
expect(el.querySelector(".hk-error-landing__details-body")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps details collapsed when detailsOpen is false", () => {
|
|
83
|
+
const el = mountLanding({ detailsOpen: false, details: () => h("pre", {}, "raw") });
|
|
84
|
+
expect(el.querySelector(".hk-error-landing__details-body")).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders no details section without a default slot", () => {
|
|
88
|
+
const el = mountLanding({});
|
|
89
|
+
expect(el.querySelector(".hk-error-landing__details")).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renders action and brand slots and applies the tone class", () => {
|
|
93
|
+
const el = mountLanding({
|
|
94
|
+
tone: "warning",
|
|
95
|
+
actions: () => h("button", { class: "fake-action" }, "Back"),
|
|
96
|
+
brand: () => h("div", { class: "fake-brand" }, "brand"),
|
|
97
|
+
});
|
|
98
|
+
expect(el.querySelector(".hk-error-landing")!.classList.contains("is-warning")).toBe(true);
|
|
99
|
+
expect(el.querySelector(".fake-action")).not.toBeNull();
|
|
100
|
+
expect(el.querySelector(".fake-brand")).not.toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
import { ChevronDown, ChevronRight, Info, TriangleAlert } from "lucide-vue-next";
|
|
3
|
+
import { useI18n } from "../i18n/context";
|
|
4
|
+
|
|
5
|
+
import "./HkErrorLanding.scss";
|
|
6
|
+
|
|
7
|
+
/** Visual severity of the landing icon and accents. */
|
|
8
|
+
export type HErrorTone = "error" | "warning" | "info";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HkErrorLanding — the shared full-page error landing.
|
|
12
|
+
*
|
|
13
|
+
* Login-page-like layout: a centered card over a full-viewport backdrop,
|
|
14
|
+
* carrying a tone icon, a (pre-translated) title and description, the wire
|
|
15
|
+
* error code / HTTP status as meta chips, a collapsible raw-details section
|
|
16
|
+
* (the default slot — hosts render HkJsonTree there) and an actions slot.
|
|
17
|
+
*
|
|
18
|
+
* The component is presentation-only and route-agnostic: it never touches
|
|
19
|
+
* the router and can be mounted by an SPA overlay, a modal, or a standalone
|
|
20
|
+
* server-rendered error page alike. All host-facing copy (`title`,
|
|
21
|
+
* `description`, action buttons) arrives pre-translated; the component only
|
|
22
|
+
* translates its own two labels via `hikari::errors.*`.
|
|
23
|
+
*/
|
|
24
|
+
export const HkErrorLanding = defineComponent({
|
|
25
|
+
name: "HkErrorLanding",
|
|
26
|
+
props: {
|
|
27
|
+
/** Pre-translated headline. Falls back to `hikari::errors.defaultTitle`. */
|
|
28
|
+
title: { type: String, default: "" },
|
|
29
|
+
/** Pre-translated secondary text; newlines render as line breaks. */
|
|
30
|
+
description: { type: String, default: "" },
|
|
31
|
+
/** Wire error code chip, e.g. `unknown_provider`. */
|
|
32
|
+
code: { type: String, default: "" },
|
|
33
|
+
/** HTTP status chip, e.g. 400. */
|
|
34
|
+
status: { type: Number, default: undefined },
|
|
35
|
+
tone: { type: String as PropType<HErrorTone>, default: "error" },
|
|
36
|
+
/** Initial expansion of the raw-details section. */
|
|
37
|
+
detailsOpen: { type: Boolean, default: true },
|
|
38
|
+
},
|
|
39
|
+
setup(props, { slots }) {
|
|
40
|
+
const { t } = useI18n();
|
|
41
|
+
const detailsExpanded = ref(props.detailsOpen);
|
|
42
|
+
|
|
43
|
+
const titleText = computed(() => props.title || t("hikari::errors.defaultTitle", "Something went wrong"));
|
|
44
|
+
const hasDetails = computed(() => slots.default != null);
|
|
45
|
+
|
|
46
|
+
function toggleDetails() {
|
|
47
|
+
detailsExpanded.value = !detailsExpanded.value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return () => (
|
|
51
|
+
<div class={`hk-error-landing is-${props.tone}`}>
|
|
52
|
+
<div class="hk-error-landing__card">
|
|
53
|
+
{slots.brand?.()}
|
|
54
|
+
|
|
55
|
+
<div class="hk-error-landing__icon" aria-hidden="true">
|
|
56
|
+
{props.tone === "info" ? <Info size={26} /> : <TriangleAlert size={26} />}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<h1 class="hk-error-landing__title">{titleText.value}</h1>
|
|
60
|
+
|
|
61
|
+
{props.description && <p class="hk-error-landing__desc">{props.description}</p>}
|
|
62
|
+
|
|
63
|
+
{(props.code || props.status != null) && (
|
|
64
|
+
<div class="hk-error-landing__meta">
|
|
65
|
+
{props.code && <code class="hk-error-landing__code">{props.code}</code>}
|
|
66
|
+
{props.status != null && <span class="hk-error-landing__status">HTTP {props.status}</span>}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{hasDetails.value && (
|
|
71
|
+
<div class="hk-error-landing__details">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
class="hk-error-landing__details-toggle"
|
|
75
|
+
aria-expanded={detailsExpanded.value}
|
|
76
|
+
onClick={toggleDetails}
|
|
77
|
+
>
|
|
78
|
+
{detailsExpanded.value ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
79
|
+
<span>{t("hikari::errors.rawDetails", "Raw error details")}</span>
|
|
80
|
+
</button>
|
|
81
|
+
{detailsExpanded.value && (
|
|
82
|
+
<div class="hk-error-landing__details-body">{slots.default?.()}</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{slots.actions && <div class="hk-error-landing__actions">{slots.actions()}</div>}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// HkJsonTree — the interactive JSON tree pane.
|
|
2
|
+
// Class names are the legacy `s-jt-*` / `s-jv-*` set shared verbatim with
|
|
3
|
+
// HkToolBlock (the tree was extracted from it); hosts may target
|
|
4
|
+
// `.s-tool-json-tree` for pane-level chrome such as overlay scrollbars.
|
|
5
|
+
|
|
6
|
+
// Use theme variables with namespace to avoid conflicts
|
|
7
|
+
@use "./hikari-vars" as vars;
|
|
8
|
+
|
|
9
|
+
.s-tool-json-tree {
|
|
10
|
+
--jt-toggle: 14px;
|
|
11
|
+
margin: var(--space-4, 0.25rem) 0;
|
|
12
|
+
padding: var(--space-6, 0.375rem);
|
|
13
|
+
font-family: var(--font-mono, vars.$hikari-font-family-mono);
|
|
14
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
15
|
+
line-height: 1.55;
|
|
16
|
+
background: rgb(var(--color-surface) / 0.5);
|
|
17
|
+
border-radius: var(--radius-sm, 4px);
|
|
18
|
+
border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
|
|
19
|
+
user-select: none;
|
|
20
|
+
max-height: 320px;
|
|
21
|
+
overflow: auto;
|
|
22
|
+
|
|
23
|
+
/* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
|
|
24
|
+
hidden, never styled. */
|
|
25
|
+
scrollbar-width: none;
|
|
26
|
+
|
|
27
|
+
&::-webkit-scrollbar {
|
|
28
|
+
display: none;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.s-jt-row {
|
|
33
|
+
/* Baseline alignment keeps the small type hints (boolean/number/string)
|
|
34
|
+
on the value's text baseline instead of floating above it. */
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: baseline;
|
|
37
|
+
min-height: 1.55em;
|
|
38
|
+
padding: 0 var(--space-6, 0.375rem);
|
|
39
|
+
border-radius: var(--radius-xs, 2px);
|
|
40
|
+
cursor: default;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.s-jt-row[data-parent] {
|
|
44
|
+
cursor: pointer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.s-jt-row:hover {
|
|
48
|
+
background: rgb(var(--color-primary) / 4%);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.s-jt-toggle {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
width: var(--jt-toggle);
|
|
56
|
+
height: var(--jt-toggle);
|
|
57
|
+
flex-shrink: 0;
|
|
58
|
+
margin-right: 2px;
|
|
59
|
+
/* The toggle box has no text baseline of its own — pin it to the line
|
|
60
|
+
top instead of letting it join the row's baseline group with a
|
|
61
|
+
synthesized border-box baseline. */
|
|
62
|
+
align-self: flex-start;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.s-jt-toggle[data-parent] {
|
|
66
|
+
color: rgb(var(--color-muted));
|
|
67
|
+
border-radius: var(--radius-xs, 2px);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.s-jt-leaf {
|
|
71
|
+
display: block;
|
|
72
|
+
width: 4px;
|
|
73
|
+
height: 4px;
|
|
74
|
+
border-radius: 50%;
|
|
75
|
+
background: rgb(var(--color-muted) / 25%);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.s-jt-group {
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.s-jt-children {
|
|
84
|
+
position: relative;
|
|
85
|
+
|
|
86
|
+
&::before {
|
|
87
|
+
content: "";
|
|
88
|
+
position: absolute;
|
|
89
|
+
left: var(--guide-left, 11px);
|
|
90
|
+
top: 0;
|
|
91
|
+
bottom: 2px;
|
|
92
|
+
width: 1px;
|
|
93
|
+
background: var(--border-faint, rgb(var(--color-border) / 10%));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.s-jt-key {
|
|
98
|
+
color: rgb(var(--color-primary));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.s-jt-colon {
|
|
102
|
+
color: rgb(var(--color-muted));
|
|
103
|
+
margin-right: 0.35em;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.s-jv-null {
|
|
107
|
+
color: rgb(var(--color-muted));
|
|
108
|
+
font-style: italic;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.s-jv-str {
|
|
112
|
+
color: #98c379;
|
|
113
|
+
word-break: break-all;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.s-jv-num {
|
|
117
|
+
color: #d19a66;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.s-jv-bool {
|
|
121
|
+
color: #c678dd;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.s-jv-str-raw {
|
|
125
|
+
color: #98c379;
|
|
126
|
+
white-space: pre-wrap;
|
|
127
|
+
word-break: break-all;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.s-jt-type {
|
|
131
|
+
color: rgb(var(--color-muted) / 0.6);
|
|
132
|
+
font-size: var(--text-3xs, 0.5rem);
|
|
133
|
+
font-style: italic;
|
|
134
|
+
margin-left: 0.75em;
|
|
135
|
+
font-weight: 400;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.s-jt-badge {
|
|
139
|
+
color: rgb(var(--color-muted));
|
|
140
|
+
font-size: var(--text-3xs, 0.5rem);
|
|
141
|
+
font-weight: 500;
|
|
142
|
+
background: rgb(var(--color-surface) / 0.6);
|
|
143
|
+
border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
|
|
144
|
+
border-radius: var(--radius-sm, 4px);
|
|
145
|
+
padding: 0 0.3em;
|
|
146
|
+
line-height: 1.4;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.s-jt-badge.is-open {
|
|
150
|
+
color: rgb(var(--color-muted));
|
|
151
|
+
background: transparent;
|
|
152
|
+
border-color: transparent;
|
|
153
|
+
font-weight: 400;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.s-jt-preview {
|
|
157
|
+
color: rgb(var(--color-muted));
|
|
158
|
+
font-size: var(--text-3xs, 0.5rem);
|
|
159
|
+
font-weight: 400;
|
|
160
|
+
opacity: 0.7;
|
|
161
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkJsonTree, buildJsonTree, tryParseJson } from "./HkJsonTree";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mountTree(props: Record<string, unknown>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => h(HkJsonTree, props) });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
mounts.length = 0;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("tryParseJson", () => {
|
|
26
|
+
it("parses objects and arrays but rejects primitives", () => {
|
|
27
|
+
expect(tryParseJson('{"a":1}')).toEqual({ a: 1 });
|
|
28
|
+
expect(tryParseJson("[1,2]")).toEqual([1, 2]);
|
|
29
|
+
expect(tryParseJson("42")).toBeNull();
|
|
30
|
+
expect(tryParseJson('"str"')).toBeNull();
|
|
31
|
+
expect(tryParseJson("not json")).toBeNull();
|
|
32
|
+
expect(tryParseJson("")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("buildJsonTree", () => {
|
|
37
|
+
it("builds nested nodes with stable unique ids", () => {
|
|
38
|
+
const root = buildJsonTree({ a: 1, b: { c: "x" } });
|
|
39
|
+
expect(root).not.toBeNull();
|
|
40
|
+
expect(root!.childCount).toBe(2);
|
|
41
|
+
expect(root!.children.map(c => c.key)).toEqual(["a", "b"]);
|
|
42
|
+
const ids = [root!.id, ...root!.children.flatMap(c => [c.id, ...c.children.map(g => g.id)])];
|
|
43
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null for non-containers", () => {
|
|
47
|
+
expect(buildJsonTree("text")).toBeNull();
|
|
48
|
+
expect(buildJsonTree(null)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("respects maxDepth when building children", () => {
|
|
52
|
+
const root = buildJsonTree({ a: { b: { c: 1 } } }, 1);
|
|
53
|
+
expect(root!.children[0]!.children).toHaveLength(0);
|
|
54
|
+
expect(root!.children[0]!.childCount).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("HkJsonTree component", () => {
|
|
59
|
+
it("renders rows for keys and primitive values from text", () => {
|
|
60
|
+
const el = mountTree({ text: '{"name":"chest","tries":3}' });
|
|
61
|
+
const rows = el.querySelectorAll(".s-jt-row");
|
|
62
|
+
expect(rows.length).toBe(3);
|
|
63
|
+
expect(el.querySelector(".s-jt-key")!.textContent).toBe("name");
|
|
64
|
+
expect(el.querySelector(".s-jv-str")!.textContent).toBe('"chest"');
|
|
65
|
+
expect(el.querySelector(".s-jv-num")!.textContent).toBe("3");
|
|
66
|
+
expect(el.querySelectorAll(".s-jt-type").length).toBeGreaterThan(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders nothing for non-container input", () => {
|
|
70
|
+
const el = mountTree({ text: '"just a string"' });
|
|
71
|
+
expect(el.querySelector(".s-tool-json-tree")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("toggles containers open and closed on row clicks", async () => {
|
|
75
|
+
const el = mountTree({ text: '{"cfg":{"retries":2}}' });
|
|
76
|
+
// Root + first-level children start expanded: the leaf row is visible.
|
|
77
|
+
expect(el.querySelector(".s-jv-num")!.textContent).toBe("2");
|
|
78
|
+
// Collapse the root container.
|
|
79
|
+
const rootRow = el.querySelector('.s-jt-row[data-parent]')!;
|
|
80
|
+
(rootRow as HTMLElement).click();
|
|
81
|
+
await nextTick();
|
|
82
|
+
// Closed container shows the inline preview instead of children.
|
|
83
|
+
expect(el.querySelector(".s-jt-preview")).not.toBeNull();
|
|
84
|
+
expect(el.querySelector(".s-jv-num")).toBeNull();
|
|
85
|
+
// Re-open restores the child rows.
|
|
86
|
+
(rootRow as HTMLElement).click();
|
|
87
|
+
await nextTick();
|
|
88
|
+
expect(el.querySelector(".s-jv-num")!.textContent).toBe("2");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("collapses and re-expands long strings via row clicks", async () => {
|
|
92
|
+
const long = Array.from({ length: 15 }, (_, i) => `line-${i}`).join("\n");
|
|
93
|
+
const el = mountTree({ text: JSON.stringify({ log: long }) });
|
|
94
|
+
// Direct children of the root start expanded: raw per-line rows show.
|
|
95
|
+
expect(el.querySelectorAll(".s-jv-str-raw")).toHaveLength(15);
|
|
96
|
+
// data-parent rows: [0] is the root container, [1] the long string.
|
|
97
|
+
const row = el.querySelectorAll(".s-jt-row[data-parent]")[1]!;
|
|
98
|
+
(row as HTMLElement).click();
|
|
99
|
+
await nextTick();
|
|
100
|
+
expect(el.querySelectorAll(".s-jv-str-raw")).toHaveLength(0);
|
|
101
|
+
(row as HTMLElement).click();
|
|
102
|
+
await nextTick();
|
|
103
|
+
expect(el.querySelectorAll(".s-jv-str-raw")).toHaveLength(15);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("prefers the value prop over text", () => {
|
|
107
|
+
const el = mountTree({ value: { from: "value" }, text: '{"from":"text"}' });
|
|
108
|
+
expect(el.querySelector(".s-jt-key")!.textContent).toBe("from");
|
|
109
|
+
expect(el.querySelector(".s-jv-str")!.textContent).toBe('"value"');
|
|
110
|
+
});
|
|
111
|
+
});
|