@celestia-island/hikari 0.33.0 → 0.34.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/HkAffixPicker.scss +2 -11
- package/src/components/HkAffixPicker.test.tsx +97 -17
- package/src/components/HkAffixPicker.tsx +52 -32
- package/src/components/HkLocalizedInput.test.tsx +105 -55
- package/src/components/HkMessageBox.scss +44 -0
- package/src/components/HkMessageBox.test.tsx +151 -0
- package/src/components/HkMessageBox.tsx +283 -0
- package/src/i18n/locales/en/components.json +9 -2
- package/src/i18n/locales/zh-Hans/components.json +9 -2
- package/src/i18n/locales/zh-Hant/components.json +9 -2
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/* One selected entry: [flag] label (meta) •active-dot ×]
|
|
71
|
-
* The ×
|
|
72
|
-
*
|
|
71
|
+
* The × opens the shared confirm message box — the dialog's Confirm is
|
|
72
|
+
* what actually erases; the × alone never does. */
|
|
73
73
|
.hk-affix-tag {
|
|
74
74
|
display: flex;
|
|
75
75
|
align-items: center;
|
|
@@ -94,10 +94,6 @@
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
&[data-armed] {
|
|
98
|
-
border-color: rgb(var(--color-danger, 240 70 80) / 55%);
|
|
99
|
-
background: rgb(var(--color-danger, 240 70 80) / 12%);
|
|
100
|
-
}
|
|
101
97
|
}
|
|
102
98
|
|
|
103
99
|
.hk-affix-tag-body {
|
|
@@ -168,11 +164,6 @@
|
|
|
168
164
|
color: rgb(var(--color-danger, 240 70 80));
|
|
169
165
|
background: rgb(var(--color-danger, 240 70 80) / 12%);
|
|
170
166
|
}
|
|
171
|
-
|
|
172
|
-
[data-armed] & {
|
|
173
|
-
color: rgb(var(--color-danger, 240 70 80));
|
|
174
|
-
background: rgb(var(--color-danger, 240 70 80) / 18%);
|
|
175
|
-
}
|
|
176
167
|
}
|
|
177
168
|
|
|
178
169
|
/* ── option rows ────────────────────────────────────────────────────── */
|
|
@@ -18,6 +18,7 @@ interface MountOptions {
|
|
|
18
18
|
allowCustom?: boolean;
|
|
19
19
|
searchable?: boolean;
|
|
20
20
|
closeOnSelect?: boolean;
|
|
21
|
+
confirmRemove?: boolean;
|
|
21
22
|
disabled?: boolean;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -40,6 +41,7 @@ function mountPicker(opts: MountOptions = {}) {
|
|
|
40
41
|
allowCustom: opts.allowCustom ?? false,
|
|
41
42
|
searchable: opts.searchable ?? true,
|
|
42
43
|
closeOnSelect: opts.closeOnSelect,
|
|
44
|
+
confirmRemove: opts.confirmRemove,
|
|
43
45
|
disabled: opts.disabled ?? false,
|
|
44
46
|
onSelect: (key: string) => events.select.push(key),
|
|
45
47
|
onRemove: (key: string) => events.remove.push(key),
|
|
@@ -100,7 +102,42 @@ async function untilSettled(check: () => number): Promise<void> {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
|
|
105
|
+
/** Let the message box mount (own app) and its promise chain settle. */
|
|
106
|
+
async function flush() {
|
|
107
|
+
await nextTick();
|
|
108
|
+
await nextTick();
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function confirmButton(): HTMLButtonElement {
|
|
113
|
+
const btn = document.body.querySelector<HTMLButtonElement>(".hk-message-box-confirm");
|
|
114
|
+
expect(btn, "message box confirm button renders").toBeTruthy();
|
|
115
|
+
return btn!;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Dismiss every open message box and wait out its host's self-unmount
|
|
119
|
+
* (leave transition + timer) so no zombie app re-renders into the next
|
|
120
|
+
* test's DOM. No-op when a test never opened one. */
|
|
121
|
+
async function teardownMessageBoxes() {
|
|
122
|
+
if (!document.body.querySelector(".hk-message-box-confirm")) return;
|
|
123
|
+
for (const btn of [...document.body.querySelectorAll<HTMLButtonElement>(".hk-message-box-confirm")]) {
|
|
124
|
+
btn.click();
|
|
125
|
+
}
|
|
126
|
+
const deadline = Date.now() + 2500;
|
|
127
|
+
// The modal root (not just the confirm button) must be gone: the
|
|
128
|
+
// leave transition + cleanup timer fully unmount the host app, so no
|
|
129
|
+
// zombie registration lingers in the shared popup stack.
|
|
130
|
+
while (
|
|
131
|
+
(document.body.querySelector(".hk-message-box-confirm") ||
|
|
132
|
+
document.body.querySelector(".hk-modal-root")) &&
|
|
133
|
+
Date.now() < deadline
|
|
134
|
+
) {
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
afterEach(async () => {
|
|
140
|
+
await teardownMessageBoxes();
|
|
104
141
|
for (const { app, container } of mounts.splice(0)) {
|
|
105
142
|
app.unmount();
|
|
106
143
|
container.remove();
|
|
@@ -162,29 +199,72 @@ describe("HkAffixPicker", () => {
|
|
|
162
199
|
expect(rows().length).toBeGreaterThan(0);
|
|
163
200
|
});
|
|
164
201
|
|
|
165
|
-
it("
|
|
202
|
+
it("tag × opens a confirm dialog: Cancel keeps the tag, Confirm fires remove", async () => {
|
|
166
203
|
const { events, container } = mountPicker({ mode: "multi", selected: ["cn", "jp"] });
|
|
167
204
|
await openPopup(container);
|
|
168
|
-
|
|
169
|
-
x
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
// One tap never erases: the dialog names the entry instead.
|
|
206
|
+
tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
|
|
207
|
+
// The dialog mounts over several transition frames; wait for the
|
|
208
|
+
// settled state (box open, tag untouched) before asserting.
|
|
209
|
+
await untilBoxOpen('Remove "China"');
|
|
172
210
|
expect(events.remove).toHaveLength(0);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
expect(tag("China")).toBeTruthy();
|
|
212
|
+
// Cancel keeps everything as it was.
|
|
213
|
+
[...document.body.querySelectorAll<HTMLButtonElement>(".hk-message-box-actions button")]
|
|
214
|
+
.find((b) => !b.classList.contains("hk-message-box-confirm"))!
|
|
215
|
+
.click();
|
|
216
|
+
await flush();
|
|
217
|
+
console.log("DBG-cancel openEvents", JSON.stringify(events.open), "tags", tags().length, "box", !!document.body.querySelector(".hk-message-box-text"));
|
|
218
|
+
expect(events.remove).toHaveLength(0);
|
|
219
|
+
expect(tag("China")).toBeTruthy();
|
|
220
|
+
// Second pass: the dialog's Confirm is what erases.
|
|
221
|
+
tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
|
|
222
|
+
await untilBoxOpen('Remove "China"');
|
|
223
|
+
confirmButton().click();
|
|
224
|
+
const deadline = Date.now() + 1500;
|
|
225
|
+
while (events.remove.length === 0 && Date.now() < deadline) {
|
|
226
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
227
|
+
await nextTick();
|
|
228
|
+
}
|
|
176
229
|
expect(events.remove).toEqual(["cn"]);
|
|
177
|
-
// Body click on an ARMED tag disarms it instead of picking.
|
|
178
|
-
const jpBody = tag("Japan")!.querySelector<HTMLButtonElement>(".hk-affix-tag-body")!;
|
|
179
|
-
tag("Japan")!.querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
|
|
180
|
-
await nextTick();
|
|
181
|
-
expect(tag("Japan")?.hasAttribute("data-armed")).toBe(true);
|
|
182
|
-
jpBody.click();
|
|
183
|
-
await nextTick();
|
|
184
|
-
expect(tag("Japan")?.hasAttribute("data-armed")).toBe(false);
|
|
185
230
|
expect(events.select).toHaveLength(0);
|
|
186
231
|
});
|
|
187
232
|
|
|
233
|
+
it("confirmRemove=false lets the × fire remove immediately, no dialog", async () => {
|
|
234
|
+
const { events, container } = mountPicker({
|
|
235
|
+
mode: "multi",
|
|
236
|
+
selected: ["cn"],
|
|
237
|
+
confirmRemove: false,
|
|
238
|
+
});
|
|
239
|
+
await openPopup(container);
|
|
240
|
+
tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
|
|
241
|
+
await flush();
|
|
242
|
+
expect(events.remove).toEqual(["cn"]);
|
|
243
|
+
expect(document.body.querySelector(".hk-message-box-text")).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/** Poll until the confirm dialog is mounted AND shows the given body
|
|
247
|
+
* text — the box rides HkModal's multi-frame open transition. */
|
|
248
|
+
async function untilBoxOpen(fragment: string): Promise<void> {
|
|
249
|
+
const deadline = Date.now() + 1500;
|
|
250
|
+
while (Date.now() < deadline) {
|
|
251
|
+
const text = document.body.querySelector(".hk-message-box-text")?.textContent ?? "";
|
|
252
|
+
if (text.includes(fragment)) {
|
|
253
|
+
// One extra frame so sibling re-renders (scrollbar lock, panel
|
|
254
|
+
// reposition) triggered by the modal settle too.
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
256
|
+
await nextTick();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
260
|
+
await nextTick();
|
|
261
|
+
}
|
|
262
|
+
expect(
|
|
263
|
+
document.body.querySelector(".hk-message-box-text")?.textContent ?? "",
|
|
264
|
+
`confirm dialog showing "${fragment}"`,
|
|
265
|
+
).toContain(fragment);
|
|
266
|
+
}
|
|
267
|
+
|
|
188
268
|
it("tag body click emits select; close-on-select default keeps multi open", async () => {
|
|
189
269
|
const { events, container } = mountPicker({ mode: "multi", selected: ["cn", "jp"] });
|
|
190
270
|
await openPopup(container);
|
|
@@ -7,6 +7,7 @@ import { useI18n } from "../i18n/context";
|
|
|
7
7
|
import HkInput from "./HkInput";
|
|
8
8
|
import HkListTransition from "./HkListTransition";
|
|
9
9
|
import HkMenu from "./HkMenu";
|
|
10
|
+
import HkMessageBox from "./HkMessageBox";
|
|
10
11
|
import "./HkAffixPicker.scss";
|
|
11
12
|
|
|
12
13
|
/** One pickable entry of the affix picker's searchable list. */
|
|
@@ -31,11 +32,12 @@ export interface HkAffixOption {
|
|
|
31
32
|
* opens one canonical popup:
|
|
32
33
|
*
|
|
33
34
|
* - a SEARCH FIELD on top (live filter over label / meta / keywords);
|
|
34
|
-
* - in `multi` mode, a TAG LIST of the currently selected keys
|
|
35
|
-
*
|
|
36
|
-
* (danger
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* - in `multi` mode, a TAG LIST of the currently selected keys.
|
|
36
|
+
* Deleting a tag is a TWO-STEP interaction: the × opens the shared
|
|
37
|
+
* HkMessageBox confirm dialog (danger tone, naming the entry), and
|
|
38
|
+
* only its Confirm fires `remove`. `confirmRemove={false}` opts
|
|
39
|
+
* out for hosts that confirm themselves. The popup stays open
|
|
40
|
+
* behind the dialog so several tags can be managed in one pass;
|
|
39
41
|
* - the option rows themselves: single mode shows every option and
|
|
40
42
|
* closes on pick; multi mode lists the NOT-yet-selected options and
|
|
41
43
|
* stays open for batch adds;
|
|
@@ -73,6 +75,9 @@ export const HkAffixPicker = defineComponent({
|
|
|
73
75
|
searchable: { type: Boolean, default: true },
|
|
74
76
|
/** Offer a "Use <query>" row for free-text entries. */
|
|
75
77
|
allowCustom: { type: Boolean, default: false },
|
|
78
|
+
/** Gate tag deletion behind a confirm message box (multi mode).
|
|
79
|
+
* Default true; pass false when the host runs its own guard. */
|
|
80
|
+
confirmRemove: { type: Boolean, default: true },
|
|
76
81
|
/** Override the default close-on-pick (single: true, multi: false).
|
|
77
82
|
* E.g. a multi picker that should close after each add passes
|
|
78
83
|
* true; a single picker that should stay open passes false. */
|
|
@@ -90,7 +95,8 @@ export const HkAffixPicker = defineComponent({
|
|
|
90
95
|
emits: {
|
|
91
96
|
/** A row or tag body was picked (multi add / single choose). */
|
|
92
97
|
select: (_key: string) => true,
|
|
93
|
-
/** A tag's
|
|
98
|
+
/** A tag's × (after the confirm dialog accepted) — the host drops
|
|
99
|
+
* that key. */
|
|
94
100
|
remove: (_key: string) => true,
|
|
95
101
|
/** The "Use <query>" row fired with the typed text. */
|
|
96
102
|
custom: (_query: string) => true,
|
|
@@ -106,14 +112,15 @@ export const HkAffixPicker = defineComponent({
|
|
|
106
112
|
const open = ref(false);
|
|
107
113
|
const chipRef = ref<HTMLElement | null>(null);
|
|
108
114
|
const query = ref("");
|
|
109
|
-
/**
|
|
110
|
-
|
|
115
|
+
/** True while our confirm dialog is up: clicks inside the dialog
|
|
116
|
+
* are outside THIS popup, and the panel's outside-close must not
|
|
117
|
+
* tear the tag list down mid-decision. */
|
|
118
|
+
const confirmHeld = ref(false);
|
|
111
119
|
|
|
112
|
-
// A fresh open starts calm: empty filter
|
|
120
|
+
// A fresh open starts calm: empty filter.
|
|
113
121
|
watch(open, (v) => {
|
|
114
122
|
if (!v) {
|
|
115
123
|
query.value = "";
|
|
116
|
-
armedKey.value = null;
|
|
117
124
|
}
|
|
118
125
|
emit("update:open", v);
|
|
119
126
|
});
|
|
@@ -179,13 +186,32 @@ export const HkAffixPicker = defineComponent({
|
|
|
179
186
|
if (resolvedCloseOnSelect.value) open.value = false;
|
|
180
187
|
}
|
|
181
188
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
/** Tag × pressed: unless the host opts out, the shared message box
|
|
190
|
+
* names the entry and asks for confirmation — the × alone never
|
|
191
|
+
* erases anything. Only an accepted dialog fires `remove`. The
|
|
192
|
+
* popup deliberately STAYS OPEN behind the dialog (both while it
|
|
193
|
+
* is up and after Confirm/Cancel) so several tags can be managed
|
|
194
|
+
* in one pass. */
|
|
195
|
+
async function requestRemove(tag: HkAffixOption) {
|
|
196
|
+
if (!props.confirmRemove) {
|
|
197
|
+
emit("remove", tag.key);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const removeLabel = t("hikari::affixPicker.remove", "Remove");
|
|
201
|
+
confirmHeld.value = true;
|
|
202
|
+
try {
|
|
203
|
+
const confirmed = await HkMessageBox.confirm({
|
|
204
|
+
title: t("hikari::affixPicker.removeConfirmTitle", "Remove entry"),
|
|
205
|
+
message: interpolate(t("hikari::affixPicker.removeConfirm", 'Remove "{label}"? This cannot be undone.'), {
|
|
206
|
+
label: tag.label,
|
|
207
|
+
}),
|
|
208
|
+
tone: "danger",
|
|
209
|
+
confirmText: removeLabel,
|
|
210
|
+
});
|
|
211
|
+
if (confirmed) emit("remove", tag.key);
|
|
212
|
+
} finally {
|
|
213
|
+
confirmHeld.value = false;
|
|
214
|
+
}
|
|
189
215
|
}
|
|
190
216
|
|
|
191
217
|
function useCustom() {
|
|
@@ -220,7 +246,6 @@ export const HkAffixPicker = defineComponent({
|
|
|
220
246
|
props.searchPlaceholder ?? t("hikari::affixPicker.search", "Search");
|
|
221
247
|
const emptyText = props.emptyText ?? t("hikari::affixPicker.empty", "No matches");
|
|
222
248
|
const removeLabel = t("hikari::affixPicker.remove", "Remove");
|
|
223
|
-
const confirmLabel = t("hikari::affixPicker.confirmDelete", "Confirm remove");
|
|
224
249
|
const placement = props.side === "suffix" ? "bottom-end" : "bottom-start";
|
|
225
250
|
return (
|
|
226
251
|
<>
|
|
@@ -263,6 +288,9 @@ export const HkAffixPicker = defineComponent({
|
|
|
263
288
|
items={[]}
|
|
264
289
|
open={open.value}
|
|
265
290
|
onUpdate:open={(v: boolean) => {
|
|
291
|
+
// Close requests that arrive while the confirm dialog is
|
|
292
|
+
// up are the dialog's own clicks — ignore them.
|
|
293
|
+
if (!v && confirmHeld.value) return;
|
|
266
294
|
open.value = v;
|
|
267
295
|
}}
|
|
268
296
|
anchorRef={chipRef.value}
|
|
@@ -287,13 +315,11 @@ export const HkAffixPicker = defineComponent({
|
|
|
287
315
|
class="hk-affix-tag-list"
|
|
288
316
|
>
|
|
289
317
|
{tags.map((tag) => {
|
|
290
|
-
const armed = armedKey.value === tag.key;
|
|
291
318
|
return (
|
|
292
319
|
<div
|
|
293
320
|
key={tag.key}
|
|
294
321
|
class="hk-affix-tag"
|
|
295
322
|
data-active={activeSet.value.includes(tag.key) || undefined}
|
|
296
|
-
data-armed={armed || undefined}
|
|
297
323
|
>
|
|
298
324
|
<button
|
|
299
325
|
type="button"
|
|
@@ -302,10 +328,6 @@ export const HkAffixPicker = defineComponent({
|
|
|
302
328
|
aria-label={`${t("hikari::affixPicker.switchTo", "Switch to")} ${tag.label}`}
|
|
303
329
|
onClick={(e: MouseEvent) => {
|
|
304
330
|
e.stopPropagation();
|
|
305
|
-
if (armed) {
|
|
306
|
-
armedKey.value = null;
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
331
|
pick(tag);
|
|
310
332
|
}}
|
|
311
333
|
>
|
|
@@ -325,16 +347,14 @@ export const HkAffixPicker = defineComponent({
|
|
|
325
347
|
<button
|
|
326
348
|
type="button"
|
|
327
349
|
class="hk-affix-tag-x"
|
|
328
|
-
aria-label={
|
|
329
|
-
title={
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// intent, so the first click erases.
|
|
350
|
+
aria-label={`${removeLabel} — ${tag.label}`}
|
|
351
|
+
title={`${removeLabel} — ${tag.label}`}
|
|
352
|
+
// One tap opens the confirm dialog; the
|
|
353
|
+
// dialog's Confirm is what actually
|
|
354
|
+
// erases (fires `remove`).
|
|
334
355
|
onClick={(e: MouseEvent) => {
|
|
335
356
|
e.stopPropagation();
|
|
336
|
-
|
|
337
|
-
else toggleArm(tag.key);
|
|
357
|
+
void requestRemove(tag);
|
|
338
358
|
}}
|
|
339
359
|
onMousedown={(e: MouseEvent) => e.preventDefault()}
|
|
340
360
|
>
|
|
@@ -143,33 +143,76 @@ function tagBody(label: string): HTMLButtonElement | undefined {
|
|
|
143
143
|
return tag(label)?.querySelector<HTMLButtonElement>(".hk-affix-tag-body") ?? undefined;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
/** The
|
|
146
|
+
/** The × of the language tag (opens the confirm dialog). */
|
|
147
147
|
function tagX(label: string): HTMLButtonElement | undefined {
|
|
148
148
|
return tag(label)?.querySelector<HTMLButtonElement>(".hk-affix-tag-x") ?? undefined;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
151
|
+
/** The message box's confirm/cancel buttons (mounted at body level). */
|
|
152
|
+
function boxButton(confirm: boolean): HTMLButtonElement {
|
|
153
|
+
const selector = confirm ? ".hk-message-box-confirm" : ".hk-message-box-actions button:not(.hk-message-box-confirm)";
|
|
154
|
+
const btn = document.body.querySelector<HTMLButtonElement>(selector);
|
|
155
|
+
expect(btn, `message box ${confirm ? "confirm" : "cancel"} button renders`).toBeTruthy();
|
|
156
|
+
return btn!;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Full erase flow: the × opens the shared confirm dialog naming the
|
|
160
|
+
* entry; the dialog's Confirm erases. The leaving tag lingers through
|
|
161
|
+
* its transition window (jsdom has no CSS engine, so the ghost clears
|
|
162
|
+
* on the next-frame fallback) — poll until the tag is really gone. */
|
|
163
|
+
/** Poll until the condition turns truthy (box leave animations and
|
|
164
|
+
* tag transitions lag a few frames behind the click). */
|
|
165
|
+
async function until(condition: () => boolean, what: string): Promise<void> {
|
|
166
|
+
const deadline = Date.now() + 1500;
|
|
167
|
+
while (!condition() && Date.now() < deadline) {
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
169
|
+
await nextTick();
|
|
170
|
+
}
|
|
171
|
+
expect(condition(), `${what} within the deadline`).toBe(true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Wait until the confirm dialog is mounted and naming the entry —
|
|
175
|
+
* the box rides HkModal's multi-frame open transition. */
|
|
176
|
+
async function untilBoxOpen(label: string): Promise<void> {
|
|
177
|
+
const deadline = Date.now() + 1500;
|
|
178
|
+
while (Date.now() < deadline) {
|
|
179
|
+
const text = document.body.querySelector(".hk-message-box-text")?.textContent ?? "";
|
|
180
|
+
if (text.includes(label)) {
|
|
181
|
+
// One extra frame so sibling re-renders (scrollbar lock, panel
|
|
182
|
+
// reposition) triggered by the modal settle too.
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
184
|
+
await nextTick();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
164
187
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
165
188
|
await nextTick();
|
|
166
189
|
}
|
|
190
|
+
expect(
|
|
191
|
+
document.body.querySelector(".hk-message-box-text")?.textContent ?? "",
|
|
192
|
+
`confirm dialog showing "${label}"`,
|
|
193
|
+
).toContain(label);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function eraseViaConfirm(label: string, tagGone = true) {
|
|
197
|
+
tagX(label)!.click();
|
|
198
|
+
await untilBoxOpen(label);
|
|
199
|
+
boxButton(true).click();
|
|
200
|
+
await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on confirm");
|
|
201
|
+
// The edited language's tag deliberately survives its own erase when
|
|
202
|
+
// no other language remains (it stays as the active tag), so the
|
|
203
|
+
// absence wait only applies to genuinely-removed entries.
|
|
204
|
+
if (tagGone) {
|
|
205
|
+
await until(() => !tag(label), `tag "${label}" erased`);
|
|
206
|
+
} else {
|
|
207
|
+
await nextTick();
|
|
208
|
+
await nextTick();
|
|
209
|
+
}
|
|
167
210
|
}
|
|
168
211
|
|
|
169
212
|
/** Wait for the popup's leave-transition window to finish — rows linger
|
|
170
213
|
* briefly after a close while the popout animates shut. */
|
|
171
214
|
async function untilPickerSettled(): Promise<void> {
|
|
172
|
-
const deadline = Date.now() +
|
|
215
|
+
const deadline = Date.now() + 1500;
|
|
173
216
|
while (pickerRows().length > 0 && Date.now() < deadline) {
|
|
174
217
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
175
218
|
await nextTick();
|
|
@@ -185,7 +228,29 @@ async function typeIntoSearch(value: string) {
|
|
|
185
228
|
await nextTick();
|
|
186
229
|
}
|
|
187
230
|
|
|
188
|
-
|
|
231
|
+
/** Dismiss every open message box and wait out its host's self-unmount
|
|
232
|
+
* (leave transition + timer) so no zombie app re-renders into the next
|
|
233
|
+
* test's DOM. No-op when a test never opened one. */
|
|
234
|
+
async function teardownMessageBoxes() {
|
|
235
|
+
if (!document.body.querySelector(".hk-message-box-confirm")) return;
|
|
236
|
+
for (const btn of [...document.body.querySelectorAll<HTMLButtonElement>(".hk-message-box-confirm")]) {
|
|
237
|
+
btn.click();
|
|
238
|
+
}
|
|
239
|
+
const deadline = Date.now() + 2500;
|
|
240
|
+
// The modal root (not just the confirm button) must be gone: the
|
|
241
|
+
// leave transition + cleanup timer fully unmount the host app, so no
|
|
242
|
+
// zombie registration lingers in the shared popup stack.
|
|
243
|
+
while (
|
|
244
|
+
(document.body.querySelector(".hk-message-box-confirm") ||
|
|
245
|
+
document.body.querySelector(".hk-modal-root")) &&
|
|
246
|
+
Date.now() < deadline
|
|
247
|
+
) {
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
afterEach(async () => {
|
|
253
|
+
await teardownMessageBoxes();
|
|
189
254
|
for (const { app, container } of mounts.splice(0)) {
|
|
190
255
|
app.unmount();
|
|
191
256
|
container.remove();
|
|
@@ -487,72 +552,57 @@ describe("HkLocalizedInput", () => {
|
|
|
487
552
|
expect(document.activeElement).not.toBe(queryField(container));
|
|
488
553
|
});
|
|
489
554
|
|
|
490
|
-
it("
|
|
555
|
+
it("tag × opens the confirm dialog; Cancel keeps the translation intact", async () => {
|
|
491
556
|
const { container } = mountInput({
|
|
492
557
|
modelValue: "Plant overview",
|
|
493
558
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
494
559
|
});
|
|
495
560
|
await openPicker(container);
|
|
496
561
|
tagX("简体中文")!.click();
|
|
497
|
-
await
|
|
498
|
-
|
|
499
|
-
expect(
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
await
|
|
503
|
-
|
|
504
|
-
// Arming a sibling disarms the previous tag.
|
|
505
|
-
tagX("简体中文")!.click();
|
|
506
|
-
await nextTick();
|
|
507
|
-
tagX("English")!.click();
|
|
508
|
-
await nextTick();
|
|
509
|
-
expect(tag("English")?.hasAttribute("data-armed")).toBe(true);
|
|
510
|
-
expect(tag("简体中文")?.hasAttribute("data-armed")).toBe(false);
|
|
562
|
+
await untilBoxOpen("简体中文");
|
|
563
|
+
// The dialog names the entry and carries a danger-toned confirm.
|
|
564
|
+
expect(boxButton(true).className).toContain("hk-btn-danger");
|
|
565
|
+
// Cancel → nothing is erased, the dialog closes.
|
|
566
|
+
boxButton(false).click();
|
|
567
|
+
await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on cancel");
|
|
568
|
+
await until(() => !!tag("简体中文"), "tag stays after cancel");
|
|
511
569
|
});
|
|
512
570
|
|
|
513
|
-
it("names the tag body by its action and the × by its
|
|
571
|
+
it("names the tag body by its switch action and the × by its remove intent", async () => {
|
|
514
572
|
const { container } = mountInput({
|
|
515
573
|
modelValue: "Plant overview",
|
|
516
574
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
517
575
|
});
|
|
518
576
|
await openPicker(container);
|
|
519
577
|
expect(tagBody("简体中文")?.getAttribute("aria-label")).toBe("Switch to 简体中文");
|
|
520
|
-
// Calm × announces the remove; armed × announces the confirm.
|
|
521
578
|
expect(tagX("简体中文")?.getAttribute("aria-label")).toBe("Remove — 简体中文");
|
|
522
|
-
tagX("简体中文")!.click();
|
|
523
|
-
await nextTick();
|
|
524
|
-
expect(tagX("简体中文")?.getAttribute("aria-label")).toBe("Confirm remove");
|
|
525
579
|
});
|
|
526
580
|
|
|
527
|
-
it("
|
|
581
|
+
it("keeps the picker usable after a dismissed delete dialog", async () => {
|
|
528
582
|
const { container } = mountInput({
|
|
529
583
|
modelValue: "Plant overview",
|
|
530
584
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
531
585
|
});
|
|
532
586
|
await openPicker(container);
|
|
533
587
|
tagX("简体中文")!.click();
|
|
534
|
-
await
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
await nextTick();
|
|
541
|
-
await nextTick();
|
|
542
|
-
queryChip(container).click();
|
|
588
|
+
await untilBoxOpen("简体中文");
|
|
589
|
+
boxButton(false).click();
|
|
590
|
+
await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on cancel");
|
|
591
|
+
// The dialog never leaves the picker half-broken: the tag can still
|
|
592
|
+
// switch the edited language right after a dismissal.
|
|
593
|
+
tagBody("简体中文")!.click();
|
|
543
594
|
await nextTick();
|
|
544
595
|
await nextTick();
|
|
545
|
-
expect(
|
|
546
|
-
expect(tag("English")?.hasAttribute("data-armed")).toBe(false);
|
|
596
|
+
expect(document.activeElement).toBe(queryField(container));
|
|
547
597
|
});
|
|
548
598
|
|
|
549
|
-
it("erases a non-current translation via
|
|
599
|
+
it("erases a non-current translation via confirm dialog and keeps the popup open", async () => {
|
|
550
600
|
const { events, container } = mountReactive({
|
|
551
601
|
modelValue: "Plant overview",
|
|
552
602
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
553
603
|
});
|
|
554
604
|
await openPicker(container);
|
|
555
|
-
await
|
|
605
|
+
await eraseViaConfirm("简体中文");
|
|
556
606
|
// The map loses only the erased language; no edit-state events fire.
|
|
557
607
|
expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
|
|
558
608
|
expect(events.modelValue).toEqual([]);
|
|
@@ -570,8 +620,8 @@ describe("HkLocalizedInput", () => {
|
|
|
570
620
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览", ja: "プラント概覧" },
|
|
571
621
|
});
|
|
572
622
|
await openPicker(container);
|
|
573
|
-
await
|
|
574
|
-
await
|
|
623
|
+
await eraseViaConfirm("简体中文");
|
|
624
|
+
await eraseViaConfirm("日本語");
|
|
575
625
|
expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
|
|
576
626
|
expect(tagLabels()).toEqual(["English"]);
|
|
577
627
|
});
|
|
@@ -591,7 +641,7 @@ describe("HkLocalizedInput", () => {
|
|
|
591
641
|
expect(queryChip(container).textContent).toContain("简体中文");
|
|
592
642
|
expect(queryChip(container).textContent).not.toContain("(zh-Hans)");
|
|
593
643
|
await openPicker(container);
|
|
594
|
-
await
|
|
644
|
+
await eraseViaConfirm("简体中文");
|
|
595
645
|
expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
|
|
596
646
|
expect(events.modelValue.at(-1)).toBe("Plant overview");
|
|
597
647
|
expect(events.languagechange.at(-1)).toBe("en");
|
|
@@ -608,7 +658,7 @@ describe("HkLocalizedInput", () => {
|
|
|
608
658
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
609
659
|
});
|
|
610
660
|
await openPicker(container);
|
|
611
|
-
await
|
|
661
|
+
await eraseViaConfirm("English");
|
|
612
662
|
expect(events.translations.at(-1)).toEqual({ "zh-Hans": "工厂总览" });
|
|
613
663
|
expect(events.modelValue.at(-1)).toBe("工厂总览");
|
|
614
664
|
expect(events.languagechange.at(-1)).toBe("zh-Hans");
|
|
@@ -622,7 +672,7 @@ describe("HkLocalizedInput", () => {
|
|
|
622
672
|
translations: { en: "Plant overview" },
|
|
623
673
|
});
|
|
624
674
|
await openPicker(container);
|
|
625
|
-
await
|
|
675
|
+
await eraseViaConfirm("English", false);
|
|
626
676
|
expect(events.translations.at(-1)).toEqual({});
|
|
627
677
|
expect(events.modelValue.at(-1)).toBe("");
|
|
628
678
|
// The edited language tag stays (active); the popup stays open.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* HkMessageBox — the shared transient message box (confirm / alert /
|
|
2
|
+
* single-field prompt). Hosted in its own HkModal; only the body
|
|
3
|
+
* layout (icon + text + the one prompt field) and the action row are
|
|
4
|
+
* styled here. */
|
|
5
|
+
|
|
6
|
+
.hk-message-box {
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
gap: var(--space-8, 8px);
|
|
10
|
+
padding-top: var(--space-2, 2px);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.hk-message-box-icon {
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
color: rgb(var(--color-primary));
|
|
16
|
+
|
|
17
|
+
[data-tone="success"] & {
|
|
18
|
+
color: rgb(var(--color-success, 90 180 110));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
[data-tone="warning"] &,
|
|
22
|
+
[data-tone="danger"] & {
|
|
23
|
+
color: rgb(var(--color-danger, 240 70 80));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.hk-message-box-text {
|
|
28
|
+
margin: 0;
|
|
29
|
+
color: rgb(var(--color-text));
|
|
30
|
+
font-size: var(--text-base, 15px);
|
|
31
|
+
line-height: 1.55;
|
|
32
|
+
white-space: pre-line;
|
|
33
|
+
overflow-wrap: anywhere;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.hk-message-box-prompt {
|
|
37
|
+
margin-top: var(--space-6, 6px);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.hk-message-box-actions {
|
|
41
|
+
display: flex;
|
|
42
|
+
justify-content: flex-end;
|
|
43
|
+
gap: var(--space-8, 8px);
|
|
44
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkMessageBox } from "./HkMessageBox";
|
|
5
|
+
|
|
6
|
+
function confirmButton(): HTMLButtonElement {
|
|
7
|
+
const btn = document.body.querySelector<HTMLButtonElement>(".hk-message-box-confirm");
|
|
8
|
+
expect(btn, "confirm button renders").toBeTruthy();
|
|
9
|
+
return btn!;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function cancelButton(): HTMLButtonElement {
|
|
13
|
+
const btns = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-message-box-actions button")]
|
|
14
|
+
.filter((b) => !b.classList.contains("hk-message-box-confirm"));
|
|
15
|
+
expect(btns.length, "cancel button renders").toBeGreaterThan(0);
|
|
16
|
+
return btns[0]!;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function messageText(): string {
|
|
20
|
+
return document.body.querySelector(".hk-message-box-text")?.textContent ?? "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function flush() {
|
|
24
|
+
await nextTick();
|
|
25
|
+
await nextTick();
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
// Resolve any box still open, then wait for the leave transition +
|
|
31
|
+
// cleanup timer to fully unmount the host app.
|
|
32
|
+
for (const btn of [...document.body.querySelectorAll<HTMLButtonElement>(".hk-message-box-confirm")]) {
|
|
33
|
+
btn.click();
|
|
34
|
+
}
|
|
35
|
+
const deadline = Date.now() + 2500;
|
|
36
|
+
while (
|
|
37
|
+
(document.body.querySelector(".hk-message-box-confirm") ||
|
|
38
|
+
document.body.querySelector(".hk-modal-root")) &&
|
|
39
|
+
Date.now() < deadline
|
|
40
|
+
) {
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
42
|
+
}
|
|
43
|
+
document.body.innerHTML = "";
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("HkMessageBox", () => {
|
|
47
|
+
it("confirm resolves true on Confirm and false on Cancel", async () => {
|
|
48
|
+
const first = HkMessageBox.confirm({ message: "Delete the file?" });
|
|
49
|
+
await flush();
|
|
50
|
+
expect(messageText()).toBe("Delete the file?");
|
|
51
|
+
confirmButton().click();
|
|
52
|
+
await expect(first).resolves.toBe(true);
|
|
53
|
+
await flush();
|
|
54
|
+
|
|
55
|
+
const second = HkMessageBox.confirm({ message: "Delete the file?" });
|
|
56
|
+
await flush();
|
|
57
|
+
cancelButton().click();
|
|
58
|
+
await expect(second).resolves.toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("danger tone paints the confirm button in the danger variant", async () => {
|
|
62
|
+
const pending = HkMessageBox.confirm({ message: "Erase everything", tone: "danger" });
|
|
63
|
+
await flush();
|
|
64
|
+
expect(confirmButton().className).toContain("hk-btn-danger");
|
|
65
|
+
confirmButton().click();
|
|
66
|
+
await expect(pending).resolves.toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("alert has a single action and resolves on dismissal", async () => {
|
|
70
|
+
const pending = HkMessageBox.alert({ message: "All done", tone: "success" });
|
|
71
|
+
await flush();
|
|
72
|
+
expect(document.body.querySelectorAll(".hk-message-box-actions button")).toHaveLength(1);
|
|
73
|
+
confirmButton().click();
|
|
74
|
+
await expect(pending).resolves.toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("prompt resolves the typed value, null on cancel", async () => {
|
|
78
|
+
const first = HkMessageBox.prompt({ message: "Pick a name", prompt: { value: "draft" } });
|
|
79
|
+
await flush();
|
|
80
|
+
const input = document.body.querySelector<HTMLInputElement>(".hk-message-box-prompt input");
|
|
81
|
+
expect(input, "prompt field renders").toBeTruthy();
|
|
82
|
+
expect(input!.value).toBe("draft");
|
|
83
|
+
input!.value = "renamed";
|
|
84
|
+
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
85
|
+
await flush();
|
|
86
|
+
confirmButton().click();
|
|
87
|
+
await expect(first).resolves.toBe("renamed");
|
|
88
|
+
await flush();
|
|
89
|
+
|
|
90
|
+
const second = HkMessageBox.prompt({ message: "Pick a name", prompt: {} });
|
|
91
|
+
await flush();
|
|
92
|
+
cancelButton().click();
|
|
93
|
+
await expect(second).resolves.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("prompt renders the configured field variant and affixes", async () => {
|
|
97
|
+
const pending = HkMessageBox.prompt({
|
|
98
|
+
message: "Set a passphrase",
|
|
99
|
+
prompt: {
|
|
100
|
+
type: "password",
|
|
101
|
+
placeholder: "hunter2",
|
|
102
|
+
prefix: () => h("span", { class: "prompt-prefix-probe" }, "P"),
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
await flush();
|
|
106
|
+
const input = document.body.querySelector<HTMLInputElement>(".hk-message-box-prompt input");
|
|
107
|
+
expect(input?.getAttribute("type")).toBe("password");
|
|
108
|
+
expect(input?.getAttribute("placeholder")).toBe("hunter2");
|
|
109
|
+
expect(document.body.querySelector(".prompt-prefix-probe")?.textContent).toBe("P");
|
|
110
|
+
confirmButton().click();
|
|
111
|
+
await expect(pending).resolves.toBe("");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("prompt validate hook blocks confirmation and shows the error", async () => {
|
|
115
|
+
const pending = HkMessageBox.prompt({
|
|
116
|
+
message: "Set a name",
|
|
117
|
+
prompt: {
|
|
118
|
+
validate: (v) => (v.trim() ? undefined : "Name is required"),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
await flush();
|
|
122
|
+
// Empty value + strict validator → the confirm is blocked, the box
|
|
123
|
+
// stays open and the error is visible.
|
|
124
|
+
confirmButton().click();
|
|
125
|
+
await flush();
|
|
126
|
+
expect(document.body.querySelector(".hk-message-box-prompt")).toBeTruthy();
|
|
127
|
+
expect(document.body.querySelector(".hk-message-box-prompt")?.textContent).toContain(
|
|
128
|
+
"Name is required",
|
|
129
|
+
);
|
|
130
|
+
// Typing clears the error; a valid value then confirms.
|
|
131
|
+
const input = document.body.querySelector<HTMLInputElement>(".hk-message-box-prompt input")!;
|
|
132
|
+
input.value = "ok-name";
|
|
133
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
134
|
+
await flush();
|
|
135
|
+
confirmButton().click();
|
|
136
|
+
await expect(pending).resolves.toBe("ok-name");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("custom button labels override the i18n defaults", async () => {
|
|
140
|
+
const pending = HkMessageBox.confirm({
|
|
141
|
+
message: "Proceed?",
|
|
142
|
+
confirmText: "Yes, go",
|
|
143
|
+
cancelText: "Nope",
|
|
144
|
+
});
|
|
145
|
+
await flush();
|
|
146
|
+
expect(confirmButton().textContent).toBe("Yes, go");
|
|
147
|
+
expect(cancelButton().textContent).toBe("Nope");
|
|
148
|
+
confirmButton().click();
|
|
149
|
+
await expect(pending).resolves.toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { createApp, defineComponent, h, ref, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, CheckCircle2, Info, ShieldAlert } from "lucide-vue-next";
|
|
4
|
+
|
|
5
|
+
import { useI18n } from "../i18n/context";
|
|
6
|
+
|
|
7
|
+
import HkButton from "./HkButton";
|
|
8
|
+
import HkInput from "./HkInput";
|
|
9
|
+
import HkModal from "./HkModal";
|
|
10
|
+
import "./HkMessageBox.scss";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HkMessageBox — the shared transient message box (a Windows-style
|
|
14
|
+
* MessageBox / InputBox): a small modal that asks for a confirmation,
|
|
15
|
+
* shows a notice, or collects ONE value.
|
|
16
|
+
*
|
|
17
|
+
* - `HkMessageBox.confirm(opts)` → Promise<boolean>
|
|
18
|
+
* - `HkMessageBox.alert(opts)` → Promise<void>
|
|
19
|
+
* - `HkMessageBox.prompt(opts)` → Promise<string | null>
|
|
20
|
+
*
|
|
21
|
+
* The prompt's single field is customizable: input variant (`type`,
|
|
22
|
+
* e.g. a password box), label, placeholder, initial value, maxlength,
|
|
23
|
+
* a confirm-time `validate` hook, and prefix/suffix affixes supplied
|
|
24
|
+
* as render functions (e.g. a dial-code chip or a unit suffix).
|
|
25
|
+
*
|
|
26
|
+
* ┌─ SCOPE — read before reaching for this component ─────────────────┐
|
|
27
|
+
* │ The message box is deliberately minimal: at most ONE input, no │
|
|
28
|
+
* │ layout control beyond the affixes, no slots for arbitrary │
|
|
29
|
+
* │ content. If you need more than one field, checkboxes, lists or │
|
|
30
|
+
* │ any complex form, do NOT use the message box — build a real │
|
|
31
|
+
* │ HModal with a form inside. │
|
|
32
|
+
* └───────────────────────────────────────────────────────────────────┘
|
|
33
|
+
*
|
|
34
|
+
* Each call mounts its own transient host and resolves the promise on
|
|
35
|
+
* the user's decision (Esc / backdrop click count as cancel; for
|
|
36
|
+
* `alert` any dismiss resolves). Calls are fire-and-forget by design —
|
|
37
|
+
* await the returned promise at the call site. Avoid firing many boxes
|
|
38
|
+
* in a loop; sequential awaits keep the UX readable.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** The single editable field a prompt box may host. */
|
|
42
|
+
export interface HkMessageBoxPrompt {
|
|
43
|
+
/** Initial value. */
|
|
44
|
+
value?: string;
|
|
45
|
+
label?: string;
|
|
46
|
+
placeholder?: string;
|
|
47
|
+
/** Field variant — plain text, masked password, or number. */
|
|
48
|
+
type?: "text" | "password" | "number";
|
|
49
|
+
/** Render function for the input's PREFIX affix (chip, glyph…). */
|
|
50
|
+
prefix?: () => unknown;
|
|
51
|
+
/** Render function for the input's SUFFIX affix. */
|
|
52
|
+
suffix?: () => unknown;
|
|
53
|
+
/** Confirm-time validation: return an error string to block the
|
|
54
|
+
* confirmation (shown under the field), or undefined to accept. */
|
|
55
|
+
validate?: (value: string) => string | undefined | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HkMessageBoxOptions {
|
|
59
|
+
title?: string;
|
|
60
|
+
/** Body text. Kept as plain text — no arbitrary content by design. */
|
|
61
|
+
message: string;
|
|
62
|
+
/** Visual urgency: icon tone and confirm button variant. */
|
|
63
|
+
tone?: "info" | "success" | "warning" | "danger";
|
|
64
|
+
confirmText?: string;
|
|
65
|
+
cancelText?: string;
|
|
66
|
+
/** Hide the cancel button (pure notice). Default false. */
|
|
67
|
+
hideCancel?: boolean;
|
|
68
|
+
/** When present the box hosts exactly one customizable field and
|
|
69
|
+
* resolves with its value instead of a boolean. */
|
|
70
|
+
prompt?: HkMessageBoxPrompt;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Resolve = (value: unknown) => void;
|
|
74
|
+
|
|
75
|
+
const HkMessageBoxHost = defineComponent({
|
|
76
|
+
name: "HkMessageBoxHost",
|
|
77
|
+
props: {
|
|
78
|
+
title: { type: String, default: undefined },
|
|
79
|
+
message: { type: String, required: true },
|
|
80
|
+
tone: { type: String as PropType<NonNullable<HkMessageBoxOptions["tone"]>>, default: "info" },
|
|
81
|
+
confirmText: { type: String, default: undefined },
|
|
82
|
+
cancelText: { type: String, default: undefined },
|
|
83
|
+
hideCancel: { type: Boolean, default: false },
|
|
84
|
+
prompt: { type: Object as PropType<HkMessageBoxPrompt>, default: undefined },
|
|
85
|
+
resolve: { type: Function as PropType<Resolve>, required: true },
|
|
86
|
+
/** alert resolves on confirm; confirm/prompt resolve booleans/values */
|
|
87
|
+
kind: { type: String as PropType<"alert" | "confirm" | "prompt">, required: true },
|
|
88
|
+
onSettled: { type: Function as PropType<() => void>, required: true },
|
|
89
|
+
},
|
|
90
|
+
setup(props) {
|
|
91
|
+
const { t } = useI18n();
|
|
92
|
+
const open = ref(true);
|
|
93
|
+
const value = ref(props.prompt?.value ?? "");
|
|
94
|
+
const error = ref<string | undefined>(undefined);
|
|
95
|
+
|
|
96
|
+
const toneIcon = () => {
|
|
97
|
+
switch (props.tone) {
|
|
98
|
+
case "success":
|
|
99
|
+
return <CheckCircle2 size={18} aria-hidden="true" />;
|
|
100
|
+
case "warning":
|
|
101
|
+
return <AlertTriangle size={18} aria-hidden="true" />;
|
|
102
|
+
case "danger":
|
|
103
|
+
return <ShieldAlert size={18} aria-hidden="true" />;
|
|
104
|
+
default:
|
|
105
|
+
return <Info size={18} aria-hidden="true" />;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function settle(payload: unknown) {
|
|
110
|
+
props.resolve(payload);
|
|
111
|
+
open.value = false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cancel() {
|
|
115
|
+
settle(props.kind === "alert" ? undefined : props.kind === "prompt" ? null : false);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function confirm() {
|
|
119
|
+
if (props.prompt) {
|
|
120
|
+
// Run the validator first; a returned error string blocks the
|
|
121
|
+
// confirmation and is shown under the field.
|
|
122
|
+
const message = props.prompt.validate?.(value.value);
|
|
123
|
+
if (message) {
|
|
124
|
+
error.value = message;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
error.value = undefined;
|
|
128
|
+
settle(value.value);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
settle(props.kind === "confirm" ? true : undefined);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
const confirmLabel =
|
|
136
|
+
props.confirmText ?? t("hikari::messageBox.confirm", "Confirm");
|
|
137
|
+
const cancelLabel = props.cancelText ?? t("hikari::messageBox.cancel", "Cancel");
|
|
138
|
+
const confirmVariant =
|
|
139
|
+
props.tone === "danger" ? "danger" : "primary";
|
|
140
|
+
const fallbackTitle =
|
|
141
|
+
props.kind === "alert"
|
|
142
|
+
? t("hikari::messageBox.alertTitle", "Notice")
|
|
143
|
+
: props.kind === "prompt"
|
|
144
|
+
? t("hikari::messageBox.promptTitle", "Input")
|
|
145
|
+
: t("hikari::messageBox.confirmTitle", "Please confirm");
|
|
146
|
+
return (
|
|
147
|
+
<HkModal
|
|
148
|
+
modelValue={open.value}
|
|
149
|
+
onUpdate:modelValue={(v: boolean) => {
|
|
150
|
+
// Backdrop / Esc / title-bar close all count as a cancel —
|
|
151
|
+
// the message box never stays half-open after a dismissal.
|
|
152
|
+
if (!v && open.value) cancel();
|
|
153
|
+
}}
|
|
154
|
+
onAfterLeave={() => props.onSettled()}
|
|
155
|
+
title={props.title ?? fallbackTitle}
|
|
156
|
+
width="26rem"
|
|
157
|
+
>
|
|
158
|
+
{{
|
|
159
|
+
default: () => (
|
|
160
|
+
<div class="hk-message-box" data-tone={props.tone}>
|
|
161
|
+
<div class="hk-message-box-icon" aria-hidden="true">
|
|
162
|
+
{toneIcon()}
|
|
163
|
+
</div>
|
|
164
|
+
<p class="hk-message-box-text" role={props.tone === "danger" ? "alert" : undefined}>
|
|
165
|
+
{props.message}
|
|
166
|
+
</p>
|
|
167
|
+
{props.prompt && (
|
|
168
|
+
<div class="hk-message-box-prompt">
|
|
169
|
+
<HkInput
|
|
170
|
+
modelValue={value.value}
|
|
171
|
+
onUpdate:modelValue={(v: string) => {
|
|
172
|
+
value.value = v;
|
|
173
|
+
// A fresh keystroke clears a failed validation.
|
|
174
|
+
if (error.value) error.value = undefined;
|
|
175
|
+
}}
|
|
176
|
+
type={props.prompt.type ?? "text"}
|
|
177
|
+
label={props.prompt.label}
|
|
178
|
+
placeholder={props.prompt.placeholder}
|
|
179
|
+
error={error.value}
|
|
180
|
+
autocomplete="off"
|
|
181
|
+
onKeydown={(e: KeyboardEvent) => {
|
|
182
|
+
if (e.key === "Enter" && !e.isComposing) {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
void confirm();
|
|
185
|
+
}
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{{
|
|
189
|
+
prefix: props.prompt.prefix,
|
|
190
|
+
suffix: props.prompt.suffix,
|
|
191
|
+
}}
|
|
192
|
+
</HkInput>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
),
|
|
197
|
+
footer: () => (
|
|
198
|
+
<div class="hk-message-box-actions">
|
|
199
|
+
{/* Alerts have no cancel by definition; confirm/prompt
|
|
200
|
+
hide it only when the host asks. */}
|
|
201
|
+
{props.kind !== "alert" && !props.hideCancel && (
|
|
202
|
+
<HkButton variant="secondary" onClick={cancel}>
|
|
203
|
+
{cancelLabel}
|
|
204
|
+
</HkButton>
|
|
205
|
+
)}
|
|
206
|
+
<HkButton
|
|
207
|
+
variant={confirmVariant}
|
|
208
|
+
class="hk-message-box-confirm"
|
|
209
|
+
onClick={() => void confirm()}
|
|
210
|
+
>
|
|
211
|
+
{confirmLabel}
|
|
212
|
+
</HkButton>
|
|
213
|
+
</div>
|
|
214
|
+
),
|
|
215
|
+
}}
|
|
216
|
+
</HkModal>
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
function show(kind: "alert" | "confirm" | "prompt", options: HkMessageBoxOptions): Promise<unknown> {
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
let settled = false;
|
|
225
|
+
let cleaned = false;
|
|
226
|
+
let app: ReturnType<typeof createApp> | null = null;
|
|
227
|
+
let container: HTMLElement | null = null;
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
if (cleaned || !app || !container) return;
|
|
230
|
+
cleaned = true;
|
|
231
|
+
if (container.isConnected) {
|
|
232
|
+
app.unmount();
|
|
233
|
+
container.remove();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const settle = (value: unknown) => {
|
|
237
|
+
if (settled) return;
|
|
238
|
+
settled = true;
|
|
239
|
+
resolve(value);
|
|
240
|
+
// The leave transition is playing; reclaim the host when it ends
|
|
241
|
+
// (onSettled) or after the transition duration at the latest — a
|
|
242
|
+
// lost afterLeave event (host DOM wiped by a route change, a test
|
|
243
|
+
// clearing the body) must never leak the app.
|
|
244
|
+
setTimeout(cleanup, 360);
|
|
245
|
+
};
|
|
246
|
+
// Mount on a MACROTASK, never synchronously inside the caller's
|
|
247
|
+
// click dispatch: a box mounted mid-dispatch would see the very
|
|
248
|
+
// same click event reach its (freshly registered) document-level
|
|
249
|
+
// listeners and instantly dismiss itself.
|
|
250
|
+
setTimeout(() => {
|
|
251
|
+
container = document.createElement("div");
|
|
252
|
+
document.body.appendChild(container);
|
|
253
|
+
app = createApp({
|
|
254
|
+
render: () =>
|
|
255
|
+
h(HkMessageBoxHost, {
|
|
256
|
+
...options,
|
|
257
|
+
kind,
|
|
258
|
+
resolve: settle,
|
|
259
|
+
onSettled: () => cleanup(),
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
app.mount(container);
|
|
263
|
+
// Safety net for boxes that never resolve at all.
|
|
264
|
+
setTimeout(cleanup, 5 * 60 * 1000);
|
|
265
|
+
}, 0);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The imperative message box service — see HkMessageBox docs. */
|
|
270
|
+
export const HkMessageBox = {
|
|
271
|
+
/** Notice with a single OK button. Resolves when dismissed. */
|
|
272
|
+
alert: (options: HkMessageBoxOptions): Promise<void> =>
|
|
273
|
+
show("alert", options).then(() => undefined),
|
|
274
|
+
/** Confirmation. Resolves true on confirm, false on cancel/Esc. */
|
|
275
|
+
confirm: (options: HkMessageBoxOptions): Promise<boolean> =>
|
|
276
|
+
show("confirm", options).then((v) => v === true),
|
|
277
|
+
/** Single-field prompt. Resolves the value on confirm, null on
|
|
278
|
+
* cancel/Esc. The field is customizable via `options.prompt`. */
|
|
279
|
+
prompt: (options: HkMessageBoxOptions & { prompt: HkMessageBoxPrompt }): Promise<string | null> =>
|
|
280
|
+
show("prompt", options).then((v) => (typeof v === "string" ? v : null)),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export default HkMessageBox;
|
|
@@ -160,7 +160,14 @@
|
|
|
160
160
|
"hikari::affixPicker.selectedCount": "{count} selected",
|
|
161
161
|
"hikari::affixPicker.switchTo": "Switch to",
|
|
162
162
|
"hikari::affixPicker.remove": "Remove",
|
|
163
|
-
"hikari::affixPicker.confirmDelete": "Confirm remove",
|
|
164
163
|
"hikari::affixPicker.useCustom": "Use \"{query}\"",
|
|
165
|
-
"hikari::localizedInput.noMatches": "No matching language"
|
|
164
|
+
"hikari::localizedInput.noMatches": "No matching language",
|
|
165
|
+
"hikari::messageBox.confirm": "Confirm",
|
|
166
|
+
"hikari::messageBox.cancel": "Cancel",
|
|
167
|
+
"hikari::messageBox.ok": "OK",
|
|
168
|
+
"hikari::affixPicker.removeConfirmTitle": "Remove entry",
|
|
169
|
+
"hikari::affixPicker.removeConfirm": "Remove \"{label}\"? This cannot be undone.",
|
|
170
|
+
"hikari::messageBox.alertTitle": "Notice",
|
|
171
|
+
"hikari::messageBox.confirmTitle": "Please confirm",
|
|
172
|
+
"hikari::messageBox.promptTitle": "Input"
|
|
166
173
|
}
|
|
@@ -164,7 +164,14 @@
|
|
|
164
164
|
"hikari::affixPicker.selectedCount": "已选 {count} 项",
|
|
165
165
|
"hikari::affixPicker.switchTo": "切换到",
|
|
166
166
|
"hikari::affixPicker.remove": "移除",
|
|
167
|
-
"hikari::affixPicker.confirmDelete": "确认移除",
|
|
168
167
|
"hikari::affixPicker.useCustom": "使用「{query}」",
|
|
169
|
-
"hikari::localizedInput.noMatches": "没有匹配的语言"
|
|
168
|
+
"hikari::localizedInput.noMatches": "没有匹配的语言",
|
|
169
|
+
"hikari::messageBox.confirm": "确认",
|
|
170
|
+
"hikari::messageBox.cancel": "取消",
|
|
171
|
+
"hikari::messageBox.ok": "好",
|
|
172
|
+
"hikari::affixPicker.removeConfirmTitle": "移除条目",
|
|
173
|
+
"hikari::affixPicker.removeConfirm": "移除「{label}」?此操作不可撤销。",
|
|
174
|
+
"hikari::messageBox.alertTitle": "提示",
|
|
175
|
+
"hikari::messageBox.confirmTitle": "请确认",
|
|
176
|
+
"hikari::messageBox.promptTitle": "输入"
|
|
170
177
|
}
|
|
@@ -164,7 +164,14 @@
|
|
|
164
164
|
"hikari::affixPicker.selectedCount": "已選 {count} 項",
|
|
165
165
|
"hikari::affixPicker.switchTo": "切換到",
|
|
166
166
|
"hikari::affixPicker.remove": "移除",
|
|
167
|
-
"hikari::affixPicker.confirmDelete": "確認移除",
|
|
168
167
|
"hikari::affixPicker.useCustom": "使用「{query}」",
|
|
169
|
-
"hikari::localizedInput.noMatches": "沒有符合的語言"
|
|
168
|
+
"hikari::localizedInput.noMatches": "沒有符合的語言",
|
|
169
|
+
"hikari::messageBox.confirm": "確認",
|
|
170
|
+
"hikari::messageBox.cancel": "取消",
|
|
171
|
+
"hikari::messageBox.ok": "好",
|
|
172
|
+
"hikari::affixPicker.removeConfirmTitle": "移除項目",
|
|
173
|
+
"hikari::affixPicker.removeConfirm": "移除「{label}」?此操作無法復原。",
|
|
174
|
+
"hikari::messageBox.alertTitle": "提示",
|
|
175
|
+
"hikari::messageBox.confirmTitle": "請確認",
|
|
176
|
+
"hikari::messageBox.promptTitle": "輸入"
|
|
170
177
|
}
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export { default as HKeywordSearchModal } from "./components/HkKeywordSearchModa
|
|
|
47
47
|
export { default as HModalBreadcrumb } from "./components/HkModalBreadcrumb";
|
|
48
48
|
export { default as HPopover, type PopupPlacement } from "./components/HkPopover";
|
|
49
49
|
export { default as HMenu, type HkMenuItem } from "./components/HkMenu";
|
|
50
|
+
export { default as HMessageBox, HkMessageBox, type HkMessageBoxOptions, type HkMessageBoxPrompt } from "./components/HkMessageBox";
|
|
50
51
|
export { HkLocalizedInput as HLocalizedInput, type HkLocaleOption } from "./components/HkLocalizedInput";
|
|
51
52
|
export { default as HMenuPanel } from "./components/HkMenuPanel";
|
|
52
53
|
export { default as HMenuActionItem } from "./components/HkMenuActionItem";
|