@celestia-island/hikari 0.33.0 → 0.35.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.35.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
  >
@@ -0,0 +1,83 @@
1
+ // HkBoard — the shared node-and-edge board surface.
2
+ // Geometry semantics live in utils/boardCamera.ts + utils/boardEdges.ts;
3
+ // this file only styles the layers (grid, world, edges, node shells,
4
+ // minimap dock).
5
+
6
+ .hk-board {
7
+ position: relative;
8
+ width: 100%;
9
+ height: 100%;
10
+ min-height: 0;
11
+ overflow: hidden;
12
+ background: rgb(var(--color-surface));
13
+ // gestures own the surface: no native scroll/selection interference
14
+ touch-action: none;
15
+ user-select: none;
16
+ }
17
+
18
+ .hk-board-grid {
19
+ position: absolute;
20
+ inset: 0;
21
+ pointer-events: none;
22
+ }
23
+
24
+ .hk-board-world {
25
+ position: absolute;
26
+ top: 0;
27
+ left: 0;
28
+ transform-origin: 0 0;
29
+ will-change: transform;
30
+ }
31
+
32
+ .hk-board-edges {
33
+ position: absolute;
34
+ top: 0;
35
+ left: 0;
36
+ overflow: visible;
37
+ pointer-events: none;
38
+ }
39
+
40
+ .hk-board-edge {
41
+ fill: none;
42
+ stroke-linecap: round;
43
+ }
44
+
45
+ .hk-board-node {
46
+ position: absolute;
47
+ box-sizing: border-box;
48
+
49
+ &--draggable {
50
+ cursor: grab;
51
+
52
+ &:active {
53
+ cursor: grabbing;
54
+ }
55
+ }
56
+ }
57
+
58
+ .hk-board-node-shell {
59
+ width: 100%;
60
+ height: 100%;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ padding: 0 var(--space-6);
65
+ box-sizing: border-box;
66
+ overflow: hidden;
67
+ text-overflow: ellipsis;
68
+ white-space: nowrap;
69
+ font-size: var(--text-2xs);
70
+ color: rgb(var(--color-text));
71
+ background: rgb(var(--color-surface));
72
+ border: 1px solid rgb(var(--color-primary) / 45%);
73
+ border-radius: var(--radius-sm);
74
+ box-shadow: var(--shadow-elevated);
75
+ }
76
+
77
+ .hk-board-minimap {
78
+ position: absolute;
79
+ right: var(--space-10);
80
+ bottom: var(--space-10);
81
+ z-index: 6;
82
+ pointer-events: auto;
83
+ }