@celestia-island/hikari 0.55.213 → 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 +1 -1
- package/src/components/HkContextMenuProvider.content.test.tsx +222 -0
- package/src/components/HkContextMenuProvider.tsx +19 -1
- package/src/components/HkTagDotRow.scss +64 -0
- package/src/components/HkTagDotRow.test.tsx +95 -0
- package/src/components/HkTagDotRow.tsx +65 -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/components/_menu-item.scss +8 -4
- package/src/composables/useContextMenu.test.tsx +86 -0
- package/src/composables/useContextMenu.ts +33 -1
- package/src/index.ts +3 -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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkContextMenuProvider from "./HkContextMenuProvider";
|
|
5
|
+
import HkTagDotRow from "./HkTagDotRow";
|
|
6
|
+
import {
|
|
7
|
+
useContextMenu,
|
|
8
|
+
type ContextMenuApi,
|
|
9
|
+
} from "../composables/useContextMenu";
|
|
10
|
+
|
|
11
|
+
/** Mount into a detached div appended to the body (the menu panels
|
|
12
|
+
* teleport; assertions see them through document.querySelectorAll). */
|
|
13
|
+
function mountToDom(component: ReturnType<typeof defineComponent>): HTMLElement {
|
|
14
|
+
const el = document.createElement("div");
|
|
15
|
+
document.body.appendChild(el);
|
|
16
|
+
createApp(component).mount(el);
|
|
17
|
+
return el;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const popouts = () =>
|
|
21
|
+
Array.from(document.querySelectorAll(".hk-select-popout")) as HTMLElement[];
|
|
22
|
+
|
|
23
|
+
const menuRows = () =>
|
|
24
|
+
Array.from(document.querySelectorAll(".hk-menu-row")) as HTMLButtonElement[];
|
|
25
|
+
|
|
26
|
+
const headSlot = () => document.querySelector(".hk-menu-slot--head");
|
|
27
|
+
const footSlot = () => document.querySelector(".hk-menu-slot--foot");
|
|
28
|
+
|
|
29
|
+
/** The provider host, exposing the injected API to the test. */
|
|
30
|
+
function makeHost(): { Component: ReturnType<typeof defineComponent>; api: () => ContextMenuApi } {
|
|
31
|
+
let held: ContextMenuApi | null = null;
|
|
32
|
+
const Inner = defineComponent({
|
|
33
|
+
name: "ContentInner",
|
|
34
|
+
setup() {
|
|
35
|
+
held = useContextMenu();
|
|
36
|
+
return () => <button class="open-btn">open</button>;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const Component = defineComponent({
|
|
40
|
+
name: "ContentHost",
|
|
41
|
+
setup() {
|
|
42
|
+
return () => (
|
|
43
|
+
<HkContextMenuProvider>
|
|
44
|
+
<Inner />
|
|
45
|
+
</HkContextMenuProvider>
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return { Component, api: () => held! };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
document
|
|
54
|
+
.querySelectorAll(
|
|
55
|
+
".hk-select-popout, .hk-select-sheet-panel, .hk-select-sheet-scrim, .open-btn",
|
|
56
|
+
)
|
|
57
|
+
.forEach((el) => el.remove());
|
|
58
|
+
window.history.replaceState(null, "");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** nextTick + a macrotask + nextTick — panels mount/unmount through
|
|
62
|
+
* transitions and history, so a single nextTick races them. */
|
|
63
|
+
async function settle(): Promise<void> {
|
|
64
|
+
await nextTick();
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
66
|
+
await nextTick();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Wait until pred() holds (panel teardown settles asynchronously). */
|
|
70
|
+
async function until(pred: () => boolean, ms = 500): Promise<void> {
|
|
71
|
+
const deadline = Date.now() + ms;
|
|
72
|
+
while (!pred() && Date.now() < deadline) {
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
74
|
+
await nextTick();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("HkContextMenuProvider — custom content regions", () => {
|
|
79
|
+
it("renders header and footer regions around the item rows", async () => {
|
|
80
|
+
const { Component, api } = makeHost();
|
|
81
|
+
mountToDom(Component);
|
|
82
|
+
api().open({
|
|
83
|
+
x: 100,
|
|
84
|
+
y: 80,
|
|
85
|
+
title: "Menu",
|
|
86
|
+
items: [{ key: "copy", label: "Copy" }],
|
|
87
|
+
header: () => (
|
|
88
|
+
<HkTagDotRow
|
|
89
|
+
tags={[{ key: "red", color: "#e5484d", label: "Red", active: true }]}
|
|
90
|
+
label="tags"
|
|
91
|
+
onToggle={() => {}}
|
|
92
|
+
/>
|
|
93
|
+
),
|
|
94
|
+
footer: () => <div class="content-foot">F</div>,
|
|
95
|
+
onSelect: () => {},
|
|
96
|
+
onClose: () => {},
|
|
97
|
+
});
|
|
98
|
+
await settle();
|
|
99
|
+
expect(popouts()).toHaveLength(1);
|
|
100
|
+
|
|
101
|
+
// The header hosts the tag strip; the footer its own node.
|
|
102
|
+
expect(headSlot()?.querySelector(".hk-tag-dot")).toBeTruthy();
|
|
103
|
+
expect(footSlot()?.querySelector(".content-foot")).toBeTruthy();
|
|
104
|
+
|
|
105
|
+
// Band order: header region → item rows → footer region.
|
|
106
|
+
const level = document.querySelector(".hk-menu-level")!;
|
|
107
|
+
const kinds = Array.from(level.children).map((el) =>
|
|
108
|
+
el.classList.contains("hk-menu-slot") && el.classList.contains("hk-menu-slot--head")
|
|
109
|
+
? "head"
|
|
110
|
+
: el.classList.contains("hk-menu-slot") && el.classList.contains("hk-menu-slot--foot")
|
|
111
|
+
? "foot"
|
|
112
|
+
: "row",
|
|
113
|
+
);
|
|
114
|
+
expect(kinds).toEqual(["head", "row", "foot"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps the menu open across clicks inside the header region", async () => {
|
|
118
|
+
const picked: string[] = [];
|
|
119
|
+
const closed = vi.fn();
|
|
120
|
+
const { Component, api } = makeHost();
|
|
121
|
+
mountToDom(Component);
|
|
122
|
+
api().open({
|
|
123
|
+
x: 100,
|
|
124
|
+
y: 80,
|
|
125
|
+
title: "Menu",
|
|
126
|
+
items: [{ key: "copy", label: "Copy" }],
|
|
127
|
+
header: () => (
|
|
128
|
+
<HkTagDotRow
|
|
129
|
+
tags={[
|
|
130
|
+
{ key: "red", color: "#e5484d", label: "Red" },
|
|
131
|
+
{ key: "yellow", color: "#ffd400", label: "Yellow" },
|
|
132
|
+
]}
|
|
133
|
+
label="tags"
|
|
134
|
+
onToggle={() => {}}
|
|
135
|
+
/>
|
|
136
|
+
),
|
|
137
|
+
onSelect: (key) => picked.push(key),
|
|
138
|
+
onClose: closed,
|
|
139
|
+
});
|
|
140
|
+
await settle();
|
|
141
|
+
const dot = document.querySelector(".hk-tag-dot") as HTMLButtonElement;
|
|
142
|
+
dot.click();
|
|
143
|
+
await settle();
|
|
144
|
+
// An inside-region click toggles, it never dismisses…
|
|
145
|
+
expect(popouts()).toHaveLength(1);
|
|
146
|
+
expect(picked).toEqual([]);
|
|
147
|
+
expect(closed).not.toHaveBeenCalled();
|
|
148
|
+
// …and an item pick after it still resolves normally.
|
|
149
|
+
menuRows()[0].click();
|
|
150
|
+
await settle();
|
|
151
|
+
expect(picked).toEqual(["copy"]);
|
|
152
|
+
await until(() => popouts().length === 0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps the header region live while the menu is open", async () => {
|
|
156
|
+
const { Component, api } = makeHost();
|
|
157
|
+
mountToDom(Component);
|
|
158
|
+
const count = ref(0);
|
|
159
|
+
api().open({
|
|
160
|
+
x: 100,
|
|
161
|
+
y: 80,
|
|
162
|
+
items: [{ key: "copy", label: "Copy" }],
|
|
163
|
+
header: () => <div class="content-hdr">n{count.value}</div>,
|
|
164
|
+
onSelect: () => {},
|
|
165
|
+
onClose: () => {},
|
|
166
|
+
});
|
|
167
|
+
await settle();
|
|
168
|
+
expect(headSlot()?.textContent).toContain("n0");
|
|
169
|
+
count.value = 3;
|
|
170
|
+
await settle();
|
|
171
|
+
expect(headSlot()?.textContent).toContain("n3");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("renders no region wrappers when the request carries none", async () => {
|
|
175
|
+
const { Component, api } = makeHost();
|
|
176
|
+
mountToDom(Component);
|
|
177
|
+
api().open({
|
|
178
|
+
x: 100,
|
|
179
|
+
y: 80,
|
|
180
|
+
items: [{ key: "copy", label: "Copy" }],
|
|
181
|
+
onSelect: () => {},
|
|
182
|
+
onClose: () => {},
|
|
183
|
+
});
|
|
184
|
+
await settle();
|
|
185
|
+
expect(popouts()).toHaveLength(1);
|
|
186
|
+
expect(headSlot()).toBeNull();
|
|
187
|
+
expect(footSlot()).toBeNull();
|
|
188
|
+
expect(menuRows()).toHaveLength(1);
|
|
189
|
+
api().close();
|
|
190
|
+
await until(() => popouts().length === 0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("swaps the regions when a second open replaces the menu", async () => {
|
|
194
|
+
const { Component, api } = makeHost();
|
|
195
|
+
mountToDom(Component);
|
|
196
|
+
api().open({
|
|
197
|
+
x: 100,
|
|
198
|
+
y: 80,
|
|
199
|
+
items: [{ key: "a", label: "A" }],
|
|
200
|
+
header: () => <div class="content-a">A</div>,
|
|
201
|
+
onSelect: () => {},
|
|
202
|
+
onClose: () => {},
|
|
203
|
+
});
|
|
204
|
+
await settle();
|
|
205
|
+
expect(headSlot()?.querySelector(".content-a")).toBeTruthy();
|
|
206
|
+
api().open({
|
|
207
|
+
x: 120,
|
|
208
|
+
y: 90,
|
|
209
|
+
items: [{ key: "b", label: "B" }],
|
|
210
|
+
header: () => <div class="content-b">B</div>,
|
|
211
|
+
onSelect: () => {},
|
|
212
|
+
onClose: () => {},
|
|
213
|
+
});
|
|
214
|
+
await settle();
|
|
215
|
+
// The epoch remount drops the stale region and its rows wholesale.
|
|
216
|
+
expect(headSlot()?.querySelector(".content-a")).toBeNull();
|
|
217
|
+
expect(headSlot()?.querySelector(".content-b")).toBeTruthy();
|
|
218
|
+
expect(menuRows().map((r) => r.textContent)).toEqual(["B"]);
|
|
219
|
+
api().close();
|
|
220
|
+
await until(() => popouts().length === 0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -110,6 +110,22 @@ export default defineComponent({
|
|
|
110
110
|
quadrantPlacement(request.value?.x ?? 0, request.value?.y ?? 0),
|
|
111
111
|
);
|
|
112
112
|
|
|
113
|
+
/** The request's custom regions become HkMenu's level-0 header/
|
|
114
|
+
* footer slots (object children = slots in Vue JSX). Only the
|
|
115
|
+
* keys that exist are handed over, so a request without custom
|
|
116
|
+
* content renders exactly as before. The slot closures re-read
|
|
117
|
+
* the CURRENT request through the shallowRef, and each open
|
|
118
|
+
* bumps menuEpoch — a fresh HkMenu mounts with fresh slots, and
|
|
119
|
+
* the region's own reactive reads (host state) keep it live
|
|
120
|
+
* while the menu stays open. */
|
|
121
|
+
const menuSlots = () => {
|
|
122
|
+
const req = request.value;
|
|
123
|
+
return {
|
|
124
|
+
...(req?.header ? { header: () => req.header!() } : {}),
|
|
125
|
+
...(req?.footer ? { footer: () => req.footer!() } : {}),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
113
129
|
return () => (
|
|
114
130
|
<>
|
|
115
131
|
{slots.default?.()}
|
|
@@ -136,7 +152,9 @@ export default defineComponent({
|
|
|
136
152
|
anchorRef={anchor.value}
|
|
137
153
|
placement={placement.value}
|
|
138
154
|
title={request.value?.title ?? ""}
|
|
139
|
-
|
|
155
|
+
>
|
|
156
|
+
{menuSlots()}
|
|
157
|
+
</HkMenu>
|
|
140
158
|
</>
|
|
141
159
|
);
|
|
142
160
|
},
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/* ── HkTagDotRow — the Finder-style tag strip ────────────────────
|
|
2
|
+
* A horizontal row of toggleable colored dots with captions beneath
|
|
3
|
+
* (the macOS file-tag picker pattern): one dot per tag, a click
|
|
4
|
+
* toggles, several tags flip in one visit. Metrics and states come
|
|
5
|
+
* from the unified menu-item spec (see ./_menu-item.scss), so the
|
|
6
|
+
* strip keeps menu rhythm inside HkMenu header/footer slots,
|
|
7
|
+
* HkMenuPanel or any popover, and grows with the sheet tokens inside
|
|
8
|
+
* mobile sheets (registered in the spec's sheet-context list). This
|
|
9
|
+
* sheet owns only what is the strip's own: the even column layout,
|
|
10
|
+
* the dot itself, and the caption rhythm. */
|
|
11
|
+
|
|
12
|
+
@use "./menu-item" as mi;
|
|
13
|
+
|
|
14
|
+
.hk-tag-dot-row {
|
|
15
|
+
@include mi.item-tokens;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: row;
|
|
18
|
+
align-items: stretch;
|
|
19
|
+
gap: var(--hk-menu-item-gap);
|
|
20
|
+
/* A band of its own inside the slot wrapper: rows above and below
|
|
21
|
+
* keep their rhythm, the strip reads as one control. */
|
|
22
|
+
padding-block: var(--hk-menu-item-pad-y);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.hk-tag-dot {
|
|
26
|
+
@include mi.item;
|
|
27
|
+
flex: 1 1 0;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: calc(var(--hk-menu-item-gap) / 2);
|
|
32
|
+
min-width: 0;
|
|
33
|
+
text-align: center;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.hk-tag-dot__dot {
|
|
37
|
+
flex: none;
|
|
38
|
+
width: calc(var(--hk-menu-item-icon-box) * 0.65);
|
|
39
|
+
height: calc(var(--hk-menu-item-icon-box) * 0.65);
|
|
40
|
+
border-radius: var(--radius-full, 9999px);
|
|
41
|
+
background: var(--hk-tag-color, rgb(var(--color-primary)));
|
|
42
|
+
transition: box-shadow 0.12s ease, opacity 0.12s ease;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Unapplied tags recede; the applied dot carries a soft ring of its
|
|
46
|
+
* own ink so the COLOR — not just the button wash — reads as on. */
|
|
47
|
+
.hk-tag-dot:not([data-active]) .hk-tag-dot__dot {
|
|
48
|
+
opacity: 0.4;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.hk-tag-dot[data-active] .hk-tag-dot__dot {
|
|
52
|
+
box-shadow: 0 0 0 2px
|
|
53
|
+
color-mix(in srgb, var(--hk-tag-color, rgb(var(--color-primary))) 30%, transparent);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* The caption is secondary to the dot: the row grammar above sets
|
|
57
|
+
* size/weight; captions only keep their own line rhythm and may wrap
|
|
58
|
+
* (three short columns, never a wide table). */
|
|
59
|
+
.hk-tag-dot__label {
|
|
60
|
+
min-width: 0;
|
|
61
|
+
max-width: 100%;
|
|
62
|
+
overflow-wrap: anywhere;
|
|
63
|
+
line-height: var(--hk-menu-item-lh);
|
|
64
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkTagDotRow, { type HkTagDot } from "./HkTagDotRow";
|
|
5
|
+
|
|
6
|
+
/** Mount into a detached div appended to the body — the family's
|
|
7
|
+
* mount pattern (assertions go through document.querySelectorAll). */
|
|
8
|
+
function mountRow(tags: HkTagDot[], onToggle: (key: string) => void, label?: string): void {
|
|
9
|
+
const el = document.createElement("div");
|
|
10
|
+
document.body.appendChild(el);
|
|
11
|
+
createApp(
|
|
12
|
+
defineComponent({
|
|
13
|
+
name: "TagDotRowHost",
|
|
14
|
+
setup() {
|
|
15
|
+
return () => <HkTagDotRow tags={tags} label={label} onToggle={onToggle} />;
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
).mount(el);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const dots = () =>
|
|
22
|
+
Array.from(document.querySelectorAll(".hk-tag-dot")) as HTMLButtonElement[];
|
|
23
|
+
|
|
24
|
+
const dotByAria = (name: string) =>
|
|
25
|
+
dots().find((b) => b.getAttribute("aria-label") === name);
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
dots().forEach((b) => b.remove());
|
|
29
|
+
document.querySelectorAll(".hk-tag-dot-row").forEach((el) => el.remove());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("HkTagDotRow", () => {
|
|
33
|
+
it("renders one toggle per tag: dot ink, caption, pressed state", () => {
|
|
34
|
+
mountRow(
|
|
35
|
+
[
|
|
36
|
+
{ key: "red", color: "#e5484d", label: "Red" },
|
|
37
|
+
{ key: "yellow", color: "#ffd400", active: true },
|
|
38
|
+
{ key: "purple", color: "#a855f7" },
|
|
39
|
+
],
|
|
40
|
+
() => {},
|
|
41
|
+
"Tags",
|
|
42
|
+
);
|
|
43
|
+
const row = document.querySelector(".hk-tag-dot-row")!;
|
|
44
|
+
expect(row.getAttribute("role")).toBe("group");
|
|
45
|
+
expect(row.getAttribute("aria-label")).toBe("Tags");
|
|
46
|
+
|
|
47
|
+
expect(dots()).toHaveLength(3);
|
|
48
|
+
// Dot ink rides the per-tag custom property on the dot itself…
|
|
49
|
+
const red = dotByAria("Red")!;
|
|
50
|
+
const redDot = red.querySelector(".hk-tag-dot__dot") as HTMLElement;
|
|
51
|
+
expect(redDot.style.getPropertyValue("--hk-tag-color")).toBe("#e5484d");
|
|
52
|
+
expect(red.querySelector(".hk-tag-dot__label")?.textContent).toBe("Red");
|
|
53
|
+
expect(red.getAttribute("aria-pressed")).toBe("false");
|
|
54
|
+
expect(red.hasAttribute("data-active")).toBe(false);
|
|
55
|
+
|
|
56
|
+
// …the applied tag carries the selected face and pressed state.
|
|
57
|
+
const yellow = dotByAria("yellow")!;
|
|
58
|
+
expect(yellow.getAttribute("aria-pressed")).toBe("true");
|
|
59
|
+
expect(yellow.hasAttribute("data-active")).toBe(true);
|
|
60
|
+
// A labelless tag falls back to its key for the accessible name
|
|
61
|
+
// and renders no caption span.
|
|
62
|
+
expect(yellow.querySelector(".hk-tag-dot__label")).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("emits toggle with the tag key on click", () => {
|
|
66
|
+
const toggled: string[] = [];
|
|
67
|
+
mountRow(
|
|
68
|
+
[
|
|
69
|
+
{ key: "red", color: "#e5484d", label: "Red" },
|
|
70
|
+
{ key: "purple", color: "#a855f7", label: "Purple" },
|
|
71
|
+
],
|
|
72
|
+
(key) => toggled.push(key),
|
|
73
|
+
);
|
|
74
|
+
dotByAria("Red")!.click();
|
|
75
|
+
dotByAria("Purple")!.click();
|
|
76
|
+
expect(toggled).toEqual(["red", "purple"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps a disabled tag inert", () => {
|
|
80
|
+
const toggled = vi.fn();
|
|
81
|
+
mountRow(
|
|
82
|
+
[
|
|
83
|
+
{ key: "red", color: "#e5484d", label: "Red", disabled: true },
|
|
84
|
+
{ key: "purple", color: "#a855f7", label: "Purple" },
|
|
85
|
+
],
|
|
86
|
+
toggled,
|
|
87
|
+
);
|
|
88
|
+
const red = dotByAria("Red")!;
|
|
89
|
+
expect(red.disabled).toBe(true);
|
|
90
|
+
red.click(); // happy-dom honors disabled on click dispatch
|
|
91
|
+
expect(toggled).not.toHaveBeenCalled();
|
|
92
|
+
dotByAria("Purple")!.click();
|
|
93
|
+
expect(toggled).toHaveBeenCalledWith("purple");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkTagDotRow.scss";
|
|
4
|
+
|
|
5
|
+
/** One toggleable tag of a HkTagDotRow. */
|
|
6
|
+
export interface HkTagDot {
|
|
7
|
+
/** Stable identity emitted by `toggle`. */
|
|
8
|
+
key: string;
|
|
9
|
+
/** Dot color — any CSS color (hex, rgb(), a var() reference…). */
|
|
10
|
+
color: string;
|
|
11
|
+
/** Caption under the dot; the host i18n's it. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Whether the tag is currently applied (the row's selected face). */
|
|
14
|
+
active?: boolean;
|
|
15
|
+
/** Inert: rendered dimmed, clicks never toggle it. */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* HkTagDotRow — a horizontal strip of toggleable colored dots, the
|
|
21
|
+
* macOS Finder tag-editor pattern: one dot per tag, the caption
|
|
22
|
+
* beneath it, one click toggles. Tags are domain-free {key, color,
|
|
23
|
+
* label} triples — the host owns the vocabulary (and its i18n), the
|
|
24
|
+
* row owns only the geometry and states.
|
|
25
|
+
*
|
|
26
|
+
* Speaks the unified menu-item spec (see ./_menu-item.scss), so it
|
|
27
|
+
* drops straight into menu surfaces — HkMenu's header/footer slots,
|
|
28
|
+
* HkMenuPanel, a popover — and keeps menu rhythm there, growing with
|
|
29
|
+
* the sheet tokens inside mobile sheets; it works anywhere else a
|
|
30
|
+
* compact tag picker belongs (settings rows, card footers). Clicking
|
|
31
|
+
* a dot never closes a surrounding menu: it emits `toggle` only, and
|
|
32
|
+
* the host decides what to do (menus stay open across toggles so
|
|
33
|
+
* several tags can be flipped in one visit).
|
|
34
|
+
*/
|
|
35
|
+
export default defineComponent({
|
|
36
|
+
name: "HkTagDotRow",
|
|
37
|
+
props: {
|
|
38
|
+
tags: { type: Array as PropType<HkTagDot[]>, required: true },
|
|
39
|
+
/** Accessible name for the whole group (aria-label). */
|
|
40
|
+
label: { type: String, default: undefined },
|
|
41
|
+
},
|
|
42
|
+
emits: ["toggle"],
|
|
43
|
+
setup(props, { emit }) {
|
|
44
|
+
return () => (
|
|
45
|
+
<div class="hk-tag-dot-row" role="group" aria-label={props.label}>
|
|
46
|
+
{props.tags.map((tag) => (
|
|
47
|
+
<button
|
|
48
|
+
key={tag.key}
|
|
49
|
+
type="button"
|
|
50
|
+
class="hk-tag-dot"
|
|
51
|
+
data-active={tag.active || undefined}
|
|
52
|
+
disabled={tag.disabled}
|
|
53
|
+
aria-pressed={tag.active ? "true" : "false"}
|
|
54
|
+
aria-label={tag.label ?? tag.key}
|
|
55
|
+
title={tag.label}
|
|
56
|
+
onClick={() => emit("toggle", tag.key)}
|
|
57
|
+
>
|
|
58
|
+
<span class="hk-tag-dot__dot" style={{ "--hk-tag-color": tag.color }} />
|
|
59
|
+
{tag.label ? <span class="hk-tag-dot__label">{tag.label}</span> : null}
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -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"
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
*
|
|
3
3
|
* Every selectable list row the library renders — cascade menu rows,
|
|
4
4
|
* action rows, select options, popup-select items, theme pills, admin
|
|
5
|
-
* header rows, nav rows, breadcrumb dropdown items
|
|
6
|
-
* grammar. Desktop (anchored popouts) and mobile (bottom sheets) are
|
|
5
|
+
* header rows, nav rows, breadcrumb dropdown items, tag strips —
|
|
6
|
+
* shares this exact grammar. Desktop (anchored popouts) and mobile (bottom sheets) are
|
|
7
7
|
* the same spec at two token sets; a sheet context merely swaps the
|
|
8
8
|
* tokens, so every row inside ANY sheet gets the touch geometry
|
|
9
9
|
* without per-component media queries.
|
|
@@ -89,7 +89,9 @@
|
|
|
89
89
|
.s-popup-menu-item,
|
|
90
90
|
.hk-popup-menu-item,
|
|
91
91
|
.hk-breadcrumb-dropdown-item,
|
|
92
|
-
.hk-menu-divider
|
|
92
|
+
.hk-menu-divider,
|
|
93
|
+
.hk-tag-dot-row,
|
|
94
|
+
.hk-tag-dot {
|
|
93
95
|
@include item-sheet-tokens;
|
|
94
96
|
}
|
|
95
97
|
}
|
|
@@ -108,7 +110,9 @@
|
|
|
108
110
|
.s-theme-item-btn,
|
|
109
111
|
.s-popup-menu-item,
|
|
110
112
|
.hk-popup-menu-item,
|
|
111
|
-
.hk-menu-divider
|
|
113
|
+
.hk-menu-divider,
|
|
114
|
+
.hk-tag-dot-row,
|
|
115
|
+
.hk-tag-dot {
|
|
112
116
|
@include item-sheet-tokens;
|
|
113
117
|
}
|
|
114
118
|
}
|
|
@@ -550,3 +550,89 @@ describe("useContextMenu + HkContextMenuProvider", () => {
|
|
|
550
550
|
expect(which).toEqual([el1, el2]); // el1 unbound, el2 bound
|
|
551
551
|
});
|
|
552
552
|
});
|
|
553
|
+
|
|
554
|
+
describe("bindContextMenu layering — a delegating outer binding defers to inner ones", () => {
|
|
555
|
+
it("suppresses the outer open when an inner binding already claimed the event", () => {
|
|
556
|
+
const inner = mockApi();
|
|
557
|
+
const outer = mockApi();
|
|
558
|
+
const outerEl = document.createElement("div");
|
|
559
|
+
outerEl.setAttribute("data-bind-target", "");
|
|
560
|
+
const innerEl = document.createElement("div");
|
|
561
|
+
innerEl.setAttribute("data-bind-target", "");
|
|
562
|
+
outerEl.appendChild(innerEl);
|
|
563
|
+
document.body.appendChild(outerEl);
|
|
564
|
+
|
|
565
|
+
const unbindOuter = bindContextMenu(outerEl, outer.api, (point) =>
|
|
566
|
+
// The delegating contract: an inner binding consumed the native
|
|
567
|
+
// event, so its menu stands — this one steps aside.
|
|
568
|
+
point.defaultPrevented
|
|
569
|
+
? null
|
|
570
|
+
: { x: point.x, y: point.y, items: [{ key: "outer", label: "Outer" }] },
|
|
571
|
+
);
|
|
572
|
+
const unbindInner = bindContextMenu(innerEl, inner.api, () => ({
|
|
573
|
+
x: 1,
|
|
574
|
+
y: 2,
|
|
575
|
+
items: [{ key: "inner", label: "Inner" }],
|
|
576
|
+
}));
|
|
577
|
+
|
|
578
|
+
innerEl.dispatchEvent(
|
|
579
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 6 }),
|
|
580
|
+
);
|
|
581
|
+
expect(inner.opens).toHaveLength(1);
|
|
582
|
+
expect(outer.opens).toHaveLength(0);
|
|
583
|
+
|
|
584
|
+
unbindOuter();
|
|
585
|
+
unbindInner();
|
|
586
|
+
outerEl.remove();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("still opens the outer menu for events the inner binding never sees", () => {
|
|
590
|
+
const inner = mockApi();
|
|
591
|
+
const outer = mockApi();
|
|
592
|
+
const outerEl = document.createElement("div");
|
|
593
|
+
outerEl.setAttribute("data-bind-target", "");
|
|
594
|
+
const innerEl = document.createElement("div");
|
|
595
|
+
innerEl.setAttribute("data-bind-target", "");
|
|
596
|
+
outerEl.appendChild(innerEl);
|
|
597
|
+
document.body.appendChild(outerEl);
|
|
598
|
+
|
|
599
|
+
const unbindOuter = bindContextMenu(outerEl, outer.api, (point) =>
|
|
600
|
+
point.defaultPrevented
|
|
601
|
+
? null
|
|
602
|
+
: { x: point.x, y: point.y, items: [{ key: "outer", label: "Outer" }] },
|
|
603
|
+
);
|
|
604
|
+
const unbindInner = bindContextMenu(innerEl, inner.api, () => ({
|
|
605
|
+
x: 1,
|
|
606
|
+
y: 2,
|
|
607
|
+
items: [{ key: "inner", label: "Inner" }],
|
|
608
|
+
}));
|
|
609
|
+
|
|
610
|
+
outerEl.dispatchEvent(
|
|
611
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 7, clientY: 8 }),
|
|
612
|
+
);
|
|
613
|
+
expect(inner.opens).toHaveLength(0);
|
|
614
|
+
expect(outer.opens).toHaveLength(1);
|
|
615
|
+
|
|
616
|
+
unbindOuter();
|
|
617
|
+
unbindInner();
|
|
618
|
+
outerEl.remove();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("reports defaultPrevented on the trigger point (false before this binding acts)", () => {
|
|
622
|
+
const target = document.createElement("div");
|
|
623
|
+
target.setAttribute("data-bind-target", "");
|
|
624
|
+
document.body.appendChild(target);
|
|
625
|
+
const { api } = mockApi();
|
|
626
|
+
const seen: (boolean | undefined)[] = [];
|
|
627
|
+
const unbind = bindContextMenu(target, api, (point) => {
|
|
628
|
+
seen.push(point.defaultPrevented);
|
|
629
|
+
return { x: point.x, y: point.y, items: [{ key: "a", label: "A" }] };
|
|
630
|
+
});
|
|
631
|
+
const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
|
632
|
+
target.dispatchEvent(event);
|
|
633
|
+
// Read BEFORE this binding's own preventDefault runs.
|
|
634
|
+
expect(seen).toEqual([false]);
|
|
635
|
+
expect(event.defaultPrevented).toBe(true);
|
|
636
|
+
unbind();
|
|
637
|
+
});
|
|
638
|
+
});
|
|
@@ -24,7 +24,14 @@
|
|
|
24
24
|
* after a long touch do not open the menu twice.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
inject,
|
|
29
|
+
onBeforeUnmount,
|
|
30
|
+
watch,
|
|
31
|
+
type InjectionKey,
|
|
32
|
+
type Ref,
|
|
33
|
+
type VNodeChild,
|
|
34
|
+
} from "vue";
|
|
28
35
|
|
|
29
36
|
import type { HkMenuItem } from "../components/HkMenu";
|
|
30
37
|
|
|
@@ -36,6 +43,21 @@ export interface ContextMenuRequest {
|
|
|
36
43
|
items: HkMenuItem[];
|
|
37
44
|
/** Sheet header on mobile / popout a11y label on desktop. */
|
|
38
45
|
title?: string;
|
|
46
|
+
/** Custom region pinned ABOVE the item rows (the root level only,
|
|
47
|
+
* exactly like HkMenu's header slot, which this forwards to). The
|
|
48
|
+
* menu chrome — popout/sheet, flipping, z-stack, outside-click,
|
|
49
|
+
* Escape — stays the panel's; the region is a normal interactive
|
|
50
|
+
* island inside it: clicks on it do NOT close the menu, so several
|
|
51
|
+
* toggles can happen in one visit. State must live in the HOST'S
|
|
52
|
+
* scope (the menu remounts on every open — see the provider's
|
|
53
|
+
* menuEpoch); the render function reading reactive sources keeps
|
|
54
|
+
* the region live while the menu is open. Conditionally render
|
|
55
|
+
* NOTHING by returning an empty array ([]) — a null/undefined
|
|
56
|
+
* return still mounts the slot wrapper and its flex gap. */
|
|
57
|
+
header?: () => VNodeChild;
|
|
58
|
+
/** Custom region pinned BELOW the item rows — the footer twin of
|
|
59
|
+
* the header field (HkMenu's footer slot). */
|
|
60
|
+
footer?: () => VNodeChild;
|
|
39
61
|
/** Called with the picked item's key. The menu closes itself. */
|
|
40
62
|
onSelect?: (key: string, item: HkMenuItem) => void;
|
|
41
63
|
/** Called when the menu closes without a selection (outside click,
|
|
@@ -83,6 +105,14 @@ export interface ContextTriggerPoint {
|
|
|
83
105
|
kind: "pointer" | "longpress" | "keyboard";
|
|
84
106
|
/** The element the trigger landed on (event.target). */
|
|
85
107
|
target: EventTarget | null;
|
|
108
|
+
/** True when the underlying native event was already consumed
|
|
109
|
+
* (preventDefault) by an INNER binding's handler before this one
|
|
110
|
+
* ran — listeners fire innermost-first, so a DELEGATING (outer)
|
|
111
|
+
* binding sees it only after an inner binding opened its menu. An
|
|
112
|
+
* outer binding must defer: return null so the inner menu stands.
|
|
113
|
+
* Absent for long-press opens (no native context event rides the
|
|
114
|
+
* timer; an outer binding resolves by target instead). */
|
|
115
|
+
defaultPrevented?: boolean;
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
/**
|
|
@@ -149,6 +179,7 @@ export function bindContextMenu(
|
|
|
149
179
|
y: event.clientY,
|
|
150
180
|
kind: "pointer",
|
|
151
181
|
target: event.target,
|
|
182
|
+
defaultPrevented: event.defaultPrevented,
|
|
152
183
|
});
|
|
153
184
|
if (opened) event.preventDefault();
|
|
154
185
|
// Not opened: let it bubble to an outer binding untouched.
|
|
@@ -184,6 +215,7 @@ export function bindContextMenu(
|
|
|
184
215
|
y: rect.bottom,
|
|
185
216
|
kind: "keyboard",
|
|
186
217
|
target: event.target,
|
|
218
|
+
defaultPrevented: event.defaultPrevented,
|
|
187
219
|
});
|
|
188
220
|
if (opened) event.preventDefault();
|
|
189
221
|
};
|
package/src/index.ts
CHANGED
|
@@ -79,6 +79,7 @@ export { HkLocalizedInput as HLocalizedInput, type HkLocaleOption } from "./comp
|
|
|
79
79
|
export { default as HMenuPanel } from "./components/HkMenuPanel";
|
|
80
80
|
export { default as HMenuActionItem } from "./components/HkMenuActionItem";
|
|
81
81
|
export { default as HMenuIdentityItem } from "./components/HkMenuIdentityItem";
|
|
82
|
+
export { default as HTagDotRow, type HkTagDot } from "./components/HkTagDotRow";
|
|
82
83
|
export { default as HPopupSelect, isAnyPopupOpen, closeAllPopups, type HkPopupSelectOption } from "./components/HkPopupSelect";
|
|
83
84
|
export { default as HProgressBar } from "./components/HkProgressBar";
|
|
84
85
|
export { default as HProgressDialog } from "./components/HkProgressDialog";
|
|
@@ -382,6 +383,7 @@ export {
|
|
|
382
383
|
type OverlayHandle,
|
|
383
384
|
type PopupHandle,
|
|
384
385
|
type PopupKind,
|
|
386
|
+
type ToastAction,
|
|
385
387
|
type ToastItem,
|
|
386
388
|
type ToastMessage,
|
|
387
389
|
type ToastType,
|
|
@@ -669,6 +671,7 @@ export { default as HkContextMenuProvider } from "./components/HkContextMenuProv
|
|
|
669
671
|
export { default as HkMenuPanel } from "./components/HkMenuPanel";
|
|
670
672
|
export { default as HkMenuActionItem } from "./components/HkMenuActionItem";
|
|
671
673
|
export { default as HkMenuIdentityItem } from "./components/HkMenuIdentityItem";
|
|
674
|
+
export { default as HkTagDotRow } from "./components/HkTagDotRow";
|
|
672
675
|
export { default as HkRowActions } from "./components/HkRowActions";
|
|
673
676
|
export { default as HkPopupSelect } from "./components/HkPopupSelect";
|
|
674
677
|
export { default as HkProgressBar } from "./components/HkProgressBar";
|
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
|
|