@celestia-island/hikari 0.55.212 → 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 +5 -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/HkTitleBar.test.tsx +68 -0
- package/src/components/HkTitleBar.tsx +11 -0
- package/src/components/HkWindowControls.test.tsx +234 -1
- package/src/components/HkWindowControls.tsx +68 -26
- 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 +28 -0
- package/src/plugins/boot-chrome.d.mts +41 -0
- package/src/plugins/boot-chrome.install.test.ts +272 -0
- package/src/plugins/boot-chrome.mjs +263 -0
- package/src/plugins/boot-chrome.test.ts +427 -0
- package/src/plugins/index.ts +10 -0
- package/src/res/boot-chrome.js +916 -0
- package/src/res/bootChrome.test.ts +648 -0
- package/src/runtime/index.ts +9 -0
- package/src/runtime/windowChrome.test.ts +238 -0
- package/src/runtime/windowChrome.ts +264 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
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",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"./plugins": {
|
|
38
38
|
"import": "./src/plugins/index.ts",
|
|
39
39
|
"types": "./src/plugins/index.ts"
|
|
40
|
+
},
|
|
41
|
+
"./plugins/boot-chrome": {
|
|
42
|
+
"import": "./src/plugins/boot-chrome.mjs",
|
|
43
|
+
"types": "./src/plugins/boot-chrome.d.mts"
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"files": [
|
|
@@ -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
|
+
});
|
|
@@ -109,6 +109,74 @@ describe("HkTitleBar", () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
describe("HkTitleBar window-chrome claim", () => {
|
|
113
|
+
// The bar IS the app's title bar, so mounting it must stand hikari's boot bar
|
|
114
|
+
// (boot-chrome.js) down — otherwise two caption bars overlap for the rest of
|
|
115
|
+
// the session. Tests use a raw window listener: that is the whole contract.
|
|
116
|
+
function collect(): { claims: Array<{ by?: string }>; stop: () => void } {
|
|
117
|
+
const claims: Array<{ by?: string }> = [];
|
|
118
|
+
const listener = (event: Event) => {
|
|
119
|
+
claims.push((event as CustomEvent<{ by?: string }>).detail ?? {});
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener("hk:chrome-claim", listener);
|
|
122
|
+
return {
|
|
123
|
+
claims,
|
|
124
|
+
stop: () => window.removeEventListener("hk:chrome-claim", listener),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cleanup(): void {
|
|
129
|
+
document.documentElement.removeAttribute("data-hk-chrome");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
it("claims the window on mount and marks the app as the chrome owner", () => {
|
|
133
|
+
const { claims, stop } = collect();
|
|
134
|
+
try {
|
|
135
|
+
mount(h(HkTitleBar, { title: "App" }));
|
|
136
|
+
|
|
137
|
+
expect(claims).toHaveLength(1);
|
|
138
|
+
expect(claims[0]?.by).toBe("HkTitleBar");
|
|
139
|
+
expect(document.documentElement.getAttribute("data-hk-chrome")).toBe("app");
|
|
140
|
+
} finally {
|
|
141
|
+
stop();
|
|
142
|
+
cleanup();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("never claims twice for one mount (a re-render is not a new takeover)", async () => {
|
|
147
|
+
const { claims, stop } = collect();
|
|
148
|
+
try {
|
|
149
|
+
const maximized = ref(false);
|
|
150
|
+
mount(h(() => h(HkTitleBar, { title: "App", maximized: maximized.value })));
|
|
151
|
+
expect(claims).toHaveLength(1);
|
|
152
|
+
|
|
153
|
+
maximized.value = true;
|
|
154
|
+
await nextTick();
|
|
155
|
+
maximized.value = false;
|
|
156
|
+
await nextTick();
|
|
157
|
+
|
|
158
|
+
expect(claims).toHaveLength(1);
|
|
159
|
+
} finally {
|
|
160
|
+
stop();
|
|
161
|
+
cleanup();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("leaves the existing props/emits contract untouched", () => {
|
|
166
|
+
const emitted: string[] = [];
|
|
167
|
+
const host = mount(
|
|
168
|
+
h(HkTitleBar, {
|
|
169
|
+
title: "App",
|
|
170
|
+
onMinimize: () => emitted.push("minimize"),
|
|
171
|
+
onClose: () => emitted.push("close"),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
host.querySelector<HTMLButtonElement>('[title="Minimize"]')!.click();
|
|
175
|
+
host.querySelector<HTMLButtonElement>('[title="Close"]')!.click();
|
|
176
|
+
expect(emitted).toEqual(["minimize", "close"]);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
112
180
|
describe("HkTitleBar stylesheet contract", () => {
|
|
113
181
|
const css = compile(resolve(componentDir, "HkTitleBar.scss"), {
|
|
114
182
|
style: "expanded",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
|
|
2
2
|
import { useI18n } from "../i18n/context";
|
|
3
|
+
import { claimWindowChrome } from "../runtime/windowChrome";
|
|
3
4
|
|
|
4
5
|
import "./HkTitleBar.scss";
|
|
5
6
|
|
|
@@ -60,6 +61,16 @@ export default defineComponent({
|
|
|
60
61
|
},
|
|
61
62
|
setup(props, { emit, slots }) {
|
|
62
63
|
const { t } = useI18n();
|
|
64
|
+
// The bar IS the app's window chrome, so mounting it means the boot chrome
|
|
65
|
+
// (boot-chrome.js, drawn before the bundle exists) must hand the window
|
|
66
|
+
// over: dispatch `hk:chrome-claim` once per mount, never revoked. See
|
|
67
|
+
// runtime/windowChrome for the contract; hydration/SSR never reaches here.
|
|
68
|
+
let claimed = false;
|
|
69
|
+
onMounted(() => {
|
|
70
|
+
if (claimed) return;
|
|
71
|
+
claimed = true;
|
|
72
|
+
claimWindowChrome("HkTitleBar");
|
|
73
|
+
});
|
|
63
74
|
return () => (
|
|
64
75
|
<div
|
|
65
76
|
class="hk-titlebar"
|