@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 +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/_menu-item.scss +8 -4
- package/src/composables/useContextMenu.test.tsx +86 -0
- package/src/composables/useContextMenu.ts +33 -1
- package/src/index.ts +2 -0
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
|
+
});
|
|
@@ -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";
|
|
@@ -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";
|