@kahitsan/ksui 0.19.0 → 0.21.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.
@@ -0,0 +1,301 @@
1
+ // U6 — FileField (Vision §11): a declarative file/media input for the spec-driven
2
+ // form runtime. Its value is an OPAQUE asset HANDLE (`{ id, name, mime, size }`),
3
+ // not a path or URL (§11 / Data Arch §4 opaque-id storage). The component is
4
+ // storage-agnostic: it knows nothing about S3/MinIO/the kernel. The HOST injects
5
+ // two callbacks:
6
+ // - onUpload(file) => Promise<handle> wires to the kernel asset service
7
+ // - presignUrl(handle) => Promise<url> resolves a handle to a time-limited URL
8
+ // ksui only models the field UX + state machine (pending / uploading / done /
9
+ // failed) and the preview. A missing/expired asset degrades gracefully — a
10
+ // rejected presign shows a broken-thumbnail fallback + warns, never throws (§11).
11
+ //
12
+ // Composite because it composes the upload/preview state machine and self-injects
13
+ // its CSS (ksui-ff-* unscoped classes + CSS custom props); no Tailwind, no
14
+ // host-brand classes (standalone-library rule).
15
+
16
+ import type { Component, JSX } from "solid-js";
17
+ import { Match, Show, Switch, createSignal } from "solid-js";
18
+ import UploadCloud from "lucide-solid/icons/cloud-upload";
19
+ import FileIcon from "lucide-solid/icons/file";
20
+ import ImageOff from "lucide-solid/icons/image-off";
21
+ import X from "lucide-solid/icons/x";
22
+
23
+ /** The opaque asset handle the field stores as its value (§11). */
24
+ export interface AssetHandle {
25
+ readonly id: string;
26
+ readonly name: string;
27
+ readonly mime: string;
28
+ readonly size: number;
29
+ }
30
+
31
+ /** Upload/preview lifecycle (§11: pending while queued, failed on hard error). */
32
+ export type FileFieldStatus = "empty" | "uploading" | "done" | "failed";
33
+
34
+ const STYLE_ID = "ksui-file-field-style";
35
+
36
+ function ensureStyle(): void {
37
+ if (typeof document === "undefined") return;
38
+ if (document.getElementById(STYLE_ID)) return;
39
+ const style = document.createElement("style");
40
+ style.id = STYLE_ID;
41
+ style.textContent = `
42
+ .ksui-ff{display:flex;flex-direction:column;gap:0.5rem;}
43
+ .ksui-ff-drop{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.375rem;padding:1.25rem;border-radius:0.625rem;border:1.5px dashed var(--ksui-ff-border,rgba(255,255,255,0.2));background:var(--ksui-ff-bg,rgba(255,255,255,0.03));color:var(--ksui-ff-fg,inherit);cursor:pointer;text-align:center;font-size:0.82rem;transition:border-color .15s,background .15s;}
44
+ .ksui-ff-drop.dragging{border-color:var(--ksui-ff-accent,#c9a961);background:var(--ksui-ff-accent-bg,rgba(201,169,97,0.08));}
45
+ .ksui-ff-drop:disabled{opacity:0.5;cursor:not-allowed;}
46
+ .ksui-ff-hint{font-size:0.72rem;opacity:0.6;}
47
+ .ksui-ff-card{display:flex;align-items:center;gap:0.625rem;padding:0.5rem 0.625rem;border-radius:0.5rem;border:1px solid var(--ksui-ff-border,rgba(255,255,255,0.15));background:var(--ksui-ff-bg,rgba(255,255,255,0.03));}
48
+ .ksui-ff-thumb{width:2.5rem;height:2.5rem;border-radius:0.375rem;object-fit:cover;flex:0 0 auto;background:rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:center;}
49
+ .ksui-ff-meta{display:flex;flex-direction:column;min-width:0;flex:1;}
50
+ .ksui-ff-name{font-size:0.82rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
51
+ .ksui-ff-sub{font-size:0.72rem;opacity:0.6;}
52
+ .ksui-ff-sub.failed{color:var(--ksui-ff-danger,#f87171);}
53
+ .ksui-ff-remove{margin-left:auto;flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;border-radius:0.375rem;border:1px solid var(--ksui-ff-border,rgba(255,255,255,0.15));background:transparent;color:inherit;cursor:pointer;}
54
+ .ksui-ff-retry{font-size:0.72rem;text-decoration:underline;cursor:pointer;color:var(--ksui-ff-accent,#c9a961);background:none;border:none;padding:0;}
55
+ `;
56
+ document.head.appendChild(style);
57
+ }
58
+
59
+ export interface FileFieldProps {
60
+ /** Field label. */
61
+ label?: string;
62
+ /** Current value — an asset handle, or null when empty. */
63
+ value?: AssetHandle | null;
64
+ /** Emitted when the handle changes (upload done, or cleared to null). */
65
+ onChange?: (handle: AssetHandle | null) => void;
66
+ /**
67
+ * HOST-injected uploader: takes the picked File, returns the asset handle once
68
+ * the kernel asset service has stored it. ksui stays storage-agnostic (§11).
69
+ */
70
+ onUpload: (file: File) => Promise<AssetHandle>;
71
+ /**
72
+ * HOST-injected resolver: turns a handle into a time-limited URL for preview
73
+ * (§11). ksui never knows about S3/kernel. A rejection degrades to a broken-
74
+ * thumbnail fallback — never throws.
75
+ */
76
+ presignUrl?: (handle: AssetHandle) => Promise<string>;
77
+ /** Accept hint (e.g. ["image/*","application/pdf"]) for the picker + filter. */
78
+ accept?: readonly string[];
79
+ /** Disable interaction. */
80
+ disabled?: boolean;
81
+ /** Optional test id prefix. */
82
+ testId?: string;
83
+ }
84
+
85
+ function formatSize(bytes: number): string {
86
+ if (bytes < 1024) return `${bytes} B`;
87
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
88
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
89
+ }
90
+
91
+ export const FileField: Component<FileFieldProps> = (props) => {
92
+ ensureStyle();
93
+ const [status, setStatus] = createSignal<FileFieldStatus>(props.value ? "done" : "empty");
94
+ // The handle currently shown. Seeded from props.value, and updated locally on a
95
+ // fresh upload so the done card shows even when the parent doesn't echo onChange
96
+ // back into `value` (controlled and uncontrolled both work).
97
+ const [current, setCurrent] = createSignal<AssetHandle | null>(props.value ?? null);
98
+ const [dragging, setDragging] = createSignal(false);
99
+ const [previewUrl, setPreviewUrl] = createSignal<string | null>(null);
100
+ const [previewFailed, setPreviewFailed] = createSignal(false);
101
+ const [errorMsg, setErrorMsg] = createSignal<string | null>(null);
102
+ let inputEl: HTMLInputElement | undefined;
103
+
104
+ const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
105
+ const acceptAttr = () => props.accept?.join(",");
106
+
107
+ const isImage = (h: AssetHandle | null | undefined) => !!h && h.mime.startsWith("image/");
108
+
109
+ // Resolve a preview URL for an image handle via the injected presigner. A
110
+ // rejected presign (missing/expired asset, §11) sets previewFailed instead of
111
+ // throwing — the card then shows the broken-thumbnail fallback + a warning.
112
+ const loadPreview = (handle: AssetHandle) => {
113
+ setPreviewUrl(null);
114
+ setPreviewFailed(false);
115
+ if (!isImage(handle) || !props.presignUrl) return;
116
+ props
117
+ .presignUrl(handle)
118
+ .then((url) => setPreviewUrl(url))
119
+ .catch((e) => {
120
+ setPreviewFailed(true);
121
+ console.warn(`[ksui] FileField: preview unavailable for "${handle.name}"`, e);
122
+ });
123
+ };
124
+
125
+ // Drive the upload state machine for a picked/dropped file.
126
+ const upload = (file: File) => {
127
+ setErrorMsg(null);
128
+ setStatus("uploading");
129
+ props
130
+ .onUpload(file)
131
+ .then((handle) => {
132
+ setStatus("done");
133
+ setCurrent(handle);
134
+ props.onChange?.(handle);
135
+ loadPreview(handle);
136
+ })
137
+ .catch((e) => {
138
+ setStatus("failed");
139
+ // §11: a hard failure tells the user it couldn't upload — never throws up.
140
+ setErrorMsg(e instanceof Error ? e.message : "Couldn't upload to the cloud");
141
+ console.warn("[ksui] FileField: upload failed", e);
142
+ });
143
+ };
144
+
145
+ // If a value handle is supplied at mount, load its preview eagerly.
146
+ if (props.value) loadPreview(props.value);
147
+
148
+ const pick = () => {
149
+ if (props.disabled) return;
150
+ inputEl?.click();
151
+ };
152
+
153
+ const onPicked = (e: Event & { currentTarget: HTMLInputElement }) => {
154
+ const file = e.currentTarget.files?.[0];
155
+ if (file) upload(file);
156
+ e.currentTarget.value = ""; // allow re-picking the same file
157
+ };
158
+
159
+ const onDrop = (e: DragEvent) => {
160
+ e.preventDefault();
161
+ setDragging(false);
162
+ if (props.disabled) return;
163
+ const file = e.dataTransfer?.files?.[0];
164
+ if (file) upload(file);
165
+ };
166
+
167
+ const clear = () => {
168
+ setStatus("empty");
169
+ setCurrent(null);
170
+ setPreviewUrl(null);
171
+ setPreviewFailed(false);
172
+ setErrorMsg(null);
173
+ props.onChange?.(null);
174
+ };
175
+
176
+ // Prefer a controlled value when the parent supplies one; else the locally
177
+ // tracked handle from the last upload.
178
+ const handle = () => props.value ?? current();
179
+
180
+ return (
181
+ <div class="ksui-ff" data-testid={tid("root")}>
182
+ <Show when={props.label}>
183
+ <span class="ksui-ff-label">{props.label}</span>
184
+ </Show>
185
+
186
+ <input
187
+ ref={inputEl}
188
+ type="file"
189
+ accept={acceptAttr()}
190
+ style={{ display: "none" }}
191
+ data-testid={tid("input")}
192
+ onChange={onPicked}
193
+ />
194
+
195
+ <Switch>
196
+ {/* empty → the drop zone / click-to-pick affordance */}
197
+ <Match when={status() === "empty"}>
198
+ <button
199
+ type="button"
200
+ class={`ksui-ff-drop${dragging() ? " dragging" : ""}`}
201
+ disabled={props.disabled}
202
+ data-testid={tid("drop")}
203
+ data-dragging={dragging() ? "true" : "false"}
204
+ onClick={pick}
205
+ onDragOver={(e) => {
206
+ e.preventDefault();
207
+ setDragging(true);
208
+ }}
209
+ onDragLeave={() => setDragging(false)}
210
+ onDrop={onDrop}
211
+ >
212
+ <UploadCloud size={22} />
213
+ <span>Drop a file or click to upload</span>
214
+ <Show when={props.accept?.length}>
215
+ <span class="ksui-ff-hint">{props.accept!.join(", ")}</span>
216
+ </Show>
217
+ </button>
218
+ </Match>
219
+
220
+ {/* uploading → pending state (§11 pending while queued) */}
221
+ <Match when={status() === "uploading"}>
222
+ <div class="ksui-ff-card" data-testid={tid("uploading")}>
223
+ <span class="ksui-ff-thumb">
224
+ <UploadCloud size={18} />
225
+ </span>
226
+ <div class="ksui-ff-meta">
227
+ <span class="ksui-ff-name">Uploading…</span>
228
+ <span class="ksui-ff-sub">Sending to the cloud</span>
229
+ </div>
230
+ </div>
231
+ </Match>
232
+
233
+ {/* failed → hard-failure notice + retry (§11: tell the user, never throw) */}
234
+ <Match when={status() === "failed"}>
235
+ <div class="ksui-ff-card" data-testid={tid("failed")}>
236
+ <span class="ksui-ff-thumb">
237
+ <ImageOff size={18} />
238
+ </span>
239
+ <div class="ksui-ff-meta">
240
+ <span class="ksui-ff-name">Upload failed</span>
241
+ <span class="ksui-ff-sub failed">{errorMsg() ?? "Couldn't upload to the cloud"}</span>
242
+ </div>
243
+ <button type="button" class="ksui-ff-retry" data-testid={tid("retry")} onClick={pick}>
244
+ Retry
245
+ </button>
246
+ </div>
247
+ </Match>
248
+
249
+ {/* done → the asset card with image preview (graceful-degrade fallback) */}
250
+ <Match when={status() === "done" && !!handle()}>
251
+ {renderDoneCard(handle()!, isImage(handle()), previewUrl(), previewFailed(), () => setPreviewFailed(true), props.disabled, clear, tid)}
252
+ </Match>
253
+ </Switch>
254
+ </div>
255
+ );
256
+ };
257
+
258
+ function renderDoneCard(
259
+ h: AssetHandle,
260
+ image: boolean,
261
+ url: string | null,
262
+ failed: boolean,
263
+ onImgError: () => void,
264
+ disabled: boolean | undefined,
265
+ onClear: () => void,
266
+ tid: (s: string) => string | undefined,
267
+ ): JSX.Element {
268
+ return (
269
+ <div class="ksui-ff-card" data-testid={tid("done")}>
270
+ <span class="ksui-ff-thumb" data-testid={tid("thumb")}>
271
+ <Switch fallback={<FileIcon size={18} />}>
272
+ {/* image + a presigned url that loaded → real thumbnail */}
273
+ <Match when={image && url && !failed}>
274
+ <img
275
+ src={url!}
276
+ alt={h.name}
277
+ class="ksui-ff-thumb"
278
+ data-testid={tid("preview")}
279
+ onError={onImgError}
280
+ />
281
+ </Match>
282
+ {/* image but the presign rejected/expired → broken-thumbnail fallback (§11) */}
283
+ <Match when={image && failed}>
284
+ <ImageOff size={18} data-testid={tid("broken")} />
285
+ </Match>
286
+ </Switch>
287
+ </span>
288
+ <div class="ksui-ff-meta">
289
+ <span class="ksui-ff-name">{h.name}</span>
290
+ <span class="ksui-ff-sub">{formatSize(h.size)}</span>
291
+ </div>
292
+ <Show when={!disabled}>
293
+ <button type="button" class="ksui-ff-remove" aria-label="Remove file" data-testid={tid("remove")} onClick={onClear}>
294
+ <X size={14} />
295
+ </button>
296
+ </Show>
297
+ </div>
298
+ );
299
+ }
300
+
301
+ export default FileField;
@@ -0,0 +1,127 @@
1
+ // U7 — FlowRunner component tests: renders each node kind, calls advance with the
2
+ // node's input, handles a terminal node + an error.
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { render, fireEvent, waitFor } from "@solidjs/testing-library";
5
+ import FlowRunner from "./FlowRunner";
6
+ import type { FlowNode } from "../../utils/flow";
7
+
8
+ const terminal: FlowNode = { kind: "terminal", id: "end", message: "Done" };
9
+
10
+ describe("FlowRunner", () => {
11
+ it("renders a form and calls advance with the form input on submit", async () => {
12
+ const advance = vi.fn(async () => terminal);
13
+ const onComplete = vi.fn();
14
+ const { getByTestId } = render(() => (
15
+ <FlowRunner
16
+ testId="fr"
17
+ initialNode={{
18
+ kind: "form",
19
+ id: "n1",
20
+ title: "Pay",
21
+ fields: [{ key: "amount", label: "Amount", type: "number", required: true }],
22
+ }}
23
+ advance={advance}
24
+ state={{ token: "abc" }}
25
+ onComplete={onComplete}
26
+ />
27
+ ));
28
+ fireEvent.input(getByTestId("fr-field-amount"), { target: { value: "50" } });
29
+ fireEvent.click(getByTestId("fr-submit"));
30
+ await waitFor(() => expect(advance).toHaveBeenCalledWith({ token: "abc" }, { amount: "50" }));
31
+ await waitFor(() => expect(getByTestId("fr-terminal")).toBeTruthy());
32
+ expect(onComplete).toHaveBeenCalled();
33
+ });
34
+
35
+ it("blocks submit while a required field is empty", () => {
36
+ const advance = vi.fn(async () => terminal);
37
+ const { getByTestId } = render(() => (
38
+ <FlowRunner
39
+ testId="fr"
40
+ initialNode={{
41
+ kind: "form",
42
+ id: "n1",
43
+ fields: [{ key: "amount", label: "Amount", required: true }],
44
+ }}
45
+ advance={advance}
46
+ />
47
+ ));
48
+ fireEvent.click(getByTestId("fr-submit"));
49
+ expect(advance).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("renders a choice and submits the picked value", async () => {
53
+ const advance = vi.fn(async () => terminal);
54
+ const { getByTestId } = render(() => (
55
+ <FlowRunner
56
+ testId="fr"
57
+ initialNode={{
58
+ kind: "choice",
59
+ id: "c1",
60
+ options: [{ value: "yes", label: "Yes" }, { value: "no", label: "No" }],
61
+ }}
62
+ advance={advance}
63
+ />
64
+ ));
65
+ fireEvent.click(getByTestId("fr-choice-yes"));
66
+ await waitFor(() => expect(advance).toHaveBeenCalledWith(undefined, { value: "yes" }));
67
+ });
68
+
69
+ it("renders a display node and continues with null input", async () => {
70
+ const advance = vi.fn(async () => terminal);
71
+ const { getByTestId } = render(() => (
72
+ <FlowRunner
73
+ testId="fr"
74
+ initialNode={{ kind: "display", id: "d1", body: "Review this" }}
75
+ advance={advance}
76
+ />
77
+ ));
78
+ expect(getByTestId("fr-display").textContent).toContain("Review this");
79
+ fireEvent.click(getByTestId("fr-continue"));
80
+ await waitFor(() => expect(advance).toHaveBeenCalledWith(undefined, null));
81
+ });
82
+
83
+ it("renders a message node and acks", async () => {
84
+ const advance = vi.fn(async () => terminal);
85
+ const { getByTestId } = render(() => (
86
+ <FlowRunner
87
+ testId="fr"
88
+ initialNode={{ kind: "message", id: "m1", text: "Heads up", tone: "info" }}
89
+ advance={advance}
90
+ />
91
+ ));
92
+ expect(getByTestId("fr-message").textContent).toContain("Heads up");
93
+ fireEvent.click(getByTestId("fr-ack"));
94
+ await waitFor(() => expect(advance).toHaveBeenCalled());
95
+ });
96
+
97
+ it("surfaces an error when advance rejects, without inventing the next node", async () => {
98
+ const advance = vi.fn(async () => {
99
+ throw new Error("server said no");
100
+ });
101
+ const { getByTestId, queryByTestId } = render(() => (
102
+ <FlowRunner
103
+ testId="fr"
104
+ initialNode={{ kind: "choice", id: "c1", options: [{ value: "go", label: "Go" }] }}
105
+ advance={advance}
106
+ />
107
+ ));
108
+ fireEvent.click(getByTestId("fr-choice-go"));
109
+ await waitFor(() => expect(getByTestId("fr-error").textContent).toContain("server said no"));
110
+ // still on the choice node — the client never invents a next node
111
+ expect(queryByTestId("fr-choice")).toBeTruthy();
112
+ });
113
+
114
+ it("takes the cancel path on a cancelable form", () => {
115
+ const onCancel = vi.fn();
116
+ const { getByTestId } = render(() => (
117
+ <FlowRunner
118
+ testId="fr"
119
+ initialNode={{ kind: "form", id: "n1", cancelable: true, fields: [] }}
120
+ advance={vi.fn(async () => terminal)}
121
+ onCancel={onCancel}
122
+ />
123
+ ));
124
+ fireEvent.click(getByTestId("fr-cancel"));
125
+ expect(onCancel).toHaveBeenCalled();
126
+ });
127
+ });