@celestia-island/hikari 0.55.214 → 0.55.215

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.55.214",
3
+ "version": "0.55.215",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -128,6 +128,39 @@
128
128
  to { transform: rotate(360deg); }
129
129
  }
130
130
 
131
+ /* Inline action (e.g. Undo): a compact text pill in the toast chrome,
132
+ * between the body and the icon buttons. Sits in the same ghost-button
133
+ * family as copy/close, but reads as an affordance. */
134
+ .hk-toast-action {
135
+ flex: none;
136
+ align-self: center;
137
+ margin-inline-start: var(--space-4, 4px);
138
+ padding: var(--space-2, 2px) var(--space-8, 8px);
139
+ border: 1px solid rgb(var(--color-border, 100 100 100) / 30%);
140
+ border-radius: var(--radius-sm, 4px);
141
+ background: transparent;
142
+ color: inherit;
143
+ font: inherit;
144
+ font-size: var(--text-sm, 0.8125rem);
145
+ font-weight: 600;
146
+ line-height: 1.4;
147
+ cursor: pointer;
148
+ white-space: nowrap;
149
+ opacity: 0.92;
150
+ transition: opacity var(--duration-fast, 0.15s) ease,
151
+ border-color var(--duration-fast, 0.15s) ease;
152
+ }
153
+
154
+ .hk-toast-action:hover {
155
+ opacity: 1;
156
+ border-color: rgb(var(--color-border, 100 100 100) / 55%);
157
+ }
158
+
159
+ .hk-toast-action:focus-visible {
160
+ outline: 2px solid rgb(var(--color-primary));
161
+ outline-offset: 2px;
162
+ }
163
+
131
164
  .hk-toast-copy-btn,
132
165
  .hk-toast-close {
133
166
  display: flex;
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick } from "vue";
3
3
 
4
4
  import HkToast from "./HkToast";
@@ -262,4 +262,75 @@ describe("HkToast live-region semantics", () => {
262
262
  expect(el.getAttribute("aria-live"), type).toBe("polite");
263
263
  }
264
264
  });
265
+
266
+ it("renders an inline action button that dismisses first, then runs", async () => {
267
+ mountHost();
268
+ const toast = useToast();
269
+ let slotGoneAtClick = false;
270
+ toast.show("Player removed", {
271
+ type: "success",
272
+ duration: 8000,
273
+ action: {
274
+ label: "Undo",
275
+ onClick: () => {
276
+ // The dismissal is synchronous and precedes the handler.
277
+ slotGoneAtClick = !toast.toasts.some((t) => t.type === "success");
278
+ },
279
+ },
280
+ });
281
+ await settle();
282
+
283
+ const item = items()[0];
284
+ const button = item.querySelector<HTMLButtonElement>(".hk-toast-action");
285
+ expect(button).not.toBeNull();
286
+ expect(button!.textContent).toBe("Undo");
287
+
288
+ button!.click();
289
+ await settle();
290
+ expect(slotGoneAtClick).toBe(true);
291
+ expect(items().length).toBe(0); // the action dismisses the toast
292
+ });
293
+
294
+ it("runs the action at most once (the leaving item stays clickable)", async () => {
295
+ mountHost();
296
+ const toast = useToast();
297
+ const undone = vi.fn();
298
+ toast.show("Player removed", {
299
+ type: "success",
300
+ duration: 8000,
301
+ action: { label: "Undo", onClick: undone },
302
+ });
303
+ await settle();
304
+ const button = items()[0].querySelector<HTMLButtonElement>(".hk-toast-action")!;
305
+ button.click();
306
+ button.click(); // the leave transition keeps the node clickable briefly
307
+ await settle();
308
+ expect(undone).toHaveBeenCalledTimes(1);
309
+ });
310
+
311
+ it("renders no action button for plain toasts", async () => {
312
+ mountHost();
313
+ const toast = useToast();
314
+ toast.success("Saved");
315
+ await settle();
316
+ expect(items()[0].querySelector(".hk-toast-action")).toBeNull();
317
+ });
318
+
319
+ it("keeps the toast dismissed even when the action handler throws", async () => {
320
+ mountHost();
321
+ const toast = useToast();
322
+ toast.show("Player removed", {
323
+ type: "success",
324
+ action: {
325
+ label: "Undo",
326
+ onClick: () => {
327
+ throw new Error("undo failed");
328
+ },
329
+ },
330
+ });
331
+ await settle();
332
+ items()[0].querySelector<HTMLButtonElement>(".hk-toast-action")!.click();
333
+ await settle();
334
+ expect(items().length).toBe(0);
335
+ });
265
336
  });
@@ -100,6 +100,24 @@ const HkToastItem = defineComponent({
100
100
  emit("remove", props.toast.id);
101
101
  }
102
102
 
103
+ /** The inline action (e.g. Undo) dismisses the toast and runs the
104
+ * host's handler; a thrown handler must not keep the toast alive.
105
+ * Single-shot: the leaving item stays clickable for the leave
106
+ * transition, and a double-click must not run an undo twice. */
107
+ let actionFired = false;
108
+ function handleAction() {
109
+ if (actionFired) return;
110
+ actionFired = true;
111
+ const action = props.toast.action;
112
+ emit("remove", props.toast.id);
113
+ try {
114
+ action?.onClick?.();
115
+ } catch {
116
+ // A throwing undo handler must not resurrect a dismissed toast
117
+ // (or break the toast stack's render for every other slot).
118
+ }
119
+ }
120
+
103
121
  return () => {
104
122
  const msg = displayedMessage.value;
105
123
  const text = msg?.text ?? "";
@@ -138,6 +156,18 @@ const HkToastItem = defineComponent({
138
156
  <span class="hk-toast-count">{renderCountLabel(count, t("hikari::toast.msgs", "msgs"))}</span>
139
157
  )}
140
158
  </div>
159
+ {props.toast.action && (
160
+ <button
161
+ class="hk-toast-action"
162
+ title={props.toast.action.label}
163
+ onClick={(e: MouseEvent) => {
164
+ e.stopPropagation();
165
+ handleAction();
166
+ }}
167
+ >
168
+ {props.toast.action.label}
169
+ </button>
170
+ )}
141
171
  {props.toast.copyable && (
142
172
  <button
143
173
  class="hk-toast-copy-btn"
package/src/index.ts CHANGED
@@ -383,6 +383,7 @@ export {
383
383
  type OverlayHandle,
384
384
  type PopupHandle,
385
385
  type PopupKind,
386
+ type ToastAction,
386
387
  type ToastItem,
387
388
  type ToastMessage,
388
389
  type ToastType,
@@ -80,7 +80,7 @@ export {
80
80
  type PopupHandle,
81
81
  type PopupKind,
82
82
  } from "./usePopupManager";
83
- export { useToast, TOAST_DURATION, type ToastItem, type ToastMessage, type ToastType } from "./useToast";
83
+ export { useToast, TOAST_DURATION, type ToastAction, type ToastItem, type ToastMessage, type ToastType } from "./useToast";
84
84
  export { useConfirm } from "./useConfirm";
85
85
  export { useBlockingToast, showBlockingToast, resolveBlockingToast, clearBlockingToasts, type BlockingToastItem, type BlockingToastOptions, type BlockingToastVariant } from "./useBlockingToast";
86
86
  export { useBreakpoint } from "./useBreakpoint";
@@ -88,3 +88,47 @@ describe("useToast stacking and dedupe", () => {
88
88
  expect(toast.toasts.some((t) => t.type === "info")).toBe(true);
89
89
  });
90
90
  });
91
+
92
+ describe("useToast inline actions", () => {
93
+ beforeEach(() => {
94
+ vi.useFakeTimers();
95
+ clearAll();
96
+ });
97
+ afterEach(() => {
98
+ clearAll();
99
+ vi.useRealTimers();
100
+ });
101
+
102
+ it("carries the action on the slot", () => {
103
+ const toast = useToast();
104
+ const run = vi.fn();
105
+ toast.show("Player removed", {
106
+ type: "success",
107
+ duration: 8000,
108
+ action: { label: "Undo", onClick: run },
109
+ });
110
+ const slot = toast.toasts.find((t) => t.type === "success")!;
111
+ expect(slot.action?.label).toBe("Undo");
112
+ expect(slot.action?.onClick).toBe(run);
113
+ });
114
+
115
+ it("the latest push owns the action (a plain message clears it)", () => {
116
+ const toast = useToast();
117
+ toast.show("Player removed", {
118
+ type: "success",
119
+ action: { label: "Undo", onClick: () => {} },
120
+ });
121
+ toast.success("Saved");
122
+ expect(toast.toasts.find((t) => t.type === "success")!.action).toBeUndefined();
123
+ });
124
+
125
+ it("re-passing the action keeps it on the shared slot", () => {
126
+ const toast = useToast();
127
+ const first = { label: "Undo", onClick: () => {} };
128
+ const second = { label: "Retry", onClick: () => {} };
129
+ toast.show("Player removed", { type: "success", action: first });
130
+ toast.show("Player removed again", { type: "success", action: second });
131
+ // Reactive slots wrap the object — compare by value, not identity.
132
+ expect(toast.toasts.find((t) => t.type === "success")!.action).toEqual(second);
133
+ });
134
+ });
@@ -26,12 +26,27 @@ export interface ToastMessage {
26
26
  text: string;
27
27
  }
28
28
 
29
+ /** One inline action button on a toast (e.g. "Undo" after a delete).
30
+ * The action belongs to the CURRENT slot state: every push sets or
31
+ * clears it, so the latest message decides what the button offers. */
32
+ export interface ToastAction {
33
+ /** Button label (the caller i18n's it). */
34
+ label: string;
35
+ /** Runs when the button is clicked. The toast dismisses FIRST, then
36
+ * this handler runs — a throwing handler can never keep the toast
37
+ * alive. The button itself is single-shot: a second click during the
38
+ * leave transition is ignored. */
39
+ onClick?: () => void;
40
+ }
41
+
29
42
  export interface ToastItem {
30
43
  id: number;
31
44
  type: ToastType;
32
45
  messages: ToastMessage[];
33
46
  duration?: number;
34
47
  copyable?: boolean;
48
+ /** Inline action button — see ToastAction. */
49
+ action?: ToastAction;
35
50
  }
36
51
 
37
52
  /** Hard cap on stacked messages per toast slot. Beyond it the oldest
@@ -96,7 +111,7 @@ function findSlot(type: ToastType): ToastItem | undefined {
96
111
  function push(
97
112
  type: ToastType,
98
113
  text: string,
99
- options: { duration?: number; copyable?: boolean } = {},
114
+ options: { duration?: number; copyable?: boolean; action?: ToastAction } = {},
100
115
  ): number {
101
116
  let slot = findSlot(type);
102
117
  if (!slot) {
@@ -106,10 +121,16 @@ function push(
106
121
  messages: [],
107
122
  duration: options.duration ?? TOAST_DURATION[type],
108
123
  copyable: options.copyable ?? defaultCopyable(type),
124
+ action: options.action,
109
125
  };
110
126
  state.toasts.push(slot);
111
- } else if (options.copyable === true && !slot.copyable) {
112
- slot.copyable = true;
127
+ } else {
128
+ if (options.copyable === true && !slot.copyable) {
129
+ slot.copyable = true;
130
+ }
131
+ // Latest push owns the action: re-passing it keeps the button, a
132
+ // plain message (no action) clears it off the shared slot.
133
+ slot.action = options.action;
113
134
  }
114
135
  let msgId: number;
115
136
  const last = slot.messages[slot.messages.length - 1];
@@ -141,6 +162,7 @@ export function useToast() {
141
162
  return push(type, message, {
142
163
  duration: options.duration,
143
164
  copyable: options.copyable,
165
+ action: options.action,
144
166
  });
145
167
  }
146
168