@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.213",
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",
@@ -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 — shares this exact
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 { inject, onBeforeUnmount, watch, type InjectionKey, type Ref } from "vue";
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";
@@ -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