@celestia-island/hikari 0.55.146 → 0.55.148
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/HkBoard.nodeActions.test.tsx +101 -0
- package/src/components/HkBoard.scss +15 -0
- package/src/components/HkBoard.tsx +20 -0
- package/src/components/HkSliderField.scss +29 -0
- package/src/components/HkSliderField.test.tsx +411 -0
- package/src/components/HkSliderField.tsx +71 -0
- package/src/components/{HkTokenField.scss → HkTokenInput.scss} +12 -29
- package/src/components/{HkTokenField.tsx → HkTokenInput.tsx} +168 -237
- package/src/index.ts +3 -2
- package/src/components/HkTokenField.test.tsx +0 -403
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkBoard, { type BoardNodeInput } from "./HkBoard";
|
|
5
|
+
|
|
6
|
+
/** Pointer event the suite can dispatch (happy-dom has PointerEvent). */
|
|
7
|
+
function pointerEvent(type: string, x: number, y: number): Event {
|
|
8
|
+
const Ctor = (globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
|
|
9
|
+
const ev = new Ctor(type, { bubbles: true, cancelable: true, clientX: x, clientY: y });
|
|
10
|
+
(ev as { pointerId?: number }).pointerId = 1;
|
|
11
|
+
return ev;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const nodes: BoardNodeInput[] = [
|
|
15
|
+
{ id: "a", x: 0, y: 0, w: 100, h: 60, draggable: true, label: "A" },
|
|
16
|
+
{ id: "b", x: 160, y: 0, w: 100, h: 60, draggable: true, label: "B" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Mounts the board with the given slots, the repo's own way (no
|
|
20
|
+
* @vue/test-utils in this package): a throwaway app on document.body. */
|
|
21
|
+
function mountBoard(slots: Record<string, () => unknown> = {}, onNodeMove?: () => void): unknown {
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
document.body.appendChild(host);
|
|
24
|
+
// (defineComponent(fn) treats fn as a FUNCTIONAL component — the wrapper
|
|
25
|
+
// needs the explicit setup form to hand HkBoard its slots.)
|
|
26
|
+
const wrapper = defineComponent({
|
|
27
|
+
setup: () => () => h(HkBoard, { nodes, onNodeMove }, slots),
|
|
28
|
+
});
|
|
29
|
+
const app = createApp(wrapper);
|
|
30
|
+
app.mount(host);
|
|
31
|
+
return app;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
document.body.innerHTML = "";
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("HkBoard nodeActions slot", () => {
|
|
39
|
+
it("renders the slot per node, docked inside the node shell", () => {
|
|
40
|
+
mountBoard({
|
|
41
|
+
nodeActions: () => [
|
|
42
|
+
h("button", { "data-test": "act" }, "act"),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
const docks = [...document.body.querySelectorAll<HTMLElement>(".hk-board-node-actions")];
|
|
46
|
+
expect(docks).toHaveLength(2);
|
|
47
|
+
expect(docks[0].querySelector("[data-test=act]")).toBeTruthy();
|
|
48
|
+
expect(docks[1].querySelector("[data-test=act]")).toBeTruthy();
|
|
49
|
+
// The dock lives INSIDE the node shell (the SCSS anchors it to the
|
|
50
|
+
// node's bottom-end corner — pinned by the source contract below).
|
|
51
|
+
expect(docks[0].closest(".hk-board-node")).toBeTruthy();
|
|
52
|
+
expect(docks[0].parentElement!.classList.contains("hk-board-node")).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders nothing when the slot is absent", () => {
|
|
56
|
+
mountBoard();
|
|
57
|
+
expect(document.body.querySelector(".hk-board-node-actions")).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a press that starts on a dock action from becoming a node drag", () => {
|
|
61
|
+
// The board captures the pointer on its own root for draggable nodes;
|
|
62
|
+
// a dock press must not enter that gesture (upstream HkMinimap guards
|
|
63
|
+
// its zoom chrome the same way). happy-dom cannot emulate capture, so
|
|
64
|
+
// the observable contract here is the DRAG: a pointer that travels
|
|
65
|
+
// after starting on the dock leaves the node where it was.
|
|
66
|
+
const onNodeMove = vi.fn();
|
|
67
|
+
const host = document.createElement("div");
|
|
68
|
+
document.body.appendChild(host);
|
|
69
|
+
const app = mountBoard(
|
|
70
|
+
{ nodeActions: () => [h("button", { "data-test": "act" }, "act")] },
|
|
71
|
+
onNodeMove,
|
|
72
|
+
) as { unmount: () => void };
|
|
73
|
+
|
|
74
|
+
const dock = document.body.querySelector<HTMLElement>(".hk-board-node-actions")!;
|
|
75
|
+
const shell = dock.closest(".hk-board-node") as HTMLElement;
|
|
76
|
+
const before = shell.style.left;
|
|
77
|
+
dock.dispatchEvent(pointerEvent("pointerdown", 90, 50));
|
|
78
|
+
shell.dispatchEvent(pointerEvent("pointermove", 400, 50));
|
|
79
|
+
shell.dispatchEvent(pointerEvent("pointerup", 400, 50));
|
|
80
|
+
expect(shell.style.left).toBe(before);
|
|
81
|
+
expect(onNodeMove).not.toHaveBeenCalled();
|
|
82
|
+
app.unmount();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Source contract for the dock's load-bearing rules (vitest cannot see
|
|
87
|
+
* stylesheets — a deleted rule leaves every DOM guard green). */
|
|
88
|
+
const boardScss = (await import("node:fs")).readFileSync(
|
|
89
|
+
(await import("node:path")).resolve(__dirname, "./HkBoard.scss"),
|
|
90
|
+
"utf8",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
describe("HkBoard nodeActions dock styles", () => {
|
|
94
|
+
it("docks the actions to the node's bottom-end corner above the body", () => {
|
|
95
|
+
const block = boardScss.slice(boardScss.indexOf("&-actions {"));
|
|
96
|
+
expect(block.slice(0, 400)).toMatch(/position:\s*absolute/);
|
|
97
|
+
expect(block.slice(0, 400)).toMatch(/inset-inline-end/);
|
|
98
|
+
expect(block.slice(0, 400)).toMatch(/bottom:/);
|
|
99
|
+
expect(block.slice(0, 400)).toMatch(/z-index/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -44,6 +44,21 @@
|
|
|
44
44
|
position: absolute;
|
|
45
45
|
box-sizing: border-box;
|
|
46
46
|
|
|
47
|
+
// Per-node action dock (the `nodeActions` slot): consumer-injected
|
|
48
|
+
// controls ride the node's bottom-end corner above the body, so hosts
|
|
49
|
+
// stop hand-drawing their own buttons into the node slot. The dock owns
|
|
50
|
+
// its own capture-safety (see HkBoard.tsx) — a press that starts here
|
|
51
|
+
// never becomes a node drag.
|
|
52
|
+
&-actions {
|
|
53
|
+
position: absolute;
|
|
54
|
+
inset-inline-end: var(--hk-board-node-action-inset, 4px);
|
|
55
|
+
bottom: var(--hk-board-node-action-inset, 4px);
|
|
56
|
+
z-index: 6;
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 4px;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
&--draggable {
|
|
48
63
|
cursor: grab;
|
|
49
64
|
|
|
@@ -453,6 +453,26 @@ export default defineComponent({
|
|
|
453
453
|
{slots.node
|
|
454
454
|
? slots.node({ node: n })
|
|
455
455
|
: <div class="hk-board-node-shell">{n.label ?? ""}</div>}
|
|
456
|
+
{slots.nodeActions
|
|
457
|
+
? (
|
|
458
|
+
<div
|
|
459
|
+
class="hk-board-node-actions"
|
|
460
|
+
// A press that starts on an action belongs to that
|
|
461
|
+
// action: this component captures the pointer on its own
|
|
462
|
+
// root for every draggable node, and a captured press
|
|
463
|
+
// retargets the following events to the ancestor — an
|
|
464
|
+
// action button's own click would never fire. Stopping
|
|
465
|
+
// the bubble here is the same guard HkMinimap puts on
|
|
466
|
+
// its zoom chrome. The keydown twin keeps a focused
|
|
467
|
+
// action's Enter/Space from reaching the node's own key
|
|
468
|
+
// handling.
|
|
469
|
+
onPointerdown={(e: PointerEvent) => e.stopPropagation()}
|
|
470
|
+
onKeydown={(e: KeyboardEvent) => e.stopPropagation()}
|
|
471
|
+
>
|
|
472
|
+
{slots.nodeActions({ node: n })}
|
|
473
|
+
</div>
|
|
474
|
+
)
|
|
475
|
+
: null}
|
|
456
476
|
</div>
|
|
457
477
|
))}
|
|
458
478
|
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.hk-slider-field {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--space-8, 8px);
|
|
5
|
+
min-width: 0;
|
|
6
|
+
padding: var(--space-4, 4px) var(--space-8, 8px);
|
|
7
|
+
border: 1px solid color-mix(in srgb, rgb(var(--color-text)) 14%, transparent);
|
|
8
|
+
border-radius: var(--radius-sm);
|
|
9
|
+
background: rgb(var(--color-surface));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.hk-slider-field.is-disabled {
|
|
13
|
+
opacity: 0.55;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.hk-slider-field-slider {
|
|
17
|
+
flex: 1 1 auto;
|
|
18
|
+
min-width: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* The flanking slots keep their content width; the slider takes
|
|
22
|
+
* whatever the row can spare. */
|
|
23
|
+
.hk-slider-field-slot {
|
|
24
|
+
flex: 0 0 auto;
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
gap: var(--space-8, 8px);
|
|
28
|
+
min-width: 0;
|
|
29
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import HkSliderField from "./HkSliderField";
|
|
8
|
+
import HkTokenInput, { compactTokenCount } from "./HkTokenInput";
|
|
9
|
+
import HkModalCtor from "./HkModal";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The two-component contract (the panel composes them; the library
|
|
13
|
+
* ships them SEPARATE and generic):
|
|
14
|
+
*
|
|
15
|
+
* HkSliderField — the boxed slider with leading/trailing slots; no
|
|
16
|
+
* opinion about the value's meaning; the slider takes the room the
|
|
17
|
+
* slots leave; both slots may host dropdowns/editors.
|
|
18
|
+
*
|
|
19
|
+
* HkTokenInput — the standalone token editor (value segment + unit
|
|
20
|
+
* dropdown + the draggable precision editor); rides any slot.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
24
|
+
|
|
25
|
+
const setWidth = (w: number) => {
|
|
26
|
+
Object.defineProperty(window, "innerWidth", {
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true,
|
|
29
|
+
value: w,
|
|
30
|
+
});
|
|
31
|
+
window.dispatchEvent(new Event("resize"));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function mountNode(renderNode: () => ReturnType<typeof h>) {
|
|
35
|
+
setWidth(1280);
|
|
36
|
+
const container = document.createElement("div");
|
|
37
|
+
document.body.appendChild(container);
|
|
38
|
+
const app = createApp({ render: renderNode });
|
|
39
|
+
app.mount(container);
|
|
40
|
+
mounts.push({ app, container });
|
|
41
|
+
return container;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
46
|
+
app.unmount();
|
|
47
|
+
container.remove();
|
|
48
|
+
}
|
|
49
|
+
for (const el of [...document.querySelectorAll(".hk-modal-root, .hk-select-popout")]) {
|
|
50
|
+
el.remove();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function pinRect(el: HTMLElement, width = 288) {
|
|
55
|
+
Object.defineProperty(el, "getBoundingClientRect", {
|
|
56
|
+
configurable: true,
|
|
57
|
+
value: () =>
|
|
58
|
+
({ left: 0, width, height: 18, top: 0, right: width, bottom: 18, x: 0, y: 0 }) as DOMRect,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("HkSliderField", () => {
|
|
63
|
+
it("renders the box with both slots and the flexing slider between", () => {
|
|
64
|
+
const c = mountNode(() =>
|
|
65
|
+
h(HkSliderField, { modelValue: 3, min: 0, max: 8, step: 1, showTicks: true, ariaLabel: "L" }, {
|
|
66
|
+
leading: () => h("span", { class: "lead" }, "L"),
|
|
67
|
+
trailing: () => h("span", { class: "trail" }, "T"),
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
const box = c.querySelector(".hk-slider-field") as HTMLElement;
|
|
71
|
+
expect(box.getAttribute("role")).toBe("group");
|
|
72
|
+
expect(box.getAttribute("aria-label")).toBe("L");
|
|
73
|
+
expect(c.querySelectorAll(".hk-slider-tick").length).toBe(9);
|
|
74
|
+
expect(c.querySelector(".hk-slider-field-slot[data-side='leading'] .lead")).toBeTruthy();
|
|
75
|
+
expect(c.querySelector(".hk-slider-field-slot[data-side='trailing'] .trail")).toBeTruthy();
|
|
76
|
+
// Source contract: the box chrome stays generic — no token/budget
|
|
77
|
+
// words in EITHER file, and the scss still carries its structure
|
|
78
|
+
// (an emptied file must not pass vacuously).
|
|
79
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
80
|
+
const scss = readFileSync(join(here, "HkSliderField.scss"), "utf-8");
|
|
81
|
+
const tsx = readFileSync(join(here, "HkSliderField.tsx"), "utf-8");
|
|
82
|
+
expect(scss).not.toMatch(/token|budget/i);
|
|
83
|
+
expect(tsx).not.toMatch(/token|budget/i);
|
|
84
|
+
expect(scss).toContain(".hk-slider-field");
|
|
85
|
+
expect(scss).toContain(".hk-slider-field-slot");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("commits drags and honors disabled", async () => {
|
|
89
|
+
const seen: number[] = [];
|
|
90
|
+
const v = ref(0);
|
|
91
|
+
const c = mountNode(() =>
|
|
92
|
+
h(HkSliderField, {
|
|
93
|
+
modelValue: v.value,
|
|
94
|
+
min: 0,
|
|
95
|
+
max: 8,
|
|
96
|
+
"onUpdate:modelValue": (n: number) => {
|
|
97
|
+
v.value = n;
|
|
98
|
+
seen.push(n);
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
const slider = c.querySelector(".hk-slider") as HTMLElement;
|
|
103
|
+
pinRect(slider);
|
|
104
|
+
slider.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 144, pointerId: 1, buttons: 1 }));
|
|
105
|
+
expect(seen.at(-1)).toBe(4);
|
|
106
|
+
// Disabled: no commits, and BOTH chrome layers flag it — the
|
|
107
|
+
// inner slider's own is-disabled proves the pass-through (a broken
|
|
108
|
+
// one would let the slider commit while the box looks disabled).
|
|
109
|
+
const seen2: number[] = [];
|
|
110
|
+
const c2 = mountNode(() =>
|
|
111
|
+
h(HkSliderField, {
|
|
112
|
+
modelValue: 0,
|
|
113
|
+
min: 0,
|
|
114
|
+
max: 8,
|
|
115
|
+
disabled: true,
|
|
116
|
+
"onUpdate:modelValue": (n: number) => seen2.push(n),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
const s2 = c2.querySelector(".hk-slider") as HTMLElement;
|
|
120
|
+
pinRect(s2);
|
|
121
|
+
s2.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 144, pointerId: 1, buttons: 1 }));
|
|
122
|
+
expect(seen2).toEqual([]);
|
|
123
|
+
expect(s2.classList.contains("is-disabled")).toBe(true);
|
|
124
|
+
expect((c2.querySelector(".hk-slider-field") as HTMLElement).classList.contains("is-disabled")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("a dropdown rides the trailing slot untouched (the composition is the host's)", () => {
|
|
128
|
+
const c = mountNode(() =>
|
|
129
|
+
h(HkSliderField, { modelValue: 0, min: 0, max: 8 }, {
|
|
130
|
+
trailing: () => h(HkTokenInput, { modelValue: 128000 }),
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
expect(c.querySelector(".hk-slider-field-slot[data-side='trailing'] .hk-token-input")).toBeTruthy();
|
|
134
|
+
expect((c.querySelector(".hk-token-input-value") as HTMLElement).textContent).toBe("128K");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("HkTokenInput", () => {
|
|
139
|
+
interface Harness {
|
|
140
|
+
container: HTMLElement;
|
|
141
|
+
emitted: Array<number | null>;
|
|
142
|
+
unitEmitted: Array<string>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mountInput(props: Record<string, unknown> = {}, slots: Record<string, unknown> = {}): Harness {
|
|
146
|
+
const value = ref<number | null>((props.modelValue as number | null) ?? null);
|
|
147
|
+
const unit = ref<string>("auto");
|
|
148
|
+
const emitted: Array<number | null> = [];
|
|
149
|
+
const unitEmitted: Array<string> = [];
|
|
150
|
+
const container = mountNode(() =>
|
|
151
|
+
h(
|
|
152
|
+
HkTokenInput,
|
|
153
|
+
{
|
|
154
|
+
...props,
|
|
155
|
+
modelValue: value.value,
|
|
156
|
+
unit: unit.value as "auto" | "K" | "M" | "B",
|
|
157
|
+
"onUpdate:modelValue": (v: number | null) => {
|
|
158
|
+
value.value = v;
|
|
159
|
+
emitted.push(v);
|
|
160
|
+
},
|
|
161
|
+
"onUpdate:unit": (u: string) => {
|
|
162
|
+
unit.value = u;
|
|
163
|
+
unitEmitted.push(u);
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
slots.editor ? { editor: () => (slots.editor as () => unknown)() } : {},
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
return { container, emitted, unitEmitted };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function pickOption(trigger: HTMLButtonElement, label: string) {
|
|
173
|
+
trigger.click();
|
|
174
|
+
await nextTick();
|
|
175
|
+
const popout = document.querySelector<HTMLElement>(".hk-select-popout");
|
|
176
|
+
expect(popout, "the select popout opened").toBeTruthy();
|
|
177
|
+
const row = [...popout!.querySelectorAll<HTMLElement>(".hk-select-option")].find(
|
|
178
|
+
(o) => o.textContent?.trim() === label,
|
|
179
|
+
);
|
|
180
|
+
expect(row, `the ${label} option`).toBeTruthy();
|
|
181
|
+
row!.click();
|
|
182
|
+
await nextTick();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it("shows the compact value and — for unset", () => {
|
|
186
|
+
const a = mountInput({ modelValue: 128000 });
|
|
187
|
+
expect((a.container.querySelector(".hk-token-input-value") as HTMLElement).textContent).toBe("128K");
|
|
188
|
+
const b = mountInput({});
|
|
189
|
+
expect((b.container.querySelector(".hk-token-input-value") as HTMLElement).textContent).toBe("—");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("a custom format wins over the default compaction", () => {
|
|
193
|
+
const a = mountInput({ modelValue: 128000, format: () => "custom" });
|
|
194
|
+
expect((a.container.querySelector(".hk-token-input-value") as HTMLElement).textContent).toBe("custom");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("opens the draggable precision editor from the value segment", async () => {
|
|
198
|
+
const a = mountInput({ modelValue: 128000, editorTitle: "Context" });
|
|
199
|
+
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
200
|
+
await nextTick();
|
|
201
|
+
const frame = document.querySelector<HTMLElement>(".hk-modal-content[data-draggable]");
|
|
202
|
+
expect(frame, "the draggable desktop window").toBeTruthy();
|
|
203
|
+
const input = frame!.querySelector("input") as HTMLInputElement;
|
|
204
|
+
expect(input.value).toBe("128");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("commits unit-scaled absolutes live; unparseable intermediates commit nothing", async () => {
|
|
208
|
+
const a = mountInput({ modelValue: 128000 });
|
|
209
|
+
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
210
|
+
await nextTick();
|
|
211
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
212
|
+
expect(input.value).toBe("128");
|
|
213
|
+
input.value = "2";
|
|
214
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
215
|
+
await nextTick();
|
|
216
|
+
expect(a.emitted.at(-1)).toBe(2000);
|
|
217
|
+
input.value = "1e";
|
|
218
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
219
|
+
await nextTick();
|
|
220
|
+
expect(input.value).toBe("1e");
|
|
221
|
+
expect(a.emitted.filter((v) => v === null)).toEqual([]);
|
|
222
|
+
input.value = "1e3";
|
|
223
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
224
|
+
await nextTick();
|
|
225
|
+
// K-seeded: 1e3 = 1000 in-unit = 1,000,000 tokens.
|
|
226
|
+
expect(a.emitted.at(-1)).toBe(1_000_000);
|
|
227
|
+
expect(input.value).toBe("1e3");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("deliberately emptying the input unsets (text-cleared, not button)", async () => {
|
|
231
|
+
const a = mountInput({ modelValue: 128000 });
|
|
232
|
+
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
233
|
+
await nextTick();
|
|
234
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
235
|
+
input.value = "";
|
|
236
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
237
|
+
await nextTick();
|
|
238
|
+
expect(a.emitted.at(-1)).toBe(null);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("an external change back to the last-emitted value still rescales", async () => {
|
|
242
|
+
// The echo marker must be CLEARED on external changes: a stale
|
|
243
|
+
// marker would swallow a later external set back to exactly the
|
|
244
|
+
// value we last typed (R2 mutation M3's survivor).
|
|
245
|
+
const value = ref<number | null>(128000);
|
|
246
|
+
const container = document.createElement("div");
|
|
247
|
+
document.body.appendChild(container);
|
|
248
|
+
const app = createApp({
|
|
249
|
+
setup: () => () =>
|
|
250
|
+
h(HkTokenInput, {
|
|
251
|
+
modelValue: value.value,
|
|
252
|
+
unit: "auto",
|
|
253
|
+
"onUpdate:modelValue": (v: number | null) => {
|
|
254
|
+
value.value = v;
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
app.mount(container);
|
|
259
|
+
mounts.push({ app, container });
|
|
260
|
+
(container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
261
|
+
await nextTick();
|
|
262
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
263
|
+
expect(input.value).toBe("128");
|
|
264
|
+
input.value = "2";
|
|
265
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
266
|
+
await nextTick();
|
|
267
|
+
expect(input.value).toBe("2");
|
|
268
|
+
value.value = 500; // external preset
|
|
269
|
+
await nextTick();
|
|
270
|
+
expect(input.value).toBe("0.5");
|
|
271
|
+
value.value = 2000; // external flip back to the last-typed budget
|
|
272
|
+
await nextTick();
|
|
273
|
+
expect(input.value).toBe("2");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("select-all + typing 0 keeps the keystroke (no transient null)", async () => {
|
|
277
|
+
const a = mountInput({ modelValue: 128000 });
|
|
278
|
+
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
279
|
+
await nextTick();
|
|
280
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
281
|
+
input.value = "0";
|
|
282
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
283
|
+
await nextTick();
|
|
284
|
+
expect(input.value).toBe("0");
|
|
285
|
+
expect(a.emitted.filter((v) => v === null)).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("clearing unsets; unit flips re-express, never rescale", async () => {
|
|
289
|
+
const a = mountInput({ modelValue: 1_500_000 });
|
|
290
|
+
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
291
|
+
await nextTick();
|
|
292
|
+
const editorSelect = document.querySelector<HTMLButtonElement>(
|
|
293
|
+
".hk-modal-content .hk-token-editor .hk-select-trigger",
|
|
294
|
+
)!;
|
|
295
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
296
|
+
await pickOption(editorSelect, "K");
|
|
297
|
+
expect(input.value).toBe("1500");
|
|
298
|
+
expect(a.emitted).toEqual([]);
|
|
299
|
+
(document.querySelector(".hk-token-input-clear") as HTMLButtonElement).click();
|
|
300
|
+
await nextTick();
|
|
301
|
+
expect(a.emitted.at(-1)).toBe(null);
|
|
302
|
+
await vi.waitFor(() => {
|
|
303
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
304
|
+
}, { timeout: 1500 });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("a unit flip with unparseable text clears rather than skewing the scale", async () => {
|
|
308
|
+
const a = mountInput({ modelValue: 128000 });
|
|
309
|
+
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
310
|
+
await nextTick();
|
|
311
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
312
|
+
const editorSelect = document.querySelector<HTMLButtonElement>(
|
|
313
|
+
".hk-modal-content .hk-token-editor .hk-select-trigger",
|
|
314
|
+
)!;
|
|
315
|
+
input.value = "1e";
|
|
316
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
317
|
+
await nextTick();
|
|
318
|
+
await pickOption(editorSelect, "M");
|
|
319
|
+
expect(input.value).toBe("");
|
|
320
|
+
expect(a.emitted.filter((v) => v === null)).toEqual([null]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("the suffix dropdown switches the display unit (fixed K re-expresses)", async () => {
|
|
324
|
+
const a = mountInput({ modelValue: 2_000_000 });
|
|
325
|
+
expect((a.container.querySelector(".hk-token-input-value") as HTMLElement).textContent).toBe("2M");
|
|
326
|
+
await pickOption(a.container.querySelector<HTMLButtonElement>(".hk-token-input .hk-select-trigger")!, "K");
|
|
327
|
+
expect(a.unitEmitted.at(-1)).toBe("K");
|
|
328
|
+
await nextTick();
|
|
329
|
+
expect((a.container.querySelector(".hk-token-input-value") as HTMLElement).textContent).toBe("2000K");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("an #editor slot replaces the built-in editor", async () => {
|
|
333
|
+
const a = mountInput({ modelValue: 64000, editorTitle: "E" }, { editor: () => h("button", { class: "custom-editor" }, "custom") });
|
|
334
|
+
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
335
|
+
await nextTick();
|
|
336
|
+
expect(document.querySelector(".custom-editor")).toBeTruthy();
|
|
337
|
+
expect(document.querySelector(".hk-token-editor")).toBeNull();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("disables both segments together", () => {
|
|
341
|
+
const a = mountInput({ modelValue: 16000, disabled: true });
|
|
342
|
+
expect((a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).disabled).toBe(true);
|
|
343
|
+
expect(a.container.querySelector(".hk-token-input .hk-select-trigger")!.hasAttribute("disabled")).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("compactTokenCount", () => {
|
|
348
|
+
it("compacts decimal magnitudes", () => {
|
|
349
|
+
expect(compactTokenCount(750)).toBe("750");
|
|
350
|
+
expect(compactTokenCount(16000)).toBe("16K");
|
|
351
|
+
expect(compactTokenCount(1500000)).toBe("1.5M");
|
|
352
|
+
expect(compactTokenCount(3_000_000_000)).toBe("3B");
|
|
353
|
+
expect(compactTokenCount(null)).toBe("");
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("HkModal draggable (carried pins)", () => {
|
|
358
|
+
async function mountDraggableModal() {
|
|
359
|
+
setWidth(1280);
|
|
360
|
+
const container = document.createElement("div");
|
|
361
|
+
document.body.appendChild(container);
|
|
362
|
+
const open = ref(true);
|
|
363
|
+
const app = createApp({
|
|
364
|
+
setup: () => () =>
|
|
365
|
+
h(
|
|
366
|
+
HkModalCtor,
|
|
367
|
+
{
|
|
368
|
+
modelValue: open.value,
|
|
369
|
+
title: "T",
|
|
370
|
+
draggable: true,
|
|
371
|
+
"onUpdate:modelValue": (v: boolean) => (open.value = v),
|
|
372
|
+
},
|
|
373
|
+
{ default: () => "body" },
|
|
374
|
+
),
|
|
375
|
+
});
|
|
376
|
+
app.mount(container);
|
|
377
|
+
mounts.push({ app, container });
|
|
378
|
+
await nextTick();
|
|
379
|
+
return { container, open };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
it("drags via pointer and hover alone never moves the frame", async () => {
|
|
383
|
+
await mountDraggableModal();
|
|
384
|
+
const frame = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
385
|
+
const header = frame.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
386
|
+
const d = (t: string, x: number, opts: Partial<PointerEventInit> = {}) =>
|
|
387
|
+
new PointerEvent(t, { bubbles: true, clientX: x, pointerId: 7, buttons: 1, ...opts });
|
|
388
|
+
header.dispatchEvent(d("pointerdown", 100));
|
|
389
|
+
header.dispatchEvent(d("pointermove", 260));
|
|
390
|
+
expect(frame.style.getPropertyValue("--hk-modal-shift-x")).toBe("160px");
|
|
391
|
+
header.dispatchEvent(d("pointerdown", 100));
|
|
392
|
+
header.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 400, pointerId: 7, buttons: 0 }));
|
|
393
|
+
expect(frame.style.getPropertyValue("--hk-modal-shift-x")).toBe("160px");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("a close mid-drag never ghosts into the next open", async () => {
|
|
397
|
+
const { open } = await mountDraggableModal();
|
|
398
|
+
const frame = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
399
|
+
const header = frame.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
400
|
+
const d = (t: string, x: number) =>
|
|
401
|
+
new PointerEvent(t, { bubbles: true, clientX: x, pointerId: 7, buttons: 1 });
|
|
402
|
+
header.dispatchEvent(d("pointerdown", 100));
|
|
403
|
+
open.value = false;
|
|
404
|
+
await nextTick();
|
|
405
|
+
open.value = true;
|
|
406
|
+
await nextTick();
|
|
407
|
+
const frame2 = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
408
|
+
frame2.querySelector<HTMLElement>(".hk-modal-header")!.dispatchEvent(d("pointermove", 300));
|
|
409
|
+
expect(frame2.style.getPropertyValue("--hk-modal-shift-x")).toBe("");
|
|
410
|
+
});
|
|
411
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import HkSlider from "./HkSlider";
|
|
4
|
+
import "./HkSliderField.scss";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkSliderField — the boxed form-slider: an HkSlider riding a form
|
|
8
|
+
* chrome row with a LEADING and a TRAILING slot flanking it (either
|
|
9
|
+
* may host arbitrary content — a value editor, a dropdown, a badge).
|
|
10
|
+
* The slider itself takes all the room the slots leave; the box owns
|
|
11
|
+
* the border/padding so the row reads as one control.
|
|
12
|
+
*
|
|
13
|
+
* Purely generic: no idea what the value means. Hosts that need a
|
|
14
|
+
* stepped ladder drive it in index space (min 0, max stops-1) and
|
|
15
|
+
* map indices themselves; the trailing slot commonly carries their
|
|
16
|
+
* value editor (the composition lives in the host, not here).
|
|
17
|
+
*/
|
|
18
|
+
export default defineComponent({
|
|
19
|
+
name: "HkSliderField",
|
|
20
|
+
props: {
|
|
21
|
+
/** Controlled value; every emitted value lands on the step grid. */
|
|
22
|
+
modelValue: { type: Number, required: true },
|
|
23
|
+
min: { type: Number, default: 0 },
|
|
24
|
+
max: { type: Number, default: 100 },
|
|
25
|
+
/** Snap step — emitted values are `min + n * step`. */
|
|
26
|
+
step: { type: Number, default: 1 },
|
|
27
|
+
disabled: { type: Boolean, default: false },
|
|
28
|
+
/** Render one tick dot per stop. */
|
|
29
|
+
showTicks: { type: Boolean, default: false },
|
|
30
|
+
size: { type: String as () => "sm" | "md", default: "md" },
|
|
31
|
+
ariaLabel: { type: String, default: undefined },
|
|
32
|
+
/** Optional aria-valuetext formatter. */
|
|
33
|
+
formatValue: { type: Function as PropType<(value: number) => string>, default: undefined },
|
|
34
|
+
},
|
|
35
|
+
emits: {
|
|
36
|
+
"update:modelValue": (_value: number) => true,
|
|
37
|
+
},
|
|
38
|
+
setup(props, { emit, slots }) {
|
|
39
|
+
return () => (
|
|
40
|
+
<div
|
|
41
|
+
class={["hk-slider-field", props.disabled && "is-disabled"].filter(Boolean).join(" ")}
|
|
42
|
+
role="group"
|
|
43
|
+
aria-label={props.ariaLabel}
|
|
44
|
+
>
|
|
45
|
+
{slots.leading && (
|
|
46
|
+
<div class="hk-slider-field-slot" data-side="leading">
|
|
47
|
+
{slots.leading()}
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
<HkSlider
|
|
51
|
+
class="hk-slider-field-slider"
|
|
52
|
+
modelValue={props.modelValue}
|
|
53
|
+
min={props.min}
|
|
54
|
+
max={props.max}
|
|
55
|
+
step={props.step}
|
|
56
|
+
disabled={props.disabled}
|
|
57
|
+
showTicks={props.showTicks}
|
|
58
|
+
size={props.size}
|
|
59
|
+
ariaLabel={props.ariaLabel}
|
|
60
|
+
formatValue={props.formatValue}
|
|
61
|
+
onUpdate:modelValue={(v: number) => emit("update:modelValue", v)}
|
|
62
|
+
/>
|
|
63
|
+
{slots.trailing && (
|
|
64
|
+
<div class="hk-slider-field-slot" data-side="trailing">
|
|
65
|
+
{slots.trailing()}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
});
|