@celestia-island/hikari 0.55.214 → 0.55.216
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/HkIconButton.scss +26 -0
- package/src/components/HkIconButton.test.tsx +75 -0
- package/src/components/HkIconButton.tsx +28 -0
- package/src/components/HkToast.scss +33 -0
- package/src/components/HkToast.test.tsx +72 -1
- package/src/components/HkToast.tsx +30 -0
- package/src/index.ts +1 -0
- package/src/runtime/index.ts +1 -1
- package/src/runtime/useToast.test.ts +44 -0
- package/src/runtime/useToast.ts +25 -3
package/package.json
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
align-items: center;
|
|
22
22
|
justify-content: center;
|
|
23
23
|
flex-shrink: 0;
|
|
24
|
+
// The status dot's anchor layer (.hk-icon-button-dot below) — a
|
|
25
|
+
// relative button keeps the mark INSIDE the control's own silhouette
|
|
26
|
+
// no matter what wrappers or flex rows do around it.
|
|
27
|
+
position: relative;
|
|
24
28
|
padding: 0;
|
|
25
29
|
line-height: 0;
|
|
26
30
|
|
|
@@ -141,3 +145,25 @@
|
|
|
141
145
|
transition: none;
|
|
142
146
|
}
|
|
143
147
|
}
|
|
148
|
+
|
|
149
|
+
// ------
|
|
150
|
+
// Button-owned status dot (dot prop)
|
|
151
|
+
// ------
|
|
152
|
+
|
|
153
|
+
/* The mark lives INSIDE the button's own box (bottom-right inset), not
|
|
154
|
+
* straddling a wrapper corner: on a borderless ghost trigger inside a
|
|
155
|
+
* centered flex row, the corner-straddling badge read as a stray dot
|
|
156
|
+
* floating below the glyph (2026-10-28 user report). Tone channels come
|
|
157
|
+
* from HkCornerBadge's variant classes (one badge grammar); the surface
|
|
158
|
+
* ring keeps the dot legible on any button fill. */
|
|
159
|
+
.hk-icon-button-dot {
|
|
160
|
+
position: absolute;
|
|
161
|
+
right: var(--hk-icon-button-dot-inset, 2px);
|
|
162
|
+
bottom: var(--hk-icon-button-dot-inset, 2px);
|
|
163
|
+
width: 8px;
|
|
164
|
+
height: 8px;
|
|
165
|
+
border-radius: 50%;
|
|
166
|
+
background: var(--hk-badge-bg, rgb(var(--color-success)));
|
|
167
|
+
box-shadow: 0 0 0 1.5px rgb(var(--color-surface) / 85%);
|
|
168
|
+
pointer-events: none;
|
|
169
|
+
}
|
|
@@ -151,3 +151,78 @@ describe("HkIconButton glyph step", () => {
|
|
|
151
151
|
expect(block24).toContain("var(--hi-icon-size-xs)");
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
describe("HkIconButton status dot", () => {
|
|
156
|
+
it("renders no dot by default", () => {
|
|
157
|
+
const c = mountComp(() =>
|
|
158
|
+
h(HkIconButton, { "aria-label": "Filter" }, { icon: () => h("span", "x") }),
|
|
159
|
+
);
|
|
160
|
+
expect(c.querySelector(".hk-icon-button-dot")).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("dot renders inside the button with the tone class and stays decorative", () => {
|
|
164
|
+
const c = mountComp(() =>
|
|
165
|
+
h(HkIconButton, { dot: true, "aria-label": "Filter" }, { icon: () => h("span", "x") }),
|
|
166
|
+
);
|
|
167
|
+
const dot = c.querySelector(".hk-icon-button-dot")!;
|
|
168
|
+
expect(dot).not.toBeNull();
|
|
169
|
+
// Tone channels ride HkCornerBadge's variant classes (one grammar).
|
|
170
|
+
expect(dot.classList.contains("hk-corner-badge-success")).toBe(true);
|
|
171
|
+
// Decorative by default: the button's own label carries the state.
|
|
172
|
+
expect(dot.getAttribute("aria-hidden")).toBe("true");
|
|
173
|
+
expect(dot.getAttribute("role")).toBeNull();
|
|
174
|
+
// Inside the button's own silhouette — never a detached wrapper mark.
|
|
175
|
+
expect(dot.closest("button")).not.toBeNull();
|
|
176
|
+
expect(dot.closest(".hk-corner-badge-anchor")).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("dotVariant switches the tone class", () => {
|
|
180
|
+
const c = mountComp(() =>
|
|
181
|
+
h(HkIconButton, { dot: true, dotVariant: "error", "aria-label": "Fail" }, { icon: () => h("span", "x") }),
|
|
182
|
+
);
|
|
183
|
+
expect(c.querySelector(".hk-icon-button-dot")!.classList.contains("hk-corner-badge-error")).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("dotLabel promotes the dot to a named mark", () => {
|
|
187
|
+
const c = mountComp(() =>
|
|
188
|
+
h(HkIconButton, { dot: true, dotLabel: "Filter engaged", "aria-label": "Filter" }, { icon: () => h("span", "x") }),
|
|
189
|
+
);
|
|
190
|
+
const dot = c.querySelector(".hk-icon-button-dot")!;
|
|
191
|
+
expect(dot.getAttribute("aria-hidden")).toBeNull();
|
|
192
|
+
expect(dot.getAttribute("role")).toBe("img");
|
|
193
|
+
expect(dot.getAttribute("aria-label")).toBe("Filter engaged");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Compiled-stylesheet guard for the button-owned status dot (R3-style
|
|
199
|
+
* contract, HkCornerBadge.styles.test.ts pattern): the button is the
|
|
200
|
+
* anchor layer (position: relative) and the dot is an absolutely
|
|
201
|
+
* positioned INSET mark with pointer-events off — the declarations are
|
|
202
|
+
* the contract where happy-dom has no cascade.
|
|
203
|
+
*/
|
|
204
|
+
describe("HkIconButton status dot stylesheet contract", () => {
|
|
205
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
206
|
+
const scss = readFileSync(join(here, "HkIconButton.scss"), "utf8");
|
|
207
|
+
|
|
208
|
+
it("the button is the dot's anchor layer (position: relative)", () => {
|
|
209
|
+
const start = scss.indexOf(".hk-icon-button {");
|
|
210
|
+
const close = scss.indexOf("}", start);
|
|
211
|
+
expect(scss.slice(start, close + 1)).toContain("position: relative");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("the dot is an absolute inset mark with pointer-events off and a round cutout ring", () => {
|
|
215
|
+
const start = scss.indexOf(".hk-icon-button-dot {");
|
|
216
|
+
const close = scss.indexOf("}", start);
|
|
217
|
+
const rule = scss.slice(start, close + 1);
|
|
218
|
+
expect(rule).toContain("position: absolute");
|
|
219
|
+
expect(rule).toContain("right: var(--hk-icon-button-dot-inset");
|
|
220
|
+
expect(rule).toContain("bottom: var(--hk-icon-button-dot-inset");
|
|
221
|
+
expect(rule).toContain("pointer-events: none");
|
|
222
|
+
expect(rule).toContain("border-radius: 50%");
|
|
223
|
+
expect(rule).toContain("box-shadow: 0 0 0 1.5px");
|
|
224
|
+
// Tone rides HkCornerBadge's variant classes; the declaration (not
|
|
225
|
+
// just the class) is pinned so a tone swap cannot slip silently.
|
|
226
|
+
expect(rule).toContain("background: var(--hk-badge-bg");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { computed, defineComponent, type PropType } from "vue";
|
|
2
2
|
import "./HkIconButton.scss";
|
|
3
3
|
import "./HkIconButtonVars.scss";
|
|
4
|
+
// The status dot borrows HkCornerBadge's tone channels (--hk-badge-bg)
|
|
5
|
+
// so the mark on the button and the badge family stay one grammar; the
|
|
6
|
+
// bundler dedupes the sheet (single source of tones).
|
|
7
|
+
import "./HkCornerBadge.scss";
|
|
4
8
|
import HIcon from "./HkIcon";
|
|
9
|
+
import type { CornerBadgeVariant } from "./HkCornerBadge";
|
|
5
10
|
|
|
6
11
|
export default defineComponent({
|
|
7
12
|
name: "HkIconButton",
|
|
@@ -16,6 +21,21 @@ export default defineComponent({
|
|
|
16
21
|
// (user report 2026-09-28: 前台全局搜索与主题切换按钮不同尺寸).
|
|
17
22
|
size: { type: Number as PropType<16 | 24 | 28 | 32 | 36 | 40>, default: 32 },
|
|
18
23
|
disabled: { type: Boolean, default: false },
|
|
24
|
+
/** Button-owned status dot (2026-10-28 user direction: 改进图标按钮
|
|
25
|
+
* 本身). The former pattern — HkCornerBadge wrapped AROUND the
|
|
26
|
+
* trigger — anchors to the WRAPPER's corner (translate 50% straddles
|
|
27
|
+
* the box edge), which on a borderless ghost trigger inside a
|
|
28
|
+
* centered flex row reads as a stray mark floating below the glyph.
|
|
29
|
+
* This dot is laid out INSIDE the button's own box (bottom-right
|
|
30
|
+
* inset), so the mark and the control are one silhouette. Intended
|
|
31
|
+
* for the ≥24 chrome steps — at 16 the 8px dot would crowd the
|
|
32
|
+
* glyph (R2 note; no such consumer today). */
|
|
33
|
+
dot: { type: Boolean, default: false },
|
|
34
|
+
/** Tone of the status dot (same variant names as HkCornerBadge). */
|
|
35
|
+
dotVariant: { type: String as PropType<CornerBadgeVariant>, default: "success" },
|
|
36
|
+
/** Accessible name for the dot (without it the dot is aria-hidden
|
|
37
|
+
* decoration for state the button already announces). */
|
|
38
|
+
dotLabel: { type: String, default: undefined },
|
|
19
39
|
},
|
|
20
40
|
emits: {
|
|
21
41
|
click: (_e: MouseEvent) => true,
|
|
@@ -59,6 +79,14 @@ export default defineComponent({
|
|
|
59
79
|
</svg>
|
|
60
80
|
)}
|
|
61
81
|
</span>
|
|
82
|
+
{props.dot && (
|
|
83
|
+
<span
|
|
84
|
+
class={["hk-icon-button-dot", `hk-corner-badge-${props.dotVariant}`]}
|
|
85
|
+
aria-hidden={props.dotLabel ? undefined : "true"}
|
|
86
|
+
role={props.dotLabel ? "img" : undefined}
|
|
87
|
+
aria-label={props.dotLabel}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
62
90
|
</button>
|
|
63
91
|
);
|
|
64
92
|
},
|
|
@@ -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
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/runtime/useToast.ts
CHANGED
|
@@ -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
|
|
112
|
-
|
|
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
|
|