@celestia-island/hikari 0.49.4 → 0.49.6
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/HkBlockingToast.test.tsx +34 -15
- package/src/components/HkBlockingToast.tsx +9 -6
- package/src/components/HkBoard.tsx +8 -1
- package/src/components/HkConfirmDialog.test.tsx +155 -0
- package/src/components/HkConfirmDialog.tsx +32 -9
- package/src/components/HkDateTimePicker.test.ts +17 -4
- package/src/components/HkMenu.scss +7 -0
- package/src/components/HkMenu.test.ts +12 -5
- package/src/components/HkMenu.tsx +31 -11
- package/src/components/HkMessageBox.tsx +9 -7
- package/src/components/HkMinimap.scss +47 -1
- package/src/components/HkMinimap.test.tsx +189 -0
- package/src/components/HkMinimap.tsx +95 -17
- package/src/components/HkModal.scss +18 -7
- package/src/components/HkModelTag.test.tsx +19 -1
- package/src/components/HkModelTag.tsx +22 -15
- package/src/components/HkThemeToggle.tsx +6 -1
- package/src/components/_menu-item.contract.test.ts +90 -0
- package/src/components/_menu-item.scss +38 -1
- package/src/i18n/locales/ar/components.json +2 -1
- package/src/i18n/locales/de/components.json +2 -1
- package/src/i18n/locales/en/components.json +2 -1
- package/src/i18n/locales/es/components.json +2 -1
- package/src/i18n/locales/fr/components.json +2 -1
- package/src/i18n/locales/ja/components.json +2 -1
- package/src/i18n/locales/ko/components.json +2 -1
- package/src/i18n/locales/pt/components.json +2 -1
- package/src/i18n/locales/ru/components.json +2 -1
- package/src/i18n/locales/zh-Hans/components.json +2 -1
- package/src/i18n/locales/zh-Hant/components.json +2 -1
- package/src/styles/admin-tokens.scss +21 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkBlockingToast from "./HkBlockingToast";
|
|
@@ -48,6 +48,16 @@ function actionButtons(card: HTMLElement): HTMLButtonElement[] {
|
|
|
48
48
|
return Array.from(card.querySelectorAll<HTMLButtonElement>(".hk-blocking-toast-actions button"));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Answer the gate by semantics, not by position: the row order is pinned
|
|
52
|
+
* separately, so these helpers keep the behaviour cases readable. */
|
|
53
|
+
function confirmButton(card: HTMLElement): HTMLButtonElement {
|
|
54
|
+
return actionButtons(card).find((b) => !b.classList.contains("hk-btn-secondary"))!;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cancelButton(card: HTMLElement): HTMLButtonElement {
|
|
58
|
+
return actionButtons(card).find((b) => b.classList.contains("hk-btn-secondary"))!;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
function toastRegistryTitles(): Array<{ kind: string; title?: string }> {
|
|
52
62
|
return [...usePopupManager().registry.value.values()].map((e) => ({ kind: e.kind, title: e.title }));
|
|
53
63
|
}
|
|
@@ -73,9 +83,9 @@ describe("HkBlockingToast", () => {
|
|
|
73
83
|
expect(card.querySelector(".hk-blocking-toast-title")!.textContent).toContain("Join group?");
|
|
74
84
|
expect(card.querySelector(".hk-blocking-toast-message")!.textContent).toContain("Admins can view your usage.");
|
|
75
85
|
const labels = actionButtons(card).map((b) => b.textContent?.trim());
|
|
76
|
-
expect(labels).toEqual(["
|
|
86
|
+
expect(labels).toEqual(["Confirm", "Cancel"]);
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
confirmButton(card).click();
|
|
79
89
|
await expect(gate).resolves.toBe(true);
|
|
80
90
|
await settle();
|
|
81
91
|
expect(cards().length).toBe(0);
|
|
@@ -87,7 +97,7 @@ describe("HkBlockingToast", () => {
|
|
|
87
97
|
await settle();
|
|
88
98
|
const card = cards()[0];
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
cancelButton(card).click();
|
|
91
101
|
await expect(gate).resolves.toBe(false);
|
|
92
102
|
await settle();
|
|
93
103
|
expect(cards().length).toBe(0);
|
|
@@ -103,7 +113,7 @@ describe("HkBlockingToast", () => {
|
|
|
103
113
|
expect(cards().length).toBe(1);
|
|
104
114
|
|
|
105
115
|
// Clean up the deliberately unanswered gate.
|
|
106
|
-
|
|
116
|
+
confirmButton(cards()[0]).click();
|
|
107
117
|
await expect(gate).resolves.toBe(true);
|
|
108
118
|
});
|
|
109
119
|
|
|
@@ -115,21 +125,29 @@ describe("HkBlockingToast", () => {
|
|
|
115
125
|
await settle();
|
|
116
126
|
expect(toastRegistryTitles().some((e) => e.kind === "toast" && e.title === "Consent")).toBe(true);
|
|
117
127
|
|
|
118
|
-
|
|
128
|
+
cancelButton(cards()[0]).click();
|
|
119
129
|
await expect(gate).resolves.toBe(false);
|
|
120
130
|
await settle();
|
|
121
131
|
expect(toastRegistryTitles().some((e) => e.kind === "toast")).toBe(false);
|
|
122
132
|
});
|
|
123
133
|
|
|
124
134
|
it("resolves false when timeoutMs expires", async () => {
|
|
135
|
+
// Virtual clock: a real 40ms timer races suite load on busy runners
|
|
136
|
+
// (the card teardown could lag past the resolution window). settle()'s
|
|
137
|
+
// own sleeps ride the same clock via advanceTimersByTimeAsync.
|
|
138
|
+
vi.useFakeTimers();
|
|
125
139
|
mountHost();
|
|
126
140
|
const gate = showBlockingToast("Times out", { timeoutMs: 40 });
|
|
127
|
-
await
|
|
141
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
142
|
+
await nextTick();
|
|
128
143
|
expect(cards().length).toBe(1);
|
|
129
144
|
|
|
145
|
+
await vi.advanceTimersByTimeAsync(41);
|
|
130
146
|
await expect(gate).resolves.toBe(false);
|
|
131
|
-
await
|
|
147
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
148
|
+
await nextTick();
|
|
132
149
|
expect(cards().length).toBe(0);
|
|
150
|
+
vi.useRealTimers();
|
|
133
151
|
});
|
|
134
152
|
|
|
135
153
|
it("stacks multiple prompts that resolve independently", async () => {
|
|
@@ -144,13 +162,13 @@ describe("HkBlockingToast", () => {
|
|
|
144
162
|
expect(cardTwo.querySelector(".hk-blocking-toast-title")!.textContent).toContain("Two");
|
|
145
163
|
|
|
146
164
|
// Answer only the second; the first keeps blocking.
|
|
147
|
-
|
|
165
|
+
confirmButton(cardTwo).click();
|
|
148
166
|
await expect(second).resolves.toBe(true);
|
|
149
167
|
await settle();
|
|
150
168
|
expect(cards().length).toBe(1);
|
|
151
169
|
expect(cards()[0].querySelector(".hk-blocking-toast-title")!.textContent).toContain("One");
|
|
152
170
|
|
|
153
|
-
|
|
171
|
+
cancelButton(cards()[0]).click();
|
|
154
172
|
await expect(first).resolves.toBe(false);
|
|
155
173
|
});
|
|
156
174
|
|
|
@@ -166,9 +184,9 @@ describe("HkBlockingToast", () => {
|
|
|
166
184
|
const card = cards()[0];
|
|
167
185
|
expect(card.classList.contains("hk-blocking-toast-danger")).toBe(true);
|
|
168
186
|
const labels = actionButtons(card).map((b) => b.textContent?.trim());
|
|
169
|
-
expect(labels).toEqual(["
|
|
187
|
+
expect(labels).toEqual(["Join", "Back out"]);
|
|
170
188
|
|
|
171
|
-
|
|
189
|
+
confirmButton(card).click();
|
|
172
190
|
await expect(gate).resolves.toBe(true);
|
|
173
191
|
});
|
|
174
192
|
|
|
@@ -179,9 +197,9 @@ describe("HkBlockingToast", () => {
|
|
|
179
197
|
await settle();
|
|
180
198
|
|
|
181
199
|
const labels = actionButtons(cards()[0]).map((b) => b.textContent?.trim());
|
|
182
|
-
expect(labels).toEqual(["
|
|
200
|
+
expect(labels).toEqual(["确认", "取消"]);
|
|
183
201
|
|
|
184
|
-
|
|
202
|
+
confirmButton(cards()[0]).click();
|
|
185
203
|
await expect(gate).resolves.toBe(true);
|
|
186
204
|
await setLocale("en");
|
|
187
205
|
});
|
|
@@ -203,7 +221,8 @@ describe("HkBlockingToast", () => {
|
|
|
203
221
|
Object.defineProperty(slot!, "offsetWidth", { value: 384, configurable: true });
|
|
204
222
|
Object.defineProperty(slot!, "offsetHeight", { value: 120, configurable: true });
|
|
205
223
|
|
|
206
|
-
|
|
224
|
+
// Dismiss (not confirm): this case only cares about the leave animation.
|
|
225
|
+
cancelButton(cards()[0]).click();
|
|
207
226
|
await nextTick();
|
|
208
227
|
|
|
209
228
|
const leaving = document.querySelector<HTMLElement>(".hk-blocking-toast-leave-active");
|
|
@@ -79,19 +79,22 @@ const HkBlockingToastCard = defineComponent({
|
|
|
79
79
|
) : null}
|
|
80
80
|
<p class="hk-blocking-toast-message">{item.message}</p>
|
|
81
81
|
<div class="hk-blocking-toast-actions">
|
|
82
|
+
{/* Confirmation surfaces share one order: primary action first
|
|
83
|
+
(left), dismiss second (right) — matching HkConfirmDialog
|
|
84
|
+
and HkMessageBox. */}
|
|
82
85
|
<HButton
|
|
83
|
-
variant="
|
|
86
|
+
variant={item.variant === "danger" ? "danger" : "primary"}
|
|
84
87
|
size="sm"
|
|
85
|
-
onClick={() => emit("
|
|
88
|
+
onClick={() => emit("confirm")}
|
|
86
89
|
>
|
|
87
|
-
{item.
|
|
90
|
+
{item.confirmLabel ?? t("hikari::blockingToast.confirm", "Confirm")}
|
|
88
91
|
</HButton>
|
|
89
92
|
<HButton
|
|
90
|
-
variant=
|
|
93
|
+
variant="secondary"
|
|
91
94
|
size="sm"
|
|
92
|
-
onClick={() => emit("
|
|
95
|
+
onClick={() => emit("cancel")}
|
|
93
96
|
>
|
|
94
|
-
{item.
|
|
97
|
+
{item.cancelLabel ?? t("hikari::blockingToast.cancel", "Cancel")}
|
|
95
98
|
</HButton>
|
|
96
99
|
</div>
|
|
97
100
|
</div>
|
|
@@ -45,6 +45,7 @@ import HkMinimap, { type MinimapBox } from "./HkMinimap";
|
|
|
45
45
|
import {
|
|
46
46
|
boardBBox,
|
|
47
47
|
boardStepRung,
|
|
48
|
+
quantizeBoardK,
|
|
48
49
|
type BoardCamera,
|
|
49
50
|
type BoardPoint,
|
|
50
51
|
type BoardRect,
|
|
@@ -450,6 +451,9 @@ export default defineComponent({
|
|
|
450
451
|
))}
|
|
451
452
|
</div>
|
|
452
453
|
{props.minimap && (
|
|
454
|
+
// Slider drags arrive as ABSOLUTE rung targets — land the camera
|
|
455
|
+
// on the thumb's rung exactly (boardStepRung's directional push
|
|
456
|
+
// is only for the ± buttons' relative steps).
|
|
453
457
|
<div class="hk-board-minimap">
|
|
454
458
|
<HkMinimap
|
|
455
459
|
boxes={minimapBoxes.value}
|
|
@@ -466,7 +470,10 @@ export default defineComponent({
|
|
|
466
470
|
minZoomPercent={Math.round(props.minK * 100)}
|
|
467
471
|
maxZoomPercent={Math.round(props.maxK * 100)}
|
|
468
472
|
showReset
|
|
469
|
-
onZoomTo={(percent: number
|
|
473
|
+
onZoomTo={(percent: number, source?: "slider" | "step") =>
|
|
474
|
+
cam.zoomToK(source === "slider"
|
|
475
|
+
? quantizeBoardK(percent / 100)
|
|
476
|
+
: boardStepRung(camera.value.k, percent / 100))}
|
|
470
477
|
onReset={() => cam.fit()}
|
|
471
478
|
onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
|
|
472
479
|
/>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkConfirmDialog from "./HkConfirmDialog";
|
|
5
|
+
|
|
6
|
+
const apps: ReturnType<typeof createApp>[] = [];
|
|
7
|
+
const containers: HTMLElement[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const app of apps.splice(0)) app.unmount();
|
|
11
|
+
for (const el of containers.splice(0)) el.remove();
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
async function flush() {
|
|
16
|
+
await nextTick();
|
|
17
|
+
await nextTick();
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Mount an open confirm dialog and record every event it emits. */
|
|
22
|
+
function mountDialog(extra: Record<string, unknown> = {}) {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
containers.push(container);
|
|
26
|
+
|
|
27
|
+
const open = ref(true);
|
|
28
|
+
const events: string[] = [];
|
|
29
|
+
const Host = defineComponent({
|
|
30
|
+
setup() {
|
|
31
|
+
return () =>
|
|
32
|
+
h(HkConfirmDialog, {
|
|
33
|
+
open: open.value,
|
|
34
|
+
title: "Enable emergency lockdown?",
|
|
35
|
+
message: "Every agent tool call is blocked.",
|
|
36
|
+
confirmLabel: "Confirm",
|
|
37
|
+
cancelLabel: "Cancel",
|
|
38
|
+
onConfirm: () => events.push("confirm"),
|
|
39
|
+
onCancel: () => events.push("cancel"),
|
|
40
|
+
"onUpdate:open": (v: boolean) => events.push(`open:${v}`),
|
|
41
|
+
...extra,
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const app = createApp(Host);
|
|
46
|
+
apps.push(app);
|
|
47
|
+
app.mount(container);
|
|
48
|
+
return { open, events };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function actionButtons(): HTMLButtonElement[] {
|
|
52
|
+
return [
|
|
53
|
+
...document.body.querySelectorAll<HTMLButtonElement>(
|
|
54
|
+
".hk-confirm-dialog-actions button",
|
|
55
|
+
),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("HkConfirmDialog", () => {
|
|
60
|
+
it("puts the primary action before the dismiss action", async () => {
|
|
61
|
+
mountDialog();
|
|
62
|
+
await flush();
|
|
63
|
+
|
|
64
|
+
const buttons = actionButtons();
|
|
65
|
+
expect(buttons.map((b) => b.textContent)).toEqual(["Confirm", "Cancel"]);
|
|
66
|
+
// The first button is the primary one (danger by default), the second the
|
|
67
|
+
// secondary dismiss — desktop message-box ordering.
|
|
68
|
+
expect(buttons[0]!.classList.contains("hk-btn-danger")).toBe(true);
|
|
69
|
+
expect(buttons[1]!.classList.contains("hk-btn-secondary")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("resolves through confirm + update:open on the primary action", async () => {
|
|
73
|
+
const { events } = mountDialog();
|
|
74
|
+
await flush();
|
|
75
|
+
|
|
76
|
+
actionButtons()[0]!.click();
|
|
77
|
+
await flush();
|
|
78
|
+
|
|
79
|
+
expect(events).toEqual(["confirm", "open:false"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("resolves through cancel + update:open on the dismiss action", async () => {
|
|
83
|
+
const { events } = mountDialog();
|
|
84
|
+
await flush();
|
|
85
|
+
|
|
86
|
+
actionButtons()[1]!.click();
|
|
87
|
+
await flush();
|
|
88
|
+
|
|
89
|
+
expect(events).toEqual(["cancel", "open:false"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** `open` is caller-owned, so relaying the modal's close request as
|
|
93
|
+
* `update:open` alone left the window on screen and the caller's promise
|
|
94
|
+
* pending: the operator hit ✕, nothing happened, and an awaiting
|
|
95
|
+
* `useConfirm()` caller waited until unmount (user report 2026-09-11). */
|
|
96
|
+
it("dismisses as a cancel when the title-bar close button is used", async () => {
|
|
97
|
+
const { events } = mountDialog();
|
|
98
|
+
await flush();
|
|
99
|
+
|
|
100
|
+
const close = document.body.querySelector<HTMLButtonElement>(".hk-modal-close");
|
|
101
|
+
expect(close, "title-bar close button renders").toBeTruthy();
|
|
102
|
+
close!.click();
|
|
103
|
+
await flush();
|
|
104
|
+
|
|
105
|
+
expect(events).toContain("cancel");
|
|
106
|
+
expect(events).toContain("open:false");
|
|
107
|
+
expect(events).not.toContain("confirm");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("dismisses as a cancel when the overlay is clicked", async () => {
|
|
111
|
+
const { events } = mountDialog();
|
|
112
|
+
await flush();
|
|
113
|
+
|
|
114
|
+
const overlay = document.body.querySelector<HTMLElement>(".hk-modal-overlay");
|
|
115
|
+
expect(overlay, "overlay renders").toBeTruthy();
|
|
116
|
+
overlay!.click();
|
|
117
|
+
await flush();
|
|
118
|
+
|
|
119
|
+
expect(events).toContain("cancel");
|
|
120
|
+
expect(events).toContain("open:false");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("dismisses as a cancel on Escape", async () => {
|
|
124
|
+
const { events } = mountDialog();
|
|
125
|
+
await flush();
|
|
126
|
+
|
|
127
|
+
const surface = document.body.querySelector<HTMLElement>(".hk-modal-content");
|
|
128
|
+
expect(surface, "dialog surface renders").toBeTruthy();
|
|
129
|
+
surface!.dispatchEvent(
|
|
130
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
131
|
+
);
|
|
132
|
+
await flush();
|
|
133
|
+
|
|
134
|
+
expect(events).toContain("cancel");
|
|
135
|
+
expect(events).toContain("open:false");
|
|
136
|
+
expect(events).not.toContain("confirm");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("cannot be dismissed while the confirmation is loading", async () => {
|
|
140
|
+
const { events } = mountDialog({ loading: true });
|
|
141
|
+
await flush();
|
|
142
|
+
|
|
143
|
+
expect(document.body.querySelector(".hk-modal-close")).toBeNull();
|
|
144
|
+
// Both actions refuse input too, so a half-submitted confirmation cannot
|
|
145
|
+
// be answered twice.
|
|
146
|
+
const buttons = actionButtons();
|
|
147
|
+
expect(buttons.map((b) => b.disabled)).toEqual([true, true]);
|
|
148
|
+
buttons.forEach((b) => b.click());
|
|
149
|
+
const overlay = document.body.querySelector<HTMLElement>(".hk-modal-overlay");
|
|
150
|
+
overlay?.click();
|
|
151
|
+
await flush();
|
|
152
|
+
|
|
153
|
+
expect(events).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -37,36 +37,59 @@ export default defineComponent({
|
|
|
37
37
|
emit("update:open", false);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Window chrome (✕, overlay click, Escape) is a DISMISS, never a no-op.
|
|
42
|
+
*
|
|
43
|
+
* `open` is caller-owned, so relaying the modal's close request as
|
|
44
|
+
* `update:open` alone left the dialog on screen and the caller's promise
|
|
45
|
+
* unsettled: the operator closed the window, nothing happened, and an
|
|
46
|
+
* awaiting `useConfirm()` caller stayed pending until unmount (user
|
|
47
|
+
* report, 2026-09-11). Routing the close through the cancel path settles
|
|
48
|
+
* the caller and lets it take the window down.
|
|
49
|
+
*/
|
|
50
|
+
function onModalUpdate(next: boolean) {
|
|
51
|
+
if (next) {
|
|
52
|
+
emit("update:open", true);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
onCancel();
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
return () => (
|
|
41
59
|
<HModal
|
|
42
60
|
modelValue={props.open}
|
|
43
61
|
title={props.title}
|
|
44
62
|
closable={!props.loading}
|
|
45
63
|
width="24rem"
|
|
46
|
-
onUpdate:modelValue={
|
|
64
|
+
onUpdate:modelValue={onModalUpdate}
|
|
47
65
|
>
|
|
48
66
|
{{
|
|
49
67
|
default: () => (
|
|
50
68
|
<div class="hk-confirm-dialog">
|
|
51
69
|
<p class="hk-confirm-dialog-message">{props.message}</p>
|
|
70
|
+
{/* Primary action first, dismiss second — the message-box order
|
|
71
|
+
* the confirmation surfaces share (HkMessageBox,
|
|
72
|
+
* HkBlockingToast); form-dialog footers keep the primary
|
|
73
|
+
* rightmost. It mirrors under `dir="rtl"` because the row only
|
|
74
|
+
* reverses visually. */}
|
|
52
75
|
<div class="hk-confirm-dialog-actions">
|
|
53
76
|
<HButton
|
|
54
77
|
class="hk-confirm-dialog-btn"
|
|
55
|
-
variant=
|
|
78
|
+
variant={props.confirmVariant}
|
|
56
79
|
size="md"
|
|
57
|
-
|
|
58
|
-
onClick={
|
|
80
|
+
loading={props.loading}
|
|
81
|
+
onClick={onConfirm}
|
|
59
82
|
>
|
|
60
|
-
{props.
|
|
83
|
+
{props.confirmLabel || t("hikari::confirmDialog.confirm", "Confirm")}
|
|
61
84
|
</HButton>
|
|
62
85
|
<HButton
|
|
63
86
|
class="hk-confirm-dialog-btn"
|
|
64
|
-
variant=
|
|
87
|
+
variant="secondary"
|
|
65
88
|
size="md"
|
|
66
|
-
|
|
67
|
-
onClick={
|
|
89
|
+
disabled={props.loading}
|
|
90
|
+
onClick={onCancel}
|
|
68
91
|
>
|
|
69
|
-
{props.
|
|
92
|
+
{props.cancelLabel || t("hikari::confirmDialog.cancel", "Cancel")}
|
|
70
93
|
</HButton>
|
|
71
94
|
</div>
|
|
72
95
|
</div>
|
|
@@ -214,7 +214,13 @@ describe("HkDateTimePicker", () => {
|
|
|
214
214
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
215
215
|
monthBtn?.click();
|
|
216
216
|
await settle();
|
|
217
|
-
|
|
217
|
+
// Destination state = months pane mounted AND the leaving days pane
|
|
218
|
+
// gone — mid-transition the cell counts already read final while the
|
|
219
|
+
// stage still carries both panes.
|
|
220
|
+
await waitForView("the months grid settled to one pane", () =>
|
|
221
|
+
pickCells().length === 12 &&
|
|
222
|
+
picker()?.querySelectorAll(".hk-dtp-cell").length === 12 &&
|
|
223
|
+
stage?.children.length === 1);
|
|
218
224
|
expect(stage?.getAttribute("data-dir")).toBe("fwd");
|
|
219
225
|
expect(picker()?.querySelector<HTMLElement>(".hk-dtp-stage")).toBe(stage);
|
|
220
226
|
expect(stage?.children.length).toBe(1); // one pane at a time after settle
|
|
@@ -249,15 +255,22 @@ describe("HkDateTimePicker", () => {
|
|
|
249
255
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
250
256
|
monthBtn?.click();
|
|
251
257
|
await settle();
|
|
252
|
-
|
|
258
|
+
// Destination state = months pane mounted AND the leaving days pane
|
|
259
|
+
// gone — mid-transition the cell counts already read final while the
|
|
260
|
+
// stage still carries both panes.
|
|
261
|
+
await waitForView("the months grid settled to one pane", () =>
|
|
262
|
+
pickCells().length === 12 &&
|
|
263
|
+
picker()?.querySelectorAll(".hk-dtp-cell:not([data-variant])").length === 0);
|
|
253
264
|
expect(pickCells().length).toBe(12);
|
|
254
265
|
// The first step button is "Hour +"; bumping it changes only the time
|
|
255
266
|
// part of the model, which must not yank the view back to days.
|
|
256
267
|
const upBtn = picker()?.querySelector<HTMLButtonElement>(".hk-dtp-step-btn");
|
|
257
268
|
upBtn?.click();
|
|
258
269
|
await nextTick();
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
await waitForView("still the settled months grid after the bump", () =>
|
|
271
|
+
pickCells().length === 12 &&
|
|
272
|
+
picker()?.querySelectorAll(".hk-dtp-cell:not([data-variant])").length === 0);
|
|
273
|
+
expect(picker()?.querySelectorAll(".hk-dtp-cell:not([data-variant])").length).toBe(0);
|
|
261
274
|
});
|
|
262
275
|
|
|
263
276
|
it("selecting a day emits a new Date preserving the time of day", async () => {
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
@include mi.item;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/* Header/footer/extra slot wrappers: geometry-neutral by contract (the
|
|
20
|
+
* panel's own inset applies), but they are the DOM hook that says rows
|
|
21
|
+
* in here speak the menu-item grammar. */
|
|
22
|
+
.hk-menu-slot {
|
|
23
|
+
display: block;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
.hk-menu-flag {
|
|
20
27
|
flex-shrink: 0;
|
|
21
28
|
font-size: var(--text-base);
|
|
@@ -465,11 +465,16 @@ describe("HkMenu slots", () => {
|
|
|
465
465
|
expect(level).toBeTruthy();
|
|
466
466
|
expect(level.querySelector(".menu-header")!.textContent).toBe("Identity Block");
|
|
467
467
|
expect(level.querySelector(".menu-footer")!.textContent).toBe("Footer Note");
|
|
468
|
-
// Ordering: header → rows → footer
|
|
468
|
+
// Ordering: header → rows → footer, with the header/footer riding
|
|
469
|
+
// the standard .hk-menu-slot wrappers (their rows inherit the
|
|
470
|
+
// panel's inset; consumers' markup stays untouched inside).
|
|
469
471
|
const children = Array.from(level.children).map((el) => el.className);
|
|
470
|
-
expect(children[0]).toContain("menu-
|
|
472
|
+
expect(children[0]).toContain("hk-menu-slot--head");
|
|
471
473
|
expect(children[1]).toContain("hk-menu-row");
|
|
472
|
-
expect(children[children.length - 1]).toContain("menu-
|
|
474
|
+
expect(children[children.length - 1]).toContain("hk-menu-slot--foot");
|
|
475
|
+
// The consumer's own markup lives untouched INSIDE the wrappers.
|
|
476
|
+
expect(level.querySelector(".hk-menu-slot--head")!.querySelector(".menu-header")).not.toBeNull();
|
|
477
|
+
expect(level.querySelector(".hk-menu-slot--foot")!.querySelector(".menu-footer")).not.toBeNull();
|
|
473
478
|
});
|
|
474
479
|
|
|
475
480
|
it("renders header/footer inside the mobile sheet too", async () => {
|
|
@@ -806,11 +811,13 @@ describe("HkMenu sidebar variant", () => {
|
|
|
806
811
|
app.mount(container);
|
|
807
812
|
|
|
808
813
|
// The identity block leads the same inline surface (drawer account
|
|
809
|
-
// menu grammar) —
|
|
814
|
+
// menu grammar) — inside the standard slot wrapper, first child of
|
|
815
|
+
// the nav, before any row.
|
|
810
816
|
const nav = container.querySelector(".hk-menu-sidebar");
|
|
811
817
|
const identity = nav?.querySelector(".sidebar-identity");
|
|
812
818
|
expect(identity).not.toBeNull();
|
|
813
|
-
expect(nav!.firstElementChild).toBe(
|
|
819
|
+
expect(nav!.firstElementChild).toBe(nav!.querySelector(".hk-menu-slot--head"));
|
|
820
|
+
expect(nav!.querySelector(".hk-menu-slot--head")!.contains(identity!)).toBe(true);
|
|
814
821
|
});
|
|
815
822
|
|
|
816
823
|
it("renders the check column on rows that opt in (checked !== undefined)", async () => {
|
|
@@ -542,16 +542,35 @@ export default defineComponent({
|
|
|
542
542
|
}
|
|
543
543
|
|
|
544
544
|
/** One level's content inside the shared panel surface: header slot →
|
|
545
|
-
* rows (or the consumer's default-slot rows) → footer slot.
|
|
546
|
-
*
|
|
545
|
+
* rows (or the consumer's default-slot rows) → footer slot. Header
|
|
546
|
+
* and footer ride a standard `.hk-menu-slot` wrapper so their rows
|
|
547
|
+
* inherit the panel's inset and tests/contracts get a stable DOM
|
|
548
|
+
* hook — raw slot vnodes used to splice straight into the level,
|
|
549
|
+
* and every consumer hand-rolled its own spacing (PlatformSwitcher
|
|
550
|
+
* shipped a drifted divider that way). The wrapper renders only
|
|
551
|
+
* when the slot has content: an empty one would cost a phantom
|
|
552
|
+
* flex gap in headerless surfaces. */
|
|
553
|
+
function slotWrapper(kind: "head" | "foot" | "extra", nodes: VNode[]): VNode | null {
|
|
554
|
+
return nodes.length ? (
|
|
555
|
+
<div class={`hk-menu-slot hk-menu-slot--${kind}`}>{nodes}</div>
|
|
556
|
+
) : null;
|
|
557
|
+
}
|
|
558
|
+
|
|
547
559
|
function renderLevelBody(level: number, list: HkMenuItem[]): VNode[] {
|
|
548
|
-
const
|
|
549
|
-
const
|
|
560
|
+
const out: VNode[] = [];
|
|
561
|
+
const head = level === 0 ? slots.header?.() ?? [] : [];
|
|
562
|
+
const foot = level === 0 ? slots.footer?.() ?? [] : [];
|
|
563
|
+
const headWrap = level === 0 ? slotWrapper("head", head) : null;
|
|
564
|
+
const footWrap = slotWrapper("foot", foot);
|
|
565
|
+
if (headWrap) out.push(headWrap);
|
|
550
566
|
if (slots.default) {
|
|
551
|
-
|
|
567
|
+
out.push(...slots.default());
|
|
568
|
+
} else {
|
|
569
|
+
const reserved = checkReserved(list);
|
|
570
|
+
out.push(...list.map((it) => renderRow(it, level, reserved)));
|
|
552
571
|
}
|
|
553
|
-
|
|
554
|
-
return
|
|
572
|
+
if (footWrap) out.push(footWrap);
|
|
573
|
+
return out;
|
|
555
574
|
}
|
|
556
575
|
|
|
557
576
|
function levelSurface(level: number, list: HkMenuItem[]): VNode {
|
|
@@ -796,10 +815,11 @@ export default defineComponent({
|
|
|
796
815
|
return (
|
|
797
816
|
<nav class="hk-menu-sidebar" aria-label={props.title || "menu"}>
|
|
798
817
|
{/* Header slot (identity block, section label, …) above the
|
|
799
|
-
rows — mirrors the popup variant's level-0 header
|
|
800
|
-
account menu renders the same
|
|
801
|
-
on desktop or lives inside a
|
|
802
|
-
|
|
818
|
+
rows — mirrors the popup variant's level-0 header (same
|
|
819
|
+
.hk-menu-slot wrapper), so an account menu renders the same
|
|
820
|
+
grammar whether it pops up on desktop or lives inside a
|
|
821
|
+
drawer on mobile. */}
|
|
822
|
+
{slotWrapper("head", slots.header?.() ?? [])}
|
|
803
823
|
{props.items.map((it) => renderSidebarItem(it, 0))}
|
|
804
824
|
</nav>
|
|
805
825
|
);
|
|
@@ -196,13 +196,10 @@ const HkMessageBoxHost = defineComponent({
|
|
|
196
196
|
),
|
|
197
197
|
footer: () => (
|
|
198
198
|
<div class="hk-message-box-actions">
|
|
199
|
-
{/*
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
{cancelLabel}
|
|
204
|
-
</HkButton>
|
|
205
|
-
)}
|
|
199
|
+
{/* Primary action first, dismiss second — message boxes follow
|
|
200
|
+
the desktop OK-left / Cancel-right convention, matching
|
|
201
|
+
HkConfirmDialog. Alerts have no cancel by definition;
|
|
202
|
+
confirm/prompt hide it only when the host asks. */}
|
|
206
203
|
<HkButton
|
|
207
204
|
variant={confirmVariant}
|
|
208
205
|
class="hk-message-box-confirm"
|
|
@@ -210,6 +207,11 @@ const HkMessageBoxHost = defineComponent({
|
|
|
210
207
|
>
|
|
211
208
|
{confirmLabel}
|
|
212
209
|
</HkButton>
|
|
210
|
+
{props.kind !== "alert" && !props.hideCancel && (
|
|
211
|
+
<HkButton variant="secondary" onClick={cancel}>
|
|
212
|
+
{cancelLabel}
|
|
213
|
+
</HkButton>
|
|
214
|
+
)}
|
|
213
215
|
</div>
|
|
214
216
|
),
|
|
215
217
|
}}
|