@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.40.34",
3
+ "version": "0.40.36",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
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 = 66;
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
+ });
@@ -52,7 +52,6 @@ export default defineComponent({
52
52
 
53
53
  return () => {
54
54
  const inputType = props.type === "radio" ? "radio" : "checkbox";
55
- const isChecked = props.modelValue === true || props.modelValue === null;
56
55
 
57
56
  return (
58
57
  <label
@@ -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 ?? t("hikari::filePicker.confirm", "Confirm")}
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={props.title ?? t("hikari::filePicker.title", "Browse files")}
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
  >