@celestia-island/hikari 0.55.213 → 0.55.214

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.214",
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
+ });
@@ -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";
@@ -669,6 +670,7 @@ export { default as HkContextMenuProvider } from "./components/HkContextMenuProv
669
670
  export { default as HkMenuPanel } from "./components/HkMenuPanel";
670
671
  export { default as HkMenuActionItem } from "./components/HkMenuActionItem";
671
672
  export { default as HkMenuIdentityItem } from "./components/HkMenuIdentityItem";
673
+ export { default as HkTagDotRow } from "./components/HkTagDotRow";
672
674
  export { default as HkRowActions } from "./components/HkRowActions";
673
675
  export { default as HkPopupSelect } from "./components/HkPopupSelect";
674
676
  export { default as HkProgressBar } from "./components/HkProgressBar";