@celestia-island/hikari 0.55.341 → 0.55.342
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -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
|
+
});
|
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";
|