@celestia-island/hikari 0.40.35 → 0.41.0
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/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.tsx +1 -1
- package/src/components/HkStatusBar.test.tsx +46 -0
- package/src/components/HkStatusBar.tsx +22 -0
- package/src/components/HkSwitch.tsx +1 -1
- package/src/components/HkTimeline.scss +131 -12
- package/src/components/HkTimeline.test.tsx +40 -2
- package/src/components/HkTimeline.tsx +40 -21
- 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">
|
|
@@ -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
|
>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
.hk-file-picker-field {
|
|
2
|
+
display: block;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.hk-file-picker-label {
|
|
6
|
+
display: block;
|
|
7
|
+
font-size: var(--text-xs);
|
|
8
|
+
font-weight: 600;
|
|
9
|
+
letter-spacing: 0.02em;
|
|
10
|
+
margin-bottom: var(--space-4);
|
|
11
|
+
color: rgb(var(--color-text) / var(--hk-field-label-alpha, 72%));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.hk-file-picker-box {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: var(--space-6);
|
|
18
|
+
padding: var(--space-4) var(--space-6);
|
|
19
|
+
min-height: 2.5rem;
|
|
20
|
+
background: color-mix(in srgb, rgb(var(--color-surface)) 55%, transparent);
|
|
21
|
+
border: 1px solid color-mix(in srgb, rgb(var(--color-text)) 14%, transparent);
|
|
22
|
+
border-radius: var(--radius-sm);
|
|
23
|
+
transition:
|
|
24
|
+
border-color var(--duration-normal) var(--ease-standard),
|
|
25
|
+
background-color var(--duration-normal) var(--ease-standard);
|
|
26
|
+
|
|
27
|
+
&:focus-within {
|
|
28
|
+
border-color: color-mix(in srgb, rgb(var(--color-text)) 38%, transparent);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&[data-error] {
|
|
32
|
+
border-color: var(--hi-color-danger);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&[data-disabled] {
|
|
36
|
+
opacity: 0.55;
|
|
37
|
+
pointer-events: none;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* The shittim-chest file-picker badge: a primary-tinted rounded square
|
|
42
|
+
carrying the folder glyph, anchoring the field's affordance. */
|
|
43
|
+
.hk-file-picker-badge {
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
justify-content: center;
|
|
48
|
+
width: 1.75rem;
|
|
49
|
+
height: 1.75rem;
|
|
50
|
+
border-radius: var(--radius-xs);
|
|
51
|
+
background: color-mix(in srgb, var(--hi-color-primary) 14%, transparent);
|
|
52
|
+
color: var(--hi-color-primary);
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.hk-file-picker-input {
|
|
57
|
+
flex: 1 1 auto;
|
|
58
|
+
min-width: 0;
|
|
59
|
+
padding: 0;
|
|
60
|
+
background: transparent;
|
|
61
|
+
border: 0;
|
|
62
|
+
outline: none;
|
|
63
|
+
color: rgb(var(--color-text));
|
|
64
|
+
font-size: var(--text-sm);
|
|
65
|
+
font-family: inherit;
|
|
66
|
+
|
|
67
|
+
&::placeholder {
|
|
68
|
+
color: rgb(var(--color-text) / 55%);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Paths are code — mono by default, opt out with :mono="false". */
|
|
72
|
+
.hk-file-picker-box[data-mono] & {
|
|
73
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
&:disabled {
|
|
77
|
+
cursor: default;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.hk-file-picker-browse {
|
|
82
|
+
flex-shrink: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.hk-file-picker-hint {
|
|
86
|
+
margin: var(--space-4) 0 0;
|
|
87
|
+
font-size: var(--text-xs);
|
|
88
|
+
color: rgb(var(--color-text) / 60%);
|
|
89
|
+
|
|
90
|
+
&[data-error] {
|
|
91
|
+
color: var(--hi-color-danger);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkFilePickerField from "./HkFilePickerField";
|
|
5
|
+
|
|
6
|
+
vi.mock("./HkFileBrowserDialog", () => {
|
|
7
|
+
return {
|
|
8
|
+
default: defineComponent({
|
|
9
|
+
name: "HkFileBrowserDialogStub",
|
|
10
|
+
props: {
|
|
11
|
+
modelValue: { type: Boolean, default: false },
|
|
12
|
+
pickDirectory: { type: Boolean, default: false },
|
|
13
|
+
},
|
|
14
|
+
emits: ["update:modelValue", "confirm"],
|
|
15
|
+
setup(props, { emit }) {
|
|
16
|
+
return () =>
|
|
17
|
+
props.modelValue
|
|
18
|
+
? h(
|
|
19
|
+
"div",
|
|
20
|
+
{ class: "picker-dialog-stub" },
|
|
21
|
+
h("button", {
|
|
22
|
+
class: "picker-dialog-stub-confirm",
|
|
23
|
+
onClick: () => {
|
|
24
|
+
// Directory mode hands back the listed folder itself.
|
|
25
|
+
emit(
|
|
26
|
+
"confirm",
|
|
27
|
+
props.pickDirectory
|
|
28
|
+
? [{ name: "data", path: "/srv/data" }]
|
|
29
|
+
: [{ name: "a.csv", path: "/srv/data/a.csv" }],
|
|
30
|
+
);
|
|
31
|
+
emit("update:modelValue", false);
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
: null;
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
42
|
+
const containers: HTMLElement[] = [];
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
46
|
+
for (const el of containers.splice(0)) el.remove();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
interface FieldHandle {
|
|
50
|
+
input: HTMLInputElement;
|
|
51
|
+
browse: HTMLButtonElement;
|
|
52
|
+
model: { value: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function mountField(props: Record<string, unknown> = {}): Promise<FieldHandle> {
|
|
56
|
+
const container = document.createElement("div");
|
|
57
|
+
document.body.appendChild(container);
|
|
58
|
+
containers.push(container);
|
|
59
|
+
|
|
60
|
+
const model = ref("");
|
|
61
|
+
// An initial value seeds the ref; the wrapper keeps driving the prop.
|
|
62
|
+
if (typeof props.modelValue === "string") {
|
|
63
|
+
model.value = props.modelValue;
|
|
64
|
+
delete props.modelValue;
|
|
65
|
+
}
|
|
66
|
+
const Wrapper = defineComponent({
|
|
67
|
+
setup() {
|
|
68
|
+
return () =>
|
|
69
|
+
h(HkFilePickerField, {
|
|
70
|
+
modelValue: model.value,
|
|
71
|
+
"onUpdate:modelValue": (v: string) => {
|
|
72
|
+
model.value = v;
|
|
73
|
+
},
|
|
74
|
+
...props,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const app = createApp(Wrapper);
|
|
79
|
+
mounts.push(app);
|
|
80
|
+
app.mount(container);
|
|
81
|
+
await nextTick();
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
input: container.querySelector<HTMLInputElement>(".hk-file-picker-input")!,
|
|
85
|
+
browse: container.querySelector<HTMLButtonElement>(".hk-file-picker-browse")!,
|
|
86
|
+
model,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("HkFilePickerField hook backend", () => {
|
|
91
|
+
it("adopts the hook-resolved path and leaves dismissals untouched", async () => {
|
|
92
|
+
const pick = vi.fn(async () => "/opt/shun/target");
|
|
93
|
+
const field = await mountField({ pick });
|
|
94
|
+
expect(field.input.placeholder).toContain("folder");
|
|
95
|
+
await field.browse.click();
|
|
96
|
+
await nextTick();
|
|
97
|
+
expect(pick).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(field.model.value).toBe("/opt/shun/target");
|
|
99
|
+
expect(field.input.value).toBe("/opt/shun/target");
|
|
100
|
+
|
|
101
|
+
// A dismissed picker (null) keeps the current value.
|
|
102
|
+
const dismissed = vi.fn(async () => null);
|
|
103
|
+
const field2 = await mountField({ pick: dismissed, modelValue: "/keep" });
|
|
104
|
+
await field2.browse.click();
|
|
105
|
+
await nextTick();
|
|
106
|
+
expect(field2.model.value).toBe("/keep");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("disables the browse button when the hook backend has no hook", async () => {
|
|
110
|
+
const field = await mountField({ backend: "hook" });
|
|
111
|
+
expect(field.browse.disabled).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not offer browsing at all when disabled", async () => {
|
|
115
|
+
const pick = vi.fn(async () => "/x");
|
|
116
|
+
const field = await mountField({ pick, disabled: true });
|
|
117
|
+
expect(field.browse.disabled).toBe(true);
|
|
118
|
+
field.browse.click();
|
|
119
|
+
expect(pick).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("HkFilePickerField remote backend", () => {
|
|
124
|
+
it("auto-selects remote from the adapter and adopts the confirmed folder", async () => {
|
|
125
|
+
const adapter = { list: vi.fn(async (path: string) => ({ path, entries: [] })) };
|
|
126
|
+
const field = await mountField({ adapter });
|
|
127
|
+
// The dialog renders inside the field's mount, not the stub's body.
|
|
128
|
+
await field.browse.click();
|
|
129
|
+
await nextTick();
|
|
130
|
+
const confirm = field.browse
|
|
131
|
+
.closest(".hk-file-picker-field")!
|
|
132
|
+
.querySelector<HTMLButtonElement>(".picker-dialog-stub-confirm")!;
|
|
133
|
+
expect(confirm).toBeTruthy();
|
|
134
|
+
await confirm.click();
|
|
135
|
+
await nextTick();
|
|
136
|
+
expect(field.model.value).toBe("/srv/data");
|
|
137
|
+
expect(
|
|
138
|
+
field.browse
|
|
139
|
+
.closest(".hk-file-picker-field")!
|
|
140
|
+
.querySelector(".picker-dialog-stub"),
|
|
141
|
+
).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("HkFilePickerField native backend", () => {
|
|
146
|
+
it("resolves the leaf name through the FS Access directory picker", async () => {
|
|
147
|
+
const showDirectoryPicker = vi.fn(async () => ({ name: "chosen-dir" }));
|
|
148
|
+
Object.defineProperty(window, "showDirectoryPicker", {
|
|
149
|
+
configurable: true,
|
|
150
|
+
value: showDirectoryPicker,
|
|
151
|
+
});
|
|
152
|
+
const field = await mountField({ backend: "native" });
|
|
153
|
+
await field.browse.click();
|
|
154
|
+
await nextTick();
|
|
155
|
+
expect(field.model.value).toBe("chosen-dir");
|
|
156
|
+
Object.defineProperty(window, "showDirectoryPicker", {
|
|
157
|
+
configurable: true,
|
|
158
|
+
value: undefined,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("swallows a cancelled native picker without touching the value", async () => {
|
|
163
|
+
const showDirectoryPicker = vi.fn(async () => {
|
|
164
|
+
throw new DOMException("cancel", "AbortError");
|
|
165
|
+
});
|
|
166
|
+
Object.defineProperty(window, "showDirectoryPicker", {
|
|
167
|
+
configurable: true,
|
|
168
|
+
value: showDirectoryPicker,
|
|
169
|
+
});
|
|
170
|
+
const field = await mountField({ backend: "native", modelValue: "/keep" });
|
|
171
|
+
await field.browse.click();
|
|
172
|
+
await nextTick();
|
|
173
|
+
expect(field.model.value).toBe("/keep");
|
|
174
|
+
Object.defineProperty(window, "showDirectoryPicker", {
|
|
175
|
+
configurable: true,
|
|
176
|
+
value: undefined,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("HkFilePickerField typing", () => {
|
|
182
|
+
it("emits update:modelValue on input", async () => {
|
|
183
|
+
const field = await mountField();
|
|
184
|
+
field.input.value = "D:\\Games\\Demo";
|
|
185
|
+
field.input.dispatchEvent(new Event("input"));
|
|
186
|
+
await nextTick();
|
|
187
|
+
expect(field.model.value).toBe("D:\\Games\\Demo");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("shows the file placeholder when directory is off", async () => {
|
|
191
|
+
const field = await mountField({ directory: false });
|
|
192
|
+
expect(field.input.placeholder).toContain("file");
|
|
193
|
+
});
|
|
194
|
+
});
|