@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.40.35",
3
+ "version": "0.41.0",
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">
@@ -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
  >
@@ -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
+ });