@celestia-island/hikari 0.55.341 → 0.55.343
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/HkAddTile.contract.test.ts +74 -0
- package/src/components/HkAddTile.scss +71 -0
- package/src/components/HkAddTile.test.tsx +204 -0
- package/src/components/HkAddTile.tsx +138 -0
- package/src/components/HkMinimap.test.tsx +4 -2
- package/src/components/HkSlider.scss +18 -7
- package/src/components/HkSlider.test.tsx +37 -2
- package/src/components/HkSlider.tsx +56 -5
- package/src/components/HkSliderField.test.tsx +9 -5
- package/src/components/HkTokenInput.scss +0 -24
- package/src/components/HkTokenInput.tsx +7 -21
- package/src/components/closeFamily.contract.test.tsx +11 -8
- package/src/i18n/locales/ar/components.json +0 -1
- package/src/i18n/locales/de/components.json +0 -1
- package/src/i18n/locales/en/components.json +0 -1
- package/src/i18n/locales/es/components.json +0 -1
- package/src/i18n/locales/fr/components.json +0 -1
- package/src/i18n/locales/ja/components.json +0 -1
- package/src/i18n/locales/ko/components.json +0 -1
- package/src/i18n/locales/pt/components.json +0 -1
- package/src/i18n/locales/ru/components.json +0 -1
- package/src/i18n/locales/zh-Hans/components.json +0 -1
- package/src/i18n/locales/zh-Hant/components.json +0 -1
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract: HkAddTile's base class stays NEUTRAL — faint dashed
|
|
3
|
+
* border, muted ink, no surface, no primary tint. Color belongs to the
|
|
4
|
+
* drag-over state only (`--drag-over`), which carries the dashed
|
|
5
|
+
* primary + primary-wash language the product's hand-drawn drag
|
|
6
|
+
* overlays already use. Pinned because the tile invites every host to
|
|
7
|
+
* "just tint it a little" — a class-name test cannot catch the base
|
|
8
|
+
* picking up color, the source contract lives here.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/** Extract one top-level rule block's body (`{ ... }`) by selector,
|
|
18
|
+
* counting braces so nested `&:hover` blocks stay inside the slice. */
|
|
19
|
+
function fullRule(css: string, selector: string): string {
|
|
20
|
+
const start = css.indexOf(`${selector} {`);
|
|
21
|
+
expect(start, `rule ${selector} present`).toBeGreaterThanOrEqual(0);
|
|
22
|
+
const open = css.indexOf("{", start);
|
|
23
|
+
let depth = 0;
|
|
24
|
+
for (let i = open; i < css.length; i++) {
|
|
25
|
+
if (css[i] === "{") depth++;
|
|
26
|
+
if (css[i] === "}") {
|
|
27
|
+
depth--;
|
|
28
|
+
if (depth === 0) return css.slice(open + 1, i);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`unbalanced braces in ${selector}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The rule's DIRECT declarations only — up to the first nested block
|
|
35
|
+
* (`&:hover` and friends). Nested states may tint by design; the
|
|
36
|
+
* neutrality discipline is about what the resting state paints. */
|
|
37
|
+
function directDecls(css: string, selector: string): string {
|
|
38
|
+
const body = fullRule(css, selector);
|
|
39
|
+
const nested = body.indexOf("&:");
|
|
40
|
+
return nested === -1 ? body : body.slice(0, nested);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("HkAddTile neutrality contract", () => {
|
|
44
|
+
const css = readFileSync(join(here, "HkAddTile.scss"), "utf-8");
|
|
45
|
+
|
|
46
|
+
it("resting state: dashed neutral border, no surface, no wash", () => {
|
|
47
|
+
const base = directDecls(css, ".hk-add-tile");
|
|
48
|
+
expect(base).toContain("border: 1px dashed");
|
|
49
|
+
expect(base).toContain("background: transparent");
|
|
50
|
+
expect(base).not.toContain("rgb(var(--color-primary)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("compact stays a plain row form: no tint in direct declarations", () => {
|
|
54
|
+
const compact = directDecls(css, ".hk-add-tile--compact");
|
|
55
|
+
expect(compact).toContain("flex-direction: row");
|
|
56
|
+
expect(compact).not.toContain("rgb(var(--color-primary)");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("the drag-over wash lives exclusively in --drag-over", () => {
|
|
60
|
+
const drag = fullRule(css, ".hk-add-tile--drag-over");
|
|
61
|
+
expect(drag).toContain("rgb(var(--color-primary) / 8%)");
|
|
62
|
+
// Whole-file sweep: exactly one top-level rule chunk paints the
|
|
63
|
+
// wash, and it is the drag-over rule.
|
|
64
|
+
const chunks = css.split(/(?=\.hk-add-tile)/g).filter((r) => r.includes("/ 8%)"));
|
|
65
|
+
expect(chunks.length).toBe(1);
|
|
66
|
+
expect(chunks[0].startsWith(".hk-add-tile--drag-over")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("disabled resets to neutral (no primary anywhere in the rule)", () => {
|
|
70
|
+
const disabled = fullRule(css, ".hk-add-tile--disabled");
|
|
71
|
+
expect(disabled).toContain("cursor: default");
|
|
72
|
+
expect(disabled).not.toContain("primary");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
3
|
+
/* HkAddTile — the dashed add / empty affordance tile. The tile is
|
|
4
|
+
* neutral AT REST: faint dashed border, muted ink, no surface and no
|
|
5
|
+
* wash. Primary ink comes in through the states that invite (the
|
|
6
|
+
* hover border/ink tint below, and the drag-over wash) — the primary
|
|
7
|
+
* SURFACE wash lives exclusively in the --drag-over rule (pinned by
|
|
8
|
+
* HkAddTile.contract.test.ts, do not creep a wash into any other
|
|
9
|
+
* rule). */
|
|
10
|
+
.hk-add-tile {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
gap: var(--space-8, 0.5rem);
|
|
16
|
+
width: 100%;
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
padding: var(--space-16, 1rem) var(--space-12, 0.75rem);
|
|
19
|
+
border: 1px dashed var(--border-faint);
|
|
20
|
+
border-radius: var(--radius-md, 8px);
|
|
21
|
+
background: transparent;
|
|
22
|
+
color: rgb(var(--color-muted));
|
|
23
|
+
font: inherit;
|
|
24
|
+
font-size: var(--text-xs, 0.75rem);
|
|
25
|
+
text-align: center;
|
|
26
|
+
cursor: pointer;
|
|
27
|
+
@include user-select(none);
|
|
28
|
+
transition:
|
|
29
|
+
border-color var(--duration-fast, 0.15s) ease,
|
|
30
|
+
background-color var(--duration-fast, 0.15s) ease,
|
|
31
|
+
color var(--duration-fast, 0.15s) ease;
|
|
32
|
+
|
|
33
|
+
&:hover {
|
|
34
|
+
border-color: rgb(var(--color-primary) / 45%);
|
|
35
|
+
color: rgb(var(--color-primary));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&:focus-visible {
|
|
39
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
40
|
+
outline-offset: 2px;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* List-end form: a slim left-aligned row ("+ add one more") that rides
|
|
45
|
+
* the end of a row list instead of floating under it as a button. */
|
|
46
|
+
.hk-add-tile--compact {
|
|
47
|
+
flex-direction: row;
|
|
48
|
+
justify-content: flex-start;
|
|
49
|
+
gap: var(--space-8, 0.5rem);
|
|
50
|
+
padding: var(--space-8, 0.5rem) var(--space-10, 0.625rem);
|
|
51
|
+
text-align: start;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Drag-over wash — dashed primary over a faint primary floor, matching
|
|
55
|
+
* the drag overlays the product already draws by hand. */
|
|
56
|
+
.hk-add-tile--drag-over {
|
|
57
|
+
border-color: rgb(var(--color-primary) / 45%);
|
|
58
|
+
background: rgb(var(--color-primary) / 8%);
|
|
59
|
+
color: rgb(var(--color-primary));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.hk-add-tile--disabled {
|
|
63
|
+
cursor: default;
|
|
64
|
+
opacity: 0.55;
|
|
65
|
+
|
|
66
|
+
&:hover {
|
|
67
|
+
border-color: var(--border-faint);
|
|
68
|
+
color: rgb(var(--color-muted));
|
|
69
|
+
background: transparent;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkAddTile from "./HkAddTile";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/* Functional icon stub with a distinguishing class, same recipe as the
|
|
25
|
+
HkEmptyState tests, so the icon prop / slot override can be asserted
|
|
26
|
+
to have actually mounted. */
|
|
27
|
+
const StubIcon = defineComponent({
|
|
28
|
+
name: "StubIcon",
|
|
29
|
+
setup() {
|
|
30
|
+
return () => h("i", { class: "stub-add-icon", "data-stub-icon": "true" });
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A drop event carrying the given files — plain Event + a defined
|
|
35
|
+
dataTransfer, because happy-dom has no real DataTransfer. */
|
|
36
|
+
function dropEvent(files: File[]): Event {
|
|
37
|
+
const ev = new Event("drop", { bubbles: true, cancelable: true });
|
|
38
|
+
Object.defineProperty(ev, "dataTransfer", { value: { files } });
|
|
39
|
+
return ev;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function file(name: string): File {
|
|
43
|
+
return new File(["x"], name, { type: "text/plain" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tileEl(c: HTMLElement): HTMLElement {
|
|
47
|
+
return c.querySelector(".hk-add-tile") as HTMLElement;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("HkAddTile", () => {
|
|
51
|
+
it("renders the label and the default Plus icon", () => {
|
|
52
|
+
const c = mount(h(HkAddTile, null, { default: () => "Add one" }));
|
|
53
|
+
const el = tileEl(c);
|
|
54
|
+
expect(el.tagName).toBe("BUTTON");
|
|
55
|
+
expect(el.textContent).toContain("Add one");
|
|
56
|
+
expect(el.querySelector("svg")).not.toBeNull();
|
|
57
|
+
expect(el.classList.contains("hk-add-tile--compact")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("compact form carries the compact modifier", () => {
|
|
61
|
+
const c = mount(h(HkAddTile, { compact: true }, { default: () => "Add" }));
|
|
62
|
+
expect(tileEl(c).classList.contains("hk-add-tile--compact")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("icon prop replaces the default glyph", () => {
|
|
66
|
+
const c = mount(h(HkAddTile, { icon: StubIcon }, { default: () => "Add" }));
|
|
67
|
+
expect(c.querySelector(".stub-add-icon")).not.toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("icon slot wins over both the default and the icon prop", () => {
|
|
71
|
+
const c = mount(
|
|
72
|
+
h(HkAddTile, { icon: StubIcon }, { icon: () => h("i", { class: "slot-icon" }), default: () => "Add" }),
|
|
73
|
+
);
|
|
74
|
+
expect(c.querySelector(".slot-icon")).not.toBeNull();
|
|
75
|
+
expect(c.querySelector(".stub-add-icon")).toBeNull();
|
|
76
|
+
// The default Plus must not ride along either.
|
|
77
|
+
expect(tileEl(c).querySelectorAll("svg").length).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("emits click for an enabled tile", () => {
|
|
81
|
+
const onClick = vi.fn();
|
|
82
|
+
const c = mount(h(HkAddTile, { onClick }, { default: () => "Add" }));
|
|
83
|
+
tileEl(c).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
84
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not emit click when disabled", () => {
|
|
88
|
+
const onClick = vi.fn();
|
|
89
|
+
const c = mount(h(HkAddTile, { disabled: true, onClick }, { default: () => "Add" }));
|
|
90
|
+
const el = tileEl(c);
|
|
91
|
+
expect(el.classList.contains("hk-add-tile--disabled")).toBe(true);
|
|
92
|
+
// The handler-level guard (a disabled button never receives the
|
|
93
|
+
// native click in a real browser, but dispatchEvent bypasses that).
|
|
94
|
+
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
95
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("emits drop-files with the dropped files", () => {
|
|
99
|
+
const onDropFiles = vi.fn();
|
|
100
|
+
const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
|
|
101
|
+
const ev = dropEvent([file("a.toml"), file("b.toml")]);
|
|
102
|
+
tileEl(c).dispatchEvent(ev);
|
|
103
|
+
expect(onDropFiles).toHaveBeenCalledTimes(1);
|
|
104
|
+
const files = onDropFiles.mock.calls[0][0] as File[];
|
|
105
|
+
expect(files.map((f) => f.name)).toEqual(["a.toml", "b.toml"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores drops without files (the gesture contract is files)", () => {
|
|
109
|
+
const onDropFiles = vi.fn();
|
|
110
|
+
const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
|
|
111
|
+
tileEl(c).dispatchEvent(dropEvent([]));
|
|
112
|
+
expect(onDropFiles).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("ignores drops when not droppable", () => {
|
|
116
|
+
const onDropFiles = vi.fn();
|
|
117
|
+
const c = mount(h(HkAddTile, { onDropFiles }, { default: () => "Nope" }));
|
|
118
|
+
tileEl(c).dispatchEvent(dropEvent([file("a.toml")]));
|
|
119
|
+
expect(onDropFiles).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("ignores drops and drag styling when disabled", () => {
|
|
123
|
+
const onDropFiles = vi.fn();
|
|
124
|
+
const c = mount(
|
|
125
|
+
h(HkAddTile, { disabled: true, droppable: true, onDropFiles }, { default: () => "Off" }),
|
|
126
|
+
);
|
|
127
|
+
const el = tileEl(c);
|
|
128
|
+
el.dispatchEvent(new Event("dragenter", { bubbles: true }));
|
|
129
|
+
expect(el.classList.contains("hk-add-tile--drag-over")).toBe(false);
|
|
130
|
+
el.dispatchEvent(dropEvent([file("a.toml")]));
|
|
131
|
+
expect(onDropFiles).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("flips the drag-over class across child boundaries without flicker", async () => {
|
|
135
|
+
const c = mount(h(HkAddTile, { droppable: true }, { default: () => "Drop" }));
|
|
136
|
+
const el = tileEl(c);
|
|
137
|
+
// The default slot text is a direct child; enter the tile, then its
|
|
138
|
+
// label span boundary, then back out of the span only.
|
|
139
|
+
el.dispatchEvent(new Event("dragenter", { bubbles: true }));
|
|
140
|
+
await nextTick();
|
|
141
|
+
expect(el.classList.contains("hk-add-tile--drag-over")).toBe(true);
|
|
142
|
+
const inner = el.querySelector("span") ?? el.firstElementChild ?? el;
|
|
143
|
+
inner.dispatchEvent(new Event("dragenter", { bubbles: true })); // depth 2
|
|
144
|
+
inner.dispatchEvent(new Event("dragleave", { bubbles: true })); // depth 1 — still over
|
|
145
|
+
await nextTick();
|
|
146
|
+
expect(el.classList.contains("hk-add-tile--drag-over")).toBe(true);
|
|
147
|
+
el.dispatchEvent(new Event("dragleave", { bubbles: true })); // depth 0
|
|
148
|
+
await nextTick();
|
|
149
|
+
expect(el.classList.contains("hk-add-tile--drag-over")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("cancels dragenter/dragover exactly when it accepts the drag", () => {
|
|
153
|
+
// The browser only DELIVERS a drop onto a target whose last dragover
|
|
154
|
+
// was not cancelled — these defaultPrevented pins ARE the drop-target
|
|
155
|
+
// contract; deleting either preventDefault keeps every emit-level
|
|
156
|
+
// test green while killing the feature in real browsers (R2-S2).
|
|
157
|
+
const c = mount(h(HkAddTile, { droppable: true }, { default: () => "Drop" }));
|
|
158
|
+
const el = tileEl(c);
|
|
159
|
+
const enter = new Event("dragenter", { cancelable: true });
|
|
160
|
+
el.dispatchEvent(enter);
|
|
161
|
+
expect(enter.defaultPrevented).toBe(true);
|
|
162
|
+
const over = new Event("dragover", { cancelable: true });
|
|
163
|
+
el.dispatchEvent(over);
|
|
164
|
+
expect(over.defaultPrevented).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("never cancels drag events when not droppable or disabled (refusal = no preventDefault)", () => {
|
|
168
|
+
const c = mount(h(HkAddTile, {}, { default: () => "No" }));
|
|
169
|
+
const over = new Event("dragover", { cancelable: true });
|
|
170
|
+
tileEl(c).dispatchEvent(over);
|
|
171
|
+
expect(over.defaultPrevented).toBe(false);
|
|
172
|
+
const off = mount(
|
|
173
|
+
h(HkAddTile, { droppable: true, disabled: true }, { default: () => "Off" }),
|
|
174
|
+
);
|
|
175
|
+
const over2 = new Event("dragover", { cancelable: true });
|
|
176
|
+
tileEl(off).dispatchEvent(over2);
|
|
177
|
+
expect(over2.defaultPrevented).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("hands the native drop event as the second drop-files argument", () => {
|
|
181
|
+
const onDropFiles = vi.fn();
|
|
182
|
+
const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
|
|
183
|
+
const ev = dropEvent([file("a.toml")]);
|
|
184
|
+
tileEl(c).dispatchEvent(ev);
|
|
185
|
+
expect(onDropFiles.mock.calls[0][1]).toBe(ev);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("clears the drag-over state on drop", async () => {
|
|
189
|
+
const onDropFiles = vi.fn();
|
|
190
|
+
const c = mount(h(HkAddTile, { droppable: true, onDropFiles }, { default: () => "Drop" }));
|
|
191
|
+
const el = tileEl(c);
|
|
192
|
+
el.dispatchEvent(new Event("dragenter", { bubbles: true }));
|
|
193
|
+
await nextTick();
|
|
194
|
+
expect(el.classList.contains("hk-add-tile--drag-over")).toBe(true);
|
|
195
|
+
el.dispatchEvent(dropEvent([file("a.toml")]));
|
|
196
|
+
await nextTick();
|
|
197
|
+
expect(el.classList.contains("hk-add-tile--drag-over")).toBe(false);
|
|
198
|
+
// A second drop after the depth reset must still emit (no stale
|
|
199
|
+
// depth leaking into the next drag session).
|
|
200
|
+
el.dispatchEvent(new Event("dragenter", { bubbles: true }));
|
|
201
|
+
el.dispatchEvent(dropEvent([file("b.toml")]));
|
|
202
|
+
expect(onDropFiles).toHaveBeenCalledTimes(2);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { computed, defineComponent, h, ref, type Component, type PropType } from "vue";
|
|
2
|
+
import { Plus } from "lucide-vue-next";
|
|
3
|
+
|
|
4
|
+
import "./HkAddTile.scss";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkAddTile — the dashed add / empty affordance tile.
|
|
8
|
+
*
|
|
9
|
+
* One standard element for the "dashed placeholder that invites an
|
|
10
|
+
* action" pattern that product surfaces kept re-drawing by hand: the
|
|
11
|
+
* empty-list tile, the "add one more" row at the end of a staged list,
|
|
12
|
+
* and the pick-a-file / drop-files zone. Three blessed forms, one
|
|
13
|
+
* anatomy:
|
|
14
|
+
*
|
|
15
|
+
* - **default** — a centered vertical block (icon above the label):
|
|
16
|
+
* the empty state of a list or panel ("no sources yet — add one to
|
|
17
|
+
* begin"). The whole tile is the click target; hosts no longer pair
|
|
18
|
+
* a passive dashed box with a separate button below it.
|
|
19
|
+
* - **compact** — a slim left-aligned row ("+ Add node / repo"): the
|
|
20
|
+
* tile that rides the END of a list of rows and grows the list. It
|
|
21
|
+
* replaces the loose secondary button, so the add affordance reads
|
|
22
|
+
* as part of the list instead of floating under it.
|
|
23
|
+
* - **droppable** — either form additionally accepts file drags:
|
|
24
|
+
* dragover/dragenter flip the tile into the drag-over wash (same
|
|
25
|
+
* visual language as the established drag overlays — dashed primary
|
|
26
|
+
* on a faint primary floor), and a drop with a non-empty `FileList`
|
|
27
|
+
* is handed UP as `drop-files`. The host decides what files mean;
|
|
28
|
+
* the tile only standardizes the gesture. Drops of anything but
|
|
29
|
+
* files (text, URLs) are ignored — the drag-files-in gesture is the
|
|
30
|
+
* contract, so a host can rely on `drop-files` always carrying ≥1
|
|
31
|
+
* file.
|
|
32
|
+
*
|
|
33
|
+
* Accessibility: a real `<button type="button">` — keyboard focus and
|
|
34
|
+
* Enter/Space activation come free; `disabled` renders the native
|
|
35
|
+
* attribute (and a disabled tile refuses drags entirely: it never calls
|
|
36
|
+
* `preventDefault` on dragover, so the browser disallows the drop).
|
|
37
|
+
*/
|
|
38
|
+
export default defineComponent({
|
|
39
|
+
name: "HkAddTile",
|
|
40
|
+
props: {
|
|
41
|
+
/** Off switches the tile off entirely: no click, no drag acceptance. */
|
|
42
|
+
disabled: { type: Boolean, default: false },
|
|
43
|
+
/** Accept file drags; non-empty drops surface as `drop-files`. */
|
|
44
|
+
droppable: { type: Boolean, default: false },
|
|
45
|
+
/** List-end form: slim horizontal row instead of the centered block. */
|
|
46
|
+
compact: { type: Boolean, default: false },
|
|
47
|
+
/** Icon component (lucide-vue-next style); defaults to Plus. */
|
|
48
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
49
|
+
},
|
|
50
|
+
emits: {
|
|
51
|
+
click: (_e: MouseEvent) => true,
|
|
52
|
+
/** A drop carrying at least one file: `(files, nativeEvent)`. */
|
|
53
|
+
"drop-files": (_files: File[], _e: DragEvent) => true,
|
|
54
|
+
},
|
|
55
|
+
setup(props, { slots, emit }) {
|
|
56
|
+
// dragenter/dragleave fire in pairs across EVERY child boundary, so
|
|
57
|
+
// a boolean flips off the moment the pointer crosses the label —
|
|
58
|
+
// track depth instead and clear it on drop.
|
|
59
|
+
const dragDepth = ref(0);
|
|
60
|
+
const dragOver = computed(
|
|
61
|
+
() => props.droppable && !props.disabled && dragDepth.value > 0,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** Whether this tile accepts the drag at all. Refusing means NEVER
|
|
65
|
+
* calling preventDefault: the browser only permits a drop onto a
|
|
66
|
+
* target whose last dragover was not cancelled, so a disabled (or
|
|
67
|
+
* non-droppable) tile cannot receive one by construction. */
|
|
68
|
+
function acceptsDrag(): boolean {
|
|
69
|
+
return props.droppable && !props.disabled;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onDragenter(e: DragEvent) {
|
|
73
|
+
if (!acceptsDrag()) return;
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
dragDepth.value += 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onDragover(e: DragEvent) {
|
|
79
|
+
if (!acceptsDrag()) return;
|
|
80
|
+
// preventDefault on EVERY dragover (not just the first) — the
|
|
81
|
+
// drop allowance expires continuously; missing one cancels the
|
|
82
|
+
// drop for the whole hover.
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onDragleave() {
|
|
87
|
+
if (dragDepth.value > 0) dragDepth.value -= 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onDrop(e: DragEvent) {
|
|
91
|
+
dragDepth.value = 0;
|
|
92
|
+
if (!acceptsDrag()) return;
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
const files = Array.from(e.dataTransfer?.files ?? []);
|
|
95
|
+
if (files.length === 0) return;
|
|
96
|
+
emit("drop-files", files, e);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function onClick(e: MouseEvent) {
|
|
100
|
+
if (props.disabled) return;
|
|
101
|
+
emit("click", e);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
// Icon resolution reads `slots` INSIDE the render function — the
|
|
106
|
+
// slots object is not reactive, so a computed here would cache the
|
|
107
|
+
// choice across slot add/remove (R2-S1: a parent that adds an icon
|
|
108
|
+
// slot later rendered the stale default Plus ALONGSIDE the slot;
|
|
109
|
+
// removing the slot left the tile iconless until an unrelated prop
|
|
110
|
+
// churned the cache). House style: HkEmptyState reads slots.icon
|
|
111
|
+
// per render for the same reason. h() rather than JSX: the icon
|
|
112
|
+
// prop is a Component (object | function) union, which TS cannot
|
|
113
|
+
// expand as a JSX element type.
|
|
114
|
+
const Icon = slots.icon ? null : (props.icon ?? Plus);
|
|
115
|
+
return (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class={[
|
|
119
|
+
"hk-add-tile",
|
|
120
|
+
props.compact && "hk-add-tile--compact",
|
|
121
|
+
props.disabled && "hk-add-tile--disabled",
|
|
122
|
+
dragOver.value && "hk-add-tile--drag-over",
|
|
123
|
+
]}
|
|
124
|
+
disabled={props.disabled}
|
|
125
|
+
onClick={onClick}
|
|
126
|
+
onDragenter={onDragenter}
|
|
127
|
+
onDragover={onDragover}
|
|
128
|
+
onDragleave={onDragleave}
|
|
129
|
+
onDrop={onDrop}
|
|
130
|
+
>
|
|
131
|
+
{Icon ? h(Icon, { size: props.compact ? 14 : 18, "aria-hidden": true }) : null}
|
|
132
|
+
{slots.icon?.()}
|
|
133
|
+
{slots.default?.()}
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
});
|
|
@@ -105,9 +105,11 @@ describe("HkMinimap zoom bar", () => {
|
|
|
105
105
|
expect(slider.getAttribute("aria-valuemax")).toBe("150");
|
|
106
106
|
expect(slider.getAttribute("aria-valuenow")).toBe("100");
|
|
107
107
|
expect(slider.getAttribute("aria-valuetext")).toBe("100%");
|
|
108
|
-
// Off-grid press:
|
|
108
|
+
// Off-grid press: the press maps into the INSET rail (7px thumb
|
|
109
|
+
// radius each side of the 200px rect → 186px rail); ratio
|
|
110
|
+
// (46-7)/186 ≈ 0.21 → ~71% snaps to the 5% grid → 70.
|
|
109
111
|
pinRect(slider);
|
|
110
|
-
slider.dispatchEvent(pointer("pointerdown",
|
|
112
|
+
slider.dispatchEvent(pointer("pointerdown", 46));
|
|
111
113
|
await nextTick();
|
|
112
114
|
expect(seen).toEqual([[70, "slider"]]);
|
|
113
115
|
expect(slider.getAttribute("aria-valuenow")).toBe("70");
|
|
@@ -9,22 +9,28 @@
|
|
|
9
9
|
touch-action: none;
|
|
10
10
|
@include user-select(none);
|
|
11
11
|
outline: none;
|
|
12
|
+
// The drawable rail is inset by the thumb radius at each end (the value
|
|
13
|
+
// comes inline from the component; this is only the no-inline fallback).
|
|
14
|
+
// A thumb parked on the first/last stop then rests ON the rail instead
|
|
15
|
+
// of hanging off its cap (2026-11-11 user report).
|
|
16
|
+
--hk-slider-rail-inset: 7px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.hk-slider[data-size="sm"] {
|
|
20
|
+
--hk-slider-rail-inset: 6px;
|
|
21
|
+
height: 14px;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
.hk-slider::before {
|
|
15
25
|
content: "";
|
|
16
26
|
position: absolute;
|
|
17
|
-
inset: 50%
|
|
27
|
+
inset: 50% var(--hk-slider-rail-inset, 7px) auto;
|
|
18
28
|
height: 6px;
|
|
19
29
|
transform: translateY(-50%);
|
|
20
30
|
border-radius: var(--radius-full, 9999px);
|
|
21
31
|
background: rgb(var(--color-muted) / 22%);
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
.hk-slider[data-size="sm"] {
|
|
25
|
-
height: 14px;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
34
|
.hk-slider[data-size="sm"]::before {
|
|
29
35
|
height: 4px;
|
|
30
36
|
}
|
|
@@ -42,10 +48,15 @@
|
|
|
42
48
|
.hk-slider-fill {
|
|
43
49
|
position: absolute;
|
|
44
50
|
top: 50%;
|
|
45
|
-
left
|
|
51
|
+
// Anchored at the rail's left edge; the width (inline from the
|
|
52
|
+
// component) is the rail fraction. The LEFT cap stays FLAT — a pill
|
|
53
|
+
// cap on a 6px saturated bar reads as a pointed nib on screen (the
|
|
54
|
+
// same report that inset the rail) — while the right cap stays round
|
|
55
|
+
// and closes the rail off at 100%.
|
|
56
|
+
left: var(--hk-slider-rail-inset, 7px);
|
|
46
57
|
height: 6px;
|
|
47
58
|
transform: translateY(-50%);
|
|
48
|
-
border-radius: var(--radius-full, 9999px);
|
|
59
|
+
border-radius: 0 var(--radius-full, 9999px) var(--radius-full, 9999px) 0;
|
|
49
60
|
background: rgb(var(--color-primary));
|
|
50
61
|
pointer-events: none;
|
|
51
62
|
}
|
|
@@ -66,8 +66,43 @@ describe("HkSlider", () => {
|
|
|
66
66
|
|
|
67
67
|
it("sizes the fill and thumb from the snapped value percentage", () => {
|
|
68
68
|
const c = mount(() => h(HkSlider, { modelValue: 200, min: 100, max: 300, step: 25 }));
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// The rail is inset by the thumb radius (md: 7px → 14px span) and the
|
|
70
|
+
// fill/thumb ride that span (2026-11-11 rail-inset geometry); the
|
|
71
|
+
// linear calc form is the cssstyle-parseable expansion.
|
|
72
|
+
expect((c.querySelector(".hk-slider-fill") as HTMLElement).style.width).toBe(
|
|
73
|
+
"calc(50% - 7px)",
|
|
74
|
+
);
|
|
75
|
+
expect((c.querySelector(".hk-slider-thumb") as HTMLElement).style.left).toBe(
|
|
76
|
+
"calc(50% + 0px)",
|
|
77
|
+
);
|
|
78
|
+
expect(
|
|
79
|
+
(c.querySelector(".hk-slider") as HTMLElement).style.getPropertyValue(
|
|
80
|
+
"--hk-slider-rail-inset",
|
|
81
|
+
),
|
|
82
|
+
).toBe("7px");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("keeps the endpoint ticks inside the rail caps", () => {
|
|
86
|
+
const c = mount(() => h(HkSlider, {
|
|
87
|
+
modelValue: 100, min: 100, max: 300, step: 25, showTicks: true,
|
|
88
|
+
}));
|
|
89
|
+
const ticks = c.querySelectorAll(".hk-slider-tick");
|
|
90
|
+
const first = (ticks[0] as HTMLElement).style.left;
|
|
91
|
+
const last = (ticks[ticks.length - 1] as HTMLElement).style.left;
|
|
92
|
+
// Dot centers sit one dot-radius (1px) further inside the rail, so the
|
|
93
|
+
// endpoint dots never clear the rail caps.
|
|
94
|
+
expect(first).toBe("calc(0% + 8px)");
|
|
95
|
+
expect(last).toBe("calc(100% - 8px)");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("draws the fill with a flat start cap and a rounded end cap", () => {
|
|
99
|
+
// Source contract: the pill cap on a 6px saturated bar read as a
|
|
100
|
+
// pointed nib at the rail start (2026-11-11 user report).
|
|
101
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
const scss = readFileSync(join(here, "HkSlider.scss"), "utf-8");
|
|
103
|
+
const fill = scss.match(/\.hk-slider-fill\s*{[^}]*}/)?.[0] ?? "";
|
|
104
|
+
expect(fill).toContain("border-radius: 0 var(--radius-full, 9999px)");
|
|
105
|
+
expect(fill).toContain("left: var(--hk-slider-rail-inset, 7px)");
|
|
71
106
|
});
|
|
72
107
|
|
|
73
108
|
it("emits a snapped value when the track is pressed and dragged", () => {
|
|
@@ -15,7 +15,25 @@ import "./HkSlider.scss";
|
|
|
15
15
|
* track jumps and drags (emitting continuously so hosts can apply live),
|
|
16
16
|
* with window-level move/up listeners so the drag keeps tracking the
|
|
17
17
|
* pointer past the element bounds.
|
|
18
|
+
*
|
|
19
|
+
* Rail geometry (2026-11-11 user report): the rail is INSET by the thumb
|
|
20
|
+
* radius at each end — the native range-input shape. A thumb parked on
|
|
21
|
+
* the first/last stop then rests ON the rail instead of hanging off its
|
|
22
|
+
* cap, the endpoint tick dots stay inside the rail, and the fill starts
|
|
23
|
+
* flat at the rail's left edge (a pill cap on a 6px saturated bar reads
|
|
24
|
+
* as a pointed nib on screen). The inset lives in the
|
|
25
|
+
* `--hk-slider-rail-inset` var (set inline here, mirrored by the
|
|
26
|
+
* constants below — the stylesheet consumes the var for the track/fill
|
|
27
|
+
* edges; keep the numbers in sync).
|
|
18
28
|
*/
|
|
29
|
+
|
|
30
|
+
/** Thumb diameter / 2 per size (HkSlider.scss thumb rules). */
|
|
31
|
+
const RAIL_INSET_PX = { md: 7, sm: 6 } as const;
|
|
32
|
+
/** Tick dot diameter (HkSlider.scss `.hk-slider-tick`); dot centers sit
|
|
33
|
+
* one radius further inside the rail so the last dot never clears the
|
|
34
|
+
* rail cap. */
|
|
35
|
+
const TICK_DOT_PX = 2;
|
|
36
|
+
|
|
19
37
|
export default defineComponent({
|
|
20
38
|
name: "HkSlider",
|
|
21
39
|
props: {
|
|
@@ -59,6 +77,29 @@ export default defineComponent({
|
|
|
59
77
|
return span > 0 ? ((value.value - props.min) / span) * 100 : 0;
|
|
60
78
|
});
|
|
61
79
|
|
|
80
|
+
/** The value fraction mapped into the INSET rail: `f` runs 0..1
|
|
81
|
+
* across the rail's drawable span, so positions derive as
|
|
82
|
+
* `inset + railLength * f`. Shared by thumb/fill/tick positioning
|
|
83
|
+
* and the pointer mapping below. Emitted in the expanded linear
|
|
84
|
+
* calc form (`calc(F% ± Npx)`) — the `(100% - Npx) * f` product
|
|
85
|
+
* shape renders identically but cssstyle (jsdom) rejects it, and
|
|
86
|
+
* the contract tests assert on the live inline style. */
|
|
87
|
+
const railInset = computed(() => RAIL_INSET_PX[props.size] ?? RAIL_INSET_PX.md);
|
|
88
|
+
|
|
89
|
+
function round4(n: number): number {
|
|
90
|
+
return Math.round(n * 1e4) / 1e4;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A position ON the rail (thumb center / tick center); the extra
|
|
94
|
+
* inset shaves tick centers by the dot radius so the endpoint
|
|
95
|
+
* dots stay inside the rail caps. */
|
|
96
|
+
function railPos(f: number, extraInsetPx = 0): string {
|
|
97
|
+
const inset = railInset.value + extraInsetPx;
|
|
98
|
+
const px = round4(inset * (1 - 2 * f));
|
|
99
|
+
const op = px < 0 ? " - " : " + ";
|
|
100
|
+
return `calc(${round4(f * 100)}%${op}${Math.abs(px)}px)`;
|
|
101
|
+
}
|
|
102
|
+
|
|
62
103
|
const tickPositions = computed<number[]>(() => {
|
|
63
104
|
if (!props.showTicks) return [];
|
|
64
105
|
const count = Math.round((props.max - props.min) / safeStep.value);
|
|
@@ -75,8 +116,12 @@ export default defineComponent({
|
|
|
75
116
|
const el = trackRef.value;
|
|
76
117
|
if (!el) return value.value;
|
|
77
118
|
const rect = el.getBoundingClientRect();
|
|
78
|
-
|
|
79
|
-
const
|
|
119
|
+
const inset = railInset.value;
|
|
120
|
+
const rail = rect.width - inset * 2;
|
|
121
|
+
// Degenerate rail (hidden / zero-width): keep the current value
|
|
122
|
+
// rather than inventing a position.
|
|
123
|
+
if (rect.width <= 0 || rail <= 0) return value.value;
|
|
124
|
+
const ratio = Math.max(0, Math.min(1, (clientX - rect.left - inset) / rail));
|
|
80
125
|
return snap(props.min + ratio * (props.max - props.min));
|
|
81
126
|
}
|
|
82
127
|
|
|
@@ -145,6 +190,7 @@ export default defineComponent({
|
|
|
145
190
|
ref={trackRef}
|
|
146
191
|
class={["hk-slider", props.disabled && "is-disabled"].filter(Boolean).join(" ")}
|
|
147
192
|
data-size={props.size}
|
|
193
|
+
style={{ "--hk-slider-rail-inset": `${railInset.value}px` }}
|
|
148
194
|
role="slider"
|
|
149
195
|
tabindex={props.disabled ? -1 : 0}
|
|
150
196
|
aria-label={props.ariaLabel}
|
|
@@ -161,12 +207,17 @@ export default defineComponent({
|
|
|
161
207
|
key={i}
|
|
162
208
|
class="hk-slider-tick"
|
|
163
209
|
data-active={left <= pct.value + 0.001 || undefined}
|
|
164
|
-
|
|
210
|
+
// Tick centers ride the rail span, one dot-radius further
|
|
211
|
+
// inset, so the endpoint dots stay inside the rail caps.
|
|
212
|
+
style={{ left: railPos(left / 100, TICK_DOT_PX / 2) }}
|
|
165
213
|
aria-hidden="true"
|
|
166
214
|
/>
|
|
167
215
|
))}
|
|
168
|
-
<div
|
|
169
|
-
|
|
216
|
+
<div
|
|
217
|
+
class="hk-slider-fill"
|
|
218
|
+
style={{ width: `calc(${round4(pct.value)}% - ${round4(railInset.value * 2 * (pct.value / 100))}px)` }}
|
|
219
|
+
/>
|
|
220
|
+
<div class="hk-slider-thumb" style={{ left: railPos(pct.value / 100) }} />
|
|
170
221
|
</div>
|
|
171
222
|
);
|
|
172
223
|
},
|
|
@@ -285,7 +285,7 @@ describe("HkTokenInput", () => {
|
|
|
285
285
|
expect(a.emitted.filter((v) => v === null)).toEqual([]);
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
it("clearing unsets; unit flips re-express, never rescale", async () => {
|
|
288
|
+
it("clearing via empty text unsets; unit flips re-express, never rescale", async () => {
|
|
289
289
|
const a = mountInput({ modelValue: 1_500_000 });
|
|
290
290
|
(a.container.querySelector(".hk-token-input-value") as HTMLButtonElement).click();
|
|
291
291
|
await nextTick();
|
|
@@ -296,12 +296,16 @@ describe("HkTokenInput", () => {
|
|
|
296
296
|
await pickOption(editorSelect, "K");
|
|
297
297
|
expect(input.value).toBe("1500");
|
|
298
298
|
expect(a.emitted).toEqual([]);
|
|
299
|
-
|
|
299
|
+
// There is deliberately no separate clear button in the editor body
|
|
300
|
+
// (the window close ✕ plus a second ✕ read as duplicates): emptying
|
|
301
|
+
// the text IS the unset gesture, and the editor stays open so the
|
|
302
|
+
// unset value segment ("—") is visible before the host closes it.
|
|
303
|
+
expect(document.querySelector(".hk-token-input-clear")).toBeNull();
|
|
304
|
+
input.value = "";
|
|
305
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
300
306
|
await nextTick();
|
|
301
307
|
expect(a.emitted.at(-1)).toBe(null);
|
|
302
|
-
|
|
303
|
-
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
304
|
-
}, { timeout: 1500 });
|
|
308
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
305
309
|
});
|
|
306
310
|
|
|
307
311
|
it("a unit flip with unparseable text clears rather than skewing the scale", async () => {
|
|
@@ -67,27 +67,3 @@
|
|
|
67
67
|
width: 5rem;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
.hk-token-input-clear {
|
|
71
|
-
/* The editor's clear affordance is the STANDARD icon button (wave B1):
|
|
72
|
-
S24 replaces the old 26px box. The OLD contract is preserved exactly:
|
|
73
|
-
muted ink at rest, error ink + wash on hover (an R3 review round caught
|
|
74
|
-
a first draft that set the ink var at rest — red-at-rest was never the
|
|
75
|
-
design). The hover-scoped override keeps ghost's rest ink while the
|
|
76
|
-
error channels apply on hover/press only. */
|
|
77
|
-
flex: 0 0 auto;
|
|
78
|
-
margin-top: var(--space-4, 4px);
|
|
79
|
-
--hi-icon-button-bg-hover: rgb(var(--color-error) / 10%);
|
|
80
|
-
--hi-icon-button-bg-active: rgb(var(--color-error) / 16%);
|
|
81
|
-
|
|
82
|
-
&:hover {
|
|
83
|
-
--hi-icon-button-icon-color: rgb(var(--color-error));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
&:active {
|
|
87
|
-
/* Press ink reads the -active channel (HkIconButton.scss
|
|
88
|
-
`&:active .hk-icon-button-icon`), so the base var alone is a no-op
|
|
89
|
-
here — set both. */
|
|
90
|
-
--hi-icon-button-icon-color: rgb(var(--color-error));
|
|
91
|
-
--hi-icon-button-icon-color-active: rgb(var(--color-error));
|
|
92
|
-
}
|
|
93
|
-
}
|
|
@@ -3,9 +3,6 @@ import { computed, defineComponent, ref, watch, type PropType } from "vue";
|
|
|
3
3
|
import HkAdaptiveDialog from "./HkAdaptiveDialog";
|
|
4
4
|
import HkInput from "./HkInput";
|
|
5
5
|
import HkSelect from "./HkSelect";
|
|
6
|
-
import { X } from "lucide-vue-next";
|
|
7
|
-
|
|
8
|
-
import HkIconButton from "./HkIconButton";
|
|
9
6
|
|
|
10
7
|
import { useI18n } from "../i18n/context";
|
|
11
8
|
import "./HkTokenInput.scss";
|
|
@@ -38,9 +35,13 @@ function trim(n: number): string {
|
|
|
38
35
|
* The precision editor is a DRAGGABLE desktop window / slide-up
|
|
39
36
|
* mobile sheet (HkAdaptiveDialog): a numeric input (major) plus a
|
|
40
37
|
* unit picker (minor), both live-commit; a unit flip re-expresses the
|
|
41
|
-
* same absolute budget (1.5M ⇄ 1500K) instead of rescaling it;
|
|
42
|
-
*
|
|
43
|
-
*
|
|
38
|
+
* same absolute budget (1.5M ⇄ 1500K) instead of rescaling it;
|
|
39
|
+
* deliberately emptying the input unsets. There is deliberately NO
|
|
40
|
+
* separate clear button in the editor body — the dialog chrome already
|
|
41
|
+
* carries the window's close ✕, and a second ✕ read as a duplicate
|
|
42
|
+
* control (2026-11-11 user report); clearing the text is the unset
|
|
43
|
+
* gesture. Transiently unparseable intermediates ("1e") commit
|
|
44
|
+
* nothing. An `#editor` slot may replace the built-in body.
|
|
44
45
|
*/
|
|
45
46
|
export default defineComponent({
|
|
46
47
|
name: "HkTokenInput",
|
|
@@ -72,7 +73,6 @@ export default defineComponent({
|
|
|
72
73
|
const resolvedEditorTitle = computed(
|
|
73
74
|
() => props.editorTitle ?? t("hikari::tokenField.editValue", "Edit value"),
|
|
74
75
|
);
|
|
75
|
-
const clearLabel = computed(() => t("hikari::tokenField.clear", "Clear"));
|
|
76
76
|
|
|
77
77
|
function display(): string {
|
|
78
78
|
if (props.format) return props.format(props.modelValue, props.unit);
|
|
@@ -109,11 +109,6 @@ export default defineComponent({
|
|
|
109
109
|
emit("update:unit", next as "auto" | TokenUnit);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function clearValue() {
|
|
113
|
-
emit("update:modelValue", null);
|
|
114
|
-
editorOpen.value = false;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
112
|
return () => (
|
|
118
113
|
<div
|
|
119
114
|
class={["hk-token-input", props.disabled && "is-disabled"].filter(Boolean).join(" ")}
|
|
@@ -161,15 +156,6 @@ export default defineComponent({
|
|
|
161
156
|
onUpdate:modelValue={(v: number | null) => emit("update:modelValue", v)}
|
|
162
157
|
onUpdate:unit={(u: TokenUnit) => (editorUnit.value = u)}
|
|
163
158
|
/>
|
|
164
|
-
<HkIconButton
|
|
165
|
-
class="hk-token-input-clear"
|
|
166
|
-
size={24}
|
|
167
|
-
variant="ghost"
|
|
168
|
-
aria-label={clearLabel.value}
|
|
169
|
-
onClick={clearValue}
|
|
170
|
-
>
|
|
171
|
-
{{ icon: () => <X aria-hidden="true" /> }}
|
|
172
|
-
</HkIconButton>
|
|
173
159
|
</div>
|
|
174
160
|
),
|
|
175
161
|
}}
|
|
@@ -114,17 +114,20 @@ describe("close-button family rides the standard icon button", () => {
|
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
it("
|
|
118
|
-
//
|
|
119
|
-
//
|
|
117
|
+
it("the token editor has NO clear button in its editor body", () => {
|
|
118
|
+
// Retired 2026-11-11 (user report): the precision editor's dialog
|
|
119
|
+
// chrome already carries the window close ✕, and a second ✕ next to
|
|
120
|
+
// the unit picker read as a duplicate control — clearing the text is
|
|
121
|
+
// the unset gesture. Pinned NEGATIVELY at source level (the editor
|
|
122
|
+
// lives inside an HkAdaptiveDialog slot, no bare mount path) so the
|
|
123
|
+
// button cannot quietly grow back.
|
|
120
124
|
const token = readSource("HkTokenInput.tsx");
|
|
121
|
-
expect(token, "token
|
|
122
|
-
|
|
125
|
+
expect(token, "no hk-token-input-clear seat class").not.toContain(
|
|
126
|
+
"hk-token-input-clear",
|
|
123
127
|
);
|
|
124
|
-
expect(token, "
|
|
125
|
-
|
|
128
|
+
expect(token, "no HkIconButton in the editor body").not.toContain(
|
|
129
|
+
"HkIconButton",
|
|
126
130
|
);
|
|
127
|
-
expect(token, "token clear rides the 24px rung").toContain("size={24}");
|
|
128
131
|
});
|
|
129
132
|
|
|
130
133
|
it("sources the toast controls from the standard component too", () => {
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "تأكيد",
|
|
290
290
|
"hikari::userPicker.selectedCount": "تم اختيار {count}",
|
|
291
291
|
"hikari::tokenField.editValue": "تحرير القيمة",
|
|
292
|
-
"hikari::tokenField.clear": "مسح",
|
|
293
292
|
"hikari::diff.unchangedLines": "{count} أسطر غير معدّلة",
|
|
294
293
|
"hikari::diff.loadFailed": "فشل التحميل، انقر لإعادة المحاولة",
|
|
295
294
|
"hikari::search.trigger": "بحث",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "Bestätigen",
|
|
290
290
|
"hikari::userPicker.selectedCount": "{count} ausgewählt",
|
|
291
291
|
"hikari::tokenField.editValue": "Wert bearbeiten",
|
|
292
|
-
"hikari::tokenField.clear": "Leeren",
|
|
293
292
|
"hikari::diff.unchangedLines": "{count} unveränderte Zeilen",
|
|
294
293
|
"hikari::diff.loadFailed": "Laden fehlgeschlagen, zum Wiederholen klicken",
|
|
295
294
|
"hikari::search.trigger": "Suchen",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "Confirm",
|
|
290
290
|
"hikari::userPicker.selectedCount": "{count} selected",
|
|
291
291
|
"hikari::tokenField.editValue": "Edit value",
|
|
292
|
-
"hikari::tokenField.clear": "Clear",
|
|
293
292
|
"hikari::diff.unchangedLines": "{count} unchanged lines",
|
|
294
293
|
"hikari::diff.loadFailed": "Load failed, click to retry",
|
|
295
294
|
"hikari::search.trigger": "Search",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "Confirmar",
|
|
290
290
|
"hikari::userPicker.selectedCount": "{count} seleccionados",
|
|
291
291
|
"hikari::tokenField.editValue": "Editar el valor",
|
|
292
|
-
"hikari::tokenField.clear": "Borrar",
|
|
293
292
|
"hikari::diff.unchangedLines": "{count} líneas sin cambios",
|
|
294
293
|
"hikari::diff.loadFailed": "Error al cargar, haz clic para reintentar",
|
|
295
294
|
"hikari::search.trigger": "Buscar",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "Confirmer",
|
|
290
290
|
"hikari::userPicker.selectedCount": "{count} sélectionnés",
|
|
291
291
|
"hikari::tokenField.editValue": "Modifier la valeur",
|
|
292
|
-
"hikari::tokenField.clear": "Effacer",
|
|
293
292
|
"hikari::diff.unchangedLines": "{count} lignes inchangées",
|
|
294
293
|
"hikari::diff.loadFailed": "Échec du chargement, cliquez pour réessayer",
|
|
295
294
|
"hikari::search.trigger": "Rechercher",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "確認",
|
|
290
290
|
"hikari::userPicker.selectedCount": "{count} 件選択中",
|
|
291
291
|
"hikari::tokenField.editValue": "値を編集",
|
|
292
|
-
"hikari::tokenField.clear": "クリア",
|
|
293
292
|
"hikari::diff.unchangedLines": "未変更の {count} 行",
|
|
294
293
|
"hikari::diff.loadFailed": "読み込みに失敗しました。クリックで再試行",
|
|
295
294
|
"hikari::search.trigger": "検索",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "확인",
|
|
290
290
|
"hikari::userPicker.selectedCount": "{count}명 선택됨",
|
|
291
291
|
"hikari::tokenField.editValue": "값 편집",
|
|
292
|
-
"hikari::tokenField.clear": "지우기",
|
|
293
292
|
"hikari::diff.unchangedLines": "변경되지 않은 {count}줄",
|
|
294
293
|
"hikari::diff.loadFailed": "불러오기 실패, 클릭하여 재시도",
|
|
295
294
|
"hikari::search.trigger": "검색",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "Confirmar",
|
|
290
290
|
"hikari::userPicker.selectedCount": "{count} selecionados",
|
|
291
291
|
"hikari::tokenField.editValue": "Editar o valor",
|
|
292
|
-
"hikari::tokenField.clear": "Limpar",
|
|
293
292
|
"hikari::diff.unchangedLines": "{count} linhas inalteradas",
|
|
294
293
|
"hikari::diff.loadFailed": "Falha ao carregar, clique para tentar novamente",
|
|
295
294
|
"hikari::search.trigger": "Pesquisar",
|
|
@@ -289,7 +289,6 @@
|
|
|
289
289
|
"hikari::userPicker.confirm": "Подтвердить",
|
|
290
290
|
"hikari::userPicker.selectedCount": "Выбрано: {count}",
|
|
291
291
|
"hikari::tokenField.editValue": "Изменить значение",
|
|
292
|
-
"hikari::tokenField.clear": "Очистить",
|
|
293
292
|
"hikari::diff.unchangedLines": "{count} строк без изменений",
|
|
294
293
|
"hikari::diff.loadFailed": "Не удалось загрузить, нажмите для повтора",
|
|
295
294
|
"hikari::search.trigger": "Поиск",
|
|
@@ -293,7 +293,6 @@
|
|
|
293
293
|
"hikari::userPicker.confirm": "确认",
|
|
294
294
|
"hikari::userPicker.selectedCount": "已选 {count} 人",
|
|
295
295
|
"hikari::tokenField.editValue": "编辑数值",
|
|
296
|
-
"hikari::tokenField.clear": "清除",
|
|
297
296
|
"hikari::diff.unchangedLines": "{count} 行未修改",
|
|
298
297
|
"hikari::diff.loadFailed": "加载失败,点击重试",
|
|
299
298
|
"hikari::search.trigger": "搜索",
|
|
@@ -293,7 +293,6 @@
|
|
|
293
293
|
"hikari::userPicker.confirm": "確認",
|
|
294
294
|
"hikari::userPicker.selectedCount": "已選 {count} 人",
|
|
295
295
|
"hikari::tokenField.editValue": "編輯數值",
|
|
296
|
-
"hikari::tokenField.clear": "清除",
|
|
297
296
|
"hikari::diff.unchangedLines": "{count} 行未修改",
|
|
298
297
|
"hikari::diff.loadFailed": "載入失敗,點擊重試",
|
|
299
298
|
"hikari::search.trigger": "搜尋",
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
type HkDockBarAnchor as HDockBarAnchor,
|
|
27
27
|
type HkDockBarSurface as HDockBarSurface,
|
|
28
28
|
} from "./components/HkDockBar";
|
|
29
|
+
export { default as HAddTile } from "./components/HkAddTile";
|
|
29
30
|
export { default as HDrawer } from "./components/HkDrawer";
|
|
30
31
|
export { default as HEdgeGlow } from "./components/HkEdgeGlow";
|
|
31
32
|
export { default as HEmptyState } from "./components/HkEmptyState";
|
|
@@ -814,6 +815,7 @@ export { default as HkNavItem } from "./components/HkNavItem";
|
|
|
814
815
|
export { default as HkNumberInput } from "./components/HkNumberInput";
|
|
815
816
|
export { default as HkPhoneInput } from "./components/HkPhoneInput";
|
|
816
817
|
export { default as HkAffixPicker } from "./components/HkAffixPicker";
|
|
818
|
+
export { default as HkAddTile } from "./components/HkAddTile";
|
|
817
819
|
export { default as HkUserPicker, type HkUserOption } from "./components/HkUserPicker";
|
|
818
820
|
export { default as HkPhaseTransition } from "./components/HkPhaseTransition";
|
|
819
821
|
export { default as HkGaugeRing } from "./components/HkGaugeRing";
|