@celestia-island/hikari 0.45.9 → 0.47.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/DemoApp.tsx +37 -1
- package/src/components/HkAuthMethodList.scss +13 -39
- package/src/components/HkAuthMethodList.test.tsx +55 -73
- package/src/components/HkAuthMethodList.tsx +40 -149
- package/src/components/HkIconButtonGroup.scss +104 -0
- package/src/components/HkIconButtonGroup.test.tsx +182 -0
- package/src/components/HkIconButtonGroup.tsx +152 -0
- package/src/components/HkTooltip.tsx +7 -42
- package/src/composables/useConnectionProbe.ts +19 -0
- package/src/composables/useProgressDialog.ts +24 -0
- package/src/errorReporting/state.ts +30 -0
- package/src/i18n/context.ts +32 -0
- package/src/index.ts +21 -0
- package/src/runtime/animationBus.ts +42 -1
- package/src/runtime/cronBus.ts +18 -0
- package/src/runtime/historySafetyNet.ts +39 -0
- package/src/runtime/imageFallback.scss +13 -0
- package/src/runtime/imageFallback.test.ts +149 -0
- package/src/runtime/imageFallback.ts +149 -0
- package/src/runtime/index.ts +31 -0
- package/src/runtime/intervalBus.ts +18 -0
- package/src/runtime/navigationGuard.ts +20 -0
- package/src/runtime/pageLifecycle.ts +21 -1
- package/src/runtime/registry.integration.test.ts +309 -0
- package/src/runtime/registry.test.ts +279 -0
- package/src/runtime/registry.ts +315 -0
- package/src/runtime/tooltipBridge.test.ts +213 -0
- package/src/runtime/tooltipBridge.ts +314 -0
- package/src/runtime/tooltipPosition.test.ts +41 -0
- package/src/runtime/tooltipPosition.ts +61 -0
- package/src/runtime/useBlockingToast.ts +16 -0
- package/src/runtime/useOverlay.ts +27 -1
- package/src/runtime/usePopupManager.ts +32 -0
- package/src/runtime/useToast.ts +22 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkIconButtonGroup from "./HkIconButtonGroup";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkIconButtonGroup contract tests:
|
|
8
|
+
* - "buttons" mode: no selection state, select fires on every click
|
|
9
|
+
* - "single" mode: radiogroup semantics — v-model update + data-active,
|
|
10
|
+
* no re-fire on the already-active key
|
|
11
|
+
* - "multiple" mode: toggle set in v-model, aria-pressed mirrors state
|
|
12
|
+
* - tooltips: every labeled item rides an HkTooltip wrapper (label text),
|
|
13
|
+
* an explicit `tooltip: ""` suppresses it
|
|
14
|
+
* - disabled items / disabled group stay dead
|
|
15
|
+
* - extra attrs (aria-label) fall through onto the root
|
|
16
|
+
*
|
|
17
|
+
* House style: no @vue/test-utils dependency — raw createApp mounts on a
|
|
18
|
+
* shared container list torn down after each case.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const mounts: Array<ReturnType<typeof createApp>> = [];
|
|
22
|
+
|
|
23
|
+
function mountComp(render: () => ReturnType<typeof h>): HTMLElement {
|
|
24
|
+
const container = document.createElement("div");
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
const app = createApp(defineComponent({ setup: () => render }));
|
|
27
|
+
mounts.push(app);
|
|
28
|
+
app.mount(container);
|
|
29
|
+
return container;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
34
|
+
for (const popup of [...document.querySelectorAll(".hk-tooltip-popup")]) popup.remove();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const options = [
|
|
38
|
+
{ key: "totp", label: "验证器 App" },
|
|
39
|
+
{ key: "passkey", label: "Passkey" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
describe("HkIconButtonGroup", () => {
|
|
43
|
+
it("renders plain action buttons in buttons mode and emits select on every click", async () => {
|
|
44
|
+
const picked: string[] = [];
|
|
45
|
+
const c = mountComp(() =>
|
|
46
|
+
h(HkIconButtonGroup, { options, onSelect: (k: string) => picked.push(k) }),
|
|
47
|
+
);
|
|
48
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
49
|
+
expect(group.getAttribute("role")).toBe("group");
|
|
50
|
+
expect(group.classList.contains("hk-icon-group-buttons")).toBe(true);
|
|
51
|
+
const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
|
|
52
|
+
expect(buttons.length).toBe(2);
|
|
53
|
+
expect(group.querySelectorAll(".hk-tooltip-wrapper").length).toBe(2);
|
|
54
|
+
buttons[0]!.click();
|
|
55
|
+
await Promise.resolve();
|
|
56
|
+
buttons[0]!.click();
|
|
57
|
+
await Promise.resolve();
|
|
58
|
+
expect(picked).toEqual(["totp", "totp"]);
|
|
59
|
+
// No selection state exists in buttons mode.
|
|
60
|
+
expect(c.querySelectorAll("[data-active]").length).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("updates v-model and marks the active item in single mode", async () => {
|
|
64
|
+
const model = ref<string | null>(null);
|
|
65
|
+
const c = mountComp(() =>
|
|
66
|
+
h(HkIconButtonGroup, {
|
|
67
|
+
options,
|
|
68
|
+
mode: "single",
|
|
69
|
+
modelValue: model.value,
|
|
70
|
+
"onUpdate:modelValue": (v: string | string[]) => {
|
|
71
|
+
model.value = v as string;
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
76
|
+
expect(group.getAttribute("role")).toBe("radiogroup");
|
|
77
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
78
|
+
expect(buttons[0]!.getAttribute("role")).toBe("radio");
|
|
79
|
+
expect(buttons[0]!.getAttribute("aria-checked")).toBe("false");
|
|
80
|
+
buttons[0]!.click();
|
|
81
|
+
await nextTick();
|
|
82
|
+
expect(model.value).toBe("totp");
|
|
83
|
+
expect(buttons[0]!.dataset.active).toBe("true");
|
|
84
|
+
expect(buttons[0]!.getAttribute("aria-checked")).toBe("true");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not re-fire when the active key is re-clicked in single mode", async () => {
|
|
88
|
+
const events: string[] = [];
|
|
89
|
+
const c = mountComp(() =>
|
|
90
|
+
h(HkIconButtonGroup, {
|
|
91
|
+
options,
|
|
92
|
+
mode: "single",
|
|
93
|
+
modelValue: "totp",
|
|
94
|
+
"onUpdate:modelValue": () => events.push("update"),
|
|
95
|
+
onSelect: (k: string) => events.push(`select:${k}`),
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const button = c.querySelector<HTMLButtonElement>(".hk-icon-group-item")!;
|
|
99
|
+
expect(button.dataset.active).toBe("true");
|
|
100
|
+
button.click();
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
expect(events).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("toggles keys into and out of the v-model array in multiple mode", async () => {
|
|
106
|
+
const seen: Array<string | string[]> = [];
|
|
107
|
+
const c = mountComp(() =>
|
|
108
|
+
h(HkIconButtonGroup, {
|
|
109
|
+
options,
|
|
110
|
+
mode: "multiple",
|
|
111
|
+
modelValue: ["passkey"],
|
|
112
|
+
"onUpdate:modelValue": (v: string | string[]) => seen.push(v),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
116
|
+
expect(group.getAttribute("role")).toBe("group");
|
|
117
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
118
|
+
expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true");
|
|
119
|
+
buttons[0]!.click();
|
|
120
|
+
buttons[1]!.click();
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
expect(seen).toEqual([["passkey", "totp"], []]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("suppresses the tooltip with an explicit empty override and keeps the label as the a11y name", () => {
|
|
126
|
+
const c = mountComp(() =>
|
|
127
|
+
h(HkIconButtonGroup, {
|
|
128
|
+
options: [
|
|
129
|
+
{ key: "a", label: "Alpha" },
|
|
130
|
+
{ key: "b", label: "Beta", tooltip: "" },
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(c.querySelectorAll(".hk-tooltip-wrapper").length).toBe(1);
|
|
135
|
+
const beta = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")[1]!;
|
|
136
|
+
expect(beta.getAttribute("aria-label")).toBe("Beta");
|
|
137
|
+
// The HkTooltip popup owns the hover text — the button carries no title.
|
|
138
|
+
expect(beta.getAttribute("title")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("renders the given icon vnode and falls back to the label initial without one", () => {
|
|
142
|
+
const c = mountComp(() =>
|
|
143
|
+
h(HkIconButtonGroup, {
|
|
144
|
+
options: [
|
|
145
|
+
{ key: "a", label: "Alpha", icon: h("span", { class: "glyph" }) },
|
|
146
|
+
{ key: "b", label: "Beta" },
|
|
147
|
+
],
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
|
|
151
|
+
expect(buttons[0]!.querySelector(".glyph")).toBeTruthy();
|
|
152
|
+
expect(buttons[1]!.querySelector<HTMLElement>(".hk-icon-group-item-initial")?.textContent).toBe("B");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps disabled items and the group-level disable inert", async () => {
|
|
156
|
+
const picked: string[] = [];
|
|
157
|
+
const c = mountComp(() =>
|
|
158
|
+
h(HkIconButtonGroup, {
|
|
159
|
+
options: [
|
|
160
|
+
{ key: "a", label: "Alpha", disabled: true },
|
|
161
|
+
{ key: "b", label: "Beta" },
|
|
162
|
+
],
|
|
163
|
+
disabled: true,
|
|
164
|
+
onSelect: (k: string) => picked.push(k),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
168
|
+
expect(buttons.every((b) => b.disabled)).toBe(true);
|
|
169
|
+
buttons[1]!.click();
|
|
170
|
+
await Promise.resolve();
|
|
171
|
+
expect(picked).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("falls extra attrs through onto the group root", () => {
|
|
175
|
+
const c = mountComp(() =>
|
|
176
|
+
h(HkIconButtonGroup, { options, "aria-label": "验证方式", "data-test": "probe" }),
|
|
177
|
+
);
|
|
178
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
179
|
+
expect(group.getAttribute("aria-label")).toBe("验证方式");
|
|
180
|
+
expect(group.getAttribute("data-test")).toBe("probe");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { computed, defineComponent, type PropType } from "vue";
|
|
2
|
+
import HkTooltip from "./HkTooltip";
|
|
3
|
+
import type { TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
|
+
import "./HkIconButtonGroup.scss";
|
|
5
|
+
|
|
6
|
+
/** One selectable icon in the group. */
|
|
7
|
+
export interface HkIconButtonGroupOption {
|
|
8
|
+
/** Stable identity — the value surfaced through v-model / select. */
|
|
9
|
+
key: string;
|
|
10
|
+
/** Accessible name AND the default tooltip text. Required on purpose:
|
|
11
|
+
* an icon-only control without a label is an a11y hole. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Icon-only content: a prebuilt vnode (brand SVG, lucide component,
|
|
14
|
+
* <img>) or anything renderable. Typed loose on purpose (same as
|
|
15
|
+
* HkAuthMethodList entries): hosts materialize hikari against their
|
|
16
|
+
* own vue minor, and a hard VNode type breaks typecheck whenever the
|
|
17
|
+
* host's vue differs. Missing icons fall back to the label's initial. */
|
|
18
|
+
icon?: unknown;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Tooltip text override; an EMPTY string suppresses the tooltip. */
|
|
21
|
+
tooltip?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HkIconButtonGroup — the icon-only variant of the button-group family
|
|
26
|
+
* (the same centered strip grammar as HkTabs, minus the text).
|
|
27
|
+
*
|
|
28
|
+
* Three working modes:
|
|
29
|
+
* - "buttons" a plain action group (no selection state) — the auth
|
|
30
|
+
* card's OAuth provider row
|
|
31
|
+
* - "single" a radiogroup: one active key (v-model), re-clicking the
|
|
32
|
+
* active key is a no-op — radio semantics
|
|
33
|
+
* - "multiple" a toggle group: v-model is a string[] of active keys,
|
|
34
|
+
* every item carries aria-pressed
|
|
35
|
+
*
|
|
36
|
+
* Every item is icon-only and rides one size taller than a standard
|
|
37
|
+
* icon button (md = 44px vs the 32px icon button / 40px text button) —
|
|
38
|
+
* the Windows/macOS-style login chooser. Because an icon alone is
|
|
39
|
+
* unlabelable at a glance, each item wraps itself in an HkTooltip
|
|
40
|
+
* (text = the option's label unless overridden), so the hover reveal is
|
|
41
|
+
* built in rather than left to every consumer.
|
|
42
|
+
*
|
|
43
|
+
* Known a11y limitation (documented 2026-09-13): single mode uses a
|
|
44
|
+
* radiogroup role with every item tabbable, not the full WAI-APG roving
|
|
45
|
+
* tabindex. All-items-tabbable keeps keyboard reachability identical
|
|
46
|
+
* across the three modes; roving focus is a follow-up if a host needs
|
|
47
|
+
* strict APG conformance.
|
|
48
|
+
*/
|
|
49
|
+
export default defineComponent({
|
|
50
|
+
name: "HkIconButtonGroup",
|
|
51
|
+
inheritAttrs: false,
|
|
52
|
+
props: {
|
|
53
|
+
options: { type: Array as unknown as () => HkIconButtonGroupOption[], required: true },
|
|
54
|
+
/** Active key ("single") or keys ("multiple"); null when nothing is
|
|
55
|
+
* active — required/ignored by "buttons" mode. */
|
|
56
|
+
modelValue: { type: [String, Array] as PropType<string | string[] | null>, default: null },
|
|
57
|
+
mode: { type: String as PropType<"buttons" | "single" | "multiple">, default: "buttons" },
|
|
58
|
+
size: { type: String as PropType<"sm" | "md" | "lg">, default: "md" },
|
|
59
|
+
disabled: { type: Boolean, default: false },
|
|
60
|
+
tooltipPlacement: { type: String as PropType<TooltipPlacement>, default: "top" },
|
|
61
|
+
tooltipDelay: { type: Number, default: 300 },
|
|
62
|
+
},
|
|
63
|
+
emits: {
|
|
64
|
+
/** v-model for "single" (string) and "multiple" (string[]). */
|
|
65
|
+
"update:modelValue": (_value: string | string[]) => true,
|
|
66
|
+
/** An item was activated (or toggled). Never fires for a no-op
|
|
67
|
+
* re-click of the already-active key in "single" mode. */
|
|
68
|
+
select: (_key: string) => true,
|
|
69
|
+
},
|
|
70
|
+
setup(props, { emit, attrs }) {
|
|
71
|
+
const cls = computed(() => [
|
|
72
|
+
"hk-icon-group",
|
|
73
|
+
`hk-icon-group-${props.mode}`,
|
|
74
|
+
`hk-icon-group-${props.size}`,
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const groupRole = computed(() => (props.mode === "single" ? "radiogroup" : "group"));
|
|
78
|
+
|
|
79
|
+
function isActive(key: string): boolean {
|
|
80
|
+
if (props.mode === "multiple") {
|
|
81
|
+
return Array.isArray(props.modelValue) && props.modelValue.includes(key);
|
|
82
|
+
}
|
|
83
|
+
if (props.mode === "single") {
|
|
84
|
+
return props.modelValue === key;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onSelect(option: HkIconButtonGroupOption) {
|
|
90
|
+
if (props.disabled || option.disabled) return;
|
|
91
|
+
if (props.mode === "single") {
|
|
92
|
+
if (isActive(option.key)) return; // radio semantics: no re-fire
|
|
93
|
+
emit("update:modelValue", option.key);
|
|
94
|
+
} else if (props.mode === "multiple") {
|
|
95
|
+
const current = Array.isArray(props.modelValue) ? props.modelValue : [];
|
|
96
|
+
const next = current.includes(option.key)
|
|
97
|
+
? current.filter((k) => k !== option.key)
|
|
98
|
+
: [...current, option.key];
|
|
99
|
+
emit("update:modelValue", next);
|
|
100
|
+
}
|
|
101
|
+
emit("select", option.key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return () => (
|
|
105
|
+
<div class={cls.value} role={groupRole.value} {...attrs}>
|
|
106
|
+
{props.options.map((option) => {
|
|
107
|
+
const tooltip = option.tooltip !== undefined ? option.tooltip : option.label;
|
|
108
|
+
const active = isActive(option.key);
|
|
109
|
+
const button = (
|
|
110
|
+
<button
|
|
111
|
+
key={option.key}
|
|
112
|
+
type="button"
|
|
113
|
+
class="hk-icon-group-item"
|
|
114
|
+
data-key={option.key}
|
|
115
|
+
data-active={active || undefined}
|
|
116
|
+
role={props.mode === "single" ? "radio" : undefined}
|
|
117
|
+
aria-checked={props.mode === "single" ? active : undefined}
|
|
118
|
+
aria-pressed={props.mode === "multiple" ? active : undefined}
|
|
119
|
+
aria-label={option.label}
|
|
120
|
+
title={undefined} // the HkTooltip popup owns the hover text
|
|
121
|
+
disabled={props.disabled || option.disabled}
|
|
122
|
+
onClick={() => onSelect(option)}
|
|
123
|
+
>
|
|
124
|
+
<span class="hk-icon-group-item-icon">
|
|
125
|
+
{option.icon != null
|
|
126
|
+
? option.icon
|
|
127
|
+
: (
|
|
128
|
+
<span class="hk-icon-group-item-initial" aria-hidden="true">
|
|
129
|
+
{option.label.charAt(0).toUpperCase()}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
</span>
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
// An empty tooltip override suppresses the wrapper entirely.
|
|
136
|
+
return tooltip
|
|
137
|
+
? (
|
|
138
|
+
<HkTooltip
|
|
139
|
+
key={option.key}
|
|
140
|
+
text={tooltip}
|
|
141
|
+
placement={props.tooltipPlacement}
|
|
142
|
+
delay={props.tooltipDelay}
|
|
143
|
+
>
|
|
144
|
+
{button}
|
|
145
|
+
</HkTooltip>
|
|
146
|
+
)
|
|
147
|
+
: button;
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
|
|
2
2
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
3
|
-
import {
|
|
3
|
+
import { tooltipPositionStyle, type TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
4
|
import "./HkTooltip.scss";
|
|
5
5
|
|
|
6
6
|
export default defineComponent({
|
|
7
7
|
name: "HkTooltip",
|
|
8
8
|
props: {
|
|
9
9
|
text: { type: String, required: true },
|
|
10
|
-
placement: { type: String as PropType<
|
|
10
|
+
placement: { type: String as PropType<TooltipPlacement>, default: "top" },
|
|
11
11
|
delay: { type: Number, default: 300 },
|
|
12
12
|
maxWidth: { type: String, default: undefined },
|
|
13
13
|
},
|
|
@@ -33,46 +33,11 @@ export default defineComponent({
|
|
|
33
33
|
function updatePosition() {
|
|
34
34
|
if (!wrapperRef.value) return;
|
|
35
35
|
const rect = wrapperRef.value.getBoundingClientRect();
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
// rect values must be written divided by the cumulative zoom or
|
|
42
|
-
// the tooltip drifts zoom× off its trigger (chest's root-level
|
|
43
|
-
// manual DPI scale).
|
|
44
|
-
const z = ancestorZoom(document.body);
|
|
45
|
-
const local = (v: number) => `${v / z}px`;
|
|
46
|
-
const style: CSSProperties = {};
|
|
47
|
-
|
|
48
|
-
if (props.maxWidth) {
|
|
49
|
-
style.maxWidth = props.maxWidth;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
switch (props.placement) {
|
|
53
|
-
case "top":
|
|
54
|
-
style.top = local(rect.top - gap);
|
|
55
|
-
style.left = local(rect.left + rect.width / 2);
|
|
56
|
-
style.transform = "translate(-50%, -100%)";
|
|
57
|
-
break;
|
|
58
|
-
case "bottom":
|
|
59
|
-
style.top = local(rect.bottom + gap);
|
|
60
|
-
style.left = local(rect.left + rect.width / 2);
|
|
61
|
-
style.transform = "translate(-50%, 0)";
|
|
62
|
-
break;
|
|
63
|
-
case "left":
|
|
64
|
-
style.top = local(rect.top + rect.height / 2);
|
|
65
|
-
style.left = local(rect.left - gap);
|
|
66
|
-
style.transform = "translate(-100%, -50%)";
|
|
67
|
-
break;
|
|
68
|
-
case "right":
|
|
69
|
-
style.top = local(rect.top + rect.height / 2);
|
|
70
|
-
style.left = local(rect.right + gap);
|
|
71
|
-
style.transform = "translate(0, -50%)";
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
tooltipStyle.value = style;
|
|
36
|
+
// Placement geometry lives in the shared runtime helper (it also
|
|
37
|
+
// serves the document-level tooltip bridge) — including the
|
|
38
|
+
// ancestor-zoom division that keeps teleported popups pinned to
|
|
39
|
+
// their trigger inside scaled roots.
|
|
40
|
+
tooltipStyle.value = tooltipPositionStyle(rect, props.placement, props.maxWidth);
|
|
76
41
|
}
|
|
77
42
|
|
|
78
43
|
function show() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { onMounted, onUnmounted, ref, type Ref } from "vue";
|
|
2
2
|
|
|
3
3
|
import { scheduleInterval, scheduleIntervalAfter, type IntervalHandle } from "../runtime/intervalBus";
|
|
4
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
|
|
4
5
|
|
|
5
6
|
/** Fetch-based connectivity probe — the hikari-local successor of the
|
|
6
7
|
* plana-ui probe. Polls `/api/health` (or a custom endpoint set via
|
|
@@ -28,6 +29,20 @@ const DEFAULT_RETRY_TOTAL = 3;
|
|
|
28
29
|
|
|
29
30
|
let probeEndpoint: string | (() => string) = "";
|
|
30
31
|
|
|
32
|
+
// Runtime-registry reporting (the "context of contexts"): the probe is a
|
|
33
|
+
// per-component composable, so the MODULE is the hook — one registry
|
|
34
|
+
// entry counting live probe instances, with the configured endpoint in
|
|
35
|
+
// the read facet. Registration API unchanged.
|
|
36
|
+
let liveProbes = 0;
|
|
37
|
+
let probeRuntime: HkRuntimeHandle | null = null;
|
|
38
|
+
function ensureProbeRuntime(): HkRuntimeHandle {
|
|
39
|
+
return (probeRuntime ??= reportHkRuntime("connectionProbe", {
|
|
40
|
+
kind: "hook",
|
|
41
|
+
description: "The fetch-based connectivity probe: per-component /api/health polling with retry and countdown.",
|
|
42
|
+
read: () => ({ liveProbes, endpoint: endpointUrl() }),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
/** Override the probed endpoint (defaults to same-origin `/api/health`). */
|
|
32
47
|
export function setProbeEndpoint(endpoint: string | (() => string)): void {
|
|
33
48
|
probeEndpoint = endpoint;
|
|
@@ -127,11 +142,15 @@ export function useConnectionProbe(): {
|
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
onMounted(() => {
|
|
145
|
+
liveProbes += 1;
|
|
146
|
+
ensureProbeRuntime().pulse({ liveProbes });
|
|
130
147
|
void probeOnce();
|
|
131
148
|
tickHandle = scheduleInterval(applyCountdown, 1000);
|
|
132
149
|
});
|
|
133
150
|
|
|
134
151
|
onUnmounted(() => {
|
|
152
|
+
liveProbes = Math.max(0, liveProbes - 1);
|
|
153
|
+
probeRuntime?.pulse({ liveProbes });
|
|
135
154
|
pollHandle?.disconnect();
|
|
136
155
|
pollHandle = null;
|
|
137
156
|
tickHandle?.disconnect();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { reactive } from "vue";
|
|
2
2
|
|
|
3
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
|
|
4
|
+
|
|
3
5
|
export interface ProgressDialogState {
|
|
4
6
|
open: boolean;
|
|
5
7
|
title: string;
|
|
@@ -24,7 +26,27 @@ const state = reactive<ProgressDialogState>({
|
|
|
24
26
|
max: 100,
|
|
25
27
|
});
|
|
26
28
|
|
|
29
|
+
// Runtime-registry reporting (the "context of contexts"). Lazy: the
|
|
30
|
+
// progress-dialog context reports itself on first use and pulses on
|
|
31
|
+
// open/close, so the live dialog tree is answerable from
|
|
32
|
+
// readHkRuntime("progressDialog") without importing this module.
|
|
33
|
+
let runtimeReport: HkRuntimeHandle | null = null;
|
|
34
|
+
function ensureRuntimeReport(): HkRuntimeHandle {
|
|
35
|
+
return (runtimeReport ??= reportHkRuntime("progressDialog", {
|
|
36
|
+
kind: "context",
|
|
37
|
+
description: "The progress dialog context: the single global task-progress surface (title, log tail, value/max).",
|
|
38
|
+
read: () => ({
|
|
39
|
+
open: state.open,
|
|
40
|
+
title: state.title,
|
|
41
|
+
logLines: state.logs.length,
|
|
42
|
+
value: state.value,
|
|
43
|
+
max: state.max,
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
export function showProgressDialog(opts: { title: string }): ProgressDialogHandle {
|
|
49
|
+
ensureRuntimeReport().pulse({ open: true, title: opts.title });
|
|
28
50
|
state.title = opts.title;
|
|
29
51
|
state.logs = [];
|
|
30
52
|
state.value = null;
|
|
@@ -43,10 +65,12 @@ export function showProgressDialog(opts: { title: string }): ProgressDialogHandl
|
|
|
43
65
|
},
|
|
44
66
|
close() {
|
|
45
67
|
state.open = false;
|
|
68
|
+
runtimeReport?.pulse({ open: false });
|
|
46
69
|
},
|
|
47
70
|
};
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
export function useProgressDialog(): ProgressDialogState {
|
|
74
|
+
ensureRuntimeReport();
|
|
51
75
|
return state;
|
|
52
76
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ref, type Ref } from "vue";
|
|
2
2
|
|
|
3
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
|
|
4
|
+
|
|
3
5
|
/** Where a reported error came from. */
|
|
4
6
|
export type HkErrorSource = "vue" | "window" | "rejection" | "manual";
|
|
5
7
|
|
|
@@ -66,6 +68,10 @@ export function getErrorReportingOptions(): HkErrorReportingOptions {
|
|
|
66
68
|
|
|
67
69
|
export function setErrorReportingOptions(options: HkErrorReportingOptions): void {
|
|
68
70
|
activeOptions = options;
|
|
71
|
+
runtimeReport?.setMeta({
|
|
72
|
+
captureWindow: options.captureWindow !== false,
|
|
73
|
+
captureRejection: options.captureRejection !== false,
|
|
74
|
+
});
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
export function useErrorReportingState(): Ref<HkReportedError | null> {
|
|
@@ -86,6 +92,11 @@ export function reportError(err: unknown, source: HkErrorSource, info?: string):
|
|
|
86
92
|
|
|
87
93
|
if (currentError.value !== null) return null;
|
|
88
94
|
|
|
95
|
+
// Accepted onto the landing (first-error-wins): later errors while the
|
|
96
|
+
// landing is up still log/fire onError above but do not count.
|
|
97
|
+
acceptedReports += 1;
|
|
98
|
+
runtimeReport?.pulse({ reports: acceptedReports });
|
|
99
|
+
|
|
89
100
|
const normalized = normalizeError(err);
|
|
90
101
|
const record: HkReportedError = {
|
|
91
102
|
source,
|
|
@@ -102,3 +113,22 @@ export function reportError(err: unknown, source: HkErrorSource, info?: string):
|
|
|
102
113
|
export function clearErrorReportingState(): void {
|
|
103
114
|
currentError.value = null;
|
|
104
115
|
}
|
|
116
|
+
|
|
117
|
+
// Runtime-registry reporting (the "context of contexts"): the error
|
|
118
|
+
// reporting module is a global singleton, so it reports itself at module
|
|
119
|
+
// init and pulses on every accepted error. The read facet keeps the
|
|
120
|
+
// current landing card observable (name/message/source only — never the
|
|
121
|
+
// whole error object) from readHkRuntime("errorReporting").
|
|
122
|
+
let acceptedReports = 0;
|
|
123
|
+
const runtimeReport: HkRuntimeHandle = reportHkRuntime("errorReporting", {
|
|
124
|
+
kind: "plugin",
|
|
125
|
+
description: "The global error reporting landing: app.config.errorHandler + window/rejection hooks with the full-screen error card.",
|
|
126
|
+
meta: { captureWindow: activeOptions.captureWindow !== false, captureRejection: activeOptions.captureRejection !== false },
|
|
127
|
+
read: () => ({
|
|
128
|
+
reports: acceptedReports,
|
|
129
|
+
landingUp: currentError.value !== null,
|
|
130
|
+
current: currentError.value
|
|
131
|
+
? { name: currentError.value.name, message: currentError.value.message, source: currentError.value.source }
|
|
132
|
+
: null,
|
|
133
|
+
}),
|
|
134
|
+
});
|
package/src/i18n/context.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { reactive } from "vue";
|
|
2
2
|
|
|
3
|
+
import { reportHkRuntime } from "../runtime/registry";
|
|
4
|
+
|
|
3
5
|
type Messages = Record<string, string>;
|
|
4
6
|
|
|
5
7
|
interface LocaleModule {
|
|
@@ -63,6 +65,7 @@ export async function setLocale(locale: string): Promise<void> {
|
|
|
63
65
|
...(mergedMessages["en"] ?? {}),
|
|
64
66
|
...(mergedMessages[locale] ?? {}),
|
|
65
67
|
};
|
|
68
|
+
runtime.pulse({ locale });
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export function mergeMessages(userMessages: Messages, locale?: string): void {
|
|
@@ -82,6 +85,7 @@ export function mergeMessages(userMessages: Messages, locale?: string): void {
|
|
|
82
85
|
...(mergedMessages["en"] ?? {}),
|
|
83
86
|
...(mergedMessages[state.locale] ?? {}),
|
|
84
87
|
};
|
|
88
|
+
runtime.pulse({ locale: state.locale });
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
export function useI18n() {
|
|
@@ -96,3 +100,31 @@ export function useI18n() {
|
|
|
96
100
|
|
|
97
101
|
return { t, locale: state.locale };
|
|
98
102
|
}
|
|
103
|
+
|
|
104
|
+
// Runtime-registry reporting (the "context of contexts"): the i18n
|
|
105
|
+
// context is a module singleton, so it reports itself at module init and
|
|
106
|
+
// pulses on every locale switch / message merge. The facets make the
|
|
107
|
+
// locale tree reachable and steerable from anywhere via
|
|
108
|
+
// readHkRuntime("i18n") / writeHkRuntime("i18n", …) without importing
|
|
109
|
+
// this module.
|
|
110
|
+
const runtime = reportHkRuntime("i18n", {
|
|
111
|
+
kind: "context",
|
|
112
|
+
description: "The i18n context: active locale, message tables and per-locale merged overrides.",
|
|
113
|
+
meta: { locale: state.locale },
|
|
114
|
+
read: () => ({
|
|
115
|
+
locale: state.locale,
|
|
116
|
+
keys: Object.keys(state.messages).length,
|
|
117
|
+
mergedLocales: Object.keys(mergedMessages),
|
|
118
|
+
}),
|
|
119
|
+
write: (op) => {
|
|
120
|
+
if (op.type === "setLocale" && typeof op.locale === "string") {
|
|
121
|
+
void setLocale(op.locale);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (op.type === "mergeMessages" && typeof op.messages === "object" && op.messages !== null) {
|
|
125
|
+
mergeMessages(op.messages as Messages, typeof op.locale === "string" ? op.locale : undefined);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`unknown i18n write op "${op.type}"`);
|
|
129
|
+
},
|
|
130
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { default as HExpansionPanel } from "./components/HkExpansionPanel";
|
|
|
24
24
|
export { default as HFab, type HFabAction } from "./components/HkFab";
|
|
25
25
|
export { default as HIcon } from "./components/HkIcon";
|
|
26
26
|
export { default as HIconButton } from "./components/HkIconButton";
|
|
27
|
+
export { default as HIconButtonGroup, type HkIconButtonGroupOption } from "./components/HkIconButtonGroup";
|
|
27
28
|
export { default as HImageLightbox } from "./components/HkImageLightbox";
|
|
28
29
|
export { default as HImagePreview, type ImagePreviewObjectFit } from "./components/HkImagePreview";
|
|
29
30
|
export { default as HInput } from "./components/HkInput";
|
|
@@ -239,6 +240,26 @@ export {
|
|
|
239
240
|
type SafeAreaInsets,
|
|
240
241
|
type BackGuard,
|
|
241
242
|
type BackGuardOptions,
|
|
243
|
+
installHkTooltipBridge,
|
|
244
|
+
type HkTooltipBridgeOptions,
|
|
245
|
+
installHkImageFallback,
|
|
246
|
+
type HkImageFallbackOptions,
|
|
247
|
+
reportHkRuntime,
|
|
248
|
+
getHkRuntimeEntry,
|
|
249
|
+
listHkRuntime,
|
|
250
|
+
readHkRuntime,
|
|
251
|
+
writeHkRuntime,
|
|
252
|
+
hkRuntimeSnapshot,
|
|
253
|
+
useHkRegistry,
|
|
254
|
+
type HkRuntimeKind,
|
|
255
|
+
type HkRuntimeStatus,
|
|
256
|
+
type HkRuntimeMeta,
|
|
257
|
+
type HkRuntimeEntryState,
|
|
258
|
+
type HkRuntimeReport,
|
|
259
|
+
type HkRuntimeHandle,
|
|
260
|
+
type HkRuntimeWriteOp,
|
|
261
|
+
type HkRuntimeSnapshot,
|
|
262
|
+
type HkRuntimeSnapshotEntry,
|
|
242
263
|
} from "./runtime";
|
|
243
264
|
|
|
244
265
|
// i18n
|