@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -68,8 +68,8 @@
68
68
  }
69
69
 
70
70
  /* One selected entry: [flag] label (meta) •active-dot ×]
71
- * The × arms on the first activation (danger tint) and erases on the
72
- * second; desktop hover previews the × so the row reads as deletable. */
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
- afterEach(() => {
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("two-step tag delete: × arms, × again confirms; body click disarms", async () => {
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
- const x = tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!;
169
- x.click();
170
- await nextTick();
171
- expect(tags()[0].hasAttribute("data-armed")).toBe(true);
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
- // Second activation confirms — the tag leaves the list.
174
- x.click();
175
- await nextTick();
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 with
35
- * the shared arm-delete guard: the row's × arms on the first tap
36
- * (danger tint) and erases on the second — desktop hover IS the
37
- * preview, so a single click erases there; the popup stays open so
38
- * several tags can be managed in one pass;
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 armed × confirmed — the host drops that key. */
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
- /** Key of the tag whose delete is ARMED tapped once). */
110
- const armedKey = ref<string | null>(null);
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, no armed tag.
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
- function confirmRemove(key: string) {
183
- armedKey.value = null;
184
- emit("remove", key);
185
- }
186
-
187
- function toggleArm(key: string) {
188
- armedKey.value = armedKey.value === key ? null : key;
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={armed ? confirmLabel : `${removeLabel} — ${tag.label}`}
329
- title={armed ? confirmLabel : `${removeLabel} — ${tag.label}`}
330
- // The × arms on the first activation and
331
- // confirms on the second (touch guard); on
332
- // desktop the hover already previewed the
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
- if (armed) confirmRemove(tag.key);
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 arm/confirm × of the language tag. */
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
- /** Full two-step erase: the × arms (danger tint), the second activation
152
- * confirms. The leaving tag lingers through its transition window (jsdom
153
- * has no CSS engine, so the ghost clears on the next-frame fallback)
154
- * poll until the tag is really gone. */
155
- async function eraseViaArm(label: string) {
156
- tagX(label)!.click();
157
- await nextTick();
158
- expect(tag(label)?.hasAttribute("data-armed"), "tag is armed before the confirm ×").toBe(true);
159
- tagX(label)!.click();
160
- await nextTick();
161
- await nextTick();
162
- const deadline = Date.now() + 800;
163
- while (tag(label) && Date.now() < deadline) {
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() + 800;
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
- afterEach(() => {
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("arms only the tapped tag and disarms on a second tap", async () => {
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 nextTick();
498
- expect(tag("简体中文")?.hasAttribute("data-armed")).toBe(true);
499
- expect(tag("English")?.hasAttribute("data-armed")).toBe(false);
500
- // Tapping the same × again disarms.
501
- tagX("简体中文")!.click();
502
- await nextTick();
503
- expect(tag("简体中文")?.hasAttribute("data-armed")).toBe(false);
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 current step", async () => {
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("disarms every tag when the popup closes", async () => {
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 nextTick();
535
- expect(tag("简体中文")?.hasAttribute("data-armed")).toBe(true);
536
- // Close the popup, then reopen it: the armed state must not survive —
537
- // a stale armed tag would turn the next open into an armed delete
538
- // waiting to fire.
539
- queryChip(container).click();
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(tag("简体中文")?.hasAttribute("data-armed")).toBe(false);
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 arm confirm and keeps the popup open", async () => {
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 eraseViaArm("简体中文");
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 eraseViaArm("简体中文");
574
- await eraseViaArm("日本語");
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 eraseViaArm("简体中文");
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 eraseViaArm("English");
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 eraseViaArm("English");
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";