@celestia-island/hikari 0.40.34 → 0.40.36
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/DemoApp.tsx +29 -1
- package/src/components/HkButton.test.tsx +159 -0
- package/src/components/HkCheckbox.test.tsx +202 -0
- package/src/components/HkCheckbox.tsx +0 -1
- package/src/components/HkEmptyState.test.tsx +21 -1
- package/src/components/HkEmptyState.tsx +1 -1
- package/src/components/HkFileBrowserDialog.test.tsx +43 -0
- package/src/components/HkFileBrowserDialog.tsx +28 -4
- package/src/components/HkFilePickerField.scss +93 -0
- package/src/components/HkFilePickerField.test.tsx +194 -0
- package/src/components/HkFilePickerField.tsx +212 -0
- package/src/components/HkRadio.test.tsx +206 -0
- package/src/components/HkRadio.tsx +1 -1
- package/src/components/HkSwitch.test.tsx +174 -0
- package/src/components/HkSwitch.tsx +1 -1
- package/src/components/HkTooltip.test.tsx +226 -0
- package/src/components/filePicker.ts +16 -0
- package/src/i18n/locales/ar/components.json +5 -0
- package/src/i18n/locales/de/components.json +5 -0
- package/src/i18n/locales/en/components.json +5 -0
- package/src/i18n/locales/es/components.json +5 -0
- package/src/i18n/locales/fr/components.json +5 -0
- package/src/i18n/locales/ja/components.json +5 -0
- package/src/i18n/locales/ko/components.json +5 -0
- package/src/i18n/locales/pt/components.json +5 -0
- package/src/i18n/locales/ru/components.json +5 -0
- package/src/i18n/locales/zh-Hans/components.json +5 -0
- package/src/i18n/locales/zh-Hant/components.json +5 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
HProgressBar, HProgressRing, HGaugeRing,
|
|
10
10
|
HInput, HSearchInput, HNumberInput, HPasswordInput, HTextarea,
|
|
11
11
|
HFileField,
|
|
12
|
+
HFilePickerField,
|
|
12
13
|
type PickedFile, type RemoteFsAdapter,
|
|
13
14
|
HCheckbox, HSwitch, HRadio, HSelect, HSelectPanel,
|
|
14
15
|
HLocalizedInput,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
type TrendPen, type MinimapBox,
|
|
22
23
|
} from "@celestia-island/hikari";
|
|
23
24
|
|
|
24
|
-
const totalComponents =
|
|
25
|
+
const totalComponents = 67;
|
|
25
26
|
|
|
26
27
|
const icons = ["home", "settings", "user", "search", "bell", "heart", "star", "mail", "download", "upload", "trash", "edit", "plus", "check", "x"];
|
|
27
28
|
|
|
@@ -102,6 +103,12 @@ export default defineComponent({
|
|
|
102
103
|
const form = ref({ text: "", search: "", number: 0, password: "", textarea: "" });
|
|
103
104
|
const localFiles = ref<PickedFile[]>([]);
|
|
104
105
|
const remoteFiles = ref<PickedFile[]>([]);
|
|
106
|
+
const hookPath = ref("");
|
|
107
|
+
const remotePath = ref("/data");
|
|
108
|
+
// Stands in for a host-opened OS dialog (the Tauri2/Electron case —
|
|
109
|
+
// the window lives outside the webview, so the field takes a hook).
|
|
110
|
+
const fakeHostPicker = async (): Promise<string | null> =>
|
|
111
|
+
Promise.resolve("/opt/acme/install");
|
|
105
112
|
// In-memory remote FS so the remote picker demo browses something real.
|
|
106
113
|
const demoRemoteFs: RemoteFsAdapter = (() => {
|
|
107
114
|
const tree: Record<string, { name: string; kind: "file" | "dir"; size?: number }[]> = {
|
|
@@ -317,6 +324,27 @@ export default defineComponent({
|
|
|
317
324
|
) : null}
|
|
318
325
|
</section>
|
|
319
326
|
|
|
327
|
+
<section>
|
|
328
|
+
<h2>HFilePickerField</h2>
|
|
329
|
+
<div class="form-grid">
|
|
330
|
+
<HFilePickerField
|
|
331
|
+
modelValue={hookPath.value}
|
|
332
|
+
onUpdate:modelValue={(v: string) => (hookPath.value = v)}
|
|
333
|
+
label="Install location (host hook)"
|
|
334
|
+
hint="A host-opened OS dialog — e.g. the Tauri2 dialog plugin."
|
|
335
|
+
pick={fakeHostPicker}
|
|
336
|
+
/>
|
|
337
|
+
<HFilePickerField
|
|
338
|
+
modelValue={remotePath.value}
|
|
339
|
+
onUpdate:modelValue={(v: string) => (remotePath.value = v)}
|
|
340
|
+
label="Remote folder (in-app picker)"
|
|
341
|
+
backend="remote"
|
|
342
|
+
adapter={demoRemoteFs}
|
|
343
|
+
quickLinks={[{ label: "Root", path: "/" }, { label: "Data", path: "/data" }]}
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
</section>
|
|
347
|
+
|
|
320
348
|
<section>
|
|
321
349
|
<h2>HCheckbox / HSwitch / HRadio</h2>
|
|
322
350
|
<div class="row">
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkButton from "./HkButton";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkButton contract tests:
|
|
8
|
+
* - variant / size / block / loading class mapping on the root <button>
|
|
9
|
+
* - the native disabled attribute is the click guard (disabled OR loading)
|
|
10
|
+
* - loading renders the spinner INSTEAD of the icon and flags aria-busy
|
|
11
|
+
* - default-slot text, aria-label (icon-only usage), suffix, shortcut kbd
|
|
12
|
+
* - attr fallthrough: inheritAttrs is false, the component MUST spread
|
|
13
|
+
* $attrs itself (type / data-* / class merging)
|
|
14
|
+
*
|
|
15
|
+
* House style: raw createApp mounts on a shared container list torn down
|
|
16
|
+
* after each case; DOM assertions via document queries.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
20
|
+
|
|
21
|
+
function mount(node: ReturnType<typeof h>) {
|
|
22
|
+
const container = document.createElement("div");
|
|
23
|
+
document.body.appendChild(container);
|
|
24
|
+
const app = createApp({ render: () => node });
|
|
25
|
+
app.mount(container);
|
|
26
|
+
mounts.push({ app, container });
|
|
27
|
+
return container;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function button(c: HTMLElement): HTMLButtonElement {
|
|
31
|
+
return c.querySelector("button")!;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
36
|
+
app.unmount();
|
|
37
|
+
container.remove();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("HkButton class mapping", () => {
|
|
42
|
+
it("renders the default primary/md classes with default-slot text and type=button", () => {
|
|
43
|
+
const c = mount(h(HkButton, () => "Deploy"));
|
|
44
|
+
const btn = button(c);
|
|
45
|
+
expect(btn).not.toBeNull();
|
|
46
|
+
expect(btn.className).toBe("hk-btn hk-btn-primary hk-btn-md");
|
|
47
|
+
expect(btn.getAttribute("type")).toBe("button");
|
|
48
|
+
expect(btn.textContent).toContain("Deploy");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("maps variant and size props to modifier classes", () => {
|
|
52
|
+
const c = mount(h(HkButton, { variant: "danger", size: "sm" }, () => "Delete"));
|
|
53
|
+
const cls = button(c).className;
|
|
54
|
+
expect(cls).toContain("hk-btn-danger");
|
|
55
|
+
expect(cls).toContain("hk-btn-sm");
|
|
56
|
+
expect(cls).not.toContain("hk-btn-primary");
|
|
57
|
+
expect(cls).not.toContain("hk-btn-md");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("adds the block modifier only when block is set", () => {
|
|
61
|
+
const blocked = mount(h(HkButton, { block: true }, () => "wide"));
|
|
62
|
+
expect(button(blocked).className).toContain("hk-btn-block");
|
|
63
|
+
const plain = mount(h(HkButton, () => "narrow"));
|
|
64
|
+
expect(button(plain).className).not.toContain("hk-btn-block");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("HkButton click guard", () => {
|
|
69
|
+
it("emits the declared click event on activation", async () => {
|
|
70
|
+
let clicks = 0;
|
|
71
|
+
const c = mount(h(HkButton, { onClick: () => { clicks += 1; } }, () => "go"));
|
|
72
|
+
button(c).click();
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
expect(clicks).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("suppresses the click emit while disabled", async () => {
|
|
78
|
+
let clicks = 0;
|
|
79
|
+
const c = mount(h(HkButton, { disabled: true, onClick: () => { clicks += 1; } }, () => "no"));
|
|
80
|
+
// The guard is the NATIVE disabled attribute: the platform (and
|
|
81
|
+
// happy-dom) fires no click listeners on a disabled button.
|
|
82
|
+
expect(button(c).disabled).toBe(true);
|
|
83
|
+
button(c).click();
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
expect(clicks).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("disables the button and suppresses clicks while loading", async () => {
|
|
89
|
+
let clicks = 0;
|
|
90
|
+
const c = mount(h(HkButton, { loading: true, onClick: () => { clicks += 1; } }, () => "busy"));
|
|
91
|
+
const btn = button(c);
|
|
92
|
+
expect(btn.disabled).toBe(true); // loading implies disabled
|
|
93
|
+
expect(btn.className).toContain("hk-btn-loading");
|
|
94
|
+
expect(btn.getAttribute("aria-busy")).toBe("true");
|
|
95
|
+
btn.click();
|
|
96
|
+
await Promise.resolve();
|
|
97
|
+
expect(clicks).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("clears the loading state back to a clickable button (aria-busy drops off)", () => {
|
|
101
|
+
const busy = mount(h(HkButton, { loading: true }, () => "x"));
|
|
102
|
+
expect(busy.querySelector(".hk-spinner")).not.toBeNull();
|
|
103
|
+
const idle = mount(h(HkButton, () => "x"));
|
|
104
|
+
const btn = button(idle);
|
|
105
|
+
expect(idle.querySelector(".hk-spinner")).toBeNull();
|
|
106
|
+
expect(btn.disabled).toBe(false);
|
|
107
|
+
expect(btn.getAttribute("aria-busy")).toBeNull();
|
|
108
|
+
expect(btn.className).not.toContain("hk-btn-loading");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("HkButton content slots", () => {
|
|
113
|
+
it("renders the leading icon only when NOT loading", () => {
|
|
114
|
+
const idle = mount(h(HkButton, { icon: "close" }, () => "x"));
|
|
115
|
+
expect(idle.querySelector(".hk-btn-icon")).not.toBeNull();
|
|
116
|
+
expect(idle.querySelector(".hk-spinner")).toBeNull();
|
|
117
|
+
|
|
118
|
+
// Loading swaps the icon out for the spinner — never both.
|
|
119
|
+
const busy = mount(h(HkButton, { icon: "close", loading: true }, () => "x"));
|
|
120
|
+
expect(busy.querySelector(".hk-btn-icon")).toBeNull();
|
|
121
|
+
expect(busy.querySelector(".hk-spinner")).not.toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("renders the suffix icon slot", () => {
|
|
125
|
+
const c = mount(h(HkButton, { suffix: "close" }, () => "Open"));
|
|
126
|
+
expect(c.querySelector(".hk-btn-suffix")).not.toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders the shortcut kbd and its marker class", () => {
|
|
130
|
+
const c = mount(h(HkButton, { shortcut: "Ctrl+Enter" }, () => "Send"));
|
|
131
|
+
const btn = button(c);
|
|
132
|
+
expect(btn.className).toContain("hk-btn-has-shortcut");
|
|
133
|
+
const kbd = c.querySelector("kbd.hk-kbd")!;
|
|
134
|
+
expect(kbd).not.toBeNull();
|
|
135
|
+
expect(kbd.textContent).toContain("Ctrl");
|
|
136
|
+
expect(kbd.textContent).toContain("Enter");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("exposes aria-label for icon-only usage", () => {
|
|
140
|
+
const c = mount(h(HkButton, { icon: "close", ariaLabel: "Close inspector" }));
|
|
141
|
+
expect(button(c).getAttribute("aria-label")).toBe("Close inspector");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("HkButton attr fallthrough (inheritAttrs: false)", () => {
|
|
146
|
+
it("spreads extra attrs and honors an explicit type", () => {
|
|
147
|
+
const c = mount(h(HkButton, { type: "submit", "data-test": "probe" }, () => "Save"));
|
|
148
|
+
const btn = button(c);
|
|
149
|
+
expect(btn.getAttribute("type")).toBe("submit");
|
|
150
|
+
expect(btn.getAttribute("data-test")).toBe("probe");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("merges a fallthrough class onto its own class list", () => {
|
|
154
|
+
const c = mount(h(HkButton, { class: "extra-class" }, () => "x"));
|
|
155
|
+
const cls = button(c).className;
|
|
156
|
+
expect(cls).toContain("hk-btn");
|
|
157
|
+
expect(cls).toContain("extra-class");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkCheckbox from "./HkCheckbox";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkCheckbox contract tests:
|
|
8
|
+
* - tri-state model wiring: false → unlit box, true → check icon (or dot
|
|
9
|
+
* in radio mode), null → indeterminate dash (checkbox mode only)
|
|
10
|
+
* - the label shell is a real <label> wrapping the native input, so
|
|
11
|
+
* clicking the TEXT forwards to the input and toggles (label association)
|
|
12
|
+
* - change events carry the input's NEW checked value; disabled drops them
|
|
13
|
+
* - the data-animating pulse is set on change and cleared by the 300ms
|
|
14
|
+
* cron one-shot
|
|
15
|
+
*
|
|
16
|
+
* House style: raw createApp mounts on a shared container list torn down
|
|
17
|
+
* after each case; DOM assertions via document queries.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
21
|
+
|
|
22
|
+
function mount(node: ReturnType<typeof h>) {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const app = createApp({ render: () => node });
|
|
26
|
+
app.mount(container);
|
|
27
|
+
mounts.push({ app, container });
|
|
28
|
+
return container;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** v-model harness: mirrors consumer usage, records every emitted value. */
|
|
32
|
+
function mountVModel(initial: boolean | null, props: Record<string, unknown> = {}) {
|
|
33
|
+
const model = ref<boolean | null>(initial);
|
|
34
|
+
const emitted: Array<boolean> = [];
|
|
35
|
+
const container = document.createElement("div");
|
|
36
|
+
document.body.appendChild(container);
|
|
37
|
+
const app = createApp(defineComponent({
|
|
38
|
+
setup: () => () =>
|
|
39
|
+
h(HkCheckbox, {
|
|
40
|
+
...props,
|
|
41
|
+
modelValue: model.value as boolean,
|
|
42
|
+
"onUpdate:modelValue": (v: boolean) => {
|
|
43
|
+
model.value = v;
|
|
44
|
+
emitted.push(v);
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
app.mount(container);
|
|
49
|
+
mounts.push({ app, container });
|
|
50
|
+
const input = () => container.querySelector<HTMLInputElement>("input")!;
|
|
51
|
+
const box = () => container.querySelector<HTMLElement>(".hk-checkbox-box")!;
|
|
52
|
+
const root = () => container.querySelector<HTMLElement>("label")!;
|
|
53
|
+
return { container, model, emitted, input, box, root };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
59
|
+
app.unmount();
|
|
60
|
+
container.remove();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("HkCheckbox model states", () => {
|
|
65
|
+
it("renders an unlit box and an unchecked native checkbox by default", () => {
|
|
66
|
+
const c = mount(h(HkCheckbox));
|
|
67
|
+
const root = c.querySelector<HTMLElement>("label")!;
|
|
68
|
+
expect(root.className).toBe("hk-checkbox hk-checkbox-md");
|
|
69
|
+
expect(root.getAttribute("data-type")).toBe("checkbox");
|
|
70
|
+
const input = c.querySelector<HTMLInputElement>("input")!;
|
|
71
|
+
expect(input.type).toBe("checkbox");
|
|
72
|
+
expect(input.checked).toBe(false);
|
|
73
|
+
const box = c.querySelector<HTMLElement>(".hk-checkbox-box")!;
|
|
74
|
+
expect(box.hasAttribute("data-checked")).toBe(false);
|
|
75
|
+
expect(box.hasAttribute("data-indeterminate")).toBe(false);
|
|
76
|
+
expect(c.querySelector(".hk-checkbox-icon")).toBeNull();
|
|
77
|
+
expect(c.querySelector(".hk-checkbox-indeterminate")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("maps modelValue=true to the checked box with the check icon", () => {
|
|
81
|
+
const c = mount(h(HkCheckbox, { modelValue: true }));
|
|
82
|
+
const box = c.querySelector<HTMLElement>(".hk-checkbox-box")!;
|
|
83
|
+
expect(box.getAttribute("data-checked")).toBe("");
|
|
84
|
+
expect(c.querySelector<HTMLInputElement>("input")!.checked).toBe(true);
|
|
85
|
+
// The glyph is the lucide Check svg itself (lucide puts the class on
|
|
86
|
+
// its root <svg>), not the radio dot.
|
|
87
|
+
expect(c.querySelector("svg.hk-checkbox-icon")).not.toBeNull();
|
|
88
|
+
expect(c.querySelector(".hk-checkbox-dot")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("maps modelValue=null to the indeterminate dash without checking the input", () => {
|
|
92
|
+
const c = mount(h(HkCheckbox, { modelValue: null as unknown as boolean }));
|
|
93
|
+
const box = c.querySelector<HTMLElement>(".hk-checkbox-box")!;
|
|
94
|
+
expect(box.getAttribute("data-indeterminate")).toBe("");
|
|
95
|
+
expect(box.hasAttribute("data-checked")).toBe(false);
|
|
96
|
+
// null is NOT true: the native input stays unchecked.
|
|
97
|
+
expect(c.querySelector<HTMLInputElement>("input")!.checked).toBe(false);
|
|
98
|
+
expect(c.querySelector(".hk-checkbox-indeterminate")).not.toBeNull();
|
|
99
|
+
expect(c.querySelector(".hk-checkbox-icon")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("radio mode swaps the check icon for the dot and drops the indeterminate dash", () => {
|
|
103
|
+
const checked = mount(h(HkCheckbox, { modelValue: true, type: "radio" }));
|
|
104
|
+
expect(checked.querySelector("label")!.getAttribute("data-type")).toBe("radio");
|
|
105
|
+
expect(checked.querySelector<HTMLInputElement>("input")!.type).toBe("radio");
|
|
106
|
+
expect(checked.querySelector(".hk-checkbox-dot")).not.toBeNull();
|
|
107
|
+
expect(checked.querySelector(".hk-checkbox-icon")).toBeNull();
|
|
108
|
+
|
|
109
|
+
// null in radio mode: no dash — indeterminate is checkbox-only.
|
|
110
|
+
const unset = mount(h(HkCheckbox, { modelValue: null as unknown as boolean, type: "radio" }));
|
|
111
|
+
expect(unset.querySelector(".hk-checkbox-indeterminate")).toBeNull();
|
|
112
|
+
expect(unset.querySelector(".hk-checkbox-dot")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("maps the size prop to the shell modifier", () => {
|
|
116
|
+
const c = mount(h(HkCheckbox, { size: "sm" }));
|
|
117
|
+
expect(c.querySelector("label")!.className).toContain("hk-checkbox-sm");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("HkCheckbox label", () => {
|
|
122
|
+
it("renders the label prop through the HkLabel shell", () => {
|
|
123
|
+
const c = mount(h(HkCheckbox, { label: "Remember login" }));
|
|
124
|
+
const label = c.querySelector<HTMLElement>(".hk-label")!;
|
|
125
|
+
expect(label).not.toBeNull();
|
|
126
|
+
expect(label.textContent).toBe("Remember login");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("prefers default-slot content over the label prop", () => {
|
|
130
|
+
const c = mount(
|
|
131
|
+
h(HkCheckbox, { label: "plain" }, () => ["rich ", h("b", "text")]),
|
|
132
|
+
);
|
|
133
|
+
expect(c.querySelector(".hk-label")!.textContent).toBe("rich text");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("renders no label shell without label prop or slot", () => {
|
|
137
|
+
const c = mount(h(HkCheckbox));
|
|
138
|
+
expect(c.querySelector(".hk-label")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("HkCheckbox change wiring", () => {
|
|
143
|
+
it("round-trips v-model through native input activation", async () => {
|
|
144
|
+
const t = mountVModel(false);
|
|
145
|
+
t.input().click();
|
|
146
|
+
await nextTick();
|
|
147
|
+
expect(t.emitted).toEqual([true]);
|
|
148
|
+
expect(t.model.value).toBe(true);
|
|
149
|
+
expect(t.input().checked).toBe(true);
|
|
150
|
+
expect(t.box().getAttribute("data-checked")).toBe("");
|
|
151
|
+
|
|
152
|
+
t.input().click();
|
|
153
|
+
await nextTick();
|
|
154
|
+
expect(t.emitted).toEqual([true, false]);
|
|
155
|
+
expect(t.box().hasAttribute("data-checked")).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("toggles through the label text — the shell is a real <label>", async () => {
|
|
159
|
+
const t = mountVModel(false, { label: "Remember login" });
|
|
160
|
+
// Clicking the TEXT (not the input): the platform forwards the click
|
|
161
|
+
// to the first labelable descendant — the checkbox input — which
|
|
162
|
+
// toggles and fires the change the component listens to.
|
|
163
|
+
(t.container.querySelector(".hk-label") as HTMLElement).click();
|
|
164
|
+
await nextTick();
|
|
165
|
+
expect(t.emitted).toEqual([true]);
|
|
166
|
+
expect(t.input().checked).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("emits the post-click indeterminate resolution as a plain boolean", async () => {
|
|
170
|
+
// null (indeterminate) + click → the input lands checked → emits true.
|
|
171
|
+
const t = mountVModel(null);
|
|
172
|
+
t.input().click();
|
|
173
|
+
await nextTick();
|
|
174
|
+
expect(t.emitted).toEqual([true]);
|
|
175
|
+
expect(t.box().getAttribute("data-checked")).toBe("");
|
|
176
|
+
expect(t.box().hasAttribute("data-indeterminate")).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("pulses data-animating on change and clears it after the 300ms cron", async () => {
|
|
180
|
+
vi.useFakeTimers();
|
|
181
|
+
const t = mountVModel(false);
|
|
182
|
+
t.input().click();
|
|
183
|
+
await nextTick();
|
|
184
|
+
expect(t.root().getAttribute("data-animating")).toBe("");
|
|
185
|
+
|
|
186
|
+
vi.advanceTimersByTime(300);
|
|
187
|
+
await nextTick();
|
|
188
|
+
expect(t.root().hasAttribute("data-animating")).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("drops change events entirely while disabled", async () => {
|
|
192
|
+
const t = mountVModel(false, { disabled: true });
|
|
193
|
+
expect(t.input().disabled).toBe(true);
|
|
194
|
+
expect(t.root().getAttribute("data-disabled")).toBe("");
|
|
195
|
+
// The platform fires no activation on a disabled input.
|
|
196
|
+
t.input().click();
|
|
197
|
+
t.container.querySelector<HTMLElement>(".hk-checkbox-box")!.click();
|
|
198
|
+
await nextTick();
|
|
199
|
+
expect(t.emitted).toEqual([]);
|
|
200
|
+
expect(t.input().checked).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, defineComponent, h } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkEmptyState from "./HkEmptyState";
|
|
@@ -56,6 +56,26 @@ describe("HkEmptyState", () => {
|
|
|
56
56
|
expect(btn.textContent).toBe("Retry");
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it("accepts functional icon components without a prop-type warning", () => {
|
|
60
|
+
// lucide-vue-next icons are plain functions; the Object-only runtime
|
|
61
|
+
// check rejected them with a dev warning at every chest call site
|
|
62
|
+
// (fixed 2026-09-09: [Object, Function]).
|
|
63
|
+
const warnings: string[] = [];
|
|
64
|
+
const spy = vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
|
65
|
+
warnings.push(args.join(" "));
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
// Shape-mirrors a lucide icon: a functional component accepting
|
|
69
|
+
// props (size/aria) and rendering a node.
|
|
70
|
+
const lucideLike = ((_props: unknown) => h("i", { class: "fn-icon" })) as never;
|
|
71
|
+
const c = mount(h(HkEmptyState, { title: "No items", icon: lucideLike }));
|
|
72
|
+
expect(c.querySelector(".hk-empty-icon .fn-icon")).not.toBeNull();
|
|
73
|
+
expect(warnings.filter((w) => w.includes("Invalid prop"))).toEqual([]);
|
|
74
|
+
} finally {
|
|
75
|
+
spy.mockRestore();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
59
79
|
it("renders the icon prop component in the icon well", () => {
|
|
60
80
|
const c = mount(h(HkEmptyState, { title: "No items", icon: StubIcon }));
|
|
61
81
|
expect(c.querySelector(".hk-empty-icon .stub-empty-icon")).not.toBeNull();
|
|
@@ -11,7 +11,7 @@ export default defineComponent({
|
|
|
11
11
|
title: { type: String, default: undefined },
|
|
12
12
|
description: { type: String, default: undefined },
|
|
13
13
|
/** Icon component (lucide-vue-next style); rendered via h() at size 32. */
|
|
14
|
-
icon: { type: Object as PropType<Component>, default: undefined },
|
|
14
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
15
15
|
/** Loading variant: only a centered spinner, exposed as a status region. */
|
|
16
16
|
loading: { type: Boolean, default: false },
|
|
17
17
|
/** page = bounded, centered card on desktop; fill = stretch to the host. */
|
|
@@ -71,6 +71,7 @@ interface MountOptions {
|
|
|
71
71
|
multiple?: boolean;
|
|
72
72
|
quickLinks?: Array<{ label: string; path: string }>;
|
|
73
73
|
initialPath?: string;
|
|
74
|
+
pickDirectory?: boolean;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
function mountDialog(adapter: RemoteFsAdapter, opts: MountOptions = {}) {
|
|
@@ -91,6 +92,7 @@ function mountDialog(adapter: RemoteFsAdapter, opts: MountOptions = {}) {
|
|
|
91
92
|
multiple: opts.multiple ?? false,
|
|
92
93
|
quickLinks: opts.quickLinks ?? [],
|
|
93
94
|
initialPath: opts.initialPath ?? "/",
|
|
95
|
+
pickDirectory: opts.pickDirectory ?? false,
|
|
94
96
|
"onUpdate:modelValue": (v: boolean) => {
|
|
95
97
|
openEvents.push(v);
|
|
96
98
|
open.value = v;
|
|
@@ -446,3 +448,44 @@ describe("HkFileBrowserDialog load states", () => {
|
|
|
446
448
|
expect(rowNames()).toEqual(["ok.txt"]);
|
|
447
449
|
});
|
|
448
450
|
});
|
|
451
|
+
|
|
452
|
+
describe("HkFileBrowserDialog directory mode", () => {
|
|
453
|
+
it("confirms the listed folder without needing a selection", async () => {
|
|
454
|
+
const adapter = makeAdapter();
|
|
455
|
+
const { confirmed, openEvents } = mountDialog(adapter, { pickDirectory: true });
|
|
456
|
+
await flush();
|
|
457
|
+
|
|
458
|
+
// Nothing is selected, yet confirm is live: the current folder IS
|
|
459
|
+
// the choice.
|
|
460
|
+
const confirmBtn = q<HTMLButtonElement>(".hk-file-browser-confirm");
|
|
461
|
+
expect(confirmBtn.disabled).toBe(false);
|
|
462
|
+
expect(confirmBtn.textContent).toContain("Choose this folder");
|
|
463
|
+
confirmBtn.click();
|
|
464
|
+
await flush();
|
|
465
|
+
expect(confirmed).toEqual([[{ name: "/", path: "/" }]]);
|
|
466
|
+
expect(openEvents).toContain(false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("navigates into subfolders and hands back that folder's path", async () => {
|
|
470
|
+
const adapter = makeAdapter();
|
|
471
|
+
const { confirmed } = mountDialog(adapter, { pickDirectory: true });
|
|
472
|
+
await flush();
|
|
473
|
+
|
|
474
|
+
await clickRow("reports");
|
|
475
|
+
expect(adapter.list).toHaveBeenLastCalledWith("/reports");
|
|
476
|
+
const confirmBtn = q<HTMLButtonElement>(".hk-file-browser-confirm");
|
|
477
|
+
confirmBtn.click();
|
|
478
|
+
await flush();
|
|
479
|
+
expect(confirmed).toEqual([[{ name: "reports", path: "/reports" }]]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("never selects file rows while picking a folder", async () => {
|
|
483
|
+
const adapter = makeAdapter();
|
|
484
|
+
mountDialog(adapter, { pickDirectory: true });
|
|
485
|
+
await flush();
|
|
486
|
+
|
|
487
|
+
await clickRow("data.csv");
|
|
488
|
+
const rows = qa<HTMLButtonElement>(".hk-file-browser-row");
|
|
489
|
+
expect(rows.every((r) => r.dataset.selected === undefined)).toBe(true);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
@@ -112,6 +112,11 @@ export default defineComponent({
|
|
|
112
112
|
type: Array as PropType<FileQuickLink[]>,
|
|
113
113
|
default: () => [],
|
|
114
114
|
},
|
|
115
|
+
/**
|
|
116
|
+
* Directory-picking mode: confirm chooses the folder currently
|
|
117
|
+
* listed (entries only navigate), instead of selecting files.
|
|
118
|
+
*/
|
|
119
|
+
pickDirectory: { type: Boolean, default: false },
|
|
115
120
|
confirmLabel: { type: String, default: undefined },
|
|
116
121
|
cancelLabel: { type: String, default: undefined },
|
|
117
122
|
},
|
|
@@ -290,6 +295,8 @@ export default defineComponent({
|
|
|
290
295
|
navigate(joinPath(currentPath.value, entry.name));
|
|
291
296
|
return;
|
|
292
297
|
}
|
|
298
|
+
// Directory-picking mode only navigates; files are inert rows.
|
|
299
|
+
if (props.pickDirectory) return;
|
|
293
300
|
const next = new Set(selected.value);
|
|
294
301
|
if (props.multiple) {
|
|
295
302
|
if (next.has(entry.name)) next.delete(entry.name);
|
|
@@ -306,7 +313,7 @@ export default defineComponent({
|
|
|
306
313
|
/** Double-click activates: the file confirms immediately (and alone,
|
|
307
314
|
* even in multiple mode — a double-click names ONE file). */
|
|
308
315
|
function onEntryActivate(entry: RemoteFileEntry) {
|
|
309
|
-
if (entry.kind !== "file") return;
|
|
316
|
+
if (entry.kind !== "file" || props.pickDirectory) return;
|
|
310
317
|
selected.value = new Set([entry.name]);
|
|
311
318
|
confirmSelection();
|
|
312
319
|
}
|
|
@@ -394,6 +401,15 @@ export default defineComponent({
|
|
|
394
401
|
}
|
|
395
402
|
|
|
396
403
|
function confirmSelection() {
|
|
404
|
+
// Directory mode: the folder currently listed IS the choice —
|
|
405
|
+
// the pathbar has been showing it the whole time.
|
|
406
|
+
if (props.pickDirectory) {
|
|
407
|
+
const path = currentPath.value;
|
|
408
|
+
const name = path.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? "/";
|
|
409
|
+
emit("confirm", [{ name, path }]);
|
|
410
|
+
close();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
397
413
|
if (selected.value.size === 0) return;
|
|
398
414
|
const files: ConfirmedFile[] = [];
|
|
399
415
|
for (const name of selected.value) {
|
|
@@ -770,10 +786,13 @@ export default defineComponent({
|
|
|
770
786
|
variant="primary"
|
|
771
787
|
size="md"
|
|
772
788
|
class="hk-file-browser-confirm"
|
|
773
|
-
disabled={selected.value.size === 0}
|
|
789
|
+
disabled={!props.pickDirectory && selected.value.size === 0}
|
|
774
790
|
onClick={confirmSelection}
|
|
775
791
|
>
|
|
776
|
-
{props.confirmLabel ??
|
|
792
|
+
{props.confirmLabel ??
|
|
793
|
+
(props.pickDirectory
|
|
794
|
+
? t("hikari::filePicker.chooseFolder", "Choose this folder")
|
|
795
|
+
: t("hikari::filePicker.confirm", "Confirm"))}
|
|
777
796
|
</HButton>
|
|
778
797
|
</div>
|
|
779
798
|
</div>
|
|
@@ -783,7 +802,12 @@ export default defineComponent({
|
|
|
783
802
|
return () => (
|
|
784
803
|
<HModal
|
|
785
804
|
modelValue={props.modelValue}
|
|
786
|
-
title={
|
|
805
|
+
title={
|
|
806
|
+
props.title ??
|
|
807
|
+
(props.pickDirectory
|
|
808
|
+
? t("hikari::filePicker.titleFolder", "Browse folders")
|
|
809
|
+
: t("hikari::filePicker.title", "Browse files"))
|
|
810
|
+
}
|
|
787
811
|
width="56rem"
|
|
788
812
|
onUpdate:modelValue={(v: boolean) => emit("update:modelValue", v)}
|
|
789
813
|
>
|